pi-claude-supervisor 0.6.0 → 0.7.0

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.
@@ -79,9 +79,13 @@ one capability from the other.
79
79
  This is a control-boundary fixture and headless transport. It does not emulate a
80
80
  terminal. Manual compatibility mode remains `process-pipe`; automatic mode
81
81
  (`PI_CLAUDE_SUPERVISOR_MODE=auto`) defaults to Claude JSONL and can select the
82
- Supervisor-owned tmux bridge. The bridge uses the CLI contract validated by the
82
+ tmux transport, which by default drives the real interactive Claude TUI through
83
+ Claude Code hooks (`PI_CLAUDE_SUPERVISOR_TMUX_MODE=interactive`, see "Interactive
84
+ tmux transport" below) and can fall back to the Supervisor-owned bridge
85
+ (`TMUX_MODE=bridge`). The bridge uses the CLI contract validated by the
83
86
  fixed-version spike, renders the stream in the pane and carries structured records
84
- through private framing on the same PTY; explicit adoption remains manual-only.
87
+ through private framing on the same PTY; the bridge cannot adopt an existing
88
+ session, whereas the interactive mode adopts one through the user-level hook relay.
85
89
 
86
90
  A worker exit automatically triggers cleanup, and terminal status waits for
87
91
  that cleanup to be confirmed (or reports a cleanup error). Automatic Claude
@@ -185,6 +189,88 @@ private tmux server teardown, including after partial session creation, and a
185
189
  confirmed `kill-server` is sufficient cleanup evidence. A normal Claude
186
190
  `--resume` starts another process from history and is not a live PTY migration.
187
191
 
192
+ ### Interactive tmux transport
193
+
194
+ `PI_CLAUDE_SUPERVISOR_TMUX_MODE=interactive` (the default when `TRANSPORT=tmux`)
195
+ runs the real Claude Code TUI in the tmux pane instead of the structured
196
+ stream-json bridge described above (`bridge` mode is the pre-existing
197
+ transport, kept as an opt-out). `TmuxWorkerAdapter.start({ automatic: true,
198
+ interactive: true, ... })` selects it; `interactive` and `structured` (bridge)
199
+ are mutually exclusive sub-modes of the automatic tmux transport.
200
+
201
+ **Hook relay and server.** Claude Code hooks (`SessionStart`, `PreToolUse`,
202
+ `PermissionRequest`, `Stop`, `Notification`, `UserPromptSubmit`, `SessionEnd`)
203
+ are configured to run a small embedded relay script
204
+ (`src/hooks/relay.ts`'s `HOOK_RELAY_SCRIPT`, written to
205
+ `<stateDir>/hooks/relay.js`). The relay reads the hook event JSON from stdin,
206
+ hashes the event's `cwd` to find `<stateDir>/hooks/by-cwd/<sha256(cwd)>` — a
207
+ symlink to a `HookServer`'s unix socket, created only while a Supervisor holds
208
+ that cwd — and forwards the event over the socket, printing the reply as
209
+ Claude's hook output. A cwd with no owning Supervisor is a fast no-op: the
210
+ relay `stat`s the symlink path and returns before touching `net`. Non-blocking
211
+ events (`SessionStart`, `SessionEnd`, `UserPromptSubmit`, `Notification`) are
212
+ fire-and-forget; `PreToolUse`, `PermissionRequest` and `Stop` block Claude for
213
+ up to `HOOK_TIMEOUT_SECONDS` (180s) waiting for a reply, and an unanswered
214
+ request fails open to "no decision" so a relay or Supervisor bug never wedges
215
+ Claude. `HookServer` (`src/hooks/server.ts`) is one socket per Pi process, with
216
+ `subscribe(cwd, handler)` installing the per-cwd symlink; a Supervisor session
217
+ subscribes for the lifetime of its task and unsubscribes on stop/release.
218
+ `installUserHooks`/`uninstallUserHooks` (`src/hooks/install.ts`, exposed as
219
+ `/supervise install-hooks`/`uninstall-hooks`) register the relay for all seven
220
+ events in the user's real `~/.claude/settings.json` (or
221
+ `$CLAUDE_CONFIG_DIR/settings.json`), required once for `adopt-tmux`; an owned
222
+ launch instead writes a standalone settings file
223
+ (`writeHookSettingsFile`, `src/hooks/settings.ts`) passed as Claude's
224
+ `--settings <path>`, so it never touches the user's own configuration.
225
+
226
+ **Two permission phases.** `PreToolUse` fires before Claude's own permission
227
+ mode runs and is the enforced boundary: the Supervisor answers a policy denial
228
+ there (a `PreToolUse` deny is reported to block a tool such as `git push`
229
+ even under `permissions.defaultMode: auto`), forwards `AskUserQuestion` to the
230
+ Decision Worker, and otherwise returns no decision (`defer: true`) so Claude's
231
+ own permission mode decides — every other tool call is intentionally not
232
+ audited at this phase, since it
233
+ would just reproduce Claude's own prompt. `PermissionRequest` fires only when
234
+ Claude is about to show a human a permission prompt (i.e. its own mode did not
235
+ already decide); the Supervisor's existing hybrid/policy/Decision-Worker
236
+ authority answers it exactly as before. `WorkerPermissionRequest.phase` is
237
+ `"pre" | "prompt"` for an interactive session and `undefined` for the
238
+ stream-json bridge or process-pipe transports. `AskUserQuestion` is always
239
+ answered by the Decision Worker as a permission deny whose message is
240
+ `Supervisor answer: <reason>` — Claude reads a `PreToolUse`/`PermissionRequest`
241
+ deny's message as the reason the tool did not run, so the chosen answer and
242
+ its rationale reach the model as ordinary text and the turn continues.
243
+
244
+ **Human coexistence.** A prompt Claude receives that the Supervisor did not
245
+ send (a human typing into the attached tmux pane) surfaces as a `human_input`
246
+ Worker event; the Supervisor appends a bounded `human_input` event, enters
247
+ human takeover (`human_takeover` with `data.source: "worker_prompt"`) if not
248
+ already active, and pauses Decision Worker notification of further
249
+ `turn_completed` events (they are still recorded so `resume-auto` can replay
250
+ the last one) until `/supervise resume-auto`. A `pre`-phase request is still
251
+ answered automatically during a human takeover (policy deny or defer) since it
252
+ is not something a human is expected to approve; only a `prompt`-phase request
253
+ pends for `/supervise approve`.
254
+
255
+ **What stays scraped.** `pipe-pane`/`capture-pane` still provide the raw
256
+ output log and a stable-prompt readiness signal for owned startup
257
+ (`#waitForInteractiveReady`); no permission or completion decision is ever
258
+ derived from screen text in interactive mode — `Stop` (→ `turn_completed`,
259
+ `result.result` the last assistant message, `subtype: "stop"`) and the two
260
+ permission hooks are the only structured signals. `--max-budget-usd` has no
261
+ effect on the interactive TUI (Claude Code only enforces it under `-p`), so
262
+ `automaticClaudeArgs`'s injected `--permission-mode default` and
263
+ `--max-budget-usd` are stripped for an interactive launch; the Supervisor's own
264
+ cumulative cost check (`#recordWorkerUsage`) is the only budget enforcement
265
+ left, and it tolerates a `Stop` result with no `total_cost_usd`/`usage` (turn
266
+ count still increments).
267
+
268
+ A completed task normally releases (not stops) an interactive Worker,
269
+ leaving the session open for the operator to review or continue by hand;
270
+ `PI_CLAUDE_SUPERVISOR_CLOSE_WORKER_ON_COMPLETION=1` restores the old
271
+ close-on-completion behavior. A blocked or failed outcome always stops the
272
+ Worker as before.
273
+
188
274
  ## State machine
189
275
 
190
276
  ```text
@@ -202,7 +288,11 @@ updates run through one serial queue, so concurrent `poll`, `send`, `stop`,
202
288
  watchdog and shutdown work cannot produce duplicate terminal transitions. If a
203
289
  lifecycle event append fails after the state transition, it remains pending and
204
290
  is retried before the next lifecycle operation; output events restore their
205
- chunks for a lossless retry.
291
+ chunks for a lossless retry. Worker lifecycle events (`turn_completed`,
292
+ `permission_request`, `exited`) whose processing fails are kept in a deferred
293
+ set and retried both by the watchdog tick and by `poll`, so an unattended task
294
+ cannot lose its exit or turn result. `worker_output` events are size-capped
295
+ (per-chunk and per-event) while the in-memory Reviewer tail stays complete.
206
296
 
207
297
  ### Cross-process cwd leases and startup cancellation
208
298
 
@@ -234,14 +324,40 @@ is alive; the extension periodically rechecks released sessions and removes the
234
324
  lease only after the pane is confirmed gone. If that check fails, the lease is
235
325
  retained rather than allowing a cwd overlap.
236
326
 
327
+ A lease record that cannot be read (corrupt JSON, unexpected shape) is
328
+ quarantined into `<leaseDir>/quarantine/` instead of blocking every other cwd
329
+ lookup; `/supervise sessions` lists the current quarantined records so an
330
+ operator can inspect and clean them up. A lease whose own directory has since
331
+ disappeared is not treated the same as an unreadable record: it still fails
332
+ closed for its own (possibly reused) path, but a directory that merely
333
+ disappeared does not block lookups for unrelated, non-overlapping cwds.
334
+ Releasing a retained cgroup removes verified-empty nested child cgroups
335
+ bottom-up, since systemd/Claude can create child cgroups under a Worker's own
336
+ cgroup that would otherwise leave the parent non-empty.
337
+
237
338
  Before model or Worker execution, automatic starts validate a full existing Git
238
- baseline, a non-bare worktree, a readable non-protected branch, the direct bare
339
+ baseline, a non-bare worktree, a readable branch, the direct bare
239
340
  `claude`/`claude.exe` command name, JSONL transport, runtime state/lease directories
240
- and, when requested, the real writable cgroup-v2 boundary. The resolved Claude
341
+ and, when requested, the real writable cgroup-v2 boundary. The task is anchored to
342
+ this baseline commit, not to the branch name: any branch, including `main`, may
343
+ start or host a supervised task, since Claude Code's own "branch first" guidance is
344
+ advisory and the Worker commonly branches mid-task on its own. A protected branch
345
+ name (`main`, `master`, `trunk`, `integration`, `develop`) is never itself a reason
346
+ to refuse a start or park a candidate; only a remote push/merge/PR or a destructive
347
+ rewrite of a protected branch (`reset`, `update-ref`, `symbolic-ref`, or `branch`
348
+ with a delete/move/force flag) remains denied by policy. The resolved Claude
241
349
  executable is checked for an operator-owned, non-writable path and then pinned by
242
350
  absolute path; `PI_CLAUDE_SUPERVISOR_TRUSTED_CLAUDE` can pin the expected identity. The initial repository HEAD is captured, and the repository boundary immediately
243
351
  before the Worker adapter starts must report that exact same HEAD (recovery captures
244
352
  and compares its current HEAD separately while retaining the persisted baseline).
353
+ Every boundary check, and candidate verification itself, also requires the recorded
354
+ baseline commit to remain an ancestor of the current HEAD; if history was rewritten
355
+ out from under it, the boundary check fails closed and a candidate is parked rather
356
+ than accepted. A branch name change (the Worker moving off its starting branch, most
357
+ often onto a fresh feature branch) is recorded once as a `worker_branch_changed`
358
+ event rather than rejected; the task context keeps the original starting branch,
359
+ and a candidate notice reports the branch a candidate currently lives on together
360
+ with whether it is protected, purely for information.
245
361
  Automatic lease acquisition also persists a no-spawn startup marker. Automatic
246
362
  adapters then persist the generated Worker/cgroup identity and clear that marker
247
363
  before the actual Worker spawn, closing the startup-registration crash window;
@@ -271,8 +387,12 @@ work, and the Supervisor rechecks cancellation before reporting `running`; a can
271
387
  Events are JSONL with a monotonic sequence number, timestamp, task ID and worker
272
388
  ID. Appends use a per-log atomic lock directory with owner PID, bounded waiting
273
389
  and stale-owner detection. Each append refreshes the sequence from disk while
274
- holding the lock, so independent Pi processes cannot reuse sequence numbers.
275
- The log is diagnostic evidence, not an authorization mechanism. Log contents
390
+ holding the lock, so independent Pi processes cannot reuse sequence numbers;
391
+ that refresh reads only the file's tail for the last sequence number rather
392
+ than the whole log, keeping appends O(1) in log size. `events.jsonl` rotates to
393
+ a timestamped sibling (`events.jsonl.<stamp>`) before an append that would
394
+ exceed `PI_CLAUDE_SUPERVISOR_EVENT_LOG_MAX_BYTES` (default 64 MiB), keeping the
395
+ 5 most recent rotated files. The log is diagnostic evidence, not an authorization mechanism. Log contents
276
396
  must be treated as sensitive because worker output may contain repository data.
277
397
 
278
398
  For Claude JSONL, the adapter tracks `activeRequests`, `lastInputAt` and
@@ -292,16 +412,76 @@ and task mapping are persisted under the supervisor state directory. After an
292
412
  unclean Pi restart, recovery is explicit: `/supervise recover [--takeover]
293
413
  <task-id>` restores
294
414
  the Decision Worker context and starts a new Claude Worker. It does not silently
295
- resume or duplicate a task. It does not poll to detect turn completion. A watchdog timer remains only as a deadlock safety
296
- fallback. Permission and other actions pass through the configured autonomy policy and
415
+ resume or duplicate a task. It does not poll to detect turn completion. In automatic
416
+ mode the watchdog is always armed: besides the deadline and no-output timeouts, it
417
+ retries deferred lifecycle events, classifies a Worker that exited without an exit
418
+ event, and starts verification for it. Permission and other actions pass through the configured autonomy policy and
297
419
  are recorded. The local development loop must not require synchronous human
298
420
  approval for ordinary actions; a task that cannot safely produce a candidate is
299
- parked or failed without granting remote/main authority. If the Decision Worker
421
+ parked or failed without granting remote/main authority. Model/API failures are
422
+ detected from the Pi `stopReason` (a provider error resolves the prompt normally
423
+ rather than throwing); if the Decision Worker
300
424
  API/model call fails, the system records `decision_worker_failed`, applies the
301
- bounded retry/park policy and preserves the candidate evidence. Optional alert
425
+ bounded retry/park policy and preserves the candidate evidence. An abort is never
426
+ retried, and a `noop` reply on a completed turn or a permission request parks the
427
+ candidate rather than being treated as a resolved decision, while a `noop` on a
428
+ clean Worker exit proceeds to verification. Optional alert
302
429
  delivery remains independent from event-log persistence, but notification is not
303
430
  the control boundary.
304
431
 
432
+ Permission requests are not all routed to the Decision Worker model. `autonomy.permissionAuthority`
433
+ (`policy` | `hybrid`, default | `decision-worker`) chooses the authority: `policy` answers
434
+ every request from the deterministic policy alone, `decision-worker` sends every
435
+ request to the model, and the `hybrid` default answers a request from policy alone
436
+ only when the policy already denies it or `isRoutinePermission` in `src/policy.ts`
437
+ recognizes it as a routine in-cwd file edit or a local read-only/dev shell command
438
+ it can fully account for; anything it does not recognize is not routine and still
439
+ goes to the Decision Worker. The shell classifier is deliberately narrow: every
440
+ pipeline segment must start with an allow-listed utility (`ls`, `grep`, `sed -n`,
441
+ `git status/diff/log/add/commit`, `npm test|run`, `node ./script`, `tsc`, ...),
442
+ inline scripts (`node -e`), programs named by path, `$(...)`/backticks, process
443
+ substitution, `env`, `xargs`, `sort` and every other utility that can run a
444
+ program from an option, git shapes that discard or relocate work
445
+ (`checkout`/`restore`/`reset`, `stash drop`, `-C` outside the cwd, `-c`,
446
+ `--git-dir`, `--ext-diff`/`--textconv`), file-writing modes of
447
+ `sed`/`awk`/`find`/`tsc`, any argument that names a path outside the cwd (reads
448
+ of `/etc` or `~/.ssh` included), and any redirection whose target is outside the
449
+ cwd (after resolving `..`, `~` and symlinks) are never routine. Executing repository code (`npm run <script>`,
450
+ `node ./x.js`) is routine by design: it is reviewed repository content and the
451
+ Worker could run it through the allowed file tools regardless. A policy denial always wins regardless of authority; the
452
+ classifier only ever narrows what reaches the model, never what the policy refuses.
453
+ An explicit human takeover suspends this local fast path entirely, so every
454
+ permission request stays pending for the human once takeover is active, even one
455
+ the policy would otherwise have answered alone.
456
+
457
+ The persistent Decision Worker session is kept small by construction, not by an
458
+ after-the-fact trim: the task/spec/cwd live once in the startup instructions, and
459
+ every subsequent prompt sends only a bounded event summary (`summarizeEvent` in
460
+ `src/decision-worker.ts`, capping a `turn_completed` result or `permission_request`
461
+ input that can otherwise run to tens of kilobytes) plus the small pieces of state
462
+ that actually change turn to turn. On top of that, the session proactively calls
463
+ `AgentSession.compact()` between decisions once its estimated context passes
464
+ `PI_CLAUDE_SUPERVISOR_DECISION_COMPACT_TOKENS` (default 60,000 tokens; `0` disables
465
+ it), asking Pi to preserve the task specification, the permission/boundary rules,
466
+ the current state and the last three decisions with their reasons. A compaction
467
+ failure never fails the decision that already succeeded. Because compaction can
468
+ drop context a decision might otherwise assume, the next primary prompt after a
469
+ successful compaction re-sends the startup instructions exactly once (tracked by an
470
+ internal "instructions stale" flag), then reverts to the compact event summary.
471
+
472
+ Every Worker `result` record and every Decision Worker/Reviewer model call is
473
+ accounted for, not just logged: a `result` becomes a `worker_usage` event carrying
474
+ its incremental and cumulative cost and token counts, and a Pi-side call becomes a
475
+ `pi_usage` event; both accumulate into `session.usage` (`SupervisorTokenUsage`),
476
+ which `/supervise status`, progress notifications (`SupervisorProgress.costUsd`/
477
+ `.piTokens`) and candidate notifications (`CandidateNotice.usage`) all read from.
478
+ `autonomy.maxWorkerCostUsd` (`PI_CLAUDE_SUPERVISOR_WORKER_MAX_BUDGET_USD`) is
479
+ enforced twice: it is passed to Claude as `--max-budget-usd` so the Worker can stop
480
+ itself first (surfaced as the `result` subtype `error_max_budget_usd`), and the
481
+ Supervisor independently parks the candidate once its own cumulative
482
+ `workerCostUsd` crosses the same limit, so a Worker that does not honor its own cap
483
+ is still bounded.
484
+
305
485
  ## Acceptance, review and repair loop
306
486
 
307
487
  A task may provide a structured `TaskSpec` with `goal`, `scope`, `constraints`,
@@ -319,7 +499,11 @@ conversation or control channel. It can inspect only `read`, `grep`, `find` and
319
499
  `pass`, `revise` or `human` with bounded structured findings. Invalid Reviewer
320
500
  output, incomplete evidence or a Reviewer API failure must prevent a candidate
321
501
  from crossing the remote/main boundary; the local system may retry, repair or
322
- park it without requiring a human to be online.
502
+ park it without requiring a human to be online. The Reviewer retries a provider
503
+ error once within a total review budget
504
+ (`PI_CLAUDE_SUPERVISOR_REVIEW_TIMEOUT_MS`, default 10 minutes). Truncated
505
+ (oversize) evidence requests a bounded repair before parking, while incomplete
506
+ evidence still parks.
323
507
 
324
508
  A `revise` result produces an audited repair round and sends a bounded corrective
325
509
  instruction to a still-live `repairableSession` Worker. Checks and review then run again.
package/docs/testing.md CHANGED
@@ -146,11 +146,12 @@ Pi Decision Worker. Setting `PI_CLAUDE_SUPERVISOR_TRANSPORT=tmux` selects the
146
146
  Supervisor-owned live bridge, which carries the same structured records through
147
147
  private framing on the PTY rather than an independent JSONL sidecar. Task autonomy
148
148
  defaults to unattended local work, a required
149
- local commit on a non-protected task branch and two bounded Decision Worker retries. Configure
149
+ local commit on the task branch (any branch, anchored to the baseline commit) and two bounded Decision Worker retries. Configure
150
150
  `PI_CLAUDE_SUPERVISOR_REQUIRE_LOCAL_COMMIT=0` or task `autonomy.requireLocalCommit`
151
151
  only to disable the local-commit deliverability check; automatic mode still requires a Git
152
- baseline and non-protected worktree. Adopted tmux sessions remain manual-only; the automatic bridge requires
153
- Supervisor ownership. `process-pipe` remains the manual compatibility mode. Candidate/failure notification is optional and outbound-only through
152
+ baseline (any branch, including `main`; the candidate must descend from it). The tmux bridge
153
+ requires Supervisor ownership; the interactive tmux mode (the default `TMUX_MODE`) also adopts an
154
+ existing session through the user-level hook relay. `process-pipe` remains the manual compatibility mode. Candidate/failure notification is optional and outbound-only through
154
155
  `PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_URL`; it is not a synchronous approval
155
156
  callback. Approval callbacks are deliberately not accepted without a separately
156
157
  authenticated endpoint.
@@ -168,7 +169,7 @@ there is no injected sandbox or automatic tool allowlist. The only automatic CLI
168
169
  safety addition is a `default` permission mode when none was supplied; Bash
169
170
  preauthorization through `--allowedTools` or loaded settings is rejected so Bash
170
171
  requests remain visible to Supervisor policy. Automatic startup also requires
171
- a full existing Git baseline, non-bare non-protected worktree and the bare
172
+ a full existing Git baseline, a non-bare worktree on any branch and the bare
172
173
  `claude`/`claude.exe` command name. It resolves and pins an
173
174
  operator-owned, non-writable executable path (or the path configured by
174
175
  `PI_CLAUDE_SUPERVISOR_TRUSTED_CLAUDE`), rejects explicit/custom executable paths, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-claude-supervisor",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "A policy-gated Pi supervisor for observing and verifying Claude Code workers.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
package/src/acceptance.ts CHANGED
@@ -85,16 +85,22 @@ function normalizeRepairRounds(value: unknown): number {
85
85
  }
86
86
 
87
87
  function normalizeAutonomy(value: unknown): TaskSpec["autonomy"] {
88
- if (value === undefined) return { unattended: true, requireLocalCommit: true, maxDecisionRetries: 2 };
88
+ if (value === undefined) return { unattended: true, requireLocalCommit: true, maxDecisionRetries: 2, permissionAuthority: "hybrid" };
89
89
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("task spec autonomy must be an object");
90
90
  const source = value as Record<string, unknown>;
91
91
  if (source.unattended !== undefined && typeof source.unattended !== "boolean") throw new Error("task spec autonomy.unattended must be boolean");
92
92
  if (source.requireLocalCommit !== undefined && typeof source.requireLocalCommit !== "boolean") throw new Error("task spec autonomy.requireLocalCommit must be boolean");
93
93
  const retries = source.maxDecisionRetries ?? 2;
94
94
  if (typeof retries !== "number" || !Number.isSafeInteger(retries) || retries < 0 || retries > 10) throw new Error("task spec autonomy.maxDecisionRetries must be between 0 and 10");
95
+ const authority = source.permissionAuthority ?? "hybrid";
96
+ if (authority !== "policy" && authority !== "hybrid" && authority !== "decision-worker") throw new Error("task spec autonomy.permissionAuthority must be policy, hybrid or decision-worker");
97
+ const maxWorkerCostUsd = source.maxWorkerCostUsd;
98
+ if (maxWorkerCostUsd !== undefined && (typeof maxWorkerCostUsd !== "number" || !Number.isFinite(maxWorkerCostUsd) || maxWorkerCostUsd <= 0)) throw new Error("task spec autonomy.maxWorkerCostUsd must be a positive number");
95
99
  return {
96
100
  unattended: source.unattended !== false,
97
101
  requireLocalCommit: source.requireLocalCommit !== false,
98
102
  maxDecisionRetries: retries,
103
+ permissionAuthority: authority,
104
+ ...(maxWorkerCostUsd !== undefined ? { maxWorkerCostUsd } : {}),
99
105
  };
100
106
  }
package/src/config.ts CHANGED
@@ -2,14 +2,19 @@ import { chmodSync, existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { redactSensitive } from "./redaction.ts";
5
+ import type { PermissionAuthority } from "./types.ts";
5
6
 
6
7
  const allowed = new Set([
7
8
  "PI_CLAUDE_SUPERVISOR_MODE",
8
9
  "PI_CLAUDE_SUPERVISOR_AUTOMATION",
9
10
  "PI_CLAUDE_SUPERVISOR_TRANSPORT",
11
+ "PI_CLAUDE_SUPERVISOR_TMUX_MODE",
12
+ "PI_CLAUDE_SUPERVISOR_AUTO_INSTALL_HOOKS",
13
+ "PI_CLAUDE_SUPERVISOR_CLOSE_WORKER_ON_COMPLETION",
10
14
  "PI_CLAUDE_SUPERVISOR_CGROUP_MODE",
11
15
  "PI_CLAUDE_SUPERVISOR_TMUX_SOCKET",
12
16
  "PI_CLAUDE_SUPERVISOR_WORKER",
17
+ "PI_CLAUDE_SUPERVISOR_NODE",
13
18
  "PI_CLAUDE_SUPERVISOR_TRUSTED_CLAUDE",
14
19
  "PI_CLAUDE_SUPERVISOR_STATE_DIR",
15
20
  "PI_CLAUDE_SUPERVISOR_CWD_LEASE_DIR",
@@ -20,12 +25,28 @@ const allowed = new Set([
20
25
  "PI_CLAUDE_SUPERVISOR_UNATTENDED",
21
26
  "PI_CLAUDE_SUPERVISOR_REQUIRE_LOCAL_COMMIT",
22
27
  "PI_CLAUDE_SUPERVISOR_MAX_DECISION_RETRIES",
28
+ "PI_CLAUDE_SUPERVISOR_PERMISSION_AUTHORITY",
29
+ "PI_CLAUDE_SUPERVISOR_WORKER_MAX_BUDGET_USD",
30
+ "PI_CLAUDE_SUPERVISOR_WORKER_MODEL",
31
+ "PI_CLAUDE_SUPERVISOR_WORKER_AUTOCOMPACT_TOKENS",
32
+ "PI_CLAUDE_SUPERVISOR_WORKER_MCP_CONFIG",
33
+ "PI_CLAUDE_SUPERVISOR_DECISION_MODEL",
34
+ "PI_CLAUDE_SUPERVISOR_REVIEWER_MODEL",
35
+ "PI_CLAUDE_SUPERVISOR_DECISION_COMPACT_TOKENS",
36
+ "PI_CLAUDE_SUPERVISOR_PROGRESS_HEARTBEAT_MS",
37
+ "PI_CLAUDE_SUPERVISOR_DECISION_SESSION_RETENTION_DAYS",
38
+ "PI_CLAUDE_SUPERVISOR_EVIDENCE_MAX_BYTES",
39
+ "PI_CLAUDE_SUPERVISOR_EVIDENCE_MAX_UNTRACKED_FILES",
40
+ "PI_CLAUDE_SUPERVISOR_REVIEW_TIMEOUT_MS",
41
+ "PI_CLAUDE_SUPERVISOR_EVENT_LOG_MAX_BYTES",
23
42
  ]);
24
43
 
25
44
  export interface AutonomyDefaults {
26
45
  unattended: boolean;
27
46
  requireLocalCommit: boolean;
28
47
  maxDecisionRetries: number;
48
+ permissionAuthority: PermissionAuthority;
49
+ maxWorkerCostUsd?: number;
29
50
  }
30
51
 
31
52
  export function autonomyDefaults(env: NodeJS.ProcessEnv = process.env): AutonomyDefaults {
@@ -33,9 +54,61 @@ export function autonomyDefaults(env: NodeJS.ProcessEnv = process.env): Autonomy
33
54
  unattended: readBoolean(env.PI_CLAUDE_SUPERVISOR_UNATTENDED, true),
34
55
  requireLocalCommit: readBoolean(env.PI_CLAUDE_SUPERVISOR_REQUIRE_LOCAL_COMMIT, true),
35
56
  maxDecisionRetries: readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_MAX_DECISION_RETRIES, 2, 0, 10),
57
+ permissionAuthority: readPermissionAuthority(env.PI_CLAUDE_SUPERVISOR_PERMISSION_AUTHORITY),
58
+ ...(readPositiveNumber(env.PI_CLAUDE_SUPERVISOR_WORKER_MAX_BUDGET_USD) !== undefined ? { maxWorkerCostUsd: readPositiveNumber(env.PI_CLAUDE_SUPERVISOR_WORKER_MAX_BUDGET_USD) } : {}),
36
59
  };
37
60
  }
38
61
 
62
+ export function reviewTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
63
+ return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_REVIEW_TIMEOUT_MS, 600_000, 30_000, 3_600_000);
64
+ }
65
+
66
+ export function eventLogMaxBytes(env: NodeJS.ProcessEnv = process.env): number {
67
+ return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_EVENT_LOG_MAX_BYTES, 64 * 1024 * 1024, 1024 * 1024, 1024 * 1024 * 1024);
68
+ }
69
+
70
+ export function workerModel(env: NodeJS.ProcessEnv = process.env): string | undefined {
71
+ return readTrimmedString(env.PI_CLAUDE_SUPERVISOR_WORKER_MODEL);
72
+ }
73
+
74
+ /** The 200_000 default applies only in automatic mode; "0" is an explicit opt-out that omits --autocompact. */
75
+ export function workerAutocompactTokens(env: NodeJS.ProcessEnv = process.env): number {
76
+ return readBoundedIntegerWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_WORKER_AUTOCOMPACT_TOKENS, 200_000, 100_000, 1_000_000);
77
+ }
78
+
79
+ export function workerMcpConfigPath(env: NodeJS.ProcessEnv = process.env): string | undefined {
80
+ return readTrimmedString(env.PI_CLAUDE_SUPERVISOR_WORKER_MCP_CONFIG);
81
+ }
82
+
83
+ export function decisionModel(env: NodeJS.ProcessEnv = process.env): string | undefined {
84
+ return readTrimmedString(env.PI_CLAUDE_SUPERVISOR_DECISION_MODEL);
85
+ }
86
+
87
+ export function reviewerModel(env: NodeJS.ProcessEnv = process.env): string | undefined {
88
+ return readTrimmedString(env.PI_CLAUDE_SUPERVISOR_REVIEWER_MODEL);
89
+ }
90
+
91
+ /** "0" is an explicit opt-out that disables Decision Worker session compaction. */
92
+ export function decisionCompactionTokens(env: NodeJS.ProcessEnv = process.env): number {
93
+ return readBoundedIntegerWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_DECISION_COMPACT_TOKENS, 60_000, 10_000, 500_000);
94
+ }
95
+
96
+ export function progressHeartbeatMs(env: NodeJS.ProcessEnv = process.env): number {
97
+ return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_PROGRESS_HEARTBEAT_MS, 60_000, 5_000, 3_600_000);
98
+ }
99
+
100
+ export function decisionSessionRetentionDays(env: NodeJS.ProcessEnv = process.env): number {
101
+ return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_DECISION_SESSION_RETENTION_DAYS, 30, 0, 3650);
102
+ }
103
+
104
+ export function evidenceMaxBytes(env: NodeJS.ProcessEnv = process.env): number {
105
+ return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_EVIDENCE_MAX_BYTES, 1024 * 1024, 64 * 1024, 64 * 1024 * 1024);
106
+ }
107
+
108
+ export function evidenceMaxUntrackedFiles(env: NodeJS.ProcessEnv = process.env): number {
109
+ return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_EVIDENCE_MAX_UNTRACKED_FILES, 512, 16, 10_000);
110
+ }
111
+
39
112
  export function loadSupervisorEnvironment(): string | undefined {
40
113
  const path = process.env.PI_CLAUDE_SUPERVISOR_ENV_FILE ?? join(homedir(), ".config", "pi-claude-supervisor", "env");
41
114
  if (!existsSync(path)) return undefined;
@@ -68,3 +141,40 @@ function readBoundedInteger(value: string | undefined, fallback: number, minimum
68
141
  const parsed = Number(value);
69
142
  return Number.isSafeInteger(parsed) && parsed >= minimum && parsed <= maximum ? parsed : fallback;
70
143
  }
144
+
145
+ /** Like readBoundedInteger, but the literal "0" is always honored as an opt-out below the normal minimum. */
146
+ function readBoundedIntegerWithZeroOptOut(value: string | undefined, fallback: number, minimum: number, maximum: number): number {
147
+ if (value !== undefined && value.trim() === "0") return 0;
148
+ return readBoundedInteger(value, fallback, minimum, maximum);
149
+ }
150
+
151
+ function readTrimmedString(value: string | undefined): string | undefined {
152
+ const trimmed = value?.trim();
153
+ return trimmed ? trimmed : undefined;
154
+ }
155
+
156
+ function readPermissionAuthority(value: string | undefined): PermissionAuthority {
157
+ const normalized = value?.trim().toLowerCase();
158
+ return normalized === "policy" || normalized === "decision-worker" ? normalized : "hybrid";
159
+ }
160
+
161
+ function readPositiveNumber(value: string | undefined): number | undefined {
162
+ if (value === undefined || value.trim() === "") return undefined;
163
+ const parsed = Number(value);
164
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
165
+ }
166
+
167
+ /** How automatic tmux supervision drives Claude: the real TUI through hooks (default) or the stream-json bridge. */
168
+ export function tmuxMode(env: NodeJS.ProcessEnv = process.env): "interactive" | "bridge" {
169
+ return env.PI_CLAUDE_SUPERVISOR_TMUX_MODE?.trim().toLowerCase() === "bridge" ? "bridge" : "interactive";
170
+ }
171
+
172
+ /** Exit the interactive Worker and its tmux session once a task completes; default keeps it open for the operator. */
173
+ export function closeWorkerOnCompletion(env: NodeJS.ProcessEnv = process.env): boolean {
174
+ return readBoolean(env.PI_CLAUDE_SUPERVISOR_CLOSE_WORKER_ON_COMPLETION, false);
175
+ }
176
+
177
+ /** Install the user-level Claude Code hook entries automatically when the interactive tmux mode is configured. */
178
+ export function autoInstallHooks(env: NodeJS.ProcessEnv = process.env): boolean {
179
+ return readBoolean(env.PI_CLAUDE_SUPERVISOR_AUTO_INSTALL_HOOKS, true);
180
+ }