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.
@@ -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(commandLine.slice("@pi:user ".length)) } }) + "\\n");
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 message = decodeLine(commandLine.slice("@pi:json ".length));
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
- record.pendingSentMessages.push(message);
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.#sendLine(record, this.#bridgeControl(record, `@pi:user ${encoded}`));
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
- const command = rawCommand ?? `@pi:json ${Buffer.from(`${JSON.stringify(value)}\n`, "utf8").toString("base64")}`;
1149
- await this.#sendLine(record, this.#bridgeControl(record, command));
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 = Math.max(0, (HOOK_TIMEOUT_SECONDS - 10) * 1_000);
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
- resolve({});
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
- // Supervisor input is echoed by the PTY after the bridge prompt. A long
2300
- // base64 frame can wrap, and the bridge's one-line erase then leaves the
2301
- // first `> @pi:user ...` line visible after the result. It is stale control
2302
- // input, not a human turn; counting it would leave activeRequests stuck at 1.
2303
- return lines.some((line) => /^>\s+.+$/u.test(line) && !/^>\s+@pi:(?:user|json)\s+/u.test(line));
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 normalized = value.replaceAll(String.fromCharCode(13, 10), "\n");
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
- if (code <= 8 || code === 11 || code === 12 || (code >= 13 && code <= 31) || code === 127 || (code >= 128 && code <= 159)) {
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 normalized;
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+/=]+\r?(?=\n|$)/gu, "\n")
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