pi-claude-supervisor 0.5.3 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,8 @@ import type {
16
16
  import { assertSafeWorkerCommand } from "../policy.ts";
17
17
  import { redactSensitive } from "../redaction.ts";
18
18
  import { workerEnvironment } from "./environment.ts";
19
+ import { claudeJsonlArgs, cleanupCgroup, currentCgroupPath, preflightCgroupContainment } from "./process-adapter.ts";
20
+ import { isClaudeLauncherProcess, isClaudeProcess, linuxCgroupProcesses, linuxProcessTree, readProcess, sameProcessIdentity, unexpectedClaudeProcess, unexpectedReviewerProcess, type ProcessTreeEntry } from "./process-tree.ts";
19
21
 
20
22
  export interface TmuxWorkerAdapterOptions {
21
23
  /** Directory for launcher and output state. */
@@ -28,6 +30,8 @@ export interface TmuxWorkerAdapterOptions {
28
30
  pollIntervalMs?: number;
29
31
  /** Time allowed for an interactive /exit before killing the owned server. */
30
32
  terminationGraceMs?: number;
33
+ /** Linux cgroup mode for automatic tmux bridge descendants. */
34
+ cgroupMode?: "off" | "auto" | "required";
31
35
  }
32
36
 
33
37
  interface TmuxRecord {
@@ -44,6 +48,11 @@ interface TmuxRecord {
44
48
  output: WorkerOutputChunk[];
45
49
  outputBytes: number;
46
50
  outputTruncated: boolean;
51
+ structured: boolean;
52
+ eventBuffer: string;
53
+ seenResultIds: Set<string>;
54
+ seenPermissionRequestIds: Set<string>;
55
+ permissionResponses: Set<string>;
47
56
  lastOutputAt?: string;
48
57
  lastInputAt?: string;
49
58
  activeRequests: number;
@@ -75,6 +84,14 @@ interface TmuxRecord {
75
84
  replacementPaneStartTime?: string;
76
85
  replacementPaneCommand?: string;
77
86
  paneDead?: boolean;
87
+ guardianPid?: number;
88
+ guardianStartTime?: string;
89
+ cgroupPath?: string;
90
+ cgroupError?: Error;
91
+ cgroupCleaned?: boolean;
92
+ expectedClaudeCommand?: string;
93
+ trustedClaudeProcesses: Map<number, ProcessTreeEntry>;
94
+ runtimeError?: Error;
78
95
  }
79
96
 
80
97
  interface TmuxPaneStatus {
@@ -83,6 +100,238 @@ interface TmuxPaneStatus {
83
100
  pid?: number;
84
101
  }
85
102
 
103
+ const BRIDGE_KEYS = {
104
+ command: "PI_CLAUDE_SUPERVISOR_TMUX_COMMAND",
105
+ args: "PI_CLAUDE_SUPERVISOR_TMUX_ARGS",
106
+ cgroup: "PI_CLAUDE_SUPERVISOR_TMUX_CGROUP",
107
+ } as const;
108
+ const BRIDGE_EVENT_START = "\u001bPPI_CLAUDE_SUPERVISOR_EVENT;";
109
+ const BRIDGE_INPUT_START = "\u001bPPI_CLAUDE_SUPERVISOR_INPUT;";
110
+ const BRIDGE_EVENT_END = "\u001b\\";
111
+
112
+ /**
113
+ * A single process in the tmux pane owns both the interactive display and the
114
+ * Claude stream-json control channel. Human input is ordinary text; Supervisor
115
+ * input/permission responses use private @pi:* lines, so the same pane remains
116
+ * live and attachable without making the safety-critical event stream a second
117
+ * worker process.
118
+ */
119
+ const TMUX_PANE_BOOTSTRAP_SCRIPT = `
120
+ const { writeFileSync } = require("node:fs");
121
+ const key = ${JSON.stringify(BRIDGE_KEYS.cgroup)};
122
+ const cgroup = Buffer.from(process.env[key] || "", "base64").toString("utf8");
123
+ if (cgroup) {
124
+ try { writeFileSync(cgroup + "/cgroup.procs", String(process.pid) + "\\n"); }
125
+ catch (error) { process.stderr.write("tmux pane bootstrap failed: " + (error instanceof Error ? error.message : String(error)) + "\\n"); process.exit(125); }
126
+ }
127
+ process.stdin.resume();
128
+ setInterval(() => {}, 10_000).unref();
129
+ `;
130
+
131
+ const TMUX_BRIDGE_SCRIPT = `
132
+ const { spawn } = require("node:child_process");
133
+ const { writeFileSync } = require("node:fs");
134
+ const readline = require("node:readline");
135
+ const decode = (key) => Buffer.from(process.env[key] || "", "base64").toString("utf8");
136
+ const command = decode("${BRIDGE_KEYS.command}");
137
+ const args = JSON.parse(decode("${BRIDGE_KEYS.args}"));
138
+ const bridgeKeys = ${JSON.stringify(Object.values(BRIDGE_KEYS))};
139
+ const cgroup = decode("${BRIDGE_KEYS.cgroup}");
140
+ const eventStart = String.fromCharCode(27) + "PPI_CLAUDE_SUPERVISOR_EVENT;";
141
+ const inputStart = String.fromCharCode(27) + "PPI_CLAUDE_SUPERVISOR_INPUT;";
142
+ const eventEnd = String.fromCharCode(27) + "\\\\";
143
+ // Claude text is untrusted and shares the PTY with the Supervisor's private
144
+ // DCS event stream. Strip every ESC from display text so a model cannot forge a
145
+ // frame, including by splitting an escape sequence across output records.
146
+ const output = (value) => { process.stdout.write(String(value).split(String.fromCharCode(27)).join("")); };
147
+ const control = (value) => { process.stdout.write(String(value)); };
148
+ // Structured records share the bridge's PTY with the human-readable display.
149
+ // DCS is ignored by normal terminals, while pipe-pane preserves it for the
150
+ // Supervisor's parser; there is no independent JSONL event sidecar.
151
+ const writeEvent = (value) => {
152
+ const payload = Buffer.from(JSON.stringify(value), "utf8").toString("base64");
153
+ control(eventStart + payload + eventEnd);
154
+ };
155
+ if (cgroup) {
156
+ try { writeFileSync(cgroup + "/cgroup.procs", String(process.pid) + "\\n"); }
157
+ catch (error) {
158
+ writeEvent({ type: "bridge_exit", code: 125, error: error instanceof Error ? error.message : String(error) });
159
+ process.exit(125);
160
+ }
161
+ }
162
+ const prompt = () => output("\\n> ");
163
+ const decodeLine = (value) => Buffer.from(value, "base64").toString("utf8");
164
+ const clearSupervisorInput = () => control("\\x1b[1A\\r\\x1b[2K\\x1b[1B\\r");
165
+ const childEnv = { ...process.env };
166
+ for (const key of bridgeKeys) delete childEnv[key];
167
+ let child;
168
+ let inputActive = false;
169
+ let stdoutBuffer = "";
170
+ try {
171
+ child = spawn(command, args, { cwd: process.cwd(), env: childEnv, stdio: ["pipe", "pipe", "pipe"] });
172
+ } catch (error) {
173
+ writeEvent({ type: "bridge_exit", code: 125, error: error instanceof Error ? error.message : String(error) });
174
+ process.exit(125);
175
+ }
176
+ const render = (record) => {
177
+ if (record.type === "stream_event") {
178
+ const event = record.event;
179
+ const delta = event && typeof event === "object" ? event.delta : undefined;
180
+ if (delta && typeof delta === "object" && delta.type === "text_delta" && typeof delta.text === "string") output(delta.text);
181
+ return;
182
+ }
183
+ if (record.type === "assistant") {
184
+ const content = record.message && typeof record.message === "object" ? record.message.content : undefined;
185
+ if (Array.isArray(content)) for (const block of content) {
186
+ if (block && block.type === "text" && typeof block.text === "string") output(block.text);
187
+ else if (block && block.type === "tool_use") output("\\n[tool " + String(block.name || "unknown") + "]\\n");
188
+ }
189
+ return;
190
+ }
191
+ if (record.type === "control_request") {
192
+ const request = record.request;
193
+ const name = request && typeof request === "object" ? request.tool_name : "unknown";
194
+ output("\\n[permission requested: " + String(name) + "]\\n");
195
+ return;
196
+ }
197
+ if (record.type === "result") {
198
+ inputActive = false;
199
+ output("\\n[result " + String(record.subtype || "completed") + "]");
200
+ prompt();
201
+ return;
202
+ }
203
+ if (record.type === "system") output("\\n[system " + String(record.subtype || "event") + "]\\n");
204
+ };
205
+ const processLine = (line) => {
206
+ if (!line) return;
207
+ let record;
208
+ try { record = JSON.parse(line); } catch { output("\\n[invalid Claude JSONL output]\\n"); return; }
209
+ writeEvent(record);
210
+ render(record);
211
+ };
212
+ child.stdout.setEncoding("utf8");
213
+ child.stdout.on("data", (chunk) => {
214
+ stdoutBuffer += String(chunk);
215
+ let newline;
216
+ while ((newline = stdoutBuffer.indexOf("\\n")) >= 0) {
217
+ processLine(stdoutBuffer.slice(0, newline).replace(/\\r$/u, ""));
218
+ stdoutBuffer = stdoutBuffer.slice(newline + 1);
219
+ }
220
+ });
221
+ child.stderr.setEncoding("utf8");
222
+ child.stderr.on("data", (chunk) => output("\\n[claude stderr] " + String(chunk)));
223
+ child.once("error", (error) => {
224
+ writeEvent({ type: "bridge_exit", code: 127, error: error.message });
225
+ process.exit(127);
226
+ });
227
+ child.once("exit", (code, signal) => {
228
+ if (stdoutBuffer.trim()) processLine(stdoutBuffer.trim());
229
+ writeEvent({ type: "bridge_exit", code, signal });
230
+ output("\\n[Claude exited " + String(code === null ? signal : code) + "]\\n");
231
+ process.exit(code ?? 1);
232
+ });
233
+ const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
234
+ input.on("line", (line) => {
235
+ if (line.startsWith(inputStart)) {
236
+ const end = line.indexOf(eventEnd, inputStart.length);
237
+ if (end >= 0) {
238
+ try {
239
+ const frame = JSON.parse(Buffer.from(line.slice(inputStart.length, end), "base64").toString("utf8"));
240
+ if (frame.type === "user") {
241
+ inputActive = true;
242
+ child.stdin.write(JSON.stringify({ type: "user", message: { role: "user", content: String(frame.message || "") } }) + "\\n");
243
+ } else if (frame.type === "json") {
244
+ child.stdin.write(String(frame.message || "").replace(/\\n$/u, "") + "\\n");
245
+ } else if (frame.type === "stop") {
246
+ try { child.kill("SIGTERM"); } catch {}
247
+ }
248
+ } catch (error) {
249
+ output("\\n[invalid Supervisor input frame: " + (error instanceof Error ? error.message : String(error)) + "]\\n");
250
+ }
251
+ return;
252
+ }
253
+ }
254
+ if (line.startsWith("@pi:user ")) {
255
+ clearSupervisorInput();
256
+ inputActive = true;
257
+ child.stdin.write(JSON.stringify({ type: "user", message: { role: "user", content: decodeLine(line.slice("@pi:user ".length)) } }) + "\\n");
258
+ return;
259
+ }
260
+ if (line.startsWith("@pi:json ")) {
261
+ clearSupervisorInput();
262
+ const message = decodeLine(line.slice("@pi:json ".length));
263
+ child.stdin.write(message.endsWith("\\n") ? message : message + "\\n");
264
+ return;
265
+ }
266
+ if (line === "@pi:stop") {
267
+ clearSupervisorInput();
268
+ try { child.kill("SIGTERM"); } catch {}
269
+ return;
270
+ }
271
+ if (!line.trim()) return;
272
+ inputActive = true;
273
+ child.stdin.write(JSON.stringify({ type: "user", message: { role: "user", content: line } }) + "\\n");
274
+ });
275
+ input.on("close", () => { try { child.kill("SIGTERM"); } catch {} });
276
+ prompt();
277
+ `;
278
+
279
+ const GUARDIAN_KEYS = {
280
+ tmux: "PI_CLAUDE_SUPERVISOR_TMUX_BINARY",
281
+ socket: "PI_CLAUDE_SUPERVISOR_TMUX_SOCKET",
282
+ session: "PI_CLAUDE_SUPERVISOR_TMUX_SESSION",
283
+ cgroup: "PI_CLAUDE_SUPERVISOR_TMUX_CGROUP",
284
+ parentPid: "PI_CLAUDE_SUPERVISOR_TMUX_PARENT_PID",
285
+ parentStart: "PI_CLAUDE_SUPERVISOR_TMUX_PARENT_START",
286
+ } as const;
287
+
288
+ const TMUX_GUARDIAN_SCRIPT = `
289
+ const { spawnSync } = require("node:child_process");
290
+ const { readFileSync, rmSync, rmdirSync, writeFileSync } = require("node:fs");
291
+ const decode = (key) => Buffer.from(process.env[key] || "", "base64").toString("utf8");
292
+ const tmux = decode("${GUARDIAN_KEYS.tmux}");
293
+ const socket = decode("${GUARDIAN_KEYS.socket}");
294
+ const session = decode("${GUARDIAN_KEYS.session}");
295
+ const cgroup = decode("${GUARDIAN_KEYS.cgroup}");
296
+ const parentPid = Number(decode("${GUARDIAN_KEYS.parentPid}"));
297
+ const parentStart = decode("${GUARDIAN_KEYS.parentStart}");
298
+ const readStart = (pid) => {
299
+ try {
300
+ const stat = readFileSync("/proc/" + pid + "/stat", "utf8");
301
+ const close = stat.lastIndexOf(")");
302
+ return close < 0 ? undefined : stat.slice(close + 2).trim().split(/\\s+/u)[19];
303
+ } catch { return undefined; }
304
+ };
305
+ const alive = () => Boolean(parentPid > 0 && (() => {
306
+ try { process.kill(parentPid, 0); } catch { return false; }
307
+ return !parentStart || readStart(parentPid) === parentStart;
308
+ })());
309
+ const timer = setInterval(() => {
310
+ if (alive()) return;
311
+ clearInterval(timer);
312
+ try { spawnSync(tmux, ["-S", socket, "kill-session", "-t", session], { stdio: "ignore" }); } catch {}
313
+ if (cgroup) {
314
+ try { writeFileSync(cgroup + "/cgroup.kill", "1\\n"); } catch {}
315
+ const reap = () => {
316
+ try {
317
+ if (/^populated 0$/mu.test(readFileSync(cgroup + "/cgroup.events", "utf8"))) {
318
+ try { rmdirSync(cgroup); } catch { try { rmSync(cgroup, { recursive: true, force: true }); } catch {} }
319
+ process.exit(0);
320
+ return;
321
+ }
322
+ } catch (error) {
323
+ if (error && typeof error === "object" && "code" in error && (error as { code?: string }).code === "ENOENT") {
324
+ process.exit(0);
325
+ return;
326
+ }
327
+ }
328
+ setTimeout(reap, 50).unref();
329
+ };
330
+ reap();
331
+ } else process.exit(0);
332
+ }, 100);
333
+ `;
334
+
86
335
  /**
87
336
  * Interactive Claude Code transport backed by a private tmux server and PTY.
88
337
  *
@@ -97,6 +346,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
97
346
  readonly #startupTimeoutMs: number;
98
347
  readonly #pollIntervalMs: number;
99
348
  readonly #terminationGraceMs: number;
349
+ readonly #cgroupMode: "off" | "auto" | "required";
100
350
  readonly #maxOutputBytes = 8 * 1024 * 1024;
101
351
  readonly #maxLogBytes = 16 * 1024 * 1024;
102
352
  readonly #commandTimeoutMs = 10_000;
@@ -107,13 +357,23 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
107
357
  this.#startupTimeoutMs = boundedDelay(options.startupTimeoutMs ?? 60_000);
108
358
  this.#pollIntervalMs = boundedDelay(options.pollIntervalMs ?? 500);
109
359
  this.#terminationGraceMs = boundedDelay(options.terminationGraceMs ?? 2_000);
360
+ this.#cgroupMode = options.cgroupMode ?? "auto";
110
361
  }
111
362
 
112
- async preflight(input: Pick<WorkerStartInput, "cwd" | "command" | "args" | "env" | "approval">): Promise<void> {
113
- assertSafeWorkerCommand(input.command, input.args ?? [], input.approval);
363
+ async preflight(input: Pick<WorkerStartInput, "cwd" | "command" | "args" | "env" | "approval" | "automatic">): Promise<void> {
364
+ const args = input.automatic ? claudeJsonlArgs(input.args) : (input.args ?? []);
365
+ assertSafeWorkerCommand(input.command, args, input.approval);
114
366
  await assertDirectory(input.cwd);
367
+ if (process.platform !== "linux") throw new Error("tmux supervision currently requires Linux process identity and cleanup support");
115
368
  await assertExecutableAvailable(input.command, input.env?.PATH ?? process.env.PATH);
116
369
  await assertExecutableAvailable(this.#tmuxBinary, process.env.PATH);
370
+ if (input.automatic) {
371
+ if (process.platform !== "linux") throw new Error("automatic tmux supervision requires Linux cgroup v2 and a parent-death guardian");
372
+ if (this.#cgroupMode === "off") throw new Error("automatic tmux supervision requires cgroup containment");
373
+ await preflightCgroupContainment();
374
+ const parent = await processIdentity(process.pid);
375
+ if (!parent?.startTime) throw new Error("automatic tmux supervision cannot verify the parent process identity");
376
+ }
117
377
  await mkdir(this.#stateDir, { recursive: true, mode: 0o700 });
118
378
  const stateInfo = await lstat(this.#stateDir);
119
379
  if (!stateInfo.isDirectory() || stateInfo.isSymbolicLink()) throw new Error(`tmux state directory is not a real directory: ${this.#stateDir}`);
@@ -135,6 +395,10 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
135
395
  async start(input: WorkerStartInput): Promise<WorkerHandle> {
136
396
  const id = randomUUID();
137
397
  const owned = !input.tmuxSession;
398
+ const structured = Boolean(input.automatic);
399
+ if (input.tmuxSession && input.sendInitialInput === true) throw new Error("adopted tmux sessions cannot replay the original task");
400
+ if (structured && !owned) throw new Error("automatic tmux supervision requires a Supervisor-owned tmux bridge; adopted sessions are manual-only");
401
+ const sendInitialInput = owned && input.sendInitialInput !== false;
138
402
  const sessionName = input.tmuxSession ?? `pi-supervisor-${id}`;
139
403
  if (!/^[A-Za-z0-9_.-]+$/u.test(sessionName)) throw new Error("tmux session names must contain only letters, numbers, dot, underscore or hyphen");
140
404
  const socketPath = input.tmuxSession ? input.tmuxSocket : join(tmpdir(), `pi-cs-${id}.sock`);
@@ -151,6 +415,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
151
415
  ownership: owned ? "owned" : "adopted",
152
416
  tmuxTarget: target,
153
417
  };
418
+ if (process.platform !== "linux") throw new Error("tmux supervision currently requires Linux process identity and cleanup support");
154
419
  const record: TmuxRecord = {
155
420
  handle,
156
421
  sessionName,
@@ -165,6 +430,13 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
165
430
  output: [],
166
431
  outputBytes: 0,
167
432
  outputTruncated: false,
433
+ structured,
434
+ eventBuffer: "",
435
+ seenResultIds: new Set(),
436
+ seenPermissionRequestIds: new Set(),
437
+ permissionResponses: new Set(),
438
+ expectedClaudeCommand: structured ? input.command : undefined,
439
+ trustedClaudeProcesses: new Map(),
168
440
  activeRequests: 0,
169
441
  turnSequence: 0,
170
442
  readyStreak: 0,
@@ -194,22 +466,49 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
194
466
  await mkdir(runtimeDir, { recursive: true, mode: 0o700 });
195
467
  await writeFile(logPath, "", { mode: 0o600 });
196
468
  if (owned) {
197
- assertSafeWorkerCommand(input.command, input.args ?? [], input.approval);
198
- assertNoCredentialArguments(input.command, input.args ?? []);
469
+ if (process.platform !== "linux") throw new Error("owned tmux supervision requires a Linux parent-death guardian");
470
+ if (structured && this.#cgroupMode === "off") throw new Error("automatic tmux supervision requires cgroup containment");
471
+ if (structured) {
472
+ try {
473
+ record.cgroupPath = await this.#createCgroup(id);
474
+ record.handle.cgroupPath = record.cgroupPath;
475
+ } catch (error) {
476
+ record.cgroupError = asError(error);
477
+ throw new Error(`automatic tmux cgroup setup failed: ${record.cgroupError.message}`, { cause: record.cgroupError });
478
+ }
479
+ }
199
480
  const env = workerEnvironment(process.env, input.env);
200
- // Create the window with its shell first so remain-on-exit is set
201
- // before the launcher can finish instantly.
202
- await this.#run(record, ["new-session", "-d", "-s", sessionName, "-x", "140", "-y", "40", "-c", input.cwd], undefined, env);
481
+ const workerArgs = structured ? claudeJsonlArgs(input.args) : (input.args ?? []);
482
+ assertSafeWorkerCommand(input.command, workerArgs, input.approval);
483
+ assertNoCredentialArguments(input.command, workerArgs);
484
+ const bridgeEnv = structured
485
+ ? bridgeEnvironment(env, input.command, workerArgs, record.cgroupPath)
486
+ : env;
487
+ // Arm the guardian before creating the session. If the Supervisor dies
488
+ // in the tmux startup window, the guardian removes any server created
489
+ // after it and also reaps the automatic bridge cgroup.
490
+ if (!socketPath) throw new Error("owned tmux startup did not allocate a private socket");
491
+ await this.#startGuardian(record, socketPath);
492
+ if (record.cleanupError) throw record.cleanupError;
493
+ const paneBootstrap = [process.execPath, "-e", TMUX_PANE_BOOTSTRAP_SCRIPT];
494
+ await this.#run(record, ["new-session", "-d", "-s", sessionName, "-x", "140", "-y", "40", "-c", input.cwd, "--", ...paneBootstrap], undefined, bridgeEnv);
203
495
  record.sessionCreated = true;
204
496
  await this.#run(record, ["set-window-option", "-t", sessionName, "remain-on-exit", "on"]);
205
- await this.#run(record, ["respawn-pane", "-k", "-t", target, "--", input.command, ...(input.args ?? [])], undefined, env);
497
+ await input.preSpawnCheck?.();
498
+ const launch = structured
499
+ ? [process.execPath, "-e", TMUX_BRIDGE_SCRIPT]
500
+ : [input.command, ...workerArgs];
501
+ await this.#run(record, ["respawn-pane", "-k", "-t", target, "--", ...launch], undefined, bridgeEnv);
206
502
  await this.#pinTarget(record);
207
503
  const ownedPane = await this.#paneStatus(record);
208
504
  record.paneDead = ownedPane.dead;
209
505
  if (!ownedPane.dead) {
506
+ // respawn-pane may still be replacing the old shell when the first
507
+ // pane_pid is observed. Pin the identity only after the bridge has
508
+ // rendered its ready prompt below; #send repeats the check immediately
509
+ // before input reservation.
210
510
  record.panePid = ownedPane.pid;
211
511
  record.handle.pid = ownedPane.pid;
212
- await this.#rememberPaneIdentity(record, ownedPane.pid);
213
512
  }
214
513
  } else {
215
514
  await this.#assertExistingSession(record, input.cwd, input.approval);
@@ -220,8 +519,17 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
220
519
  record.pipeAttached = true;
221
520
  const attachedPipe = await this.#run(record, ["display-message", "-p", "-t", record.target, "#{pane_pipe}"]);
222
521
  if (attachedPipe.stdout.trim() !== "1") throw new Error("tmux output pipe could not be attached to the pinned pane");
223
- if (input.sendInitialInput !== false || !input.tmuxSession) await this.#waitForReady(record);
224
- if (input.sendInitialInput !== false) {
522
+ if (sendInitialInput || !input.tmuxSession) await this.#waitForReady(record);
523
+ if (owned) {
524
+ const readyPane = await this.#paneStatus(record);
525
+ if (readyPane.dead) throw new Error("tmux worker exited before identity could be pinned");
526
+ record.paneDead = false;
527
+ record.panePid = readyPane.pid;
528
+ record.handle.pid = readyPane.pid;
529
+ await this.#rememberPaneIdentity(record, readyPane.pid);
530
+ }
531
+ if (record.structured) await this.#rememberClaudeProcesses(record);
532
+ if (sendInitialInput) {
225
533
  await this.#send(record, input.task, `${id}:initial`);
226
534
  } else if (input.tmuxSession) {
227
535
  const screen = await this.#capture(record);
@@ -263,8 +571,10 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
263
571
  for (const record of starts) {
264
572
  record.abortRequested = true;
265
573
  record.stopping = true;
266
- if (record.owned) await this.#run(record, ["kill-server"], undefined, undefined, true).catch(() => {});
267
- else await this.#detachPipe(record);
574
+ if (record.owned) {
575
+ await this.#stopGuardian(record).catch((error) => { record.cleanupError ??= asError(error); });
576
+ await this.#run(record, ["kill-server"], undefined, undefined, true).catch(() => {});
577
+ } else await this.#detachPipe(record);
268
578
  }
269
579
  const deadline = Date.now() + 25_000;
270
580
  while (starts.some((record) => record.starting) && Date.now() < deadline) await delay(25);
@@ -349,6 +659,27 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
349
659
  await this.#send(record, message, idempotencyKey);
350
660
  }
351
661
 
662
+ async respondPermission(handle: WorkerHandle, requestId: string, toolUseId: string, decision: { behavior: "allow" | "deny"; message?: string }, updatedInput?: unknown): Promise<void> {
663
+ const record = this.#record(handle);
664
+ if (!record.structured) throw new Error("permission responses require the automated tmux bridge");
665
+ if (record.permissionResponses.has(requestId)) return;
666
+ if (record.released || record.stopping) throw new Error("tmux worker is no longer supervised");
667
+ const response = {
668
+ type: "control_response",
669
+ response: {
670
+ subtype: "success",
671
+ request_id: requestId,
672
+ response: decision.behavior === "allow"
673
+ ? { behavior: "allow", updatedInput }
674
+ : { behavior: "deny", message: decision.message ?? "permission denied by supervisor" },
675
+ toolUseID: toolUseId,
676
+ },
677
+ };
678
+ await this.#sendControl(record, response);
679
+ record.permissionResponses.add(requestId);
680
+ record.lastInputAt = new Date().toISOString();
681
+ }
682
+
352
683
  async pause(handle: WorkerHandle): Promise<void> {
353
684
  const record = this.#record(handle);
354
685
  if (record.released) throw new Error("tmux worker is no longer supervised");
@@ -383,7 +714,8 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
383
714
  await this.#collectOutput(record);
384
715
  if (!record.paneDead) {
385
716
  try {
386
- await this.#sendRaw(record, "/exit");
717
+ if (record.structured) await this.#sendControl(record, undefined, "@pi:stop");
718
+ else await this.#sendRaw(record, "/exit");
387
719
  await this.#waitForPaneExit(record, this.#terminationGraceMs);
388
720
  } catch {
389
721
  // The private tmux server is still the authoritative cleanup boundary.
@@ -414,6 +746,10 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
414
746
  catch (error) { record.cleanupError = asError(error); }
415
747
  if (record.monitor) clearInterval(record.monitor);
416
748
  record.monitor = undefined;
749
+ if (!record.structured) {
750
+ try { await this.#stopGuardian(record); }
751
+ catch (error) { record.cleanupError ??= asError(error); }
752
+ }
417
753
  record.listeners.clear();
418
754
  try { await this.#collectOutput(record); }
419
755
  catch (error) { record.cleanupError ??= asError(error); }
@@ -451,7 +787,10 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
451
787
  }
452
788
 
453
789
  async #detachPipe(record: TmuxRecord): Promise<void> {
454
- if (!record.pipeAttached) return;
790
+ if (!record.pipeAttached || record.paneDead) {
791
+ record.pipeAttached = false;
792
+ return;
793
+ }
455
794
  try { await this.#run(record, ["pipe-pane", "-t", record.target], undefined, undefined, true); }
456
795
  catch (error) { if (!isMissingSession(error)) record.cleanupError = asError(error); }
457
796
  record.pipeAttached = false;
@@ -497,6 +836,11 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
497
836
  }
498
837
 
499
838
  async #sendRaw(record: TmuxRecord, message: string): Promise<void> {
839
+ if (record.structured) {
840
+ const encoded = Buffer.from(safeTmuxMessage(message), "utf8").toString("base64");
841
+ await this.#sendLine(record, `@pi:user ${encoded}`);
842
+ return;
843
+ }
500
844
  const safeMessage = safeTmuxMessage(message);
501
845
  const bufferName = `pi-cs-${record.handle.id}`;
502
846
  // Ask tmux to emit a real bracketed paste. Embedding the escape markers
@@ -507,6 +851,29 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
507
851
  await this.#run(record, ["send-keys", "-t", record.target, "Enter"]);
508
852
  }
509
853
 
854
+ async #sendControl(record: TmuxRecord, value?: Record<string, unknown>, rawCommand?: string): Promise<void> {
855
+ if (!record.structured) throw new Error("tmux control messages require the automated bridge");
856
+ let release!: () => void;
857
+ const gate = new Promise<void>((resolve) => { release = resolve; });
858
+ const previous = record.inputTail;
859
+ record.inputTail = previous.then(() => gate);
860
+ await previous;
861
+ try {
862
+ const encoded = rawCommand ?? `@pi:json ${Buffer.from(`${JSON.stringify(value)}\n`, "utf8").toString("base64")}`;
863
+ await this.#sendLine(record, encoded);
864
+ } finally {
865
+ release();
866
+ }
867
+ }
868
+
869
+ async #sendLine(record: TmuxRecord, line: string): Promise<void> {
870
+ const safeLine = safeTmuxMessage(line);
871
+ const bufferName = `pi-cs-${record.handle.id}`;
872
+ await this.#run(record, ["load-buffer", "-b", bufferName, "-"], safeLine);
873
+ await this.#run(record, ["paste-buffer", "-p", "-d", "-b", bufferName, "-t", record.target]);
874
+ await this.#run(record, ["send-keys", "-t", record.target, "Enter"]);
875
+ }
876
+
510
877
  #startMonitor(record: TmuxRecord): void {
511
878
  record.monitor = setInterval(() => {
512
879
  void this.#monitor(record).catch((error) => {
@@ -523,6 +890,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
523
890
  try {
524
891
  const outputBeforeInput = record.lastOutputAt;
525
892
  await this.#collectOutput(record);
893
+ if (record.structured) await this.#checkNestedClaude(record);
526
894
  if (record.inputAt && record.lastOutputAt && Date.parse(record.lastOutputAt) >= record.inputAt && record.lastOutputAt !== outputBeforeInput) record.turnObservedOutput = true;
527
895
  const pane = await this.#paneStatus(record);
528
896
  record.paneDead = pane.dead;
@@ -538,6 +906,15 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
538
906
  return;
539
907
  }
540
908
  const screen = await this.#capture(record);
909
+ if (record.structured) {
910
+ if (record.activeRequests === 0 && hasBridgePromptInput(screen)) {
911
+ // A human may have typed directly into the attached bridge prompt.
912
+ record.activeRequests = 1;
913
+ record.inputAt = Date.now();
914
+ record.lastInputAt = new Date().toISOString();
915
+ }
916
+ return;
917
+ }
541
918
  if (record.activeRequests === 0 && hasPromptInput(screen)) {
542
919
  // A human may have typed directly into the attached PTY. Treat that
543
920
  // input as an active turn so automatic sends cannot race it.
@@ -615,12 +992,10 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
615
992
  } catch (error) {
616
993
  throw new Error(`cannot inspect tmux pane command: ${error instanceof Error ? error.message : String(error)}`);
617
994
  }
618
- const commandName = command.split(/[\\/]/u).at(-1) ?? command;
619
995
  const argsText = processArgs.stdout.trim();
620
- const processExecutable = argsText.split(/\s+/u)[0] ?? "";
621
- const processExecutableName = processExecutable.split(/[\\/]/u).at(-1) ?? processExecutable;
622
996
  const commandLine = `${command} ${argsText}`.trim();
623
- if (commandName !== "claude" || !argsText || processExecutableName !== "claude") {
997
+ const paneProcess = await readProcess(pane.pid);
998
+ if (!argsText || !paneProcess || !isClaudeLauncherProcess(paneProcess)) {
624
999
  throw new Error(`tmux pane is not a Claude Code executable: ${redactSensitiveText(commandLine || "unknown")}`);
625
1000
  }
626
1001
  assertSafeWorkerCommand(command, [argsText], approval);
@@ -677,6 +1052,70 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
677
1052
  }
678
1053
  }
679
1054
 
1055
+ async #createCgroup(id: string): Promise<string> {
1056
+ const parent = await currentCgroupPath();
1057
+ const path = `${parent}/pi-claude-supervisor-tmux-${id}`;
1058
+ await mkdir(path);
1059
+ return path;
1060
+ }
1061
+
1062
+ async #startGuardian(record: TmuxRecord, socketPath: string): Promise<void> {
1063
+ if (process.platform !== "linux") return;
1064
+ const parent = await processIdentity(process.pid);
1065
+ if (!parent?.startTime) throw new Error("cannot start tmux parent-death guardian without a parent process identity");
1066
+ const encode = (value: string) => Buffer.from(value, "utf8").toString("base64");
1067
+ const env: NodeJS.ProcessEnv = {
1068
+ PATH: process.env.PATH ?? "",
1069
+ [GUARDIAN_KEYS.tmux]: encode(this.#tmuxBinary),
1070
+ [GUARDIAN_KEYS.socket]: encode(socketPath),
1071
+ [GUARDIAN_KEYS.session]: encode(record.sessionName),
1072
+ [GUARDIAN_KEYS.cgroup]: encode(record.cgroupPath ?? ""),
1073
+ [GUARDIAN_KEYS.parentPid]: encode(String(process.pid)),
1074
+ [GUARDIAN_KEYS.parentStart]: encode(parent.startTime),
1075
+ };
1076
+ const child = spawn(process.execPath, ["-e", TMUX_GUARDIAN_SCRIPT], { detached: true, stdio: "ignore", env });
1077
+ await new Promise<void>((resolve, reject) => {
1078
+ child.once("spawn", () => resolve());
1079
+ child.once("error", reject);
1080
+ });
1081
+ if (!child.pid) throw new Error("tmux parent-death guardian did not expose a pid");
1082
+ record.guardianPid = child.pid;
1083
+ const identity = await processIdentity(child.pid);
1084
+ if (!identity?.startTime) {
1085
+ try { process.kill(child.pid, "SIGKILL"); } catch {}
1086
+ throw new Error("tmux parent-death guardian identity is unavailable");
1087
+ }
1088
+ record.guardianStartTime = identity.startTime;
1089
+ child.once("exit", () => {
1090
+ if (!record.cleanupComplete && !record.stopping && !record.paneDead) record.cleanupError ??= new Error("tmux parent-death guardian exited unexpectedly");
1091
+ });
1092
+ child.unref();
1093
+ await delay(10);
1094
+ if (!isPidAlive(child.pid) || await isZombie(child.pid)) throw new Error("tmux parent-death guardian exited during startup");
1095
+ }
1096
+
1097
+ async #stopGuardian(record: TmuxRecord): Promise<void> {
1098
+ const pid = record.guardianPid;
1099
+ if (!pid) return;
1100
+ record.guardianPid = undefined;
1101
+ if (!isPidAlive(pid) || await isZombie(pid)) return;
1102
+ const identity = await processIdentity(pid);
1103
+ if (!identity || !record.guardianStartTime || identity.startTime !== record.guardianStartTime) {
1104
+ throw new Error(`refusing to terminate an unverified tmux guardian process: ${pid}`);
1105
+ }
1106
+ try { process.kill(pid, "SIGTERM"); }
1107
+ catch (error) { if (!(error instanceof Error) || !/ESRCH/u.test(error.message)) throw error; }
1108
+ for (let attempt = 0; attempt < 20 && isPidAlive(pid); attempt += 1) await delay(25);
1109
+ if (isPidAlive(pid) && !(await isZombie(pid))) {
1110
+ const replacement = await processIdentity(pid);
1111
+ if (replacement?.startTime !== record.guardianStartTime) throw new Error(`tmux guardian identity changed during cleanup: ${pid}`);
1112
+ try { process.kill(pid, "SIGKILL"); }
1113
+ catch (error) { if (!(error instanceof Error) || !/ESRCH/u.test(error.message)) throw error; }
1114
+ }
1115
+ for (let attempt = 0; attempt < 20 && isPidAlive(pid); attempt += 1) await delay(25);
1116
+ if (isPidAlive(pid) && !(await isZombie(pid))) throw new Error(`tmux parent-death guardian did not exit: ${pid}`);
1117
+ }
1118
+
680
1119
  async #cleanup(record: TmuxRecord, _force: boolean): Promise<void> {
681
1120
  if (record.cleanupComplete) return;
682
1121
  // A later cleanup call is a retry, so do not let a transient prior error
@@ -700,6 +1139,8 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
700
1139
  try { await this.#flushOutput(record); }
701
1140
  catch (error) { record.cleanupError ??= asError(error); }
702
1141
  record.monitor = undefined;
1142
+ try { await this.#stopGuardian(record); }
1143
+ catch (error) { record.cleanupError ??= asError(error); }
703
1144
  try {
704
1145
  await this.#run(record, ["kill-server"], undefined, undefined, true);
705
1146
  record.serverKilled = true;
@@ -707,10 +1148,19 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
707
1148
  if (isMissingSession(error)) record.serverKilled = true;
708
1149
  else record.cleanupError = asError(error);
709
1150
  }
710
- await this.#ensurePaneGone(record);
1151
+ if (!record.serverKilled) await this.#ensurePaneGone(record);
1152
+ if (record.cgroupPath) {
1153
+ try {
1154
+ await cleanupCgroup(record.cgroupPath, this.#terminationGraceMs);
1155
+ record.cgroupCleaned = true;
1156
+ } catch (error) {
1157
+ record.cgroupError ??= asError(error);
1158
+ record.cleanupError ??= record.cgroupError;
1159
+ }
1160
+ }
711
1161
  try { await rm(record.runtimeDir, { recursive: true, force: true }); }
712
1162
  catch (error) { record.cleanupError ??= asError(error); }
713
- record.cleanupComplete = !record.cleanupError && Boolean(record.paneStartTime || (record.serverKilled && (!record.sessionCreated || record.paneDead)));
1163
+ record.cleanupComplete = !record.cleanupError && (record.serverKilled || !record.sessionCreated) && (!record.cgroupPath || record.cgroupCleaned === true);
714
1164
  }
715
1165
 
716
1166
  async #ensurePaneGone(record: TmuxRecord): Promise<void> {
@@ -763,7 +1213,9 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
763
1213
 
764
1214
  async #run(record: TmuxRecord, args: string[], input?: string, env = workerEnvironment(process.env), ignoreAbort = false): Promise<{ stdout: string; stderr: string }> {
765
1215
  if (record.abortRequested && !ignoreAbort) throw new Error("tmux worker startup was aborted");
766
- const tmuxArgs = record.socketPath ? ["-S", record.socketPath, ...args] : args;
1216
+ const tmuxArgs = record.socketPath
1217
+ ? [...(record.structured ? ["-f", "/dev/null"] : []), "-S", record.socketPath, ...args]
1218
+ : args;
767
1219
  const result = await runCommand(this.#tmuxBinary, tmuxArgs, input, env, this.#commandTimeoutMs);
768
1220
  if (record.abortRequested && !ignoreAbort) throw new Error("tmux worker startup was aborted");
769
1221
  return result;
@@ -812,10 +1264,11 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
812
1264
  const buffer = Buffer.alloc(length);
813
1265
  const { bytesRead } = await file.read(buffer, 0, length, start);
814
1266
  if (bytesRead > 0) {
815
- const text = buffer.subarray(0, bytesRead).toString("utf8");
1267
+ const rawText = buffer.subarray(0, bytesRead).toString("utf8");
816
1268
  record.outputOffset = start + bytesRead;
817
1269
  record.lastOutputAt = new Date().toISOString();
818
- this.#appendOutput(record, { stream: "stdout", text, at: record.lastOutputAt });
1270
+ const text = record.structured ? this.#consumeBridgeStream(record, rawText) : rawText;
1271
+ if (text) this.#appendOutput(record, { stream: "stdout", text, at: record.lastOutputAt });
819
1272
  }
820
1273
  } finally {
821
1274
  await file.close();
@@ -833,6 +1286,82 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
833
1286
  return [...record.output];
834
1287
  }
835
1288
 
1289
+ #consumeBridgeStream(record: TmuxRecord, text: string): string {
1290
+ record.eventBuffer += text;
1291
+ if (Buffer.byteLength(record.eventBuffer, "utf8") > this.#maxLogBytes) {
1292
+ record.eventBuffer = "";
1293
+ record.outputTruncated = true;
1294
+ return "";
1295
+ }
1296
+ let visible = "";
1297
+ while (record.eventBuffer) {
1298
+ const eventStart = record.eventBuffer.indexOf(BRIDGE_EVENT_START);
1299
+ const inputStart = record.eventBuffer.indexOf(BRIDGE_INPUT_START);
1300
+ const candidates = [
1301
+ ...(eventStart >= 0 ? [{ index: eventStart, prefix: BRIDGE_EVENT_START, structured: true }] : []),
1302
+ ...(inputStart >= 0 ? [{ index: inputStart, prefix: BRIDGE_INPUT_START, structured: false }] : []),
1303
+ ].sort((left, right) => left.index - right.index);
1304
+ const candidate = candidates[0];
1305
+ if (!candidate) {
1306
+ const keep = Math.max(
1307
+ partialFramePrefixLength(record.eventBuffer, BRIDGE_EVENT_START),
1308
+ partialFramePrefixLength(record.eventBuffer, BRIDGE_INPUT_START),
1309
+ );
1310
+ visible += record.eventBuffer.slice(0, record.eventBuffer.length - keep);
1311
+ record.eventBuffer = keep ? record.eventBuffer.slice(-keep) : "";
1312
+ break;
1313
+ }
1314
+ visible += record.eventBuffer.slice(0, candidate.index);
1315
+ const end = record.eventBuffer.indexOf(BRIDGE_EVENT_END, candidate.index + candidate.prefix.length);
1316
+ if (end < 0) {
1317
+ record.eventBuffer = record.eventBuffer.slice(candidate.index);
1318
+ break;
1319
+ }
1320
+ const payload = record.eventBuffer.slice(candidate.index + candidate.prefix.length, end);
1321
+ record.eventBuffer = record.eventBuffer.slice(end + BRIDGE_EVENT_END.length);
1322
+ if (!candidate.structured) continue;
1323
+ try {
1324
+ const parsed = JSON.parse(Buffer.from(payload, "base64").toString("utf8")) as unknown;
1325
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) this.#handleStructuredEvent(record, parsed as Record<string, unknown>);
1326
+ } catch {
1327
+ record.outputTruncated = true;
1328
+ }
1329
+ }
1330
+ return stripInternalBridgeEcho(visible);
1331
+ }
1332
+
1333
+ #handleStructuredEvent(record: TmuxRecord, event: Record<string, unknown>): void {
1334
+ this.#emit(record, { type: "jsonl", handle: record.handle, record: event });
1335
+ const request = event.request;
1336
+ if (isPermissionRequest(event, request)) {
1337
+ const requestId = String(event.request_id);
1338
+ if (!record.seenPermissionRequestIds.has(requestId)) {
1339
+ rememberBounded(record.seenPermissionRequestIds, requestId);
1340
+ this.#emit(record, {
1341
+ type: "permission_request",
1342
+ handle: record.handle,
1343
+ request: {
1344
+ requestId,
1345
+ toolUseId: request.tool_use_id,
1346
+ toolName: request.tool_name,
1347
+ input: request.input,
1348
+ raw: event,
1349
+ },
1350
+ });
1351
+ }
1352
+ }
1353
+ if (event.type === "result" && record.activeRequests > 0) {
1354
+ const resultId = jsonlRecordId(event, record.turnSequence + 1);
1355
+ if (!record.seenResultIds.has(resultId)) {
1356
+ rememberBounded(record.seenResultIds, resultId);
1357
+ record.activeRequests = Math.max(0, record.activeRequests - 1);
1358
+ record.turnSequence += 1;
1359
+ this.#emit(record, { type: "turn_completed", handle: record.handle, result: event, sequence: record.turnSequence });
1360
+ }
1361
+ }
1362
+ if (event.type === "bridge_exit") record.activeRequests = 0;
1363
+ }
1364
+
836
1365
  async #withOutputLock<T>(record: TmuxRecord, operation: () => Promise<T>): Promise<T> {
837
1366
  let release!: () => void;
838
1367
  const gate = new Promise<void>((resolve) => { release = resolve; });
@@ -868,13 +1397,73 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
868
1397
  lastOutputAt: record.lastOutputAt,
869
1398
  lastInputAt: record.lastInputAt,
870
1399
  activeRequests: record.activeRequests,
871
- exitReason: running ? undefined : record.stopping ? "stopped" : record.exitCode === 0 ? "completed" : "failed",
1400
+ exitReason: running ? undefined : record.runtimeError ? "failed" : record.stopping ? "stopped" : record.exitCode === 0 ? "completed" : "failed",
872
1401
  processGroupCleaned: record.cleanupComplete,
873
1402
  cleanupError: record.cleanupError?.message,
1403
+ runtimeError: record.runtimeError?.message,
1404
+ cgroupCleaned: record.cgroupPath ? record.cgroupCleaned : undefined,
1405
+ cgroupRequired: record.cgroupPath ? true : undefined,
1406
+ cgroupError: record.cgroupError?.message,
874
1407
  outputTruncated: record.outputTruncated,
875
1408
  };
876
1409
  }
877
1410
 
1411
+ async #processScope(record: TmuxRecord): Promise<ProcessTreeEntry[]> {
1412
+ const rootPid = record.handle.pid ?? 0;
1413
+ const tree = await linuxProcessTree(rootPid);
1414
+ if (!record.cgroupPath) return tree;
1415
+ const group = await linuxCgroupProcesses(record.cgroupPath);
1416
+ if (!group.some((entry) => entry.pid === rootPid)) throw new Error("automatic tmux bridge is not contained by its required cgroup");
1417
+ return [...new Map([...tree, ...group].map((entry) => [entry.pid, entry])).values()];
1418
+ }
1419
+
1420
+ async #rememberClaudeProcesses(record: TmuxRecord): Promise<void> {
1421
+ const rootPid = record.handle.pid ?? 0;
1422
+ let entries: ProcessTreeEntry[] = [];
1423
+ for (let attempt = 0; attempt < 20; attempt += 1) {
1424
+ try {
1425
+ entries = await this.#processScope(record);
1426
+ if (entries.some((entry) => entry.ppid === rootPid && isClaudeProcess(entry, record.expectedClaudeCommand))) break;
1427
+ } catch (error) {
1428
+ if (attempt === 19) throw error;
1429
+ }
1430
+ await delay(25);
1431
+ }
1432
+ record.trustedClaudeProcesses = new Map(entries.filter((entry) => entry.ppid === rootPid && isClaudeProcess(entry, record.expectedClaudeCommand)).map((entry) => [entry.pid, entry]));
1433
+ }
1434
+
1435
+ async #checkNestedClaude(record: TmuxRecord): Promise<void> {
1436
+ const rootPid = record.handle.pid ?? 0;
1437
+ let entries: ProcessTreeEntry[];
1438
+ try { entries = await this.#processScope(record); }
1439
+ catch (error) {
1440
+ if (process.platform === "linux" && !record.runtimeError) {
1441
+ record.runtimeError = new Error(`nested Claude guard unavailable: ${error instanceof Error ? error.message : String(error)}`);
1442
+ this.#appendOutput(record, { stream: "stderr", text: `${record.runtimeError.message}\n`, at: new Date().toISOString() });
1443
+ try { await this.#run(record, ["kill-session", "-t", record.sessionName], undefined, undefined, true); }
1444
+ catch (cleanupError) { record.cleanupError ??= asError(cleanupError); }
1445
+ }
1446
+ return;
1447
+ }
1448
+ if (record.trustedClaudeProcesses.size === 0) {
1449
+ const directClaude = entries.filter((entry) => entry.ppid === rootPid && isClaudeProcess(entry, record.expectedClaudeCommand));
1450
+ if (directClaude.length > 0) {
1451
+ record.trustedClaudeProcesses = new Map(directClaude.map((entry) => [entry.pid, entry]));
1452
+ return;
1453
+ }
1454
+ }
1455
+ const nestedClaude = unexpectedClaudeProcess(entries, record.trustedClaudeProcesses, record.expectedClaudeCommand);
1456
+ const reviewerCandidate = unexpectedReviewerProcess(entries);
1457
+ const trustedReviewerCandidate = reviewerCandidate && record.trustedClaudeProcesses.get(reviewerCandidate.pid);
1458
+ const nestedReviewer = reviewerCandidate && (!trustedReviewerCandidate || !sameProcessIdentity(trustedReviewerCandidate, reviewerCandidate)) ? reviewerCandidate : undefined;
1459
+ const nested = nestedClaude ?? nestedReviewer;
1460
+ if (!nested || record.runtimeError) return;
1461
+ record.runtimeError = new Error(`${nested === nestedReviewer ? "nested Reviewer" : "nested Claude"} process denied (pid=${nested.pid})`);
1462
+ this.#appendOutput(record, { stream: "stderr", text: `${record.runtimeError.message}\n`, at: new Date().toISOString() });
1463
+ try { await this.#run(record, ["kill-session", "-t", record.sessionName], undefined, undefined, true); }
1464
+ catch (error) { record.cleanupError ??= asError(error); }
1465
+ }
1466
+
878
1467
  #record(handle: WorkerHandle): TmuxRecord {
879
1468
  const record = this.#records.get(handle.id);
880
1469
  if (!record) throw new Error(`unknown tmux worker handle: ${handle.id}`);
@@ -893,6 +1482,16 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
893
1482
  }
894
1483
  }
895
1484
 
1485
+ function bridgeEnvironment(env: NodeJS.ProcessEnv, command: string, args: readonly string[], cgroupPath?: string): NodeJS.ProcessEnv {
1486
+ const encode = (value: string) => Buffer.from(value, "utf8").toString("base64");
1487
+ return {
1488
+ ...env,
1489
+ [BRIDGE_KEYS.command]: encode(command),
1490
+ [BRIDGE_KEYS.args]: encode(JSON.stringify(args)),
1491
+ [BRIDGE_KEYS.cgroup]: encode(cgroupPath ?? ""),
1492
+ };
1493
+ }
1494
+
896
1495
  export function attachCommand(handle: Pick<WorkerHandle, "tmuxSocket" | "sessionName">): string {
897
1496
  const target = shellQuote(handle.sessionName ?? "");
898
1497
  return handle.tmuxSocket ? `tmux -S ${shellQuote(handle.tmuxSocket)} attach -t ${target}` : `tmux attach -t ${target}`;
@@ -940,6 +1539,11 @@ function isReadyScreen(screen: string): boolean {
940
1539
  return latestPrompt(normalized) === "ready";
941
1540
  }
942
1541
 
1542
+ function hasBridgePromptInput(screen: string): boolean {
1543
+ const lines = stripAnsi(screen).replaceAll("\u00a0", " ").split(/\r?\n/u).map((line) => line.trim());
1544
+ return lines.some((line) => /^>\s+.+$/u.test(line));
1545
+ }
1546
+
943
1547
  function hasPromptInput(screen: string): boolean {
944
1548
  // `>` is retained as a fixture-compatible ready marker, but is too common in
945
1549
  // arbitrary command output to identify human typing. Claude's TUI uses ❯/›.
@@ -987,6 +1591,21 @@ function safeTmuxMessage(value: string): string {
987
1591
  return normalized;
988
1592
  }
989
1593
 
1594
+ function stripInternalBridgeEcho(value: string): string {
1595
+ return value
1596
+ .replaceAll("\u001b[1A\r\u001b[2K\u001b[1B\r", "")
1597
+ .replace(/(?:^|\r?\n)[^\r\n]*@pi:(?:user|json) [A-Za-z0-9+/=]+\r?(?=\n|$)/gu, "\n")
1598
+ .replace(/(?:^|\r?\n)[^\r\n]*@pi:stop\r?(?=\n|$)/gu, "\n");
1599
+ }
1600
+
1601
+ function partialFramePrefixLength(value: string, prefix: string): number {
1602
+ const limit = Math.min(value.length, prefix.length - 1);
1603
+ for (let length = limit; length > 0; length -= 1) {
1604
+ if (value.endsWith(prefix.slice(0, length))) return length;
1605
+ }
1606
+ return 0;
1607
+ }
1608
+
990
1609
  function boundText(value: string, maxBytes: number): string {
991
1610
  const bytes = Buffer.from(value, "utf8");
992
1611
  return bytes.byteLength <= maxBytes ? value : bytes.subarray(-maxBytes).toString("utf8");
@@ -1094,6 +1713,31 @@ function isMissingFile(error: unknown): boolean {
1094
1713
  return error instanceof Error && /ENOENT/u.test(error.message);
1095
1714
  }
1096
1715
 
1716
+ function rememberBounded(values: Set<string>, value: string, limit = 2_000): void {
1717
+ values.add(value);
1718
+ while (values.size > limit) {
1719
+ const first = values.values().next().value;
1720
+ if (first === undefined) break;
1721
+ values.delete(first);
1722
+ }
1723
+ }
1724
+
1725
+ function jsonlRecordId(record: Record<string, unknown>, sequence?: number): string {
1726
+ for (const key of ["uuid", "request_id"]) {
1727
+ if (typeof record[key] === "string" && record[key]) return `${key}:${record[key]}`;
1728
+ }
1729
+ return `result:${sequence ?? "unknown"}:${JSON.stringify(record).slice(0, 512)}`;
1730
+ }
1731
+
1732
+ function isPermissionRequest(event: Record<string, unknown>, request: unknown): request is { subtype: "can_use_tool"; tool_use_id: string; tool_name: string; input: unknown } {
1733
+ if (event.type !== "control_request" || typeof event.request_id !== "string" || !request || typeof request !== "object") return false;
1734
+ const value = request as { subtype?: unknown; tool_use_id?: unknown; tool_name?: unknown; input?: unknown };
1735
+ return value.subtype === "can_use_tool"
1736
+ && typeof value.tool_use_id === "string"
1737
+ && typeof value.tool_name === "string"
1738
+ && "input" in value;
1739
+ }
1740
+
1097
1741
  function delay(ms: number): Promise<void> {
1098
1742
  return new Promise((resolve) => setTimeout(resolve, ms));
1099
1743
  }