pi-claude-supervisor 0.7.1 → 0.7.3

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 CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  All notable changes to this project will be documented here.
4
4
 
5
+ ## [0.7.3](https://github.com/btnalit/pi-claude-supervisor/compare/v0.7.2...v0.7.3) (2026-09-17)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * **policy:** a name bound to literal text is not an unseen argument ([b93bbf6](https://github.com/btnalit/pi-claude-supervisor/commit/b93bbf6ae019fb7137588f113c0e1113ee942be7))
11
+ * **policy:** a tilde expands only at the start of a word ([9671b23](https://github.com/btnalit/pi-claude-supervisor/commit/9671b23cd6f112f28decc2e906c029c11f5f7d1c))
12
+ * **tmux:** a subagent hand-back is Claude's own prompt, not a human's ([90d40ba](https://github.com/btnalit/pi-claude-supervisor/commit/90d40baf357c5390f1699502235459e2d4230d55))
13
+ * **tmux:** remove a dead Worker's tmux socket and sweep leftovers at load ([db83d40](https://github.com/btnalit/pi-claude-supervisor/commit/db83d4016dc210739d386d038df220cd7c1c4479))
14
+
15
+ ## [0.7.2](https://github.com/btnalit/pi-claude-supervisor/compare/v0.7.1...v0.7.2) (2026-09-17)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **policy:** a redirection target is not a dynamic argument, and drop the regex twin of the token check ([94742c7](https://github.com/btnalit/pi-claude-supervisor/commit/94742c7c8c8c810843ddb62703d7b3c1fea1254f))
21
+ * **policy:** an environment prefix's dynamic value is not a dynamic argument ([b53e77c](https://github.com/btnalit/pi-claude-supervisor/commit/b53e77c6932851a1c88452bca38c8d5bdf1030b5))
22
+ * **policy:** scope the dynamic-argument veto to the statement holding the sensitive command ([5515a2b](https://github.com/btnalit/pi-claude-supervisor/commit/5515a2b0d746d3d3cd05b0ef4ba86602f92eb072))
23
+ * **redaction:** keep numeric token counts in usage events ([6d4e183](https://github.com/btnalit/pi-claude-supervisor/commit/6d4e1831b588b71175d581764b611e5ed5cbe664))
24
+ * **supervisor:** survive Claude's own background work in an interactive session ([d883b9a](https://github.com/btnalit/pi-claude-supervisor/commit/d883b9a2b187c55693ff2669a1ba4f7f949272d3))
25
+
5
26
  ## [0.7.1](https://github.com/btnalit/pi-claude-supervisor/compare/v0.7.0...v0.7.1) (2026-09-17)
6
27
 
7
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-claude-supervisor",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "A policy-gated Pi supervisor for observing and verifying Claude Code workers.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -14,7 +14,7 @@ const MAX_DECISION_FIELD_BYTES = 8 * 1024;
14
14
  export type DecisionAction =
15
15
  | { action: "continue" | "redirect" | "answer"; message: string; reason: string; confidence?: number }
16
16
  | { action: "allow_permission" | "deny_permission"; requestId: string; toolUseId: string; reason: string; confidence?: number }
17
- | { action: "verify" | "stop" | "park" | "ask_human" | "noop"; reason: string; question?: string; confidence?: number }
17
+ | { action: "verify" | "stop" | "park" | "ask_human" | "noop" | "wait"; reason: string; question?: string; confidence?: number }
18
18
  | { action: "retry"; reason: string; message?: string; confidence?: number };
19
19
 
20
20
  export interface DecisionContext {
@@ -280,7 +280,7 @@ Current repair round: ${context.repairRound ?? 0}
280
280
  Task specification: ${boundedJson(context.spec ?? { goal: context.task })}
281
281
 
282
282
  Return exactly one JSON object and no markdown:
283
- {"action":"continue|redirect|answer|allow_permission|deny_permission|verify|retry|stop|park|noop",...}
283
+ {"action":"continue|redirect|answer|allow_permission|deny_permission|verify|retry|stop|park|wait|noop",...}
284
284
  For continue/redirect/answer include message and reason. For permission actions include
285
285
  requestId and toolUseId. Retry may include a corrective message. Never choose allow_permission
286
286
  for a command that crosses the remote push or main/integration merge boundary; the deterministic
@@ -300,7 +300,10 @@ Claude says it will stop; choose stop only for an explicit stop or technical con
300
300
  Use park only when the task cannot safely produce a candidate because required evidence,
301
301
  authority, or runtime capability is unavailable. A parked candidate is asynchronous and must not
302
302
  wait for a human to be online. For an exited event choose verify, park or stop; a noop on an exited event is treated as
303
- verify. A completed turn or a permission request always requires a concrete action.`;
303
+ verify. Choose wait when the Worker's result says it is waiting for its own background agents,
304
+ tasks or monitors: their completion re-invokes the Worker automatically, a message would only
305
+ interrupt it, and the Supervisor asks you again if the Worker has not resumed within the wait
306
+ timeout. A completed turn or a permission request always requires a concrete action.`;
304
307
  }
305
308
 
306
309
  /**
@@ -544,7 +547,7 @@ function parseDecision(text: string, event: WorkerEvent): DecisionAction {
544
547
  const value = distinct[0] as Record<string, unknown>;
545
548
  const action = value.action;
546
549
  if (typeof action !== "string") throw new Error("missing action");
547
- const allowed = new Set(["continue", "redirect", "answer", "allow_permission", "deny_permission", "verify", "retry", "stop", "park", "ask_human", "noop"]);
550
+ const allowed = new Set(["continue", "redirect", "answer", "allow_permission", "deny_permission", "verify", "retry", "stop", "park", "ask_human", "noop", "wait"]);
548
551
  if (!allowed.has(action)) throw new Error(`unsupported action: ${action}`);
549
552
  const reason = typeof value.reason === "string" && value.reason.trim() ? boundedDecisionText(value.reason, "reason") : "no reason provided";
550
553
  const confidence = value.confidence === undefined ? undefined : typeof value.confidence === "number" && Number.isFinite(value.confidence) && value.confidence >= 0 && value.confidence <= 1
@@ -564,7 +567,7 @@ function parseDecision(text: string, event: WorkerEvent): DecisionAction {
564
567
  if (action === "retry") {
565
568
  return { action, reason, message: typeof value.message === "string" ? boundedDecisionText(value.message, "message") : undefined, confidence };
566
569
  }
567
- return { action: action as "verify" | "stop" | "park" | "ask_human" | "noop", reason, question: typeof value.question === "string" ? boundedDecisionText(value.question, "question") : undefined, confidence };
570
+ return { action: action as "verify" | "stop" | "park" | "ask_human" | "noop" | "wait", reason, question: typeof value.question === "string" ? boundedDecisionText(value.question, "question") : undefined, confidence };
568
571
  } catch (error) {
569
572
  return { action: "park", reason: `invalid Decision Worker action: ${error instanceof Error ? error.message : String(error)}` };
570
573
  }
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@ import { EventLog } from "./events.ts";
8
8
  import { redactSensitive } from "./redaction.ts";
9
9
  import { ProcessWorkerAdapter } from "./worker/process-adapter.ts";
10
10
  import { automaticWorkerEnvironment } from "./worker/environment.ts";
11
- import { TmuxWorkerAdapter, attachCommand } from "./worker/tmux-adapter.ts";
11
+ import { TmuxWorkerAdapter, attachCommand, sweepDeadTmuxSockets } from "./worker/tmux-adapter.ts";
12
12
  import { Supervisor, type DecisionSessionClosedInfo, type HumanInterventionNotice, type SupervisorProgress, type SupervisorTokenUsage } from "./supervisor.ts";
13
13
  import { evaluateCommand } from "./policy.ts";
14
14
  import { HumanWebhookNotifier } from "./notifications.ts";
@@ -128,6 +128,9 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
128
128
  // with /supervise uninstall-hooks; the relay is a ~1 ms no-op when no
129
129
  // Supervisor is listening, so leaving it installed costs nothing.
130
130
  let hookInstallNotice: string | undefined;
131
+ // A Worker killed with its cgroup leaves its tmux socket behind; sweep the
132
+ // ones no server answers on so the temp directory does not fill with them.
133
+ if (transport === "tmux") sweepDeadTmuxSockets();
131
134
  if (hookServer) {
132
135
  hookServerReady = (async () => {
133
136
  await writeRelayScript(relayPath);
package/src/policy.ts CHANGED
@@ -112,10 +112,6 @@ const deniedPatterns = [
112
112
  // word (scripts/publish-package.mjs) is not a publication.
113
113
  /\b(?:npm|pnpm|yarn)\b(?:\s+-\S+)*\s+publish\b/iu,
114
114
  /\b(?:curl|wget)\b[\s\S]*(?:-X\s*(?:POST|PUT|PATCH|DELETE)|--request(?:=|\s+)(?:POST|PUT|PATCH|DELETE)|--method(?:=|\s+)(?:POST|PUT|PATCH|DELETE)|(?:^|\s)(?:-d|--data(?:[-a-z]*)(?:=|\s+)|--post-data(?:=|\s+)|--body-data(?:=|\s+)))[\s\S]*https?:\/\/(?:api\.)?(?:github|gitlab|bitbucket|registry\.npmjs)\b/iu,
115
- // A repository/package command with a dynamic argument cannot be
116
- // capability-checked (`git $ACTION origin main`); dynamic text elsewhere in a
117
- // command is ordinary shell and is not a boundary concern.
118
- /\b(?:git|gh|glab|hub|npm|pnpm|yarn)\b[^|;&\n]*(?:\$\{?[^\s`}]+\}?|`[^`]*`|\$\([^)]*\))/iu,
119
115
  /--(?:allow-)?dangerously-skip-permissions\b/iu,
120
116
  /--permission-mode\s+(?:bypasspermissions|dontask)\b/iu,
121
117
  // Only the filesystem root itself; `rm -rf /abs/path/dist` is ordinary local work.
@@ -212,11 +208,10 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
212
208
 
213
209
  // An argument the lexer cannot see through matters only where it could reach
214
210
  // the boundary: a repository, package, network or remote-shell command, or an
215
- // interpreter that would execute the expanded text. Dynamic text in an
216
- // ordinary local command (`for f in …; echo "$f"`) is Claude's own business.
217
- const dynamicSensitive = lower.some((value) => DYNAMIC_SENSITIVE_COMMANDS.has(value.split(/[\\/]/u).at(-1) ?? value))
218
- || (lower.some((value) => (value.split(/[\\/]/u).at(-1) ?? value) === "find") && lower.some((value) => FIND_EXEC_ACTIONS.has(value)));
219
- if (hasDynamicArgument && (hasGit || hasGhRemote || hasPackagePublication || dynamicSensitive || hasDynamicCommandName(tokens))) {
211
+ // interpreter that would execute the expanded text, in the same statement.
212
+ // Dynamic text in an ordinary local command (`for f in …; echo "$f"`) or in
213
+ // another statement (`npm test; echo "exit $?"`) is Claude's own business.
214
+ if ((hasDynamicArgument && hasDynamicCommandName(tokens)) || segmentsOf(tokens).some((segment) => hasDynamicSensitiveArgument(segment))) {
220
215
  return { decision: "deny", reason: "a repository, package, network or shell command with a dynamic argument cannot be capability-checked" };
221
216
  }
222
217
  if (/\bgit\b[\s\S]*\b(?:push|merge(?!-)|send-pack|receive-pack|update-ref)\b/iu.test(canonical)
@@ -250,6 +245,43 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
250
245
  return undefined;
251
246
  }
252
247
 
248
+ /** The statements of a command, split on `;`, `&&`, `||`, `|` and `&`. */
249
+ function segmentsOf(tokens: readonly ShellToken[]): ShellToken[][] {
250
+ const segments: ShellToken[][] = [[]];
251
+ for (const token of tokens) {
252
+ if (token.operator && SEGMENT_SPLIT_OPERATORS.has(token.value)) segments.push([]);
253
+ else segments.at(-1)!.push(token);
254
+ }
255
+ return segments.filter((segment) => segment.length > 0);
256
+ }
257
+
258
+ /** True when one statement combines a dynamic word with a command whose dynamic argument could reach the boundary. */
259
+ function hasDynamicSensitiveArgument(segment: readonly ShellToken[]): boolean {
260
+ // A leading `NAME=value` prefix sets the environment and a redirection
261
+ // target names a file; neither reaches the command's argv, so
262
+ // `npm_config_cache=$TMPDIR/x npm run check` and `git show HEAD:f > $OLD/f`
263
+ // are literal commands.
264
+ const words: ShellToken[] = [];
265
+ let afterRedirect = false;
266
+ for (const token of segment) {
267
+ if (token.operator) { afterRedirect = [">", ">>", "<", "<<<"].includes(token.value); continue; }
268
+ if (!afterRedirect) words.push(token);
269
+ afterRedirect = false;
270
+ }
271
+ let argvStart = 0;
272
+ while (argvStart < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[argvStart]!.value)) argvStart += 1;
273
+ if (!words.slice(argvStart).some((token) => token.dynamic)) return false;
274
+ const values = words.map((token) => token.value);
275
+ const lower = values.map((value) => value.toLowerCase());
276
+ const executables = lower.map((value) => value.split(/[\\/]/u).at(-1) ?? value);
277
+ const canonical = values.join(" ");
278
+ return values.some((value) => /(?:^|[\\/])git$/iu.test(value) || /^git-(?:send|receive|upload)-pack$/iu.test(value))
279
+ || containsRemoteCliMutation(canonical)
280
+ || (lower.some((value) => value === "npm" || value === "pnpm" || value === "yarn") && lower.includes("publish"))
281
+ || executables.some((executable) => DYNAMIC_SENSITIVE_COMMANDS.has(executable))
282
+ || (executables.includes("find") && lower.some((value) => FIND_EXEC_ACTIONS.has(value)));
283
+ }
284
+
253
285
  /** A dynamic word in command position (`$CMD …`, `; $CMD`, `do . $file`) could name anything. */
254
286
  function hasDynamicCommandName(tokens: readonly ShellToken[]): boolean {
255
287
  let commandPosition = true;
@@ -301,7 +333,8 @@ function evaluateCommandInternal(command: string, depth: number): PolicyResult {
301
333
  }
302
334
 
303
335
  function evaluateTokens(rawTokens: readonly ShellToken[], depth: number): PolicyResult {
304
- const { tokens, embedded } = resolveDataTokens(rawTokens);
336
+ const { tokens: dataResolved, embedded } = resolveDataTokens(rawTokens);
337
+ const tokens = resolveLiteralBindings(dataResolved);
305
338
  if (depth < 4) {
306
339
  for (const body of embedded) {
307
340
  const nestedResult = evaluateCommandInternal(body, depth + 1);
@@ -326,14 +359,66 @@ function evaluateTokens(rawTokens: readonly ShellToken[], depth: number): Policy
326
359
  if (/\b(?:npm|pnpm|yarn)\b(?:\s+-\S+)*\s+publish\b/iu.test(canonical)) {
327
360
  return { decision: "deny", reason: "package publication belongs to the protected release workflow" };
328
361
  }
329
- if (/\b(?:git|gh|glab|hub|npm|pnpm|yarn)\b[^|;&\n]*(?:\$\{?[^\s`}]+\}?|`[^`]*`|\$\([^)]*\))/iu.test(canonical)) {
330
- return { decision: "deny", reason: "a repository or package command with a dynamic argument cannot be capability-checked" };
331
- }
332
362
  return { decision: "deny", reason: "command matches a prohibited destructive pattern" };
333
363
  }
334
364
  return { decision: "allow", reason: "command is allowed for unattended local development" };
335
365
  }
336
366
 
367
+ /**
368
+ * `NAME=literal` and `for NAME in literal…` bind a name to text the policy can
369
+ * see, so a later `$NAME` is not an unseen argument: `for c in 5dae138 feff500;
370
+ * do git show $c; done` and `S=/tmp/x && cat > $S/log` are literal commands.
371
+ * Every bound value is substituted (a loop over `status push` yields
372
+ * `git status push …`, which the boundary checks still catch), and a name
373
+ * rebound to dynamic text is forgotten again.
374
+ */
375
+ function resolveLiteralBindings(tokens: readonly ShellToken[]): ShellToken[] {
376
+ const bindings = new Map<string, string>();
377
+ const resolved: ShellToken[] = [];
378
+ const substitute = (token: ShellToken): ShellToken => {
379
+ if (token.operator || token.data || !token.dynamic || bindings.size === 0) return token;
380
+ const value = token.value.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/gu, (match, name: string) => bindings.get(name) ?? match);
381
+ if (value === token.value) return token;
382
+ const dynamic = /[$`*?[\]{}~]/u.test(value) && !/^[[\]{}]+$/u.test(value);
383
+ return { ...token, value, dynamic };
384
+ };
385
+ let commandPosition = true;
386
+ for (let index = 0; index < tokens.length; index += 1) {
387
+ const token = substitute(tokens[index]!);
388
+ resolved.push(token);
389
+ if (token.operator) { commandPosition = SEGMENT_SPLIT_OPERATORS.has(token.value); continue; }
390
+ const word = token.value;
391
+ if (commandPosition) {
392
+ const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/su.exec(word);
393
+ if (assignment) {
394
+ if (token.dynamic) bindings.delete(assignment[1]!);
395
+ else bindings.set(assignment[1]!, assignment[2]!);
396
+ continue;
397
+ }
398
+ if (word.toLowerCase() === "for") {
399
+ const name = tokens[index + 1]?.value;
400
+ const inWord = tokens[index + 2]?.value.toLowerCase();
401
+ if (name && /^[A-Za-z_][A-Za-z0-9_]*$/u.test(name) && inWord === "in") {
402
+ const values: string[] = [];
403
+ let cursor = index + 3;
404
+ let literal = true;
405
+ for (; cursor < tokens.length; cursor += 1) {
406
+ const item = tokens[cursor]!;
407
+ if (item.operator || item.value.toLowerCase() === "do") break;
408
+ const substituted = substitute(item);
409
+ if (substituted.dynamic || substituted.data) literal = false;
410
+ values.push(substituted.value);
411
+ }
412
+ if (literal && values.length > 0) bindings.set(name, values.join(" "));
413
+ else bindings.delete(name);
414
+ }
415
+ }
416
+ if (!COMMAND_POSITION_KEYWORDS.has(word.toLowerCase())) commandPosition = false;
417
+ }
418
+ }
419
+ return resolved;
420
+ }
421
+
337
422
  /**
338
423
  * A quoted heredoc body means whatever its consumer makes of it. Fed to a shell
339
424
  * (`bash <<'EOF'`, `cat <<'EOF' | sh`, `eval "$(cat <<'EOF' …)"`) it is a
@@ -505,7 +590,10 @@ function lexShell(input: string): { tokens: ShellToken[]; error?: string } {
505
590
  // Brace, tilde and pathname expansion can change command names, targets or
506
591
  // Git refs after this lexical pass. Treat all unquoted expansion markers as
507
592
  // dynamic rather than attempting to model Bash's expansion order.
508
- if ("*?[]{}~".includes(character)) { dynamic = true; value += character; started = true; continue; }
593
+ // A tilde expands only at the start of a word (or of an assignment value);
594
+ // `HEAD~1` and `a~b` are literal text.
595
+ if (character === "~") { if (value === "" || /[=:]$/u.test(value)) dynamic = true; value += character; started = true; continue; }
596
+ if ("*?[]{}".includes(character)) { dynamic = true; value += character; started = true; continue; }
509
597
  if (";&|<>".includes(character)) {
510
598
  if (character === "<" && next === "<") {
511
599
  const third = input[index + 2];
package/src/redaction.ts CHANGED
@@ -2,7 +2,9 @@ const sensitiveKeyPattern = /(password|secret|token|api[-_]?key|authorization|cr
2
2
 
3
3
  /** Recursively redact credential-shaped values before persistence or model prompts. */
4
4
  export function redactSensitive(value: unknown, key?: string): unknown {
5
- if (key && sensitiveKeyPattern.test(key)) return "[REDACTED]";
5
+ // A credential is a string; a number or boolean under a sensitive-looking
6
+ // key (`totalTokens`, `contextTokens`, `maxTokens`) is a count, not a secret.
7
+ if (key && sensitiveKeyPattern.test(key) && typeof value === "string") return "[REDACTED]";
6
8
  if (typeof value === "string") {
7
9
  return value
8
10
  .replace(/\b(sk-ant-[A-Za-z0-9_-]+)\b/gu, "[REDACTED]")
package/src/supervisor.ts CHANGED
@@ -98,6 +98,8 @@ export interface SupervisorStartOptions {
98
98
  deadlineMs?: number;
99
99
  /** Maximum time without worker output; defaults to 20 minutes. Set to 0 to disable. */
100
100
  noOutputTimeoutMs?: number;
101
+ /** After a `wait` decision, how long the Worker may stay silent before the Decision Worker is asked again; defaults to 10 minutes. Set to 0 to disable. */
102
+ waitTimeoutMs?: number;
101
103
  /** Human approval for a review-level worker command. */
102
104
  approval?: { actor: "human"; reason: string };
103
105
  /** Adopt an existing tmux session instead of starting a new worker. */
@@ -196,6 +198,9 @@ export class Supervisor {
196
198
  #workerOutput = "";
197
199
  #lastWorkerResult?: Record<string, unknown>;
198
200
  #lastTurnCompleted?: WorkerEvent;
201
+ /** Armed by a `wait` decision: re-asks the Decision Worker if the Worker never resumes on its own. */
202
+ #waitTimer?: NodeJS.Timeout;
203
+ #waitTimeoutMs = 10 * 60_000;
199
204
  #turn = 0;
200
205
  #repairRound = 0;
201
206
  #lastFindingSignature?: string;
@@ -324,6 +329,7 @@ export class Supervisor {
324
329
  this.#turn = options.initialTurn ?? 0;
325
330
  this.#deadlineMs = options.deadlineMs ?? 4 * 60 * 60_000;
326
331
  this.#noOutputTimeoutMs = options.noOutputTimeoutMs ?? 20 * 60_000;
332
+ this.#waitTimeoutMs = options.waitTimeoutMs ?? 10 * 60_000;
327
333
  this.#noOutputBaselineAt = undefined;
328
334
  this.#verificationAbortController = undefined;
329
335
  this.#progressPhase = undefined;
@@ -608,6 +614,8 @@ export class Supervisor {
608
614
  if (this.#handledEvents.has(key)) return;
609
615
  try {
610
616
  let skipDecisionNotify = false;
617
+ // Any fresh Worker activity supersedes a pending wait.
618
+ this.#clearWaitTimer();
611
619
  if (event.type === "turn_completed") { this.#lastWorkerResult = event.result; this.#lastTurnCompleted = event; }
612
620
  // Do not call #pollInternal from within a deferred retry: it would
613
621
  // recurse back into #retryDeferredWorkerEvents through #pollInternal's
@@ -901,9 +909,16 @@ export class Supervisor {
901
909
  return;
902
910
  }
903
911
  if (action.action === "continue" || action.action === "redirect" || action.action === "answer") {
912
+ if (await this.#decisionIsStale(event)) return;
904
913
  await this.#sendInternal(action.message);
905
914
  return;
906
915
  }
916
+ if (action.action === "wait") {
917
+ // The Worker will be re-invoked by its own background work; send
918
+ // nothing, but re-ask if it stays silent for the wait timeout.
919
+ this.#armWaitTimer(event);
920
+ return;
921
+ }
907
922
  if (action.action === "verify") {
908
923
  if (this.#machine.state === "waiting" && canRepairInPlace(this.#adapter)) {
909
924
  this.#machine.transition("verifying");
@@ -938,12 +953,57 @@ export class Supervisor {
938
953
  return;
939
954
  }
940
955
  if (action.action === "retry") {
941
- if (action.message?.trim()) await this.#sendInternal(action.message);
942
- else await this.#parkCandidate(`Retry requires a concrete corrective instruction: ${action.reason}`, event);
956
+ if (!action.message?.trim()) { await this.#parkCandidate(`Retry requires a concrete corrective instruction: ${action.reason}`, event); return; }
957
+ if (await this.#decisionIsStale(event)) return;
958
+ await this.#sendInternal(action.message);
943
959
  }
944
960
  });
945
961
  }
946
962
 
963
+ /**
964
+ * A decision about a completed turn is stale once the Worker has started a
965
+ * new turn on its own (a background agent, task or monitor of its own
966
+ * re-invoked it). Sending then would be refused by the adapter; it is not a
967
+ * failure of anything, so record it and let the next turn drive a new decision.
968
+ */
969
+ async #decisionIsStale(event: WorkerEvent): Promise<boolean> {
970
+ const handle = this.#handle;
971
+ if (!handle || event.type !== "turn_completed") return false;
972
+ const status = await this.#adapter.getStatus(handle).catch(() => undefined);
973
+ if (!status || status.activeRequests === undefined || status.activeRequests === 0) return false;
974
+ await this.#appendEvent({
975
+ type: "decision_ignored",
976
+ taskId: this.#task?.taskId,
977
+ workerId: handle.id,
978
+ data: { reason: "worker resumed on its own before the decision arrived", eventType: event.type },
979
+ }).catch(() => {});
980
+ return true;
981
+ }
982
+
983
+ #armWaitTimer(event: WorkerEvent): void {
984
+ this.#clearWaitTimer();
985
+ if (this.#waitTimeoutMs <= 0) return;
986
+ this.#waitTimer = setTimeout(() => {
987
+ this.#waitTimer = undefined;
988
+ void this.#exclusive(async () => {
989
+ if (!this.#decision || !this.#automation || this.#humanRequired || this.#machine.state !== "waiting" || this.#lastTurnCompleted !== event) return;
990
+ const handle = this.#handle;
991
+ if (!handle) return;
992
+ const status = await this.#adapter.getStatus(handle).catch(() => undefined);
993
+ if (status?.activeRequests) return;
994
+ await this.#appendEvent({ type: "wait_expired", taskId: this.#task?.taskId, workerId: handle.id, data: { waitTimeoutMs: this.#waitTimeoutMs } }).catch(() => {});
995
+ this.#decision.updateContext({ state: this.#machine.state, turn: this.#turn, repairRound: this.#repairRound });
996
+ this.#decision.replay?.(event);
997
+ }).catch(() => { /* the watchdog still covers a silent Worker */ });
998
+ }, this.#waitTimeoutMs);
999
+ this.#waitTimer.unref?.();
1000
+ }
1001
+
1002
+ #clearWaitTimer(): void {
1003
+ if (this.#waitTimer) clearTimeout(this.#waitTimer);
1004
+ this.#waitTimer = undefined;
1005
+ }
1006
+
947
1007
  /**
948
1008
  * Record, best effort, that the task's current branch diverged from the
949
1009
  * last one observed. The task is anchored to its baseline commit, not to a
@@ -1069,7 +1129,11 @@ export class Supervisor {
1069
1129
  this.#humanRequired = false;
1070
1130
  this.#humanGate = undefined;
1071
1131
  await this.#appendEvent({ type: "automation_resumed", taskId: this.#task?.taskId, workerId: this.#handle?.id });
1072
- if (this.#decision && this.#machine.state === "waiting" && this.#lastTurnCompleted) {
1132
+ if (this.#decision && this.#machine.state === "waiting" && this.#lastTurnCompleted && this.#handle) {
1133
+ // Replay the last completed turn only if the Worker is really idle; if
1134
+ // it has resumed on its own, its next Stop brings a fresh turn.
1135
+ const status = await this.#adapter.getStatus(this.#handle).catch(() => undefined);
1136
+ if (status?.activeRequests) return;
1073
1137
  this.#decision.updateContext({ state: this.#machine.state, turn: this.#turn, repairRound: this.#repairRound });
1074
1138
  this.#decision.replay?.(this.#lastTurnCompleted);
1075
1139
  }
@@ -1727,6 +1791,7 @@ export class Supervisor {
1727
1791
  #clearWatchdog(): void {
1728
1792
  if (this.#watchdog) clearInterval(this.#watchdog);
1729
1793
  this.#watchdog = undefined;
1794
+ this.#clearWaitTimer();
1730
1795
  }
1731
1796
 
1732
1797
  async #checkWatchdog(): Promise<void> {
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { spawn } from "node:child_process";
3
- import { constants as fsConstants } from "node:fs";
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { constants as fsConstants, lstatSync, readdirSync, unlinkSync } from "node:fs";
4
4
  import { access, chmod, lstat, mkdir, open, readdir, readFile, realpath, rm, rmdir, stat, truncate, writeFile } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
6
6
  import { delimiter, dirname, isAbsolute, join } from "node:path";
@@ -798,6 +798,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
798
798
  if (record.owned) {
799
799
  await this.#stopGuardian(record).catch((error) => { record.cleanupError ??= asError(error); });
800
800
  await this.#run(record, ["kill-server"], undefined, undefined, true).catch(() => {});
801
+ removeDeadTmuxSocket(record.handle.tmuxSocket);
801
802
  } else await this.#detachPipe(record);
802
803
  }
803
804
  const deadline = Date.now() + 25_000;
@@ -1670,6 +1671,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
1670
1671
  if (isMissingSession(error)) record.serverKilled = true;
1671
1672
  else record.cleanupError = asError(error);
1672
1673
  }
1674
+ if (record.serverKilled) removeDeadTmuxSocket(record.handle.tmuxSocket);
1673
1675
  if (!record.serverKilled) await this.#ensurePaneGone(record);
1674
1676
  if (record.cgroupPath) {
1675
1677
  try {
@@ -2003,7 +2005,11 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
2003
2005
  record.pendingSentMessages.splice(matchedIndex, 1);
2004
2006
  return {};
2005
2007
  }
2006
- this.#emit(record, { type: "human_input", handle: record.handle, text: boundTextHead(prompt, 4_096) });
2008
+ // Claude Code delivers its own background-task, monitor and agent
2009
+ // completions through this hook as a user-role message; nobody typed it.
2010
+ if (!isClaudeRuntimePrompt(prompt)) {
2011
+ this.#emit(record, { type: "human_input", handle: record.handle, text: boundTextHead(prompt, 4_096) });
2012
+ }
2007
2013
  record.activeRequests = 1;
2008
2014
  record.inputAt = Date.now();
2009
2015
  record.lastInputAt = new Date().toISOString();
@@ -2277,6 +2283,51 @@ function normalizeForMatch(value: string): string {
2277
2283
  return value.trim().replace(/\s+/gu, " ");
2278
2284
  }
2279
2285
 
2286
+ /**
2287
+ * A tmux server removes its socket when it exits normally, but not when it is
2288
+ * killed with its cgroup; a dead `pi-cs-*.sock` is unlinked once no server
2289
+ * answers on it. `sweepDeadTmuxSockets` does the same for every socket the
2290
+ * adapter's naming scheme left behind in the temp directory.
2291
+ */
2292
+ function removeDeadTmuxSocket(socketPath: string | undefined): void {
2293
+ if (!socketPath || !/[\\/]pi-cs-[0-9a-f-]+\.sock$/u.test(socketPath)) return;
2294
+ try {
2295
+ const probe = spawnSync("tmux", ["-S", socketPath, "list-sessions"], { stdio: "ignore", timeout: 2_000 });
2296
+ // A server that answers, or a socket something else holds open (the probe
2297
+ // timed out), is left alone.
2298
+ if (probe.status === 0 || probe.signal) return;
2299
+ unlinkSync(socketPath);
2300
+ } catch { /* already gone, or not ours to remove */ }
2301
+ }
2302
+
2303
+ export function sweepDeadTmuxSockets(directory = tmpdir()): number {
2304
+ let removed = 0;
2305
+ let names: string[];
2306
+ try { names = readdirSync(directory); } catch { return 0; }
2307
+ for (const name of names) {
2308
+ if (!/^pi-cs-[0-9a-f-]+\.sock$/u.test(name)) continue;
2309
+ const socketPath = join(directory, name);
2310
+ try {
2311
+ if (!lstatSync(socketPath).isSocket()) continue;
2312
+ const probe = spawnSync("tmux", ["-S", socketPath, "list-sessions"], { stdio: "ignore", timeout: 2_000 });
2313
+ if (probe.status === 0 || probe.signal) continue;
2314
+ unlinkSync(socketPath);
2315
+ removed += 1;
2316
+ } catch { /* skip */ }
2317
+ }
2318
+ return removed;
2319
+ }
2320
+
2321
+ /**
2322
+ * True for a prompt Claude Code injected itself, which a human never typed:
2323
+ * `<task-notification>`, `<system-reminder>`, `<agent-message …>` and the
2324
+ * other hyphenated runtime wrappers it frames delivered content with. A
2325
+ * person's own message does not begin with such a tag.
2326
+ */
2327
+ function isClaudeRuntimePrompt(prompt: string): boolean {
2328
+ return /^\s*<[a-z]+(?:-[a-z]+)+(?:\s|>)/u.test(prompt);
2329
+ }
2330
+
2280
2331
  /**
2281
2332
  * A UserPromptSubmit hook reports the prompt as Claude's TUI captured it,
2282
2333
  * which can reflow long pasted text. Treat it as the adapter's own send when
@@ -2398,7 +2449,10 @@ function isPaneIdentityError(error: unknown): boolean {
2398
2449
  }
2399
2450
 
2400
2451
  function isMissingSession(error: unknown): boolean {
2401
- return error instanceof Error && /(can't find session|no server running|session not found|failed to connect)/iu.test(error.message);
2452
+ // tmux leaves its socket behind on exit ("no server running") unless it was
2453
+ // unlinked, in which case it reports "error connecting … (No such file or
2454
+ // directory)"; both mean the same thing here.
2455
+ return error instanceof Error && /(can't find session|no server running|session not found|failed to connect|error connecting to [^\n]*No such file or directory)/iu.test(error.message);
2402
2456
  }
2403
2457
 
2404
2458
  function isMissingFile(error: unknown): boolean {