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.
- package/CHANGELOG.md +40 -0
- package/README.cn.md +342 -148
- package/README.md +394 -241
- package/docs/architecture.md +196 -12
- package/docs/testing.md +5 -4
- package/package.json +1 -1
- package/src/acceptance.ts +7 -1
- package/src/config.ts +110 -0
- package/src/cwd-lease.ts +166 -37
- package/src/decision-session-store.ts +43 -2
- package/src/decision-worker.ts +284 -18
- package/src/events.ts +125 -5
- package/src/hooks/install.ts +187 -0
- package/src/hooks/relay.ts +191 -0
- package/src/hooks/server.ts +260 -0
- package/src/hooks/settings.ts +44 -0
- package/src/hooks/types.ts +83 -0
- package/src/index.ts +267 -26
- package/src/json-extract.ts +49 -0
- package/src/notifications.ts +61 -10
- package/src/pi-model.ts +44 -0
- package/src/policy.ts +394 -21
- package/src/redaction.ts +1 -1
- package/src/reviewer.ts +86 -56
- package/src/supervisor.ts +774 -82
- package/src/types.ts +55 -3
- package/src/verifier.ts +49 -16
- package/src/worker/environment.ts +41 -2
- package/src/worker/process-adapter.ts +32 -7
- package/src/worker/runtime.ts +51 -0
- package/src/worker/tmux-adapter.ts +573 -28
package/docs/architecture.md
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
296
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
153
|
-
Supervisor ownership
|
|
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
|
|
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
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
|
+
}
|