pi-claude-supervisor 0.7.1 → 0.7.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 CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to this project will be documented here.
4
4
 
5
+ ## [0.7.2](https://github.com/btnalit/pi-claude-supervisor/compare/v0.7.1...v0.7.2) (2026-09-17)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * **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))
11
+ * **policy:** an environment prefix's dynamic value is not a dynamic argument ([b53e77c](https://github.com/btnalit/pi-claude-supervisor/commit/b53e77c6932851a1c88452bca38c8d5bdf1030b5))
12
+ * **policy:** scope the dynamic-argument veto to the statement holding the sensitive command ([5515a2b](https://github.com/btnalit/pi-claude-supervisor/commit/5515a2b0d746d3d3cd05b0ef4ba86602f92eb072))
13
+ * **redaction:** keep numeric token counts in usage events ([6d4e183](https://github.com/btnalit/pi-claude-supervisor/commit/6d4e1831b588b71175d581764b611e5ed5cbe664))
14
+ * **supervisor:** survive Claude's own background work in an interactive session ([d883b9a](https://github.com/btnalit/pi-claude-supervisor/commit/d883b9a2b187c55693ff2669a1ba4f7f949272d3))
15
+
5
16
  ## [0.7.1](https://github.com/btnalit/pi-claude-supervisor/compare/v0.7.0...v0.7.1) (2026-09-17)
6
17
 
7
18
 
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.2",
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/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;
@@ -326,9 +358,6 @@ function evaluateTokens(rawTokens: readonly ShellToken[], depth: number): Policy
326
358
  if (/\b(?:npm|pnpm|yarn)\b(?:\s+-\S+)*\s+publish\b/iu.test(canonical)) {
327
359
  return { decision: "deny", reason: "package publication belongs to the protected release workflow" };
328
360
  }
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
361
  return { decision: "deny", reason: "command matches a prohibited destructive pattern" };
333
362
  }
334
363
  return { decision: "allow", reason: "command is allowed for unattended local development" };
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> {
@@ -2003,7 +2003,11 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
2003
2003
  record.pendingSentMessages.splice(matchedIndex, 1);
2004
2004
  return {};
2005
2005
  }
2006
- this.#emit(record, { type: "human_input", handle: record.handle, text: boundTextHead(prompt, 4_096) });
2006
+ // Claude Code delivers its own background-task, monitor and agent
2007
+ // completions through this hook as a user-role message; nobody typed it.
2008
+ if (!isClaudeRuntimePrompt(prompt)) {
2009
+ this.#emit(record, { type: "human_input", handle: record.handle, text: boundTextHead(prompt, 4_096) });
2010
+ }
2007
2011
  record.activeRequests = 1;
2008
2012
  record.inputAt = Date.now();
2009
2013
  record.lastInputAt = new Date().toISOString();
@@ -2277,6 +2281,11 @@ function normalizeForMatch(value: string): string {
2277
2281
  return value.trim().replace(/\s+/gu, " ");
2278
2282
  }
2279
2283
 
2284
+ /** True for a prompt Claude Code injected itself (`<task-notification>`, `<system-reminder>`), which a human never typed. */
2285
+ function isClaudeRuntimePrompt(prompt: string): boolean {
2286
+ return /^\s*<(?:task-notification|system-reminder)\b/u.test(prompt);
2287
+ }
2288
+
2280
2289
  /**
2281
2290
  * A UserPromptSubmit hook reports the prompt as Claude's TUI captured it,
2282
2291
  * which can reflow long pasted text. Treat it as the adapter's own send when