|
12 | 12 | * and wake idle Claude via exit code 2. |
13 | 13 | */ |
14 | 14 |
|
| 15 | +import { execFileSync } from "node:child_process"; |
15 | 16 | import * as fs from "node:fs"; |
16 | 17 | import * as os from "node:os"; |
17 | 18 | import * as path from "node:path"; |
@@ -42,15 +43,90 @@ function isErrnoException(err: unknown): err is NodeJS.ErrnoException { |
42 | 43 | return typeof err === "object" && err !== null && "code" in err; |
43 | 44 | } |
44 | 45 |
|
45 | | -function pendingFilePath(cwd: string): string { |
46 | | - const slug = cwd.replace(/[^a-zA-Z0-9]/g, "_"); |
47 | | - return path.join( |
48 | | - os.homedir(), |
49 | | - ".agents", |
50 | | - "bus", |
51 | | - "pending", |
52 | | - `claude-code--${slug}.jsonl`, |
53 | | - ); |
| 46 | +function cwdSlug(cwd: string): string { |
| 47 | + return cwd.replace(/[^a-zA-Z0-9]/g, "_"); |
| 48 | +} |
| 49 | + |
| 50 | +function pendingDir(): string { |
| 51 | + return path.join(os.homedir(), ".agents", "bus", "pending"); |
| 52 | +} |
| 53 | + |
| 54 | +/** |
| 55 | + * Walk up the process tree to find the Claude Code CLI PID. |
| 56 | + * |
| 57 | + * The bridge runs ~3 hops below claude (tsx wrapper → tsx loader → bridge), |
| 58 | + * so process.ppid alone isn't enough. We walk until we find an ancestor |
| 59 | + * whose command basename is "claude". |
| 60 | + * |
| 61 | + * Two Claude Code instances in the same cwd would otherwise share one |
| 62 | + * pending file and consume each other's messages. Keying by Claude PID |
| 63 | + * isolates them. Returns undefined if no claude ancestor can be located |
| 64 | + * within 10 hops — caller falls back to cwd-only path. |
| 65 | + */ |
| 66 | +function findClaudeCodePid(): number | undefined { |
| 67 | + let pid = process.ppid; |
| 68 | + for (let i = 0; i < 10 && pid > 1; i++) { |
| 69 | + let out: string; |
| 70 | + try { |
| 71 | + out = execFileSync("ps", ["-o", "ppid=,comm=", "-p", String(pid)], { |
| 72 | + encoding: "utf-8", |
| 73 | + }); |
| 74 | + } catch { |
| 75 | + return undefined; |
| 76 | + } |
| 77 | + const trimmed = out.trim(); |
| 78 | + const match = /^(\d+)\s+(.+)$/.exec(trimmed); |
| 79 | + if (!match) return undefined; |
| 80 | + const parentPid = Number.parseInt(match[1] ?? "", 10); |
| 81 | + const comm = (match[2] ?? "").trim(); |
| 82 | + if (/(^|\/)claude$/.test(comm)) return pid; |
| 83 | + if (!Number.isFinite(parentPid)) return undefined; |
| 84 | + pid = parentPid; |
| 85 | + } |
| 86 | + return undefined; |
| 87 | +} |
| 88 | + |
| 89 | +function pendingFilePath(cwd: string, claudePid: number | undefined): string { |
| 90 | + const slug = cwdSlug(cwd); |
| 91 | + const name = |
| 92 | + claudePid !== undefined |
| 93 | + ? `claude-code--${slug}--${String(claudePid)}.jsonl` |
| 94 | + : `claude-code--${slug}.jsonl`; |
| 95 | + return path.join(pendingDir(), name); |
| 96 | +} |
| 97 | + |
| 98 | +/** |
| 99 | + * Drop pending files in this cwd whose owning Claude Code PID is no longer |
| 100 | + * alive. Prevents accumulation when sessions exit without graceful shutdown. |
| 101 | + */ |
| 102 | +function cleanupStalePendingFiles(cwd: string): void { |
| 103 | + const dir = pendingDir(); |
| 104 | + const slug = cwdSlug(cwd); |
| 105 | + const prefix = `claude-code--${slug}--`; |
| 106 | + let entries: string[]; |
| 107 | + try { |
| 108 | + entries = fs.readdirSync(dir); |
| 109 | + } catch (err) { |
| 110 | + if (isErrnoException(err) && err.code === "ENOENT") return; |
| 111 | + return; |
| 112 | + } |
| 113 | + for (const entry of entries) { |
| 114 | + if (!entry.startsWith(prefix) || !entry.endsWith(".jsonl")) continue; |
| 115 | + const pidStr = entry.slice(prefix.length, -".jsonl".length); |
| 116 | + const pid = Number.parseInt(pidStr, 10); |
| 117 | + if (!Number.isFinite(pid) || pid <= 1) continue; |
| 118 | + try { |
| 119 | + process.kill(pid, 0); |
| 120 | + } catch (err) { |
| 121 | + if (isErrnoException(err) && err.code === "ESRCH") { |
| 122 | + try { |
| 123 | + fs.unlinkSync(path.join(dir, entry)); |
| 124 | + } catch { |
| 125 | + // best-effort cleanup, ignore failures |
| 126 | + } |
| 127 | + } |
| 128 | + } |
| 129 | + } |
54 | 130 | } |
55 | 131 |
|
56 | 132 | function appendPending(filePath: string, line: string): void { |
@@ -82,7 +158,14 @@ export async function run(): Promise<void> { |
82 | 158 | const tool = new CommsTool(store, store.discovery); |
83 | 159 | let agentId: string | undefined; |
84 | 160 |
|
85 | | - const pendingFile = pendingFilePath(process.cwd()); |
| 161 | + const claudeCodePid = findClaudeCodePid(); |
| 162 | + if (claudeCodePid === undefined) { |
| 163 | + process.stderr.write( |
| 164 | + "agent-comms bridge: could not locate Claude Code PID; using shared cwd pending file (concurrent sessions in the same cwd will share messages)\n", |
| 165 | + ); |
| 166 | + } |
| 167 | + cleanupStalePendingFiles(process.cwd()); |
| 168 | + const pendingFile = pendingFilePath(process.cwd(), claudeCodePid); |
86 | 169 |
|
87 | 170 | const mcp = new McpServer( |
88 | 171 | { name: "agent-comms", version: "0.2.0" }, |
|
0 commit comments