Skip to content

Commit 8ef9149

Browse files
committed
fix(bridge): key pending file by Claude Code PID, not just cwd
Two Claude Code instances in the same cwd shared one pending file and consumed each other's messages — whichever drain hook fired first won the message regardless of which instance it was addressed to. Both bridge and drain.sh now walk up the process tree to find the Claude Code ancestor (matching comm against /claude$/) and include its PID in the pending file name. Each session gets its own file. Bridge also cleans up pending files whose owning Claude Code PID is dead on startup, so stale files don't accumulate after ungraceful exits. Falls back to cwd-only path with a stderr warning if no claude ancestor can be located within 10 hops (unusual harness, custom process tree).
1 parent e5714fe commit 8ef9149

2 files changed

Lines changed: 125 additions & 11 deletions

File tree

hooks/drain.sh

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,38 @@
1111
set -euo pipefail
1212

1313
SLUG="${PWD//[^a-zA-Z0-9]/_}"
14-
PENDING="$HOME/.agents/bus/pending/claude-code--${SLUG}.jsonl"
14+
15+
# Walk up the process tree to find the Claude Code PID. Two Claude Code
16+
# instances in the same cwd would otherwise share one pending file and consume
17+
# each other's messages. Each Claude Code session has a unique PID; the bridge
18+
# discovers and uses the same value, so each session gets its own file.
19+
find_claude_pid() {
20+
local pid=$PPID
21+
for _ in 1 2 3 4 5 6 7 8 9 10; do
22+
[ "$pid" -le 1 ] && return 1
23+
local info ppid comm
24+
info=$(ps -p "$pid" -o ppid=,comm= 2>/dev/null) || return 1
25+
info=${info#"${info%%[![:space:]]*}"}
26+
[ -z "$info" ] && return 1
27+
ppid=${info%%[[:space:]]*}
28+
comm=${info#*[[:space:]]}
29+
comm=${comm#"${comm%%[![:space:]]*}"}
30+
case "$comm" in
31+
*/claude|claude) echo "$pid"; return 0 ;;
32+
esac
33+
pid=$ppid
34+
done
35+
return 1
36+
}
37+
38+
CLAUDE_PID=$(find_claude_pid 2>/dev/null || true)
39+
if [ -n "$CLAUDE_PID" ]; then
40+
PENDING="$HOME/.agents/bus/pending/claude-code--${SLUG}--${CLAUDE_PID}.jsonl"
41+
else
42+
echo "agent-comms drain.sh: could not locate Claude Code PID, falling back to shared cwd file" >&2
43+
PENDING="$HOME/.agents/bus/pending/claude-code--${SLUG}.jsonl"
44+
fi
45+
1546
DRAINING="${PENDING}.draining-$$-$(date +%s%N 2>/dev/null || date +%s)"
1647

1748
# Atomic drain — if rename fails (file absent), nothing to surface.

src/bridges/claude-code/channel.ts

Lines changed: 93 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* and wake idle Claude via exit code 2.
1313
*/
1414

15+
import { execFileSync } from "node:child_process";
1516
import * as fs from "node:fs";
1617
import * as os from "node:os";
1718
import * as path from "node:path";
@@ -42,15 +43,90 @@ function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
4243
return typeof err === "object" && err !== null && "code" in err;
4344
}
4445

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+
}
54130
}
55131

56132
function appendPending(filePath: string, line: string): void {
@@ -82,7 +158,14 @@ export async function run(): Promise<void> {
82158
const tool = new CommsTool(store, store.discovery);
83159
let agentId: string | undefined;
84160

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);
86169

87170
const mcp = new McpServer(
88171
{ name: "agent-comms", version: "0.2.0" },

0 commit comments

Comments
 (0)