From e9bd7891cc13a046e4c7329b0e1df988a83b6bfb Mon Sep 17 00:00:00 2001 From: LPF Date: Thu, 19 Mar 2026 22:46:37 +0800 Subject: [PATCH] Fix provider config hot reload --- cmd/cmd_gateway.go | 14 +- pkg/agent/loop.go | 35 +++- pkg/api/server.go | 10 +- pkg/api/server_test.go | 51 +++++- workspace/skills/context7/.env | 1 - workspace/skills/context7/SKILL.md | 19 --- workspace/skills/context7/package.json | 20 --- workspace/skills/context7/query.ts | 161 ------------------ workspace/skills/tmux/SKILL.md | 135 --------------- .../skills/tmux/scripts/find-sessions.sh | 112 ------------ .../skills/tmux/scripts/wait-for-text.sh | 83 --------- 11 files changed, 93 insertions(+), 548 deletions(-) delete mode 100644 workspace/skills/context7/.env delete mode 100644 workspace/skills/context7/SKILL.md delete mode 100644 workspace/skills/context7/package.json delete mode 100644 workspace/skills/context7/query.ts delete mode 100644 workspace/skills/tmux/SKILL.md delete mode 100755 workspace/skills/tmux/scripts/find-sessions.sh delete mode 100755 workspace/skills/tmux/scripts/wait-for-text.sh diff --git a/cmd/cmd_gateway.go b/cmd/cmd_gateway.go index 3018b54..0e65191 100644 --- a/cmd/cmd_gateway.go +++ b/cmd/cmd_gateway.go @@ -175,14 +175,14 @@ func gatewayCmd() { } bindAgentLoopHandlers(agentLoop) var reloadMu sync.Mutex - var applyReload func() error - registryServer.SetConfigAfterHook(func() error { + var applyReload func(forceRuntimeReload bool) error + registryServer.SetConfigAfterHook(func(forceRuntimeReload bool) error { reloadMu.Lock() defer reloadMu.Unlock() if applyReload == nil { return fmt.Errorf("reload handler not ready") } - return applyReload() + return applyReload(forceRuntimeReload) }) whatsAppBridge, whatsAppEmbedded := setupEmbeddedWhatsAppBridge(ctx, cfg) if whatsAppBridge != nil { @@ -341,7 +341,7 @@ func gatewayCmd() { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, gatewayNotifySignals()...) - applyReload = func() error { + applyReload = func(forceRuntimeReload bool) error { fmt.Println("\nReloading config...") newCfg, err := config.LoadConfig(getConfigPath()) if err != nil { @@ -357,7 +357,7 @@ func gatewayCmd() { fmt.Printf("Error starting heartbeat service: %v\n", err) } - if reflect.DeepEqual(cfg, newCfg) { + if !forceRuntimeReload && reflect.DeepEqual(cfg, newCfg) { fmt.Println("Config unchanged, skip reload") return nil } @@ -376,7 +376,7 @@ func gatewayCmd() { reflect.DeepEqual(cfg.Tools, newCfg.Tools) && reflect.DeepEqual(cfg.Channels, newCfg.Channels) - if runtimeSame { + if runtimeSame && !forceRuntimeReload { configureLogging(newCfg) sentinelService.Stop() sentinelService = sentinel.NewService( @@ -451,7 +451,7 @@ func gatewayCmd() { switch { case isGatewayReloadSignal(sig): reloadMu.Lock() - err := applyReload() + err := applyReload(false) reloadMu.Unlock() if err != nil { fmt.Printf("Reload failed: %v\n", err) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index f31eefc..f5bb170 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -67,6 +67,9 @@ type AgentLoop struct { subagentDigestMu sync.Mutex subagentDigestDelay time.Duration subagentDigests map[string]*subagentDigestState + runMu sync.Mutex + runCancel context.CancelFunc + runWG sync.WaitGroup } type providerCandidate struct { @@ -403,19 +406,34 @@ func (al *AgentLoop) readSubagentPromptFile(relPath string) string { } func (al *AgentLoop) Run(ctx context.Context) error { + al.runMu.Lock() + if al.runCancel != nil { + al.runMu.Unlock() + return fmt.Errorf("agent loop already running") + } + runCtx, cancel := context.WithCancel(ctx) + al.runCancel = cancel al.running = true + al.runMu.Unlock() + defer func() { + al.runMu.Lock() + al.running = false + al.runCancel = nil + al.runMu.Unlock() + }() - shards := al.buildSessionShards(ctx) + shards := al.buildSessionShards(runCtx) defer func() { for _, ch := range shards { close(ch) } + al.runWG.Wait() }() for al.running { - msg, ok := al.bus.ConsumeInbound(ctx) + msg, ok := al.bus.ConsumeInbound(runCtx) if !ok { - if ctx.Err() != nil { + if runCtx.Err() != nil { return nil } continue @@ -423,7 +441,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { idx := sessionShardIndex(msg.SessionKey, len(shards)) select { case shards[idx] <- msg: - case <-ctx.Done(): + case <-runCtx.Done(): return nil } } @@ -432,7 +450,14 @@ func (al *AgentLoop) Run(ctx context.Context) error { } func (al *AgentLoop) Stop() { + al.runMu.Lock() + cancel := al.runCancel + al.runMu.Unlock() + if cancel != nil { + cancel() + } al.running = false + al.runWG.Wait() } func (al *AgentLoop) buildSessionShards(ctx context.Context) []chan bus.InboundMessage { @@ -440,7 +465,9 @@ func (al *AgentLoop) buildSessionShards(ctx context.Context) []chan bus.InboundM shards := make([]chan bus.InboundMessage, count) for i := 0; i < count; i++ { shards[i] = make(chan bus.InboundMessage, 64) + al.runWG.Add(1) go func(ch <-chan bus.InboundMessage) { + defer al.runWG.Done() for msg := range ch { al.processInbound(ctx, msg) } diff --git a/pkg/api/server.go b/pkg/api/server.go index 1be2b71..ada4c0c 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -48,7 +48,7 @@ type Server struct { logFilePath string onChat func(ctx context.Context, sessionKey, content string) (string, error) onChatHistory func(sessionKey string) []map[string]interface{} - onConfigAfter func() error + onConfigAfter func(forceRuntimeReload bool) error onCron func(action string, args map[string]interface{}) (interface{}, error) onToolsCatalog func() interface{} whatsAppBridge *channels.WhatsAppBridgeService @@ -85,7 +85,7 @@ func (s *Server) SetChatHandler(fn func(ctx context.Context, sessionKey, content func (s *Server) SetChatHistoryHandler(fn func(sessionKey string) []map[string]interface{}) { s.onChatHistory = fn } -func (s *Server) SetConfigAfterHook(fn func() error) { s.onConfigAfter = fn } +func (s *Server) SetConfigAfterHook(fn func(forceRuntimeReload bool) error) { s.onConfigAfter = fn } func (s *Server) SetCronHandler(fn func(action string, args map[string]interface{}) (interface{}, error)) { s.onCron = fn } @@ -414,7 +414,7 @@ func (s *Server) persistWebUIConfig(cfg *cfgpkg.Config) error { return err } if s.onConfigAfter != nil { - return s.onConfigAfter() + return s.onConfigAfter(false) } return requestSelfReloadSignal() } @@ -978,7 +978,9 @@ func (s *Server) saveProviderConfig(cfg *cfgpkg.Config, name string, pc cfgpkg.P return err } if s.onConfigAfter != nil { - if err := s.onConfigAfter(); err != nil { + // Provider updates may only change external credential file contents, + // so force a runtime rebuild even when config JSON remains identical. + if err := s.onConfigAfter(true); err != nil { return err } } else { diff --git a/pkg/api/server_test.go b/pkg/api/server_test.go index 33548dc..90e513c 100644 --- a/pkg/api/server_test.go +++ b/pkg/api/server_test.go @@ -100,7 +100,10 @@ func TestHandleWebUIConfigPostSavesRawConfig(t *testing.T) { srv := NewServer("127.0.0.1", 0, "") srv.SetConfigPath(cfgPath) hookCalled := 0 - srv.SetConfigAfterHook(func() error { + srv.SetConfigAfterHook(func(forceRuntimeReload bool) error { + if forceRuntimeReload { + t.Fatalf("expected raw config save to use non-forced reload") + } hookCalled++ return nil }) @@ -150,7 +153,12 @@ func TestHandleWebUIConfigPostSavesNormalizedConfig(t *testing.T) { srv := NewServer("127.0.0.1", 0, "") srv.SetConfigPath(cfgPath) - srv.SetConfigAfterHook(func() error { return nil }) + srv.SetConfigAfterHook(func(forceRuntimeReload bool) error { + if forceRuntimeReload { + t.Fatalf("expected normalized config save to use non-forced reload") + } + return nil + }) req := httptest.NewRequest(http.MethodPost, "/api/config?mode=normalized", strings.NewReader(`{"core":{"gateway":{"host":"127.0.0.1","port":18790},"tools":{"shell_enabled":false,"mcp_enabled":true}},"runtime":{"router":{"enabled":true,"strategy":"rules_first","max_hops":2,"default_timeout_sec":90},"providers":{"openai":{"api_base":"https://api.openai.com/v1","auth":"bearer","timeout_sec":150}}}}`)) req.Header.Set("Content-Type", "application/json") @@ -172,6 +180,45 @@ func TestHandleWebUIConfigPostSavesNormalizedConfig(t *testing.T) { } } +func TestSaveProviderConfigForcesRuntimeReload(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfgPath := filepath.Join(tmp, "config.json") + cfg := cfgpkg.DefaultConfig() + cfg.Logging.Enabled = false + cfg.Models.Providers["openai"] = cfgpkg.ProviderConfig{ + APIBase: "https://api.openai.com/v1", + Auth: "oauth", + Models: []string{"gpt-5"}, + TimeoutSec: 120, + OAuth: cfgpkg.ProviderOAuthConfig{ + Provider: "codex", + CredentialFile: filepath.Join(tmp, "auth.json"), + }, + } + if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + + srv := NewServer("127.0.0.1", 0, "") + srv.SetConfigPath(cfgPath) + + forced := false + srv.SetConfigAfterHook(func(forceRuntimeReload bool) error { + forced = forceRuntimeReload + return nil + }) + + pc := cfg.Models.Providers["openai"] + if err := srv.saveProviderConfig(cfg, "openai", pc); err != nil { + t.Fatalf("save provider config: %v", err) + } + if !forced { + t.Fatalf("expected provider config save to force runtime reload") + } +} + func TestWithCORSEchoesPreflightHeaders(t *testing.T) { t.Parallel() diff --git a/workspace/skills/context7/.env b/workspace/skills/context7/.env deleted file mode 100644 index ce76b83..0000000 --- a/workspace/skills/context7/.env +++ /dev/null @@ -1 +0,0 @@ -CONTEXT7_API_KEY=ctx7sk-dfce3619-d4cf-4ce2-a1a1-9d0ba9d071ad diff --git a/workspace/skills/context7/SKILL.md b/workspace/skills/context7/SKILL.md deleted file mode 100644 index 8ce322e..0000000 --- a/workspace/skills/context7/SKILL.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -name: context7 -description: Intelligent documentation search and context using context7 service. ---- - -# Context7 - -Query documentation and repositories for intelligent context. - -## Usage - -```bash -npx tsx /root/.clawgo/skills/context7/query.ts context -``` - -Example: -```bash -npx tsx /root/.clawgo/skills/context7/query.ts context YspCoder/clawgo "How does the skill system work?" -``` diff --git a/workspace/skills/context7/package.json b/workspace/skills/context7/package.json deleted file mode 100644 index 67310b2..0000000 --- a/workspace/skills/context7/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "context7-cli", - "version": "1.0.0", - "description": "Context7 MCP CLI for querying documentation", - "type": "module", - "scripts": { - "query": "tsx query.ts", - "read": "tsx read.ts", - "explore": "tsx explore.ts" - }, - "dependencies": { - "mcp-client": "^1.13.1", - "zod": "^3.22.4" - }, - "devDependencies": { - "@types/node": "^20.10.0", - "tsx": "^4.6.0", - "typescript": "^5.3.0" - } -} diff --git a/workspace/skills/context7/query.ts b/workspace/skills/context7/query.ts deleted file mode 100644 index f596d41..0000000 --- a/workspace/skills/context7/query.ts +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env tsx -/** - * Context7 Query CLI - * - * Query Context7 API to search documentation and repository code. - * - * Usage: - * Search: npx tsx query.ts search - * Context: npx tsx query.ts context - * - * Examples: - * npx tsx query.ts search "better-auth/better-auth" "signIn social redirect callback" - * npx tsx query.ts context "facebook/react" "useState hook" - */ - -import { readFileSync } from "fs"; -import { join, dirname } from "path"; -import { fileURLToPath } from "url"; - -// Load API key from .env file in the same directory as this script -const __dirname = dirname(fileURLToPath(import.meta.url)); -const envPath = join(__dirname, ".env"); -let API_KEY = process.env.CONTEXT7_API_KEY; - -// Try to load from .env file if not in environment -if (!API_KEY) { - try { - const envContent = readFileSync(envPath, "utf-8"); - const match = envContent.match(/CONTEXT7_API_KEY=(.+)/); - API_KEY = match?.[1]?.trim(); - } catch { - // .env file doesn't exist, continue with null - } -} - -if (!API_KEY) { - console.error("Error: CONTEXT7_API_KEY not found"); - console.error("Set it in environment or in .env file in this directory"); - process.exit(1); -} - -const command = process.argv[2]; -const repoName = process.argv[3]; -const query = process.argv[4]; - -// Help text -if (!command || command === "--help" || command === "-h") { - console.log(` -Context7 Query CLI - -Usage: - npx tsx query.ts - -Commands: - search Search for libraries by name with intelligent LLM-powered ranking - context Retrieve intelligent, LLM-reranked documentation context - -Examples: - npx tsx query.ts search "nextjs" "setup ssr" - npx tsx query.ts context "better-auth/better-auth" "signIn social redirect" - -For more info: https://context7.com/docs -`); - process.exit(0); -} - -if (!repoName || !query) { - console.error("Error: Missing arguments"); - console.error("Usage:"); - console.error(" Search: npx tsx query.ts search "); - console.error(" Context: npx tsx query.ts context "); - process.exit(1); -} - -// Ensure repo name starts with / -const libraryId = repoName.startsWith("/") ? repoName : `/${repoName}`; - -async function searchLibraries() { - try { - console.log(`Searching Context7 for libraries matching "${query}"...`); - - // Context7 Search API - const url = new URL("https://context7.com/api/v2/libs/search"); - url.searchParams.set("libraryName", libraryId.split("/")[1] || ""); - url.searchParams.set("query", query); - - const response = await fetch(url.toString(), { - headers: { - "Authorization": `Bearer ${API_KEY}`, - }, - }); - - if (!response.ok) { - const error = await response.text(); - console.error(`Context7 API error (${response.status}):`, error); - process.exit(1); - } - - const data = await response.json(); - - console.log("\n=== Search Results ===\n"); - - if (Array.isArray(data) && data.length > 0) { - data.forEach((lib: any, i: number) => { - console.log(`${i + 1}. ${lib.name || lib.id}`); - console.log(` Trust Score: ${lib.trustScore || "N/A"}`); - console.log(` Benchmark: ${lib.benchmarkScore || "N/A"}`); - if (lib.versions) { - console.log(` Versions: ${lib.versions.slice(0, 5).join(", ")}${lib.versions.length > 5 ? "..." : ""}`); - } - console.log(""); - }); - } else { - console.log("No results found."); - } - } catch (error: any) { - console.error("Error searching Context7:", error.message); - process.exit(1); - } -} - -async function getContext() { - try { - console.log(`Getting context for: "${query}" in ${libraryId}...`); - - // Context7 REST API - const url = new URL("https://context7.com/api/v2/context"); - url.searchParams.set("libraryId", libraryId); - url.searchParams.set("query", query); - url.searchParams.set("type", "txt"); - - const response = await fetch(url.toString(), { - headers: { - "Authorization": `Bearer ${API_KEY}`, - }, - }); - - if (!response.ok) { - const error = await response.text(); - console.error(`Context7 API error (${response.status}):`, error); - process.exit(1); - } - - const text = await response.text(); - console.log("\n=== Context Results ===\n"); - console.log(text); - } catch (error: any) { - console.error("Error querying Context7:", error.message); - process.exit(1); - } -} - -if (command === "search" || command === "s") { - searchLibraries(); -} else if (command === "context" || command === "c") { - getContext(); -} else { - console.error(`Unknown command: ${command}`); - console.error("Use 'search' or 'context'"); - process.exit(1); -} diff --git a/workspace/skills/tmux/SKILL.md b/workspace/skills/tmux/SKILL.md deleted file mode 100644 index 6502959..0000000 --- a/workspace/skills/tmux/SKILL.md +++ /dev/null @@ -1,135 +0,0 @@ ---- -name: tmux -description: Remote-control tmux sessions for interactive CLIs by sending keystrokes and scraping pane output. -metadata: - { "openclaw": { "emoji": "🧵", "os": ["darwin", "linux"], "requires": { "bins": ["tmux"] } } } ---- - -# tmux Skill (OpenClaw) - -Use tmux only when you need an interactive TTY. Prefer exec background mode for long-running, non-interactive tasks. - -## Quickstart (isolated socket, exec tool) - -```bash -SOCKET_DIR="${OPENCLAW_TMUX_SOCKET_DIR:-${CLAWDBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/openclaw-tmux-sockets}}" -mkdir -p "$SOCKET_DIR" -SOCKET="$SOCKET_DIR/openclaw.sock" -SESSION=openclaw-python - -tmux -S "$SOCKET" new -d -s "$SESSION" -n shell -tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- 'PYTHON_BASIC_REPL=1 python3 -q' Enter -tmux -S "$SOCKET" capture-pane -p -J -t "$SESSION":0.0 -S -200 -``` - -After starting a session, always print monitor commands: - -``` -To monitor: - tmux -S "$SOCKET" attach -t "$SESSION" - tmux -S "$SOCKET" capture-pane -p -J -t "$SESSION":0.0 -S -200 -``` - -## Socket convention - -- Use `OPENCLAW_TMUX_SOCKET_DIR` (legacy `CLAWDBOT_TMUX_SOCKET_DIR` also supported). -- Default socket path: `"$OPENCLAW_TMUX_SOCKET_DIR/openclaw.sock"`. - -## Targeting panes and naming - -- Target format: `session:window.pane` (defaults to `:0.0`). -- Keep names short; avoid spaces. -- Inspect: `tmux -S "$SOCKET" list-sessions`, `tmux -S "$SOCKET" list-panes -a`. - -## Finding sessions - -- List sessions on your socket: `{baseDir}/scripts/find-sessions.sh -S "$SOCKET"`. -- Scan all sockets: `{baseDir}/scripts/find-sessions.sh --all` (uses `OPENCLAW_TMUX_SOCKET_DIR`). - -## Sending input safely - -- Prefer literal sends: `tmux -S "$SOCKET" send-keys -t target -l -- "$cmd"`. -- Control keys: `tmux -S "$SOCKET" send-keys -t target C-c`. -- For interactive TUI apps like Claude Code/Codex, this guidance covers **how to send commands**. - Do **not** append `Enter` in the same `send-keys`. These apps may treat a fast text+Enter - sequence as paste/multi-line input and not submit; this is timing-dependent. Send text and - `Enter` as separate commands with a small delay (tune per environment; increase if needed, - or use `sleep 1` if sub-second sleeps aren't supported): - -```bash -tmux -S "$SOCKET" send-keys -t target -l -- "$cmd" && sleep 0.1 && tmux -S "$SOCKET" send-keys -t target Enter -``` - -## Watching output - -- Capture recent history: `tmux -S "$SOCKET" capture-pane -p -J -t target -S -200`. -- Wait for prompts: `{baseDir}/scripts/wait-for-text.sh -t session:0.0 -p 'pattern'`. -- Attaching is OK; detach with `Ctrl+b d`. - -## Spawning processes - -- For python REPLs, set `PYTHON_BASIC_REPL=1` (non-basic REPL breaks send-keys flows). - -## Windows / WSL - -- tmux is supported on macOS/Linux. On Windows, use WSL and install tmux inside WSL. -- This skill is gated to `darwin`/`linux` and requires `tmux` on PATH. - -## Orchestrating Coding Agents (Codex, Claude Code) - -tmux excels at running multiple coding agents in parallel: - -```bash -SOCKET="${TMPDIR:-/tmp}/codex-army.sock" - -# Create multiple sessions -for i in 1 2 3 4 5; do - tmux -S "$SOCKET" new-session -d -s "agent-$i" -done - -# Launch agents in different workdirs -tmux -S "$SOCKET" send-keys -t agent-1 "cd /tmp/project1 && codex --yolo 'Fix bug X'" Enter -tmux -S "$SOCKET" send-keys -t agent-2 "cd /tmp/project2 && codex --yolo 'Fix bug Y'" Enter - -# When sending prompts to Claude Code/Codex TUI, split text + Enter with a delay -tmux -S "$SOCKET" send-keys -t agent-1 -l -- "Please make a small edit to README.md." && sleep 0.1 && tmux -S "$SOCKET" send-keys -t agent-1 Enter - -# Poll for completion (check if prompt returned) -for sess in agent-1 agent-2; do - if tmux -S "$SOCKET" capture-pane -p -t "$sess" -S -3 | grep -q "❯"; then - echo "$sess: DONE" - else - echo "$sess: Running..." - fi -done - -# Get full output from completed session -tmux -S "$SOCKET" capture-pane -p -t agent-1 -S -500 -``` - -**Tips:** - -- Use separate git worktrees for parallel fixes (no branch conflicts) -- `pnpm install` first before running codex in fresh clones -- Check for shell prompt (`❯` or `$`) to detect completion -- Codex needs `--yolo` or `--full-auto` for non-interactive fixes - -## Cleanup - -- Kill a session: `tmux -S "$SOCKET" kill-session -t "$SESSION"`. -- Kill all sessions on a socket: `tmux -S "$SOCKET" list-sessions -F '#{session_name}' | xargs -r -n1 tmux -S "$SOCKET" kill-session -t`. -- Remove everything on the private socket: `tmux -S "$SOCKET" kill-server`. - -## Helper: wait-for-text.sh - -`{baseDir}/scripts/wait-for-text.sh` polls a pane for a regex (or fixed string) with a timeout. - -```bash -{baseDir}/scripts/wait-for-text.sh -t session:0.0 -p 'pattern' [-F] [-T 20] [-i 0.5] [-l 2000] -``` - -- `-t`/`--target` pane target (required) -- `-p`/`--pattern` regex to match (required); add `-F` for fixed string -- `-T` timeout seconds (integer, default 15) -- `-i` poll interval seconds (default 0.5) -- `-l` history lines to search (integer, default 1000) diff --git a/workspace/skills/tmux/scripts/find-sessions.sh b/workspace/skills/tmux/scripts/find-sessions.sh deleted file mode 100755 index 00552c6..0000000 --- a/workspace/skills/tmux/scripts/find-sessions.sh +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat <<'USAGE' -Usage: find-sessions.sh [-L socket-name|-S socket-path|-A] [-q pattern] - -List tmux sessions on a socket (default tmux socket if none provided). - -Options: - -L, --socket tmux socket name (passed to tmux -L) - -S, --socket-path tmux socket path (passed to tmux -S) - -A, --all scan all sockets under NANOBOT_TMUX_SOCKET_DIR - -q, --query case-insensitive substring to filter session names - -h, --help show this help -USAGE -} - -socket_name="" -socket_path="" -query="" -scan_all=false -socket_dir="${NANOBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/nanobot-tmux-sockets}" - -while [[ $# -gt 0 ]]; do - case "$1" in - -L|--socket) socket_name="${2-}"; shift 2 ;; - -S|--socket-path) socket_path="${2-}"; shift 2 ;; - -A|--all) scan_all=true; shift ;; - -q|--query) query="${2-}"; shift 2 ;; - -h|--help) usage; exit 0 ;; - *) echo "Unknown option: $1" >&2; usage; exit 1 ;; - esac -done - -if [[ "$scan_all" == true && ( -n "$socket_name" || -n "$socket_path" ) ]]; then - echo "Cannot combine --all with -L or -S" >&2 - exit 1 -fi - -if [[ -n "$socket_name" && -n "$socket_path" ]]; then - echo "Use either -L or -S, not both" >&2 - exit 1 -fi - -if ! command -v tmux >/dev/null 2>&1; then - echo "tmux not found in PATH" >&2 - exit 1 -fi - -list_sessions() { - local label="$1"; shift - local tmux_cmd=(tmux "$@") - - if ! sessions="$("${tmux_cmd[@]}" list-sessions -F '#{session_name}\t#{session_attached}\t#{session_created_string}' 2>/dev/null)"; then - echo "No tmux server found on $label" >&2 - return 1 - fi - - if [[ -n "$query" ]]; then - sessions="$(printf '%s\n' "$sessions" | grep -i -- "$query" || true)" - fi - - if [[ -z "$sessions" ]]; then - echo "No sessions found on $label" - return 0 - fi - - echo "Sessions on $label:" - printf '%s\n' "$sessions" | while IFS=$'\t' read -r name attached created; do - attached_label=$([[ "$attached" == "1" ]] && echo "attached" || echo "detached") - printf ' - %s (%s, started %s)\n' "$name" "$attached_label" "$created" - done -} - -if [[ "$scan_all" == true ]]; then - if [[ ! -d "$socket_dir" ]]; then - echo "Socket directory not found: $socket_dir" >&2 - exit 1 - fi - - shopt -s nullglob - sockets=("$socket_dir"/*) - shopt -u nullglob - - if [[ "${#sockets[@]}" -eq 0 ]]; then - echo "No sockets found under $socket_dir" >&2 - exit 1 - fi - - exit_code=0 - for sock in "${sockets[@]}"; do - if [[ ! -S "$sock" ]]; then - continue - fi - list_sessions "socket path '$sock'" -S "$sock" || exit_code=$? - done - exit "$exit_code" -fi - -tmux_cmd=(tmux) -socket_label="default socket" - -if [[ -n "$socket_name" ]]; then - tmux_cmd+=(-L "$socket_name") - socket_label="socket name '$socket_name'" -elif [[ -n "$socket_path" ]]; then - tmux_cmd+=(-S "$socket_path") - socket_label="socket path '$socket_path'" -fi - -list_sessions "$socket_label" "${tmux_cmd[@]:1}" diff --git a/workspace/skills/tmux/scripts/wait-for-text.sh b/workspace/skills/tmux/scripts/wait-for-text.sh deleted file mode 100755 index 56354be..0000000 --- a/workspace/skills/tmux/scripts/wait-for-text.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat <<'USAGE' -Usage: wait-for-text.sh -t target -p pattern [options] - -Poll a tmux pane for text and exit when found. - -Options: - -t, --target tmux target (session:window.pane), required - -p, --pattern regex pattern to look for, required - -F, --fixed treat pattern as a fixed string (grep -F) - -T, --timeout seconds to wait (integer, default: 15) - -i, --interval poll interval in seconds (default: 0.5) - -l, --lines number of history lines to inspect (integer, default: 1000) - -h, --help show this help -USAGE -} - -target="" -pattern="" -grep_flag="-E" -timeout=15 -interval=0.5 -lines=1000 - -while [[ $# -gt 0 ]]; do - case "$1" in - -t|--target) target="${2-}"; shift 2 ;; - -p|--pattern) pattern="${2-}"; shift 2 ;; - -F|--fixed) grep_flag="-F"; shift ;; - -T|--timeout) timeout="${2-}"; shift 2 ;; - -i|--interval) interval="${2-}"; shift 2 ;; - -l|--lines) lines="${2-}"; shift 2 ;; - -h|--help) usage; exit 0 ;; - *) echo "Unknown option: $1" >&2; usage; exit 1 ;; - esac -done - -if [[ -z "$target" || -z "$pattern" ]]; then - echo "target and pattern are required" >&2 - usage - exit 1 -fi - -if ! [[ "$timeout" =~ ^[0-9]+$ ]]; then - echo "timeout must be an integer number of seconds" >&2 - exit 1 -fi - -if ! [[ "$lines" =~ ^[0-9]+$ ]]; then - echo "lines must be an integer" >&2 - exit 1 -fi - -if ! command -v tmux >/dev/null 2>&1; then - echo "tmux not found in PATH" >&2 - exit 1 -fi - -# End time in epoch seconds (integer, good enough for polling) -start_epoch=$(date +%s) -deadline=$((start_epoch + timeout)) - -while true; do - # -J joins wrapped lines, -S uses negative index to read last N lines - pane_text="$(tmux capture-pane -p -J -t "$target" -S "-${lines}" 2>/dev/null || true)" - - if printf '%s\n' "$pane_text" | grep $grep_flag -- "$pattern" >/dev/null 2>&1; then - exit 0 - fi - - now=$(date +%s) - if (( now >= deadline )); then - echo "Timed out after ${timeout}s waiting for pattern: $pattern" >&2 - echo "Last ${lines} lines from $target:" >&2 - printf '%s\n' "$pane_text" >&2 - exit 1 - fi - - sleep "$interval" -done