pi-claude-supervisor 0.7.0 → 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,25 @@
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
+
16
+ ## [0.7.1](https://github.com/btnalit/pi-claude-supervisor/compare/v0.7.0...v0.7.1) (2026-09-17)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * **policy:** judge heredoc bodies by their consumer and separate statements on newlines ([ac69208](https://github.com/btnalit/pi-claude-supervisor/commit/ac692083d824b05b7665c2864aad9478f7b367a4))
22
+ * **policy:** stop vetoing ordinary shell and scratchpad writes on the Worker's behalf ([eb72c8f](https://github.com/btnalit/pi-claude-supervisor/commit/eb72c8f267f77f4097f20c325c4087c925be5803))
23
+
5
24
  ## [0.7.0](https://github.com/btnalit/pi-claude-supervisor/compare/v0.6.0...v0.7.0) (2026-09-17)
6
25
 
7
26
 
package/README.cn.md CHANGED
@@ -171,7 +171,13 @@ stream-json` 的方式运行 Claude,完全没有终端界面;一旦设置
171
171
  - 无论策略或权限模式如何,始终拒绝:远程 push、合并/PR 进 `main` 或 integration
172
172
  分支、其他远程 CLI 变更、`.git` 元数据写入,以及对受保护分支的破坏性改写
173
173
  (`reset`、`update-ref`、`symbolic-ref`,或带删除/移动/强制标志的 `branch`)。
174
- 除此之外的一切都按配置的策略处理。
174
+ 策略看不透的 shell 参数(`$VAR`、`$(…)`、通配符)只在可能触及这条边界的命令上
175
+ 被尽力拦截——git、gh、npm/pnpm/yarn、curl/wget/ssh、嵌套的 `claude`,以及
176
+ `eval`、`sh -c`、`xargs`、`find -exec` 之类的解释器/执行器。带引号分隔符的
177
+ heredoc 正文按其消费者判断:交给 shell 就是命令,交给 `cat > file` 或
178
+ `git commit -m` 就是数据。除此之外的一切(`for f in …; do echo "$f"`、
179
+ `rm -rf ./dist`、写入 Claude 自己的 scratchpad)都按配置的策略处理——由
180
+ Claude 自己的权限模式决定,和你亲自运行 Claude 时一样。
175
181
  - `autonomy.permissionAuthority`(`policy` | `hybrid` 默认 |
176
182
  `decision-worker`)决定谁来回答权限请求——headless 模式下是每一个请求,交互式
177
183
  tmux 模式下只是那些 Claude 本来会弹窗问你的请求:`hybrid` 会让策略独自回答
package/README.md CHANGED
@@ -203,7 +203,14 @@ Supervisor being able to see it, or when you don't need to attach.
203
203
  merge/PR into `main` or an integration branch, other remote CLI mutations,
204
204
  `.git` metadata writes, and destructive rewrites of protected branches
205
205
  (`reset`, `update-ref`, `symbolic-ref`, or a delete/move/force `branch`).
206
- Everything else follows the configured policy.
206
+ A shell argument the policy cannot see through (`$VAR`, `$(…)`, a glob)
207
+ is vetoed, best-effort, only on the commands where it could reach that
208
+ boundary — git, gh, npm/pnpm/yarn, curl/wget/ssh, a nested `claude`, or an
209
+ interpreter/runner such as `eval`, `sh -c`, `xargs`, `find -exec`. A quoted
210
+ heredoc body is judged by its consumer: a shell runs it, `cat > file` or
211
+ `git commit -m` stores it. Everything else (`for f in …; do echo "$f"`,
212
+ `rm -rf ./dist`, a Write to Claude's own scratchpad) follows the configured
213
+ policy — Claude's own permission mode governs it, as when you run Claude.
207
214
  - `autonomy.permissionAuthority` (`policy` | `hybrid` default |
208
215
  `decision-worker`) controls who answers a permission request — every
209
216
  request in headless mode, and in interactive tmux mode only those Claude
@@ -21,8 +21,12 @@ custom/nested descendants are trusted rather than denied by a nested-process gua
21
21
  ## Findings addressed in this pass
22
22
 
23
23
  - Policy now evaluates every shell argument as well as the executable and argv
24
- together; dynamic arguments are denied because their capability cannot be checked,
25
- including Claude permission-bypass flags.
24
+ together. A dynamic argument (`$VAR`, `$(…)`, a glob) is denied on the commands
25
+ where it could reach the boundary — repository, package, network, nested
26
+ `claude`, interpreters and runners, or a dynamic command name — as a best-effort
27
+ veto; elsewhere it is ordinary shell that Claude's own permission mode governs.
28
+ Quoted heredoc bodies are evaluated according to their consumer (a shell runs
29
+ them, a data sink stores them, anything else keeps them visible to the checks).
26
30
  - Automatic startup pins a secure resolved Claude executable identity and rejects
27
31
  explicit paths, persists that identity for recovery, and rechecks the exact startup
28
32
  HEAD through the built-in adapter's final `preSpawnCheck` immediately before spawn.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-claude-supervisor",
3
- "version": "0.7.0",
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
  }
@@ -24,6 +24,8 @@ export interface ClaudeHookEvent {
24
24
  permission_mode?: string;
25
25
  /** SessionStart */
26
26
  source?: string;
27
+ /** SessionStart: Claude Code's per-session scratchpad directory (outside the cwd). */
28
+ scratchpad_dir?: string;
27
29
  /** SessionEnd */
28
30
  reason?: string;
29
31
  /** UserPromptSubmit */
package/src/policy.ts CHANGED
@@ -15,12 +15,17 @@ export interface PolicyResult {
15
15
  * allowed; only the explicit unattended interaction and repository/remote
16
16
  * authority boundaries below remain special-cased.
17
17
  */
18
- export function evaluatePermission(toolName: string, input: unknown, cwd = process.cwd()): PolicyResult {
18
+ export interface PermissionPolicyOptions {
19
+ /** Extra directories the Worker may write to (Claude's per-session scratchpad); each must be an absolute path. */
20
+ writeRoots?: readonly string[];
21
+ }
22
+
23
+ export function evaluatePermission(toolName: string, input: unknown, cwd = process.cwd(), options: PermissionPolicyOptions = {}): PolicyResult {
19
24
  if (toolName === "AskUserQuestion") return { decision: "deny", reason: "interactive questions are converted to ordinary Worker text" };
20
25
  if (toolName === "Edit" || toolName === "Write" || toolName === "NotebookEdit") {
21
26
  const paths = fileToolPaths(input);
22
27
  if (paths.length === 0) return { decision: "deny", reason: `${toolName} request has no recognizable file path` };
23
- const violation = paths.map((path) => ({ path, classification: classifyWritePath(path, cwd) })).find((entry) => entry.classification !== undefined);
28
+ const violation = paths.map((path) => ({ path, classification: classifyWritePath(path, cwd, options.writeRoots) })).find((entry) => entry.classification !== undefined);
24
29
  if (violation?.classification === "outside-cwd") return { decision: "deny", reason: `Worker cannot write outside the task working directory: ${violation.path}` };
25
30
  if (violation?.classification === "git-metadata") return { decision: "deny", reason: "Worker cannot write Git metadata or protected branch refs" };
26
31
  return { decision: "allow", reason: `local Claude file tool is allowed by the task policy: ${toolName}` };
@@ -30,7 +35,9 @@ export function evaluatePermission(toolName: string, input: unknown, cwd = proce
30
35
  ? (input as { command: string }).command
31
36
  : "";
32
37
  if (!command) return { decision: "deny", reason: "Bash request has no recognizable command" };
33
- return evaluateCommand("bash", ["-lc", command]);
38
+ // Lex the command itself: wrapping it as a literal `bash -lc` argument would
39
+ // hide its structure (heredoc bodies, dynamic words) from the token checks.
40
+ return evaluateCommand(command);
34
41
  }
35
42
 
36
43
  function fileToolPaths(input: unknown): string[] {
@@ -43,8 +50,16 @@ function fileToolPaths(input: unknown): string[] {
43
50
 
44
51
  type WritePathViolation = "outside-cwd" | "git-metadata";
45
52
 
46
- function classifyWritePath(value: string, cwd: string): WritePathViolation | undefined {
53
+ function classifyWritePath(value: string, cwd: string, writeRoots: readonly string[] = []): WritePathViolation | undefined {
47
54
  if (value.replaceAll("\\", "/").split("/").some((segment) => segment.toLowerCase() === ".git")) return "git-metadata";
55
+ // A path inside an extra write root (Claude's own scratchpad) is judged
56
+ // against that root instead of the cwd, with the same symlink/metadata rules.
57
+ for (const root of writeRoots) {
58
+ if (!isAbsolute(root) || !isAbsolute(value)) continue;
59
+ const rel = relative(root, value);
60
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) continue;
61
+ return classifyWritePath(value, root);
62
+ }
48
63
  let root: string;
49
64
  try { root = realpathSync(cwd); }
50
65
  catch { return "git-metadata"; }
@@ -93,12 +108,14 @@ function classifyWritePath(value: string, cwd: string): WritePathViolation | und
93
108
  }
94
109
 
95
110
  const deniedPatterns = [
96
- /\b(?:npm|pnpm|yarn)\b[\s\S]*\bpublish\b/iu,
111
+ // `publish` must be the subcommand; a later argument that merely contains the
112
+ // word (scripts/publish-package.mjs) is not a publication.
113
+ /\b(?:npm|pnpm|yarn)\b(?:\s+-\S+)*\s+publish\b/iu,
97
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,
98
- /(?:\$\{?[^\s`}]+\}?|`[^`]*`|\$\([^)]*\))[\s\S]*\b(?:push|merge|publish)\b|\b(?:push|merge|publish)\b[\s\S]*(?:\$\{?[^\s`}]+\}?|`[^`]*`|\$\([^)]*\))/iu,
99
115
  /--(?:allow-)?dangerously-skip-permissions\b/iu,
100
116
  /--permission-mode\s+(?:bypasspermissions|dontask)\b/iu,
101
- /\brm\s+-rf\s+\//iu,
117
+ // Only the filesystem root itself; `rm -rf /abs/path/dist` is ordinary local work.
118
+ /\brm\s+(?:-\S+\s+)*\/+(?:\*|\s|$)/iu,
102
119
  /\bmkfs(?:\.|\s)/iu,
103
120
  /\bdd\s+if=/iu,
104
121
  /:\(\)\s*\{\s*:\|/u,
@@ -109,9 +126,34 @@ interface ShellToken {
109
126
  value: string;
110
127
  operator: boolean;
111
128
  dynamic: boolean;
129
+ /** A quoted heredoc body: literal text whose meaning depends on the command that consumes it. */
130
+ data?: boolean;
112
131
  }
113
132
 
114
133
  const protectedBranches = new Set(["main", "master", "trunk", "integration", "develop"]);
134
+ /**
135
+ * Commands whose dynamic arguments could carry a boundary-crossing action or
136
+ * execute arbitrary expanded text: the repository, package, network and
137
+ * nested-worker surfaces, plus interpreters, runners and the catastrophe guards.
138
+ */
139
+ const DYNAMIC_SENSITIVE_COMMANDS = new Set([
140
+ "git", "gh", "glab", "hub", "npm", "pnpm", "yarn", "npx", "curl", "wget", "ssh", "scp", "rsync", "sftp", "claude",
141
+ "eval", "exec", "source", "sh", "bash", "zsh", "dash", "ksh", "fish", "xargs", "env", "sudo", "su", "doas",
142
+ "timeout", "time", "nice", "nohup", "command", "builtin", "watch", "setsid", "strace", "ltrace", "stdbuf", "flock",
143
+ "unshare", "nsenter", "chroot", "script", "parallel", "expect", "ionice", "chrt", "taskset", "crontab", "at", "batch",
144
+ "systemd-run", "dd", "mkfs", "shred",
145
+ ]);
146
+ /** `find` runs its `-exec`/`-ok` argv and so joins the sensitive set when one is present. */
147
+ const FIND_EXEC_ACTIONS = new Set(["-exec", "-execdir", "-ok", "-okdir"]);
148
+ /** Commands that only store or display their input; a quoted heredoc fed to one never executes. */
149
+ const DATA_SINK_COMMANDS = new Set([
150
+ "cat", "tee", "head", "tail", "grep", "rg", "wc", "sort", "uniq", "cut", "tr", "diff", "less", "more", "base64",
151
+ "md5sum", "sha1sum", "sha256sum", "jq", "column", "fold", "paste", "comm", "cmp", "od", "hexdump", "xxd", "nl", "tac", "rev",
152
+ "echo", "printf",
153
+ ]);
154
+ const SHELL_NAMES = new Set(["sh", "bash", "dash", "zsh", "fish", "ksh"]);
155
+ /** Shell words after which the next word is again in command position. */
156
+ const COMMAND_POSITION_KEYWORDS = new Set(["if", "then", "elif", "else", "while", "until", "do", "!", "(", "{", "time", "exec", "command", "builtin", "nohup", "sudo", "doas"]);
115
157
  /** Rewriting a protected ref's identity directly; `checkout`/`switch`/`restore`/`worktree` are read-only uses of a branch name and are not included. */
116
158
  const protectedBranchRewriteOperations = new Set(["reset", "update-ref", "symbolic-ref"]);
117
159
  /** `branch` only rewrites or deletes a protected branch when combined with one of these flags. */
@@ -164,8 +206,13 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
164
206
  const hasForcedBranchCreate = (lower.includes("checkout") && rawValues.includes("-B"))
165
207
  || (lower.includes("switch") && (rawValues.includes("-C") || rawValues.includes("--force-create")));
166
208
 
167
- if (hasDynamicArgument) {
168
- return { decision: "deny", reason: "dynamic shell arguments cannot be capability-checked safely" };
209
+ // An argument the lexer cannot see through matters only where it could reach
210
+ // the boundary: a repository, package, network or remote-shell command, or an
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))) {
215
+ return { decision: "deny", reason: "a repository, package, network or shell command with a dynamic argument cannot be capability-checked" };
169
216
  }
170
217
  if (/\bgit\b[\s\S]*\b(?:push|merge(?!-)|send-pack|receive-pack|update-ref)\b/iu.test(canonical)
171
218
  || /\bgit-(?:send|receive|upload)-pack\b/iu.test(canonical)
@@ -198,14 +245,71 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
198
245
  return undefined;
199
246
  }
200
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
+
285
+ /** A dynamic word in command position (`$CMD …`, `; $CMD`, `do . $file`) could name anything. */
286
+ function hasDynamicCommandName(tokens: readonly ShellToken[]): boolean {
287
+ let commandPosition = true;
288
+ for (const token of tokens) {
289
+ if (token.operator) {
290
+ commandPosition = SEGMENT_SPLIT_OPERATORS.has(token.value);
291
+ continue;
292
+ }
293
+ if (!commandPosition) continue;
294
+ // `VAR=value cmd` keeps the following word in command position; the value itself is data.
295
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(token.value)) continue;
296
+ if (token.dynamic) return true;
297
+ const word = token.value.toLowerCase().replace(/^\(+/u, "") || "(";
298
+ if (word === "." || word === "source") return true;
299
+ if (COMMAND_POSITION_KEYWORDS.has(word)) continue;
300
+ commandPosition = false;
301
+ }
302
+ return false;
303
+ }
304
+
201
305
  function nestedShellCommands(lower: readonly string[], values: readonly string[]): string[] {
202
306
  const nested: string[] = [];
203
- const shellNames = new Set(["sh", "bash", "dash", "zsh", "fish", "ksh"]);
204
307
  for (let index = 0; index < lower.length; index += 1) {
205
308
  const executable = lower[index]!.split(/[\\/]/u).at(-1);
206
- if (executable && shellNames.has(executable)) {
309
+ if (executable && SHELL_NAMES.has(executable)) {
207
310
  for (let option = index + 1; option < lower.length; option += 1) {
208
- if (["-c", "--command"].includes(lower[option]!)) {
311
+ // `-c`, `--command`, or a combined short option such as `-lc` / `-ec`.
312
+ if (lower[option] === "--command" || /^-[a-z]*c[a-z]*$/u.test(lower[option]!)) {
209
313
  const command = values.slice(option + 1).join(" ").trim();
210
314
  if (command) nested.push(command);
211
315
  break;
@@ -228,7 +332,14 @@ function evaluateCommandInternal(command: string, depth: number): PolicyResult {
228
332
  return evaluateTokens(lexical.tokens, depth);
229
333
  }
230
334
 
231
- function evaluateTokens(tokens: readonly ShellToken[], depth: number): PolicyResult {
335
+ function evaluateTokens(rawTokens: readonly ShellToken[], depth: number): PolicyResult {
336
+ const { tokens, embedded } = resolveDataTokens(rawTokens);
337
+ if (depth < 4) {
338
+ for (const body of embedded) {
339
+ const nestedResult = evaluateCommandInternal(body, depth + 1);
340
+ if (nestedResult.decision === "deny") return nestedResult;
341
+ }
342
+ }
232
343
  const canonical = tokens.map((token) => token.value).join(" ").trim();
233
344
  if (!canonical) return { decision: "deny", reason: "empty command" };
234
345
  const boundary = evaluateRepositoryBoundary(tokens, canonical, depth);
@@ -244,7 +355,7 @@ function evaluateTokens(tokens: readonly ShellToken[], depth: number): PolicyRes
244
355
  || /\bgit\b[\s\S]*\bbranch\b[\s\S]*(?:^|\s)(?:-d|-m|-f|--force|--delete|--move)\b[\s\S]*\b(?:main|master|trunk|integration|develop)\b/iu.test(canonical)) {
245
356
  return { decision: "deny", reason: "Worker cannot rewrite or delete a protected integration branch" };
246
357
  }
247
- if (/\b(?:npm|pnpm|yarn)\b[\s\S]*\bpublish\b/iu.test(canonical)) {
358
+ if (/\b(?:npm|pnpm|yarn)\b(?:\s+-\S+)*\s+publish\b/iu.test(canonical)) {
248
359
  return { decision: "deny", reason: "package publication belongs to the protected release workflow" };
249
360
  }
250
361
  return { decision: "deny", reason: "command matches a prohibited destructive pattern" };
@@ -252,23 +363,126 @@ function evaluateTokens(tokens: readonly ShellToken[], depth: number): PolicyRes
252
363
  return { decision: "allow", reason: "command is allowed for unattended local development" };
253
364
  }
254
365
 
366
+ /**
367
+ * A quoted heredoc body means whatever its consumer makes of it. Fed to a shell
368
+ * (`bash <<'EOF'`, `cat <<'EOF' | sh`, `eval "$(cat <<'EOF' …)"`) it is a
369
+ * command and is evaluated as one; fed to a pure data sink (`cat > file`,
370
+ * `git commit -m`) it is text the boundary never needs to see; fed to anything
371
+ * else it stays in the command as literal words for the pattern checks.
372
+ */
373
+ function resolveDataTokens(tokens: readonly ShellToken[]): { tokens: ShellToken[]; embedded: string[] } {
374
+ if (!tokens.some((token) => token.data)) return { tokens: [...tokens], embedded: [] };
375
+ const segments: { tokens: ShellToken[]; joiner?: string }[] = [{ tokens: [] }];
376
+ for (const token of tokens) {
377
+ if (token.operator && SEGMENT_SPLIT_OPERATORS.has(token.value)) {
378
+ segments.at(-1)!.joiner = token.value;
379
+ segments.push({ tokens: [] });
380
+ continue;
381
+ }
382
+ segments.at(-1)!.tokens.push(token);
383
+ }
384
+ const commandOf = (segment: readonly ShellToken[]): { name: string; words: string[] } | undefined => {
385
+ let afterRedirect = false;
386
+ let name: string | undefined;
387
+ const words: string[] = [];
388
+ for (const token of segment) {
389
+ if (token.operator) { afterRedirect = [">", ">>", "<", "<<", "<<<"].includes(token.value); continue; }
390
+ const skip = afterRedirect;
391
+ afterRedirect = false;
392
+ if (skip || token.data) continue;
393
+ const word = token.value.toLowerCase();
394
+ if (name === undefined) {
395
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(token.value) || COMMAND_POSITION_KEYWORDS.has(word)) continue;
396
+ name = word.replace(/^\(+/u, "").split(/[\\/]/u).at(-1) ?? word;
397
+ }
398
+ words.push(word);
399
+ }
400
+ return name === undefined ? undefined : { name, words };
401
+ };
402
+ const resolved: ShellToken[] = [];
403
+ const embedded: string[] = [];
404
+ segments.forEach((segment, index) => {
405
+ const bodies = segment.tokens.filter((token) => token.data);
406
+ if (bodies.length === 0) {
407
+ resolved.push(...segment.tokens, ...(segment.joiner ? [{ value: segment.joiner, operator: true, dynamic: false }] : []));
408
+ return;
409
+ }
410
+ const consumers: { name: string; words: string[] }[] = [];
411
+ for (let cursor = index; cursor < segments.length; cursor += 1) {
412
+ const command = commandOf(segments[cursor]!.tokens);
413
+ if (command) consumers.push(command);
414
+ if (segments[cursor]!.joiner !== "|") break;
415
+ }
416
+ const shellConsumer = consumers.some((consumer) => SHELL_NAMES.has(consumer.name) || consumer.name === "eval" || consumer.name === "source" || consumer.name === ".");
417
+ const sinkOnly = consumers.length > 0 && consumers.every((consumer) => DATA_SINK_COMMANDS.has(consumer.name) || (consumer.name === "git" && consumer.words.includes("commit")));
418
+ if (shellConsumer) embedded.push(...bodies.map((token) => token.value));
419
+ const kept = shellConsumer || sinkOnly ? segment.tokens.filter((token) => !token.data) : segment.tokens.map((token) => ({ ...token, data: false }));
420
+ resolved.push(...kept, ...(segment.joiner ? [{ value: segment.joiner, operator: true, dynamic: false }] : []));
421
+ });
422
+ return { tokens: resolved, embedded };
423
+ }
424
+
425
+ /**
426
+ * `$(cat <<'EOF' … EOF\n)` inside double quotes: Claude Code's commit-message
427
+ * idiom. With a quoted delimiter nothing in the body expands or runs, so the
428
+ * whole substitution is literal data.
429
+ */
430
+ const LITERAL_CAT_HEREDOC = /^\$\(\s*cat\s+<<-?\s*(['"])([^'"\s]+)\1[ \t]*\n(?:([\s\S]*?)\n)?[ \t]*\2[ \t]*\n?\s*\)/u;
431
+
255
432
  function lexShell(input: string): { tokens: ShellToken[]; error?: string } {
256
433
  const tokens: ShellToken[] = [];
257
434
  let value = "";
258
435
  let dynamic = false;
259
436
  let started = false;
437
+ let tokenQuoted = false;
438
+ let tokenData = false;
260
439
  let quote: "single" | "double" | undefined;
440
+ // Heredocs: the word after `<<` names the delimiter; the body starts on the
441
+ // next line and ends at a line equal to it. A quoted delimiter makes the body
442
+ // pure data, which the boundary never needs to see.
443
+ let expectDelimiter: { stripTabs: boolean } | undefined;
444
+ const pendingHeredocs: { delimiter: string; quoted: boolean; stripTabs: boolean }[] = [];
261
445
  const flush = (): void => {
262
446
  if (!started) return;
263
- tokens.push({ value, operator: false, dynamic });
447
+ // A bare `[`, `[[`, `]`, `]]`, `{` or `}` is shell syntax, not an expansion.
448
+ if (/^[[\]{}]+$/u.test(value)) dynamic = false;
449
+ if (expectDelimiter) {
450
+ pendingHeredocs.push({ delimiter: value, quoted: tokenQuoted, stripTabs: expectDelimiter.stripTabs });
451
+ expectDelimiter = undefined;
452
+ dynamic = false;
453
+ }
454
+ tokens.push({ value, operator: false, dynamic, ...(tokenData ? { data: true } : {}) });
264
455
  value = "";
265
456
  dynamic = false;
266
457
  started = false;
458
+ tokenQuoted = false;
459
+ tokenData = false;
267
460
  };
268
461
  const pushOperator = (operator: string): void => {
269
462
  flush();
270
463
  tokens.push({ value: operator, operator: true, dynamic: false });
271
464
  };
465
+ /** Consume the heredoc bodies that start after the newline at `newlineIndex`; returns the index to resume lexing at. */
466
+ const consumeHeredocs = (newlineIndex: number): number => {
467
+ let position = newlineIndex + 1;
468
+ for (const heredoc of pendingHeredocs.splice(0)) {
469
+ const bodyLines: string[] = [];
470
+ let terminated = false;
471
+ while (position <= input.length) {
472
+ const lineEnd = input.indexOf("\n", position);
473
+ const line = input.slice(position, lineEnd === -1 ? input.length : lineEnd);
474
+ position = lineEnd === -1 ? input.length + 1 : lineEnd + 1;
475
+ if ((heredoc.stripTabs ? line.replace(/^\t+/u, "") : line) === heredoc.delimiter) { terminated = true; break; }
476
+ bodyLines.push(line);
477
+ }
478
+ const body = bodyLines.join("\n");
479
+ // A quoted delimiter suppresses expansion: the body is data for its
480
+ // consumer. An unquoted one expands, so the body is an ordinary argument.
481
+ tokens.push(heredoc.quoted ? { value: body, operator: false, dynamic: false, data: true } : { value: body, operator: false, dynamic: /[$`]/u.test(body) });
482
+ if (!terminated) break;
483
+ }
484
+ return Math.min(position, input.length);
485
+ };
272
486
  for (let index = 0; index < input.length; index += 1) {
273
487
  const character = input[index]!;
274
488
  const next = input[index + 1];
@@ -282,18 +496,39 @@ function lexShell(input: string): { tokens: ShellToken[]; error?: string } {
282
496
  if (character === '"') quote = undefined;
283
497
  else if (character === "\\" && next === "\n") index += 1;
284
498
  else if (character === "\\" && next !== undefined && /[\\"$`]/u.test(next)) { value += next; index += 1; }
285
- else { value += character; if (character === "$" || character === "`") dynamic = true; }
499
+ else if (character === "$") {
500
+ const literal = LITERAL_CAT_HEREDOC.exec(input.slice(index));
501
+ if (literal) { value += literal[3] ?? ""; tokenData = true; index += literal[0].length - 1; }
502
+ else { value += character; dynamic = true; }
503
+ }
504
+ else { value += character; if (character === "`") dynamic = true; }
286
505
  started = true;
287
506
  continue;
288
507
  }
289
- if (character === "'") { quote = "single"; started = true; continue; }
290
- if (character === '"') { quote = "double"; started = true; continue; }
508
+ if (character === "'") { quote = "single"; started = true; tokenQuoted = true; continue; }
509
+ if (character === '"') { quote = "double"; started = true; tokenQuoted = true; continue; }
291
510
  if (character === "\\") {
292
511
  if (next === "\n") index += 1;
293
512
  else if (next !== undefined) { value += next; index += 1; }
294
513
  started = true;
295
514
  continue;
296
515
  }
516
+ // A comment runs to the end of the line.
517
+ if (character === "#" && !started) {
518
+ const lineEnd = input.indexOf("\n", index);
519
+ index = (lineEnd === -1 ? input.length : lineEnd) - 1;
520
+ continue;
521
+ }
522
+ // A newline ends the statement. Any pending heredoc bodies belong to the
523
+ // statement just lexed, so they are emitted before the separator.
524
+ if (character === "\n") {
525
+ flush();
526
+ expectDelimiter = undefined;
527
+ if (pendingHeredocs.length > 0) index = consumeHeredocs(index) - 1;
528
+ const last = tokens.at(-1);
529
+ if (last && !(last.operator && SEGMENT_SPLIT_OPERATORS.has(last.value))) pushOperator(";");
530
+ continue;
531
+ }
297
532
  if (/\s/u.test(character)) { flush(); continue; }
298
533
  if (character === "$" || character === "`") { dynamic = true; value += character; started = true; continue; }
299
534
  // Brace, tilde and pathname expansion can change command names, targets or
@@ -301,7 +536,16 @@ function lexShell(input: string): { tokens: ShellToken[]; error?: string } {
301
536
  // dynamic rather than attempting to model Bash's expansion order.
302
537
  if ("*?[]{}~".includes(character)) { dynamic = true; value += character; started = true; continue; }
303
538
  if (";&|<>".includes(character)) {
304
- const operator = next && ((character === "&" && next === "&") || (character === "|" && next === "|") || (character === ">" && next === ">") || (character === "<" && next === "<"))
539
+ if (character === "<" && next === "<") {
540
+ const third = input[index + 2];
541
+ if (third === "<") { pushOperator("<<<"); index += 2; continue; }
542
+ const stripTabs = third === "-";
543
+ pushOperator("<<");
544
+ expectDelimiter = { stripTabs };
545
+ index += stripTabs ? 2 : 1;
546
+ continue;
547
+ }
548
+ const operator = next && ((character === "&" && next === "&") || (character === "|" && next === "|") || (character === ">" && next === ">"))
305
549
  ? `${character}${next}`
306
550
  : character;
307
551
  pushOperator(operator);
@@ -360,8 +604,8 @@ const FIND_WRITE_ACTIONS = new Set(["-delete", "-exec", "-execdir", "-ok", "-okd
360
604
  * escalation, destructive git operations and any write outside the task cwd
361
605
  * are never routine; the Decision Worker judges those.
362
606
  */
363
- export function isRoutinePermission(toolName: string, input: unknown, cwd: string): boolean {
364
- const policyResult = evaluatePermission(toolName, input, cwd);
607
+ export function isRoutinePermission(toolName: string, input: unknown, cwd: string, options: PermissionPolicyOptions = {}): boolean {
608
+ const policyResult = evaluatePermission(toolName, input, cwd, options);
365
609
  if (policyResult.decision === "deny") return false;
366
610
  if (toolName === "Edit" || toolName === "Write" || toolName === "NotebookEdit") return true;
367
611
  if (toolName === "Read" || toolName === "Glob" || toolName === "Grep" || toolName === "LS" || toolName === "TodoWrite") return true;
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
@@ -651,7 +659,7 @@ export class Supervisor {
651
659
  this.#pendingPermissions.delete(event.request.requestId);
652
660
  skipDecisionNotify = true;
653
661
  } else {
654
- const policy = evaluatePermission(event.request.toolName, event.request.input, task.cwd);
662
+ const policy = evaluatePermission(event.request.toolName, event.request.input, task.cwd, { writeRoots: event.request.writeRoots });
655
663
  if (policy.decision === "deny") {
656
664
  if (this.#adapter.respondPermission) {
657
665
  await this.#adapter.respondPermission(handle, event.request.requestId, event.request.toolUseId, {
@@ -682,9 +690,9 @@ export class Supervisor {
682
690
  skipDecisionNotify = true;
683
691
  } else if (this.#automation && !this.#humanRequired) {
684
692
  const authority = task.spec.autonomy.permissionAuthority;
685
- const policy = evaluatePermission(event.request.toolName, event.request.input, task.cwd);
693
+ const policy = evaluatePermission(event.request.toolName, event.request.input, task.cwd, { writeRoots: event.request.writeRoots });
686
694
  const answerLocally = authority === "policy"
687
- || (authority === "hybrid" && (policy.decision === "deny" || isRoutinePermission(event.request.toolName, event.request.input, task.cwd)));
695
+ || (authority === "hybrid" && (policy.decision === "deny" || isRoutinePermission(event.request.toolName, event.request.input, task.cwd, { writeRoots: event.request.writeRoots })));
688
696
  if (answerLocally && this.#adapter.respondPermission) {
689
697
  const behavior: "allow" | "deny" = policy.decision === "deny" ? "deny" : "allow";
690
698
  await this.#adapter.respondPermission(handle, event.request.requestId, event.request.toolUseId, {
@@ -865,7 +873,7 @@ export class Supervisor {
865
873
  // checked before the generic policy-deny reason, or the Decision
866
874
  // Worker's chosen answer would never reach Claude.
867
875
  const isAskUserQuestionAnswer = event.request.toolName === "AskUserQuestion" && action.action === "deny_permission";
868
- const policy = evaluatePermission(event.request.toolName, event.request.input, task.cwd);
876
+ const policy = evaluatePermission(event.request.toolName, event.request.input, task.cwd, { writeRoots: event.request.writeRoots });
869
877
  const behavior = policy.decision === "deny" ? "deny" : action.action === "allow_permission" ? "allow" : "deny";
870
878
  const message = behavior !== "deny"
871
879
  ? undefined
@@ -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
@@ -1042,7 +1102,7 @@ export class Supervisor {
1042
1102
  const request = requestId ? this.#pendingPermissions.get(requestId) : [...this.#pendingPermissions.values()].at(-1);
1043
1103
  if (!request) throw new Error("no pending permission request");
1044
1104
  if (this.#humanRequired && this.#humanGate !== "permission") throw new Error("automatic decisions are held by a separate human gate; use resume-auto explicitly");
1045
- const policy = evaluatePermission(request.toolName, request.input, task.cwd);
1105
+ const policy = evaluatePermission(request.toolName, request.input, task.cwd, { writeRoots: request.writeRoots });
1046
1106
  if (policy.decision === "deny" && behavior === "allow") throw new Error(`permission denied by policy: ${policy.reason}`);
1047
1107
  await this.#adapter.respondPermission(handle, request.requestId, request.toolUseId, { behavior: policy.decision === "deny" ? "deny" : behavior }, behavior === "allow" ? request.input : undefined);
1048
1108
  this.#pendingPermissions.delete(request.requestId);
@@ -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> {
package/src/types.ts CHANGED
@@ -25,6 +25,12 @@ export interface WorkerPermissionRequest {
25
25
  * prompt to a human. Bridge/JSONL requests have no phase.
26
26
  */
27
27
  phase?: "pre" | "prompt";
28
+ /**
29
+ * Directories outside the task cwd that the Worker may write to for this
30
+ * session — today Claude Code's own per-session scratchpad, reported by its
31
+ * SessionStart hook. Never a repository or a home directory.
32
+ */
33
+ writeRoots?: string[];
28
34
  }
29
35
 
30
36
  export type WorkerEvent =
@@ -120,6 +120,8 @@ interface TmuxRecord {
120
120
  hookUnsubscribe?: () => Promise<void>;
121
121
  claudeSessionId?: string;
122
122
  transcriptPath?: string;
123
+ /** Claude Code's per-session scratchpad directory (from SessionStart); an extra write root for the policy. */
124
+ scratchpadDir?: string;
123
125
  /** Primary readiness signal for interactive startup: SessionStart observed. */
124
126
  sessionStartReceived: boolean;
125
127
  /** Messages the adapter itself pasted, awaiting UserPromptSubmit acknowledgement. */
@@ -1990,6 +1992,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
1990
1992
  record.claudeSessionId ??= event.session_id;
1991
1993
  record.handle.sessionId = event.session_id;
1992
1994
  if (event.transcript_path) record.transcriptPath = event.transcript_path;
1995
+ if (typeof event.scratchpad_dir === "string" && isAbsolute(event.scratchpad_dir) && !event.scratchpad_dir.includes("\0")) record.scratchpadDir = event.scratchpad_dir;
1993
1996
  record.sessionStartReceived = true;
1994
1997
  return {};
1995
1998
  }
@@ -2000,7 +2003,11 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
2000
2003
  record.pendingSentMessages.splice(matchedIndex, 1);
2001
2004
  return {};
2002
2005
  }
2003
- 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
+ }
2004
2011
  record.activeRequests = 1;
2005
2012
  record.inputAt = Date.now();
2006
2013
  record.lastInputAt = new Date().toISOString();
@@ -2069,6 +2076,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
2069
2076
  input: event.tool_input,
2070
2077
  raw: event as unknown as Record<string, unknown>,
2071
2078
  phase,
2079
+ ...(record.scratchpadDir ? { writeRoots: [record.scratchpadDir] } : {}),
2072
2080
  },
2073
2081
  });
2074
2082
  return new Promise<HookRelayReply>((resolve) => {
@@ -2273,6 +2281,11 @@ function normalizeForMatch(value: string): string {
2273
2281
  return value.trim().replace(/\s+/gu, " ");
2274
2282
  }
2275
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
+
2276
2289
  /**
2277
2290
  * A UserPromptSubmit hook reports the prompt as Claude's TUI captured it,
2278
2291
  * which can reflow long pasted text. Treat it as the adapter's own send when