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.
- package/CHANGELOG.md +11 -2
- package/README.cn.md +24 -15
- package/README.md +27 -15
- package/docs/architecture.md +43 -27
- package/docs/automation-hardening-plan.md +2 -1
- package/docs/autonomy-target.md +9 -6
- package/docs/engineering-plan.md +1 -1
- package/docs/implementation-review.md +8 -0
- package/docs/testing.md +22 -10
- package/package.json +1 -1
- package/src/decision-session-store.ts +8 -1
- package/src/index.ts +12 -9
- package/src/policy.ts +98 -1
- package/src/reviewer.ts +64 -9
- package/src/supervisor.ts +6 -4
- package/src/types.ts +3 -1
- package/src/verifier.ts +18 -1
- package/src/worker/environment.ts +51 -3
- package/src/worker/process-adapter.ts +366 -79
- package/src/worker/process-tree.ts +218 -0
- package/src/worker/tmux-adapter.ts +669 -25
|
@@ -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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
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 (
|
|
224
|
-
if (
|
|
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)
|
|
267
|
-
|
|
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.#
|
|
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)
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
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
|
|
1267
|
+
const rawText = buffer.subarray(0, bytesRead).toString("utf8");
|
|
816
1268
|
record.outputOffset = start + bytesRead;
|
|
817
1269
|
record.lastOutputAt = new Date().toISOString();
|
|
818
|
-
this.#
|
|
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
|
}
|