mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-27 06:27:28 +08:00
Fix provider config hot reload
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
CONTEXT7_API_KEY=ctx7sk-dfce3619-d4cf-4ce2-a1a1-9d0ba9d071ad
|
||||
@@ -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 <owner/repo> <query>
|
||||
```
|
||||
|
||||
Example:
|
||||
```bash
|
||||
npx tsx /root/.clawgo/skills/context7/query.ts context YspCoder/clawgo "How does the skill system work?"
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 <repo_owner/repo_name> <search_query>
|
||||
* Context: npx tsx query.ts context <repo_owner/repo_name> <search_query>
|
||||
*
|
||||
* 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 <command> <repo_owner/repo_name> <search_query>
|
||||
|
||||
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 <repo> <query>");
|
||||
console.error(" Context: npx tsx query.ts context <repo> <query>");
|
||||
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);
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user