pi-claude-supervisor 0.9.0 → 0.9.2
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 +16 -0
- package/README.cn.md +16 -8
- package/README.md +19 -8
- package/docs/architecture.md +42 -10
- package/docs/autonomy-target.md +1 -1
- package/docs/testing.md +40 -3
- package/package.json +2 -1
- package/src/acceptance.ts +2 -1
- package/src/config.ts +78 -32
- package/src/cwd-lease.ts +34 -1
- package/src/decision-session-store.ts +5 -14
- package/src/decision-worker.ts +89 -11
- package/src/events.ts +5 -11
- package/src/hooks/install.ts +2 -11
- package/src/hooks/server.ts +2 -10
- package/src/hooks/settings.ts +2 -11
- package/src/hooks/types.ts +17 -9
- package/src/index.ts +83 -4
- package/src/json-extract.ts +41 -2
- package/src/lock-owner.ts +55 -0
- package/src/redaction.ts +15 -0
- package/src/reviewer.ts +228 -53
- package/src/supervisor.ts +197 -16
- package/src/worker/process-adapter.ts +40 -5
- package/src/worker/tmux-adapter.ts +105 -21
|
@@ -36,6 +36,8 @@ export interface TmuxWorkerAdapterOptions {
|
|
|
36
36
|
terminationGraceMs?: number;
|
|
37
37
|
/** Linux cgroup mode for automatic tmux bridge descendants. */
|
|
38
38
|
cgroupMode?: "off" | "auto" | "required";
|
|
39
|
+
/** How long an interactive permission hook waits for the Supervisor; defaults to just under the hook timeout. */
|
|
40
|
+
permissionDecisionTimeoutMs?: number;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
interface TmuxRecord {
|
|
@@ -369,14 +371,44 @@ child.once("exit", (code, signal) => {
|
|
|
369
371
|
output("\\n[Claude exited " + String(code === null ? signal : code) + "]\\n");
|
|
370
372
|
process.exit(code ?? 1);
|
|
371
373
|
});
|
|
374
|
+
// The PTY's canonical line limit (4095 bytes) truncates a long control line,
|
|
375
|
+
// so the Supervisor splits a payload's base64 across numbered
|
|
376
|
+
// "@pi:part <index> <chunk>" lines and names the part count on the final
|
|
377
|
+
// "@pi:user|json <count> <chunk>" line. Part 0 discards whatever a failed
|
|
378
|
+
// earlier send left behind, and a count that does not match drops the frame
|
|
379
|
+
// rather than delivering a spliced message.
|
|
380
|
+
let pendingPayload = "";
|
|
381
|
+
let pendingParts = 0;
|
|
372
382
|
const forwardSupervisorCommand = (commandLine) => {
|
|
383
|
+
const part = commandLine.match(/^@pi:part (\\d+) ([A-Za-z0-9+/=]*)$/u);
|
|
384
|
+
if (part) {
|
|
385
|
+
if (part[1] === "0") { pendingPayload = ""; pendingParts = 0; }
|
|
386
|
+
if (Number(part[1]) !== pendingParts) { pendingPayload = ""; pendingParts = -1; return; }
|
|
387
|
+
pendingPayload += part[2];
|
|
388
|
+
pendingParts += 1;
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const payload = (prefix) => {
|
|
392
|
+
const framed = commandLine.slice(prefix.length).match(/^(\\d+) ([A-Za-z0-9+/=]*)$/u);
|
|
393
|
+
const count = framed ? Number(framed[1]) : -1;
|
|
394
|
+
// 0 parts: a single-line frame; anything pending is a failed send's leftover.
|
|
395
|
+
const value = framed && (count === 0 || count === pendingParts) ? (count === 0 ? "" : pendingPayload) + framed[2] : undefined;
|
|
396
|
+
pendingPayload = "";
|
|
397
|
+
pendingParts = 0;
|
|
398
|
+
if (value === undefined) output("\\n[incomplete Supervisor input discarded]\\n");
|
|
399
|
+
return value;
|
|
400
|
+
};
|
|
373
401
|
if (commandLine.startsWith("@pi:user ")) {
|
|
402
|
+
const value = payload("@pi:user ");
|
|
403
|
+
if (value === undefined) return;
|
|
374
404
|
inputActive = true;
|
|
375
|
-
child.stdin.write(JSON.stringify({ type: "user", message: { role: "user", content: decodeLine(
|
|
405
|
+
child.stdin.write(JSON.stringify({ type: "user", message: { role: "user", content: decodeLine(value) } }) + "\\n");
|
|
376
406
|
return;
|
|
377
407
|
}
|
|
378
408
|
if (commandLine.startsWith("@pi:json ")) {
|
|
379
|
-
const
|
|
409
|
+
const value = payload("@pi:json ");
|
|
410
|
+
if (value === undefined) return;
|
|
411
|
+
const message = decodeLine(value);
|
|
380
412
|
child.stdin.write(message.endsWith("\\n") ? message : message + "\\n");
|
|
381
413
|
return;
|
|
382
414
|
}
|
|
@@ -394,6 +426,8 @@ input.on("line", (line) => {
|
|
|
394
426
|
const commandLine = separator >= 0 ? line.slice(separator + 1) : "";
|
|
395
427
|
clearSupervisorInput();
|
|
396
428
|
if (generation !== bridgeGeneration) {
|
|
429
|
+
pendingPayload = "";
|
|
430
|
+
pendingParts = 0;
|
|
397
431
|
output("\\n[stale Supervisor input rejected]\\n");
|
|
398
432
|
return;
|
|
399
433
|
}
|
|
@@ -408,6 +442,9 @@ input.on("close", () => { try { child.kill("SIGTERM"); } catch {} });
|
|
|
408
442
|
prompt();
|
|
409
443
|
`;
|
|
410
444
|
|
|
445
|
+
/** Base64 characters per bridge control line; with its prefix, well under the PTY's 4095-byte line limit. */
|
|
446
|
+
const BRIDGE_FRAME_CHUNK_CHARS = 2_000;
|
|
447
|
+
|
|
411
448
|
const GUARDIAN_KEYS = {
|
|
412
449
|
tmux: "PI_CLAUDE_SUPERVISOR_TMUX_BINARY",
|
|
413
450
|
socket: "PI_CLAUDE_SUPERVISOR_TMUX_SOCKET",
|
|
@@ -529,6 +566,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
529
566
|
readonly #pollIntervalMs: number;
|
|
530
567
|
readonly #terminationGraceMs: number;
|
|
531
568
|
readonly #cgroupMode: "off" | "auto" | "required";
|
|
569
|
+
readonly #permissionDecisionTimeoutMs: number;
|
|
532
570
|
readonly #maxOutputBytes = 8 * 1024 * 1024;
|
|
533
571
|
readonly #maxLogBytes = 16 * 1024 * 1024;
|
|
534
572
|
readonly #commandTimeoutMs = 10_000;
|
|
@@ -540,6 +578,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
540
578
|
this.#pollIntervalMs = boundedDelay(options.pollIntervalMs ?? 500);
|
|
541
579
|
this.#terminationGraceMs = boundedDelay(options.terminationGraceMs ?? 2_000);
|
|
542
580
|
this.#cgroupMode = options.cgroupMode ?? "auto";
|
|
581
|
+
this.#permissionDecisionTimeoutMs = boundedDelay(options.permissionDecisionTimeoutMs ?? Math.max(0, (HOOK_TIMEOUT_SECONDS - 10) * 1_000));
|
|
543
582
|
}
|
|
544
583
|
|
|
545
584
|
async preflight(input: Pick<WorkerStartInput, "cwd" | "command" | "args" | "env" | "approval" | "automatic" | "interactive">): Promise<void> {
|
|
@@ -1096,7 +1135,10 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
1096
1135
|
record.turnObservedOutput = false;
|
|
1097
1136
|
record.inputAt = Date.now();
|
|
1098
1137
|
if (record.interactive) {
|
|
1099
|
-
|
|
1138
|
+
// What the pane receives (and UserPromptSubmit echoes) is the
|
|
1139
|
+
// sanitised text; matching the raw message would count the
|
|
1140
|
+
// Supervisor's own turn as human input and pause automation.
|
|
1141
|
+
record.pendingSentMessages.push(safeTmuxMessage(message));
|
|
1100
1142
|
while (record.pendingSentMessages.length > 50) record.pendingSentMessages.shift();
|
|
1101
1143
|
}
|
|
1102
1144
|
try {
|
|
@@ -1105,7 +1147,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
1105
1147
|
record.lastInputAt = new Date().toISOString();
|
|
1106
1148
|
} catch (error) {
|
|
1107
1149
|
if (record.interactive) {
|
|
1108
|
-
const index = record.pendingSentMessages.lastIndexOf(message);
|
|
1150
|
+
const index = record.pendingSentMessages.lastIndexOf(safeTmuxMessage(message));
|
|
1109
1151
|
if (index >= 0) record.pendingSentMessages.splice(index, 1);
|
|
1110
1152
|
}
|
|
1111
1153
|
record.activeRequests = 0;
|
|
@@ -1120,7 +1162,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
1120
1162
|
async #sendRaw(record: TmuxRecord, message: string): Promise<void> {
|
|
1121
1163
|
if (record.structured) {
|
|
1122
1164
|
const encoded = Buffer.from(safeTmuxMessage(message), "utf8").toString("base64");
|
|
1123
|
-
await this.#
|
|
1165
|
+
await this.#sendFramed(record, "@pi:user", encoded);
|
|
1124
1166
|
return;
|
|
1125
1167
|
}
|
|
1126
1168
|
const safeMessage = safeTmuxMessage(message);
|
|
@@ -1145,13 +1187,29 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
1145
1187
|
// monitor interval. Revalidate the exact pane process after acquiring
|
|
1146
1188
|
// the input gate, not merely at the last status poll.
|
|
1147
1189
|
await this.#assertControlPaneIdentity(record);
|
|
1148
|
-
|
|
1149
|
-
await this.#
|
|
1190
|
+
if (rawCommand) await this.#sendLine(record, this.#bridgeControl(record, rawCommand));
|
|
1191
|
+
else await this.#sendFramed(record, "@pi:json", Buffer.from(`${JSON.stringify(value)}\n`, "utf8").toString("base64"));
|
|
1150
1192
|
} finally {
|
|
1151
1193
|
release();
|
|
1152
1194
|
}
|
|
1153
1195
|
}
|
|
1154
1196
|
|
|
1197
|
+
/**
|
|
1198
|
+
* Send one base64 payload as bridge control lines no longer than the PTY's
|
|
1199
|
+
* canonical line limit: every part but the last as `@pi:part`, then the
|
|
1200
|
+
* final command. A repair prompt or a permission reply echoing a large Write
|
|
1201
|
+
* is far past 4 KB; a single line was silently truncated by the terminal.
|
|
1202
|
+
*/
|
|
1203
|
+
async #sendFramed(record: TmuxRecord, command: "@pi:user" | "@pi:json", base64: string): Promise<void> {
|
|
1204
|
+
let parts = 0;
|
|
1205
|
+
for (let offset = 0; offset + BRIDGE_FRAME_CHUNK_CHARS < base64.length; offset += BRIDGE_FRAME_CHUNK_CHARS) {
|
|
1206
|
+
await this.#sendLine(record, this.#bridgeControl(record, `@pi:part ${parts} ${base64.slice(offset, offset + BRIDGE_FRAME_CHUNK_CHARS)}`));
|
|
1207
|
+
parts += 1;
|
|
1208
|
+
}
|
|
1209
|
+
const lastOffset = base64.length === 0 ? 0 : Math.floor((base64.length - 1) / BRIDGE_FRAME_CHUNK_CHARS) * BRIDGE_FRAME_CHUNK_CHARS;
|
|
1210
|
+
await this.#sendLine(record, this.#bridgeControl(record, `${command} ${parts} ${base64.slice(lastOffset)}`));
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1155
1213
|
#bridgeControl(record: TmuxRecord, commandLine: string): string {
|
|
1156
1214
|
if (!record.bridgeGeneration) throw new Error("tmux bridge generation is not established for control input");
|
|
1157
1215
|
return `@pi:control ${record.bridgeGeneration} ${commandLine}`;
|
|
@@ -2105,11 +2163,20 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
2105
2163
|
},
|
|
2106
2164
|
});
|
|
2107
2165
|
return new Promise<HookRelayReply>((resolve) => {
|
|
2108
|
-
const timeoutMs =
|
|
2166
|
+
const timeoutMs = this.#permissionDecisionTimeoutMs;
|
|
2109
2167
|
const timer = setTimeout(() => {
|
|
2110
2168
|
record.pendingPermissionRequests.delete(requestId);
|
|
2169
|
+
// A decision that arrives after this is answered already: accept it
|
|
2170
|
+
// as a no-op instead of failing the Supervisor's action handler.
|
|
2171
|
+
record.permissionResponses.add(requestId);
|
|
2111
2172
|
this.#logOutput(record, `[supervisor] permission request ${requestId} timed out waiting for a Supervisor decision\n`);
|
|
2112
|
-
|
|
2173
|
+
// PreToolUse: no opinion, so Claude's own flow continues into its
|
|
2174
|
+
// PermissionRequest hook. PermissionRequest: an empty reply would open
|
|
2175
|
+
// Claude's interactive dialog and wait for a human who is not there;
|
|
2176
|
+
// deny instead, so Claude can simply try the call again.
|
|
2177
|
+
resolve(phase === "prompt"
|
|
2178
|
+
? { permissionDecision: "deny", permissionDecisionReason: "the Supervisor's permission decision timed out; retry the same tool call" }
|
|
2179
|
+
: {});
|
|
2113
2180
|
}, timeoutMs);
|
|
2114
2181
|
timer.unref?.();
|
|
2115
2182
|
record.pendingPermissionRequests.set(requestId, {
|
|
@@ -2295,12 +2362,15 @@ function isReadyScreen(screen: string): boolean {
|
|
|
2295
2362
|
}
|
|
2296
2363
|
|
|
2297
2364
|
function hasBridgePromptInput(screen: string): boolean {
|
|
2298
|
-
const lines = stripAnsi(screen).replaceAll("\u00a0", " ").split(/\r?\n/u).map((line) => line.trim());
|
|
2299
|
-
//
|
|
2300
|
-
//
|
|
2301
|
-
//
|
|
2302
|
-
//
|
|
2303
|
-
|
|
2365
|
+
const lines = stripAnsi(screen).replaceAll("\u00a0", " ").split(/\r?\n/u).map((line) => line.trim()).filter(Boolean);
|
|
2366
|
+
// Only the line the cursor sits on can be a human typing at the bridge
|
|
2367
|
+
// prompt: earlier lines are scrollback, where Claude's own Markdown
|
|
2368
|
+
// blockquotes (`> Note …`) also start with `>`. Supervisor input is echoed
|
|
2369
|
+
// by the PTY (`> @pi:control <generation> @pi:user …`, possibly wrapped);
|
|
2370
|
+
// it is stale control input, not a human turn, and counting it would leave
|
|
2371
|
+
// activeRequests stuck at 1 after every result.
|
|
2372
|
+
const last = lines.at(-1) ?? "";
|
|
2373
|
+
return /^>\s+.+$/u.test(last) && !/^>\s+@pi:/u.test(last);
|
|
2304
2374
|
}
|
|
2305
2375
|
|
|
2306
2376
|
function hasPromptInput(screen: string): boolean {
|
|
@@ -2339,21 +2409,35 @@ function redactSensitiveText(value: string): string {
|
|
|
2339
2409
|
return String(redactSensitive(value));
|
|
2340
2410
|
}
|
|
2341
2411
|
|
|
2412
|
+
/**
|
|
2413
|
+
* Make text safe to paste into a terminal. Supervisor messages routinely
|
|
2414
|
+
* embed command output (a failed check's colours, a progress bar's `\r`), so
|
|
2415
|
+
* control bytes are neutralised rather than refused — refusing blocked the
|
|
2416
|
+
* task at its first repair turn. Escape sequences are removed, a lone CR
|
|
2417
|
+
* becomes a newline, and any other C0/C1 control byte becomes a space; tab
|
|
2418
|
+
* and newline pass through. Nothing that reaches the pane can drive the
|
|
2419
|
+
* terminal.
|
|
2420
|
+
*/
|
|
2342
2421
|
function safeTmuxMessage(value: string): string {
|
|
2343
|
-
const
|
|
2422
|
+
const withoutEscapes = value
|
|
2423
|
+
// OSC … BEL/ST, then CSI sequences, then any other two-byte ESC sequence.
|
|
2424
|
+
.replace(/\u001b\][^\u0007\u001b\r\n]*(?:\u0007|\u001b\\)?/gu, "")
|
|
2425
|
+
.replace(/(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/gu, "")
|
|
2426
|
+
// Charset designation (ESC ( B from tput sgr0) and other ESC + final byte.
|
|
2427
|
+
.replace(/\u001b[ -/]*[0-~]?/gu, "");
|
|
2428
|
+
const normalized = withoutEscapes.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
|
|
2429
|
+
let safe = "";
|
|
2344
2430
|
for (const character of normalized) {
|
|
2345
2431
|
const code = character.codePointAt(0) ?? 0;
|
|
2346
|
-
|
|
2347
|
-
throw new Error("tmux input contains terminal control bytes; refusing to send it");
|
|
2348
|
-
}
|
|
2432
|
+
safe += code === 9 || code === 10 || (code > 31 && code !== 127 && (code < 128 || code > 159)) ? character : " ";
|
|
2349
2433
|
}
|
|
2350
|
-
return
|
|
2434
|
+
return safe;
|
|
2351
2435
|
}
|
|
2352
2436
|
|
|
2353
2437
|
function stripInternalBridgeEcho(value: string): string {
|
|
2354
2438
|
return value
|
|
2355
2439
|
.replaceAll("\u001b[1A\r\u001b[2K\u001b[1B\r", "")
|
|
2356
|
-
.replace(/(?:^|\r?\n)[^\r\n]*@pi:(?:user|json) [A-Za-z0-9+/=]
|
|
2440
|
+
.replace(/(?:^|\r?\n)[^\r\n]*@pi:(?:user|json|part) (?:\d+ )?[A-Za-z0-9+/=]*\r?(?=\n|$)/gu, "\n")
|
|
2357
2441
|
.replace(/(?:^|\r?\n)[^\r\n]*@pi:stop\r?(?=\n|$)/gu, "\n");
|
|
2358
2442
|
}
|
|
2359
2443
|
|