pi-claude-supervisor 0.9.0 → 0.9.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 +16 -0
- package/README.cn.md +16 -8
- package/README.md +19 -8
- package/docs/architecture.md +42 -10
- package/docs/autonomy-target.md +1 -1
- package/docs/testing.md +40 -3
- package/package.json +2 -1
- package/src/acceptance.ts +2 -1
- package/src/config.ts +78 -32
- package/src/cwd-lease.ts +34 -1
- package/src/decision-session-store.ts +5 -14
- package/src/decision-worker.ts +89 -11
- package/src/events.ts +5 -11
- package/src/hooks/install.ts +2 -11
- package/src/hooks/server.ts +2 -10
- package/src/hooks/settings.ts +2 -11
- package/src/hooks/types.ts +17 -9
- package/src/index.ts +83 -4
- package/src/json-extract.ts +41 -2
- package/src/lock-owner.ts +55 -0
- package/src/redaction.ts +15 -0
- package/src/reviewer.ts +228 -53
- package/src/supervisor.ts +197 -16
- package/src/worker/process-adapter.ts +40 -5
- package/src/worker/tmux-adapter.ts +105 -21
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented here.
|
|
4
4
|
|
|
5
|
+
## [0.9.2](https://github.com/btnalit/pi-claude-supervisor/compare/v0.9.1...v0.9.2) (2026-09-24)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **supervisor:** finish stop and startup races; add gated live Decision spike ([#67](https://github.com/btnalit/pi-claude-supervisor/issues/67)) ([648b269](https://github.com/btnalit/pi-claude-supervisor/commit/648b269f185fbeb5ab39fe22ce855cc8d4342747))
|
|
11
|
+
* **supervisor:** stability fixes found by end-to-end runs with a real Decision Worker ([#65](https://github.com/btnalit/pi-claude-supervisor/issues/65)) ([cd0b8ef](https://github.com/btnalit/pi-claude-supervisor/commit/cd0b8efdd0898833036b14acab5ca7b29ca70d04))
|
|
12
|
+
|
|
13
|
+
## [0.9.1](https://github.com/btnalit/pi-claude-supervisor/compare/v0.9.0...v0.9.1) (2026-09-24)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* long-task stability findings from an independent 0.9.0 review ([#62](https://github.com/btnalit/pi-claude-supervisor/issues/62)) ([35c6789](https://github.com/btnalit/pi-claude-supervisor/commit/35c678962c357bc188eb2ed82141e4046ae793aa))
|
|
19
|
+
* **review:** pin Reviewer finding key order so quoted text cannot swap a P0's description ([#64](https://github.com/btnalit/pi-claude-supervisor/issues/64)) ([6df3fd2](https://github.com/btnalit/pi-claude-supervisor/commit/6df3fd2b75a9555eb6dc9112b1c19f5e35bfa3cd))
|
|
20
|
+
|
|
5
21
|
## [0.9.0](https://github.com/btnalit/pi-claude-supervisor/compare/v0.8.1...v0.9.0) (2026-09-20)
|
|
6
22
|
|
|
7
23
|
|
package/README.cn.md
CHANGED
|
@@ -202,8 +202,11 @@ stream-json` 的方式运行 Claude,完全没有终端界面;一旦设置
|
|
|
202
202
|
时间。只有收尾窗口也耗尽,Worker 才会被硬停(`worker_watchdog_timeout`);对
|
|
203
203
|
接管的交互式会话来说这个硬停只是 release:Claude 继续运行,但不再受监督。
|
|
204
204
|
收尾窗口只属于自动模式任务;手动任务仍在到期时停止,`DEADLINE_GRACE_MS=0`
|
|
205
|
-
让自动任务也恢复这一行为。20 分钟无输出 watchdog(`NO_OUTPUT_TIMEOUT_MS
|
|
206
|
-
|
|
205
|
+
让自动任务也恢复这一行为。20 分钟无输出 watchdog(`NO_OUTPUT_TIMEOUT_MS`,
|
|
206
|
+
从 Worker 最后一次输出或 Supervisor 最后一次发给它的消息起算)会停止在一轮
|
|
207
|
+
中途沉默的 Worker;自动模式下只是空闲了这么久的 Worker(在等永远没回来的
|
|
208
|
+
后台工作)则改为直接验收(`worker_idle_timeout`),人工接管中的 Worker 不会
|
|
209
|
+
因沉默超时。
|
|
207
210
|
- 验收命令、证据收集和 Reviewer 共用一个 abort signal,因此 stop 或 shutdown
|
|
208
211
|
不必等待完整的命令或模型超时。
|
|
209
212
|
- 每个任务只持有一个 cwd 租约;并发任务需要各自独立的 worktree。
|
|
@@ -285,7 +288,7 @@ commit,通知能说清已验证的 commit 是否在工作树变动之前就已
|
|
|
285
288
|
"autonomy": {
|
|
286
289
|
"unattended": true,
|
|
287
290
|
"requireLocalCommit": true,
|
|
288
|
-
"maxDecisionRetries":
|
|
291
|
+
"maxDecisionRetries": 4,
|
|
289
292
|
"permissionAuthority": "hybrid",
|
|
290
293
|
"maxWorkerCostUsd": 20
|
|
291
294
|
}
|
|
@@ -298,7 +301,9 @@ commit,通知能说清已验证的 commit 是否在工作树变动之前就已
|
|
|
298
301
|
## 配置参考
|
|
299
302
|
|
|
300
303
|
环境变量(或 `~/.config/pi-claude-supervisor/env`),均以 `PI_CLAUDE_SUPERVISOR_`
|
|
301
|
-
为前缀;完整模板见 `.env.example`。
|
|
304
|
+
为前缀;完整模板见 `.env.example`。env 文件每行是 `KEY=value`,可以带 `export `
|
|
305
|
+
前缀和行尾 ` # 注释`。数值、时长或布尔类配置超出范围或无法解析时会沿用默认值,
|
|
306
|
+
并以 `pi-claude-supervisor: ignoring …` 警告报告一次。
|
|
302
307
|
|
|
303
308
|
| 变量 | 默认值 | 含义 |
|
|
304
309
|
| --- | --- | --- |
|
|
@@ -321,7 +326,7 @@ commit,通知能说清已验证的 commit 是否在工作树变动之前就已
|
|
|
321
326
|
| `HUMAN_WEBHOOK_SECRET` | 未设置 | HMAC 签名密钥;以 `x-pi-supervisor-signature` header 发送 |
|
|
322
327
|
| `UNATTENDED` | `true` | 任务无需同步人工回调即可运行 |
|
|
323
328
|
| `REQUIRE_LOCAL_COMMIT` | `true` | 完成前要求在候选所在分支上有本地 commit |
|
|
324
|
-
| `MAX_DECISION_RETRIES` | `
|
|
329
|
+
| `MAX_DECISION_RETRIES` | `4`(0–10) | Decision Worker 调用超时或失败(429/529、网络、鉴权)时的重试次数;两次尝试之间依次等待 15s、45s、60s |
|
|
325
330
|
| `PERMISSION_AUTHORITY` | `hybrid` | `policy` \| `hybrid` \| `decision-worker` |
|
|
326
331
|
| `REMOTE_AUTHORITY` | `none` | `none` \| `push` \| `pr`;验收通过后开启发布阶段。`--remote` 可按任务覆盖 |
|
|
327
332
|
| `REMOTE_NAME` | `origin` | 发布授权唯一允许的 remote 名 |
|
|
@@ -336,8 +341,8 @@ commit,通知能说清已验证的 commit 是否在工作树变动之前就已
|
|
|
336
341
|
| `DECISION_SESSION_RETENTION_DAYS` | `30` | 启动时清理早于此天数的已关闭 Decision Worker session 记录;`0` 表示永久保留 |
|
|
337
342
|
| `EVIDENCE_MAX_BYTES` | `1048576`(1 MiB) | 每个任务收集的最大仓库证据字节数 |
|
|
338
343
|
| `EVIDENCE_MAX_UNTRACKED_FILES` | `512` | 每个任务作为证据收集的最大未跟踪文件数 |
|
|
339
|
-
| `REVIEW_TIMEOUT_MS` | `
|
|
340
|
-
| `DEADLINE_MS` | `4h` |
|
|
344
|
+
| `REVIEW_TIMEOUT_MS` | `10m`(30s–1h) | 每轮独立 Reviewer 的总预算;预算未用完时,provider 错误会用新会话重试 |
|
|
345
|
+
| `DEADLINE_MS` | `4h` | 每个任务从启动起算的墙钟总时限,Pi 停机期间也计入,因此恢复时用 `recover --extend` 重新给预算(`8h`、`90m`、`2h30m` 或毫秒;5 分钟到 7 天);`0`(或 `0m`)关闭;`--deadline` 可按任务覆盖 |
|
|
341
346
|
| `DEADLINE_GRACE_MS` | `30m` | 自动任务到期后的收尾窗口:空闲的 Worker 会被验收而不是停止;`0` 恢复到期立即停止 |
|
|
342
347
|
| `DEADLINE_WARNING_MS` | `15m` | 到期前多久提醒并重新询问 Decision Worker;`0` 关闭提醒 |
|
|
343
348
|
| `NO_OUTPUT_TIMEOUT_MS` | `20m` | Worker 多久没有输出就停止;`0` 关闭该检查 |
|
|
@@ -358,7 +363,10 @@ Worker,它不会静默恢复或重复执行任务。只有在租约证明旧 Wor
|
|
|
358
363
|
会拒绝它;`recover --takeover --extend <duration> <task-id>` 从现在起再给这么
|
|
359
364
|
多预算(恢复后的 Supervisor 会把新时限持久化),`--extend 0` 则立即进入收尾:
|
|
360
365
|
新 Worker 的第一个 watchdog tick 就会对仓库现状做验收和 review,修复轮会告诉
|
|
361
|
-
|
|
366
|
+
它还剩多少时间。带 `--extend` 时,恢复的任务会直接交回自动化:非零的延长会
|
|
367
|
+
给新 Worker 发送原任务的续做指令(先让它查看已有的工作),`--extend 0` 则无需
|
|
368
|
+
指令。不带 `--extend` 的普通 `recover` 仍让 Worker 在人工接管下空闲——先发送
|
|
369
|
+
续做指令,再执行 `resume-auto`。确定不再恢复的记录用 `/supervise discard <task-id>` 丢弃
|
|
362
370
|
(会话文件保留到保留期清理为止)。
|
|
363
371
|
|
|
364
372
|
每个任务在 `CWD_LEASE_DIR` 下持有一个 cwd 租约;并发任务需要各自独立的
|
package/README.md
CHANGED
|
@@ -247,8 +247,11 @@ Supervisor being able to see it, or when you don't need to attach.
|
|
|
247
247
|
is a release: Claude keeps running, unsupervised. The close-out belongs to
|
|
248
248
|
automatic tasks; a manual task is stopped at the deadline as before, and
|
|
249
249
|
`DEADLINE_GRACE_MS=0` restores that for automatic ones too. A 20-minute
|
|
250
|
-
no-output watchdog (`NO_OUTPUT_TIMEOUT_MS
|
|
251
|
-
|
|
250
|
+
no-output watchdog (`NO_OUTPUT_TIMEOUT_MS`, counted from the Worker's last
|
|
251
|
+
output or the Supervisor's last message to it) stops a Worker that falls
|
|
252
|
+
silent mid-turn; an automatic Worker that is merely idle that long (waiting on
|
|
253
|
+
background work that never came back) is verified instead
|
|
254
|
+
(`worker_idle_timeout`), and a Worker under human takeover is never timed out.
|
|
252
255
|
- Acceptance checks, evidence collection, and the Reviewer share an abort
|
|
253
256
|
signal, so a stop or shutdown does not wait for a full command or model
|
|
254
257
|
timeout.
|
|
@@ -374,7 +377,7 @@ confirmed publish says so in its candidate notice instead of reporting a bare
|
|
|
374
377
|
"autonomy": {
|
|
375
378
|
"unattended": true,
|
|
376
379
|
"requireLocalCommit": true,
|
|
377
|
-
"maxDecisionRetries":
|
|
380
|
+
"maxDecisionRetries": 4,
|
|
378
381
|
"permissionAuthority": "hybrid",
|
|
379
382
|
"maxWorkerCostUsd": 20
|
|
380
383
|
}
|
|
@@ -388,7 +391,11 @@ check (120s timeout) and the env autonomy defaults below.
|
|
|
388
391
|
## Configuration reference
|
|
389
392
|
|
|
390
393
|
Environment variables (or `~/.config/pi-claude-supervisor/env`), all prefixed
|
|
391
|
-
`PI_CLAUDE_SUPERVISOR_`; see `.env.example` for a template.
|
|
394
|
+
`PI_CLAUDE_SUPERVISOR_`; see `.env.example` for a template. The env file takes
|
|
395
|
+
`KEY=value` lines, optionally prefixed with `export ` and followed by a
|
|
396
|
+
` # comment`. A numeric, duration or boolean value that is out of range or
|
|
397
|
+
unparsable keeps the default and is reported once as a
|
|
398
|
+
`pi-claude-supervisor: ignoring …` warning.
|
|
392
399
|
|
|
393
400
|
| Variable | Default | Meaning |
|
|
394
401
|
| --- | --- | --- |
|
|
@@ -411,7 +418,7 @@ Environment variables (or `~/.config/pi-claude-supervisor/env`), all prefixed
|
|
|
411
418
|
| `HUMAN_WEBHOOK_SECRET` | unset | HMAC signing secret; sent as the `x-pi-supervisor-signature` header |
|
|
412
419
|
| `UNATTENDED` | `true` | Task runs without a synchronous human callback |
|
|
413
420
|
| `REQUIRE_LOCAL_COMMIT` | `true` | Require a local commit on the candidate's branch before completion |
|
|
414
|
-
| `MAX_DECISION_RETRIES` | `
|
|
421
|
+
| `MAX_DECISION_RETRIES` | `4` (0–10) | Retries of a Decision Worker call that times out or fails (429/529, network, auth); waits 15s, 45s, then 60s between attempts |
|
|
415
422
|
| `PERMISSION_AUTHORITY` | `hybrid` | `policy` \| `hybrid` \| `decision-worker` |
|
|
416
423
|
| `REMOTE_AUTHORITY` | `none` | `none` \| `push` \| `pr`; grants the publish phase after verification passes. `--remote` overrides it per task |
|
|
417
424
|
| `REMOTE_NAME` | `origin` | The single remote a publish grant may name |
|
|
@@ -426,8 +433,8 @@ Environment variables (or `~/.config/pi-claude-supervisor/env`), all prefixed
|
|
|
426
433
|
| `DECISION_SESSION_RETENTION_DAYS` | `30` | Prunes closed Decision Worker session records older than this; `0` keeps forever |
|
|
427
434
|
| `EVIDENCE_MAX_BYTES` | `1048576` (1 MiB) | Maximum repository evidence bytes collected per task |
|
|
428
435
|
| `EVIDENCE_MAX_UNTRACKED_FILES` | `512` | Maximum untracked files collected as evidence per task |
|
|
429
|
-
| `REVIEW_TIMEOUT_MS` | `
|
|
430
|
-
| `DEADLINE_MS` | `4h` |
|
|
436
|
+
| `REVIEW_TIMEOUT_MS` | `10m` (30s–1h) | Total independent Reviewer budget per round; a provider error is retried with a fresh session while budget remains |
|
|
437
|
+
| `DEADLINE_MS` | `4h` | Wall-clock budget per task, measured from its start — time Pi was down counts too, so `recover --extend` grants a fresh budget (`8h`, `90m`, `2h30m` or ms; 5m–7d); `0` (or `0m`) disables it; `--deadline` overrides it per task |
|
|
431
438
|
| `DEADLINE_GRACE_MS` | `30m` | Close-out window after the deadline for automatic tasks: an idle Worker is verified instead of stopped; `0` restores the immediate stop |
|
|
432
439
|
| `DEADLINE_WARNING_MS` | `15m` | How long before the deadline the Decision Worker is warned and re-asked; `0` disables the warning |
|
|
433
440
|
| `NO_OUTPUT_TIMEOUT_MS` | `20m` | Stop a Worker that has produced no output for this long; `0` disables the check |
|
|
@@ -452,7 +459,11 @@ A task that stopped at its wall-clock deadline is listed with `deadline=expired
|
|
|
452
459
|
<task-id>` grants that much budget from now (the recovered Supervisor persists
|
|
453
460
|
the new deadline), and `--extend 0` opens the close-out at once, so the fresh
|
|
454
461
|
Worker's first watchdog tick verifies and reviews the repository as it stands
|
|
455
|
-
and any repair round tells it how long it has.
|
|
462
|
+
and any repair round tells it how long it has. With `--extend` the recovered
|
|
463
|
+
task goes straight back to automation: a real extension sends the fresh Worker a
|
|
464
|
+
continuation of the original task (telling it to inspect the earlier work first),
|
|
465
|
+
and `--extend 0` needs none. A plain `recover` still leaves the Worker idle under
|
|
466
|
+
takeover — send it a continuation, then `resume-auto`. A record nobody will recover is
|
|
456
467
|
dropped with `/supervise discard <task-id>` (its session file is kept until
|
|
457
468
|
retention pruning).
|
|
458
469
|
|
package/docs/architecture.md
CHANGED
|
@@ -143,7 +143,8 @@ start); credentials are not copied into a file, and credential-shaped command
|
|
|
143
143
|
arguments are still rejected.
|
|
144
144
|
`load-buffer`, bracketed `paste-buffer` and `send-keys Enter` provide the input
|
|
145
145
|
boundary without interpolating a task into a shell command. C0/C1 terminal
|
|
146
|
-
control bytes are
|
|
146
|
+
control bytes are neutralized (escape sequences removed, a lone CR becomes a newline,
|
|
147
|
+
other C0/C1 bytes become spaces); CRLF is normalized to a newline. Automatic agents,
|
|
147
148
|
background tasks, plugins, MCP servers and nested Claude processes stay in the
|
|
148
149
|
same cgroup and are cleaned with the Worker; they are intentionally not rejected
|
|
149
150
|
or polled as a nested-process policy failure. The lexical Bash/file-tool policy
|
|
@@ -399,8 +400,11 @@ For Claude JSONL, the adapter tracks `activeRequests`, `lastInputAt` and
|
|
|
399
400
|
`lastOutputAt`. A `result` record closes an active request; malformed output does
|
|
400
401
|
not. JSONL sends are rejected while a request is active, and a valid terminal
|
|
401
402
|
result moves the session to `waiting`; only then may the next turn be sent. A paused
|
|
402
|
-
Worker does not consume its no-output budget; resume
|
|
403
|
-
|
|
403
|
+
Worker does not consume its no-output budget; resume, and every message the
|
|
404
|
+
Supervisor sends, establishes a fresh no-output baseline while the cumulative
|
|
405
|
+
wall-clock deadline remains active. An idle automatic Worker that reaches the
|
|
406
|
+
no-output timeout is verified (`worker_idle_timeout`) rather than stopped, and a
|
|
407
|
+
Worker under human takeover is exempt.
|
|
404
408
|
|
|
405
409
|
The wall-clock deadline is a budget, not a kill switch. The watchdog drives it
|
|
406
410
|
through three phases, each recorded once per task: `worker_deadline_approaching`
|
|
@@ -513,10 +517,17 @@ parked or failed without granting remote/main authority. Model/API failures are
|
|
|
513
517
|
detected from the Pi `stopReason` (a provider error resolves the prompt normally
|
|
514
518
|
rather than throwing); if the Decision Worker
|
|
515
519
|
API/model call fails, the system records `decision_worker_failed`, applies the
|
|
516
|
-
bounded retry/park policy and preserves the candidate evidence.
|
|
520
|
+
bounded retry/park policy and preserves the candidate evidence. The startup
|
|
521
|
+
instructions prompt retries provider errors on the same backoff and budget, so a
|
|
522
|
+
provider overload at start does not fail the task before its first turn. An abort is never
|
|
517
523
|
retried, and a `noop` reply on a completed turn or a permission request parks the
|
|
518
524
|
candidate rather than being treated as a resolved decision, while a `noop` on a
|
|
519
|
-
clean Worker exit proceeds to verification.
|
|
525
|
+
clean Worker exit proceeds to verification. A `stop` on a completed turn of an
|
|
526
|
+
unattended task without remote authority stops the Worker (never keeping it open)
|
|
527
|
+
and then verifies its finished work instead of discarding it (`decision_overridden`);
|
|
528
|
+
no repair round may follow, so a failure blocks the candidate. A `stop` on a
|
|
529
|
+
pending permission, on a turn the Worker has already resumed, or on a task with
|
|
530
|
+
remote authority stays a plain stop. Optional alert
|
|
520
531
|
delivery remains independent from event-log persistence, but notification is not
|
|
521
532
|
the control boundary.
|
|
522
533
|
|
|
@@ -587,19 +598,40 @@ When automatic supervision is enabled, a successful check set is passed to a
|
|
|
587
598
|
fresh read-only Reviewer session. The Reviewer receives the task specification, repository status/diff evidence,
|
|
588
599
|
check results and bounded Worker completion evidence, but not the Decision Worker
|
|
589
600
|
conversation or control channel. It can inspect only `read`, `grep`, `find` and `ls`, and must return
|
|
590
|
-
`pass`, `revise` or `human` with bounded structured findings.
|
|
601
|
+
`pass`, `revise` or `human` with bounded structured findings. Its whole reply
|
|
602
|
+
must be that one JSON object (an optional ```json fence aside), carrying a
|
|
603
|
+
random `reviewId` that appears only in its own prompt, with no key repeated and
|
|
604
|
+
nothing beyond the schema (a string `summary`, and `findings` as flat objects of
|
|
605
|
+
the finding fields with scalar values). Each finding opens with `severity`
|
|
606
|
+
then a non-empty `message` (an `id` may lead) and closes with `evidence` if it
|
|
607
|
+
has one — the only field the prompt allows repository quotes in. Repository
|
|
608
|
+
text it quotes or copies therefore cannot stand in for the answer, change its
|
|
609
|
+
verdict, or drop a finding it wrote, and a quote in `evidence` that closes a
|
|
610
|
+
finding early can only add findings after it — never change the severity,
|
|
611
|
+
message, fix or location the Reviewer already wrote. A quote the Reviewer puts
|
|
612
|
+
in any other field against the prompt can still reach the rest of that
|
|
613
|
+
finding. Added findings cannot unblock a candidate (any P0/P1 blocks, even
|
|
614
|
+
under `pass`), and repair instructions list findings most severe first so
|
|
615
|
+
added lesser ones cannot crowd out a blocking one. A reply that breaks any of
|
|
616
|
+
these earns one corrective re-prompt. Invalid Reviewer
|
|
591
617
|
output, incomplete evidence or a Reviewer API failure must prevent a candidate
|
|
592
618
|
from crossing the remote/main boundary; the local system may retry, repair or
|
|
593
|
-
park it without requiring a human to be online. The Reviewer retries
|
|
594
|
-
|
|
619
|
+
park it without requiring a human to be online. The Reviewer retries provider
|
|
620
|
+
errors with a fresh session within a total review budget
|
|
595
621
|
(`PI_CLAUDE_SUPERVISOR_REVIEW_TIMEOUT_MS`, default 10 minutes). Truncated
|
|
596
622
|
(oversize) evidence requests a bounded repair before parking, while incomplete
|
|
597
623
|
evidence still parks.
|
|
598
624
|
|
|
599
625
|
A `revise` result produces an audited repair round and sends a bounded corrective
|
|
600
626
|
instruction to a still-live `repairableSession` Worker. Checks and review then run again.
|
|
601
|
-
The
|
|
602
|
-
|
|
627
|
+
The Decision Worker sees the last result tagged with the Worker turn it judged, and chooses
|
|
628
|
+
when to verify again; if it keeps steering instead, the Supervisor verifies on its own once
|
|
629
|
+
the Worker has taken three turns since that failure (`decision_overridden`), so a Decision
|
|
630
|
+
Worker reasoning from the stale failure cannot hold a fixed Worker in a loop until the deadline.
|
|
631
|
+
The repair budget defaults to three rounds. P0/P1 findings block a `pass` but are repair
|
|
632
|
+
inputs like any other concrete finding (a `pass` carrying one is treated as `revise`); a
|
|
633
|
+
`human` verdict, repeated findings or an exhausted budget stop automation and park a
|
|
634
|
+
non-publishable candidate. A Worker that has already exited cannot be silently recreated
|
|
603
635
|
for repair; it remains failed/recoverable rather than replaying the original task. If a repair
|
|
604
636
|
or candidate branch cannot continue, a single idempotent terminalizer records
|
|
605
637
|
`verification_failed`, closes the Decision Worker and reports cleanup evidence; it never performs
|
package/docs/autonomy-target.md
CHANGED
|
@@ -106,7 +106,7 @@ stop or park safely and retain evidence; it must not silently grant remote or ma
|
|
|
106
106
|
Automatic mode implements the local loop: policy decisions allow ordinary local development,
|
|
107
107
|
`AskUserQuestion` is converted to a denied interactive permission, the Decision Worker can
|
|
108
108
|
continue/redirect/answer/repair, acceptance and independent Review run without a human callback,
|
|
109
|
-
and unresolved situations become `blocked` candidates. The default task autonomy is unattended, requires a local commit, and permits
|
|
109
|
+
and unresolved situations become `blocked` candidates. The default task autonomy is unattended, requires a local commit, and permits four bounded
|
|
110
110
|
Decision Worker request retries. Automatic startup rejects non-Git/detached/bare/protected
|
|
111
111
|
repository states, malformed baselines, startup-HEAD races, the unstructured
|
|
112
112
|
process-pipe transport, Bash-preauthorizing Claude arguments/settings and non-Claude or untrusted
|
package/docs/testing.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Testing
|
|
2
2
|
|
|
3
|
-
> Autonomy target: local editing, testing, repair and local commits run without a human being online. Invalid output, unavailable evidence, duplicate findings,
|
|
3
|
+
> Autonomy target: local editing, testing, repair and local commits run without a human being online. Invalid output (after one corrective re-prompt), unavailable evidence, duplicate findings, a `human` Reviewer verdict and exhausted budgets become parked/non-publishable candidates rather than synchronous human gates. Remote push and main/integration merge remain independent-boundary tests. See [autonomy-target.md](autonomy-target.md).
|
|
4
4
|
|
|
5
5
|
## Local checks
|
|
6
6
|
|
|
@@ -113,6 +113,43 @@ PI_CLAUDE_SUPERVISOR_REAL_CLAUDE_PATH="$HOME/.local/share/mise/installs/claude/l
|
|
|
113
113
|
PI_CLAUDE_SUPERVISOR_REAL_CLAUDE=1 npm run spike:tmux
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
+
The Decision spike runs a real Pi Decision Worker and Reviewer, on any Pi
|
|
117
|
+
model, against a scripted Worker that edits a temporary git repository. It
|
|
118
|
+
needs no Claude Code, cgroup or tmux, so it runs on hosts that cannot run the
|
|
119
|
+
real-Claude spikes. It is gated and excluded from normal CI:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
# Default model: google/gemini-3.5-flash-lite
|
|
123
|
+
PI_CLAUDE_SUPERVISOR_REAL_DECISION=1 npm run spike:decision
|
|
124
|
+
# Optional: another Pi model, a subset of scenarios, a per-scenario deadline,
|
|
125
|
+
# and keeping the temp repositories
|
|
126
|
+
SPIKE_DECISION_MODEL=google/gemini-3.1-flash-lite \
|
|
127
|
+
SPIKE_DECISION_SCENARIOS=review,stuck SPIKE_TIMEOUT_MS=600000 SPIKE_KEEP=1 \
|
|
128
|
+
PI_CLAUDE_SUPERVISOR_REAL_DECISION=1 npm run spike:decision
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Credentials come only from Pi's own sources (for example `GEMINI_API_KEY` in
|
|
132
|
+
the environment, or `~/.pi/agent/auth.json`); the script never reads, prints or
|
|
133
|
+
stores a key, and redacts what it prints. The scenarios cover:
|
|
134
|
+
- `review`: an incomplete first turn is caught and repaired. Two model
|
|
135
|
+
behaviors fail it without being regressions: a Reviewer that passes the
|
|
136
|
+
incomplete turn, and a Decision Worker that answers the "task is complete"
|
|
137
|
+
turn with `stop`, which ends the task blocked with no repair;
|
|
138
|
+
- `question`: a mid-task question is answered from the spec without a human;
|
|
139
|
+
- `stuck`: a Worker that only claims success ends `blocked` within its repair
|
|
140
|
+
budget.
|
|
141
|
+
|
|
142
|
+
The scripted Worker writes the full implementation only after a Supervisor
|
|
143
|
+
message that mentions the RangeError (or min > max). A Decision Worker answer
|
|
144
|
+
that never names it leaves the work undone, and the scenario fails.
|
|
145
|
+
|
|
146
|
+
Each prints a redacted summary: state, decisions, overrides, Reviewer verdicts
|
|
147
|
+
and answer-format failures. The script exits non-zero when a scenario misses
|
|
148
|
+
its expected outcome. Weak and rate-limited models are useful here, because
|
|
149
|
+
they exercise the deterministic guards that the prompt alone does not
|
|
150
|
+
guarantee. A daily quota error at startup or mid-task is expected to fail
|
|
151
|
+
closed (park), and is not a regression.
|
|
152
|
+
|
|
116
153
|
The tmux spike is gated, authenticated, and excluded from normal CI. It uses
|
|
117
154
|
plan mode with a fixed `opus` model, records only protocol metadata, and
|
|
118
155
|
verifies three real Claude turns, exact screen-result markers, pause/resume,
|
|
@@ -146,7 +183,7 @@ Pi Decision Worker. Setting `PI_CLAUDE_SUPERVISOR_TRANSPORT=tmux` selects the
|
|
|
146
183
|
Supervisor-owned live bridge, which carries the same structured records through
|
|
147
184
|
private framing on the PTY rather than an independent JSONL sidecar. Task autonomy
|
|
148
185
|
defaults to unattended local work, a required
|
|
149
|
-
local commit on the task branch (any branch, anchored to the baseline commit) and
|
|
186
|
+
local commit on the task branch (any branch, anchored to the baseline commit) and four bounded Decision Worker retries. Configure
|
|
150
187
|
`PI_CLAUDE_SUPERVISOR_REQUIRE_LOCAL_COMMIT=0` or task `autonomy.requireLocalCommit`
|
|
151
188
|
only to disable the local-commit deliverability check; automatic mode still requires a Git
|
|
152
189
|
baseline (any branch, including `main`; the candidate must descend from it). The tmux bridge
|
|
@@ -240,7 +277,7 @@ Deterministic tests must cover:
|
|
|
240
277
|
- multiple required/optional checks with bounded output, timeout and exit-code evidence;
|
|
241
278
|
- independent read-only Reviewer pass/revise/human results;
|
|
242
279
|
- invalid Reviewer JSON and Reviewer API failure becoming a parked/non-publishable candidate without requiring a live callback;
|
|
243
|
-
- repair rounds, repeated finding detection
|
|
280
|
+
- repair rounds (P0/P1 findings are repaired, never passed), repeated finding detection and repair-budget exhaustion;
|
|
244
281
|
- non-persistent JSONL verification failure without duplicate terminal transitions;
|
|
245
282
|
- repairable-but-not-persistent JSONL multi-turn repair;
|
|
246
283
|
- stop and Pi shutdown from `verifying`, including Decision Worker closure and cwd lease release;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-claude-supervisor",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "A policy-gated Pi supervisor for observing and verifying Claude Code workers.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
"spike:permissions": "node scripts/spike-claude-permissions.mjs",
|
|
62
62
|
"spike:signals": "node scripts/spike-claude-signals.mjs",
|
|
63
63
|
"spike:automation": "node scripts/spike-claude-automation.mjs",
|
|
64
|
+
"spike:decision": "node scripts/spike-decision.mjs",
|
|
64
65
|
"check": "npm run typecheck && npm test && npm run check:package && npm run check:docs && npm run check:automation",
|
|
65
66
|
"build": "node scripts/build-package.mjs"
|
|
66
67
|
},
|
package/src/acceptance.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DEFAULT_MAX_DECISION_RETRIES } from "./config.ts";
|
|
1
2
|
import { isPlainRemoteName } from "./policy.ts";
|
|
2
3
|
import type { AcceptanceCheck, TaskSpec } from "./types.ts";
|
|
3
4
|
|
|
@@ -94,7 +95,7 @@ function normalizeAutonomy(value: unknown, defaults?: Partial<TaskSpec["autonomy
|
|
|
94
95
|
const source = value as Record<string, unknown>;
|
|
95
96
|
if (source.unattended !== undefined && typeof source.unattended !== "boolean") throw new Error("task spec autonomy.unattended must be boolean");
|
|
96
97
|
if (source.requireLocalCommit !== undefined && typeof source.requireLocalCommit !== "boolean") throw new Error("task spec autonomy.requireLocalCommit must be boolean");
|
|
97
|
-
const retries = source.maxDecisionRetries ?? defaults?.maxDecisionRetries ??
|
|
98
|
+
const retries = source.maxDecisionRetries ?? defaults?.maxDecisionRetries ?? DEFAULT_MAX_DECISION_RETRIES;
|
|
98
99
|
if (typeof retries !== "number" || !Number.isSafeInteger(retries) || retries < 0 || retries > 10) throw new Error("task spec autonomy.maxDecisionRetries must be between 0 and 10");
|
|
99
100
|
const authority = source.permissionAuthority ?? defaults?.permissionAuthority ?? "hybrid";
|
|
100
101
|
if (authority !== "policy" && authority !== "hybrid" && authority !== "decision-worker") throw new Error("task spec autonomy.permissionAuthority must be policy, hybrid or decision-worker");
|
package/src/config.ts
CHANGED
|
@@ -60,22 +60,22 @@ export interface AutonomyDefaults {
|
|
|
60
60
|
|
|
61
61
|
export function autonomyDefaults(env: NodeJS.ProcessEnv = process.env): AutonomyDefaults {
|
|
62
62
|
return {
|
|
63
|
-
unattended: readBoolean(env.PI_CLAUDE_SUPERVISOR_UNATTENDED, true),
|
|
64
|
-
requireLocalCommit: readBoolean(env.PI_CLAUDE_SUPERVISOR_REQUIRE_LOCAL_COMMIT, true),
|
|
65
|
-
maxDecisionRetries: readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_MAX_DECISION_RETRIES,
|
|
63
|
+
unattended: readBoolean(env.PI_CLAUDE_SUPERVISOR_UNATTENDED, true, "PI_CLAUDE_SUPERVISOR_UNATTENDED"),
|
|
64
|
+
requireLocalCommit: readBoolean(env.PI_CLAUDE_SUPERVISOR_REQUIRE_LOCAL_COMMIT, true, "PI_CLAUDE_SUPERVISOR_REQUIRE_LOCAL_COMMIT"),
|
|
65
|
+
maxDecisionRetries: readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_MAX_DECISION_RETRIES, DEFAULT_MAX_DECISION_RETRIES, 0, 10, "PI_CLAUDE_SUPERVISOR_MAX_DECISION_RETRIES"),
|
|
66
66
|
permissionAuthority: readPermissionAuthority(env.PI_CLAUDE_SUPERVISOR_PERMISSION_AUTHORITY),
|
|
67
67
|
remoteAuthority: readRemoteAuthority(env.PI_CLAUDE_SUPERVISOR_REMOTE_AUTHORITY),
|
|
68
68
|
remoteName: readRemoteName(env.PI_CLAUDE_SUPERVISOR_REMOTE_NAME),
|
|
69
|
-
...(readPositiveNumber(env.PI_CLAUDE_SUPERVISOR_WORKER_MAX_BUDGET_USD) !== undefined ? { maxWorkerCostUsd: readPositiveNumber(env.PI_CLAUDE_SUPERVISOR_WORKER_MAX_BUDGET_USD) } : {}),
|
|
69
|
+
...(readPositiveNumber(env.PI_CLAUDE_SUPERVISOR_WORKER_MAX_BUDGET_USD, "PI_CLAUDE_SUPERVISOR_WORKER_MAX_BUDGET_USD") !== undefined ? { maxWorkerCostUsd: readPositiveNumber(env.PI_CLAUDE_SUPERVISOR_WORKER_MAX_BUDGET_USD, "PI_CLAUDE_SUPERVISOR_WORKER_MAX_BUDGET_USD") } : {}),
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
export function reviewTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
74
|
-
return
|
|
74
|
+
return readBoundedDuration(env.PI_CLAUDE_SUPERVISOR_REVIEW_TIMEOUT_MS, 600_000, 30_000, 3_600_000, "PI_CLAUDE_SUPERVISOR_REVIEW_TIMEOUT_MS");
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
export function eventLogMaxBytes(env: NodeJS.ProcessEnv = process.env): number {
|
|
78
|
-
return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_EVENT_LOG_MAX_BYTES, 64 * 1024 * 1024, 1024 * 1024, 1024 * 1024 * 1024);
|
|
78
|
+
return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_EVENT_LOG_MAX_BYTES, 64 * 1024 * 1024, 1024 * 1024, 1024 * 1024 * 1024, "PI_CLAUDE_SUPERVISOR_EVENT_LOG_MAX_BYTES");
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
export function workerModel(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
@@ -84,7 +84,7 @@ export function workerModel(env: NodeJS.ProcessEnv = process.env): string | unde
|
|
|
84
84
|
|
|
85
85
|
/** The 200_000 default applies only in automatic mode; "0" is an explicit opt-out that omits --autocompact. */
|
|
86
86
|
export function workerAutocompactTokens(env: NodeJS.ProcessEnv = process.env): number {
|
|
87
|
-
return readBoundedIntegerWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_WORKER_AUTOCOMPACT_TOKENS, 200_000, 100_000, 1_000_000);
|
|
87
|
+
return readBoundedIntegerWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_WORKER_AUTOCOMPACT_TOKENS, 200_000, 100_000, 1_000_000, "PI_CLAUDE_SUPERVISOR_WORKER_AUTOCOMPACT_TOKENS");
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
export function workerMcpConfigPath(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
@@ -101,25 +101,27 @@ export function reviewerModel(env: NodeJS.ProcessEnv = process.env): string | un
|
|
|
101
101
|
|
|
102
102
|
/** "0" is an explicit opt-out that disables Decision Worker session compaction. */
|
|
103
103
|
export function decisionCompactionTokens(env: NodeJS.ProcessEnv = process.env): number {
|
|
104
|
-
return readBoundedIntegerWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_DECISION_COMPACT_TOKENS, 60_000, 10_000, 500_000);
|
|
104
|
+
return readBoundedIntegerWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_DECISION_COMPACT_TOKENS, 60_000, 10_000, 500_000, "PI_CLAUDE_SUPERVISOR_DECISION_COMPACT_TOKENS");
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export function progressHeartbeatMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
108
|
-
return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_PROGRESS_HEARTBEAT_MS, 60_000, 5_000, 3_600_000);
|
|
108
|
+
return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_PROGRESS_HEARTBEAT_MS, 60_000, 5_000, 3_600_000, "PI_CLAUDE_SUPERVISOR_PROGRESS_HEARTBEAT_MS");
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
export function decisionSessionRetentionDays(env: NodeJS.ProcessEnv = process.env): number {
|
|
112
|
-
return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_DECISION_SESSION_RETENTION_DAYS, 30, 0, 3650);
|
|
112
|
+
return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_DECISION_SESSION_RETENTION_DAYS, 30, 0, 3650, "PI_CLAUDE_SUPERVISOR_DECISION_SESSION_RETENTION_DAYS");
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
export function evidenceMaxBytes(env: NodeJS.ProcessEnv = process.env): number {
|
|
116
|
-
return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_EVIDENCE_MAX_BYTES, 1024 * 1024, 64 * 1024, 64 * 1024 * 1024);
|
|
116
|
+
return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_EVIDENCE_MAX_BYTES, 1024 * 1024, 64 * 1024, 64 * 1024 * 1024, "PI_CLAUDE_SUPERVISOR_EVIDENCE_MAX_BYTES");
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
export function evidenceMaxUntrackedFiles(env: NodeJS.ProcessEnv = process.env): number {
|
|
120
|
-
return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_EVIDENCE_MAX_UNTRACKED_FILES, 512, 16, 10_000);
|
|
120
|
+
return readBoundedInteger(env.PI_CLAUDE_SUPERVISOR_EVIDENCE_MAX_UNTRACKED_FILES, 512, 16, 10_000, "PI_CLAUDE_SUPERVISOR_EVIDENCE_MAX_UNTRACKED_FILES");
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/** Retries of a failed Decision Worker request before the task is parked. */
|
|
124
|
+
export const DEFAULT_MAX_DECISION_RETRIES = 4;
|
|
123
125
|
export const DEFAULT_DEADLINE_MS = 4 * 60 * 60_000;
|
|
124
126
|
export const DEFAULT_DEADLINE_GRACE_MS = 30 * 60_000;
|
|
125
127
|
export const DEFAULT_DEADLINE_WARNING_MS = 15 * 60_000;
|
|
@@ -131,7 +133,7 @@ export const DEFAULT_NO_OUTPUT_TIMEOUT_MS = 20 * 60_000;
|
|
|
131
133
|
* accepted as well as plain milliseconds.
|
|
132
134
|
*/
|
|
133
135
|
export function deadlineMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
134
|
-
return readBoundedDurationWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_DEADLINE_MS, DEFAULT_DEADLINE_MS, 5 * 60_000, 7 * 24 * 60 * 60_000);
|
|
136
|
+
return readBoundedDurationWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_DEADLINE_MS, DEFAULT_DEADLINE_MS, 5 * 60_000, 7 * 24 * 60 * 60_000, "PI_CLAUDE_SUPERVISOR_DEADLINE_MS");
|
|
135
137
|
}
|
|
136
138
|
|
|
137
139
|
/**
|
|
@@ -140,17 +142,17 @@ export function deadlineMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
|
140
142
|
* restores the immediate stop at the deadline. Up to 24 hours.
|
|
141
143
|
*/
|
|
142
144
|
export function deadlineGraceMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
143
|
-
return readBoundedDurationWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_DEADLINE_GRACE_MS, DEFAULT_DEADLINE_GRACE_MS, 60_000, 24 * 60 * 60_000);
|
|
145
|
+
return readBoundedDurationWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_DEADLINE_GRACE_MS, DEFAULT_DEADLINE_GRACE_MS, 60_000, 24 * 60 * 60_000, "PI_CLAUDE_SUPERVISOR_DEADLINE_GRACE_MS");
|
|
144
146
|
}
|
|
145
147
|
|
|
146
148
|
/** How long before the deadline the Decision Worker is warned (up to 24 hours); "0" disables the warning. */
|
|
147
149
|
export function deadlineWarningMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
148
|
-
return readBoundedDurationWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_DEADLINE_WARNING_MS, DEFAULT_DEADLINE_WARNING_MS, 60_000, 24 * 60 * 60_000);
|
|
150
|
+
return readBoundedDurationWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_DEADLINE_WARNING_MS, DEFAULT_DEADLINE_WARNING_MS, 60_000, 24 * 60 * 60_000, "PI_CLAUDE_SUPERVISOR_DEADLINE_WARNING_MS");
|
|
149
151
|
}
|
|
150
152
|
|
|
151
153
|
/** Stop a Worker that has produced no output for this long (1 minute to 24 hours); "0" disables the check. */
|
|
152
154
|
export function noOutputTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
153
|
-
return readBoundedDurationWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_NO_OUTPUT_TIMEOUT_MS, DEFAULT_NO_OUTPUT_TIMEOUT_MS, 60_000, 24 * 60 * 60_000);
|
|
155
|
+
return readBoundedDurationWithZeroOptOut(env.PI_CLAUDE_SUPERVISOR_NO_OUTPUT_TIMEOUT_MS, DEFAULT_NO_OUTPUT_TIMEOUT_MS, 60_000, 24 * 60 * 60_000, "PI_CLAUDE_SUPERVISOR_NO_OUTPUT_TIMEOUT_MS");
|
|
154
156
|
}
|
|
155
157
|
|
|
156
158
|
const DURATION_UNITS_MS: Record<string, number> = { ms: 1, s: 1_000, m: 60_000, h: 60 * 60_000, d: 24 * 60 * 60_000 };
|
|
@@ -193,10 +195,12 @@ export function loadSupervisorEnvironment(): string | undefined {
|
|
|
193
195
|
// Best effort: the file is local configuration, never a repository asset.
|
|
194
196
|
try { chmodSync(path, 0o600); } catch { /* read-only filesystems may reject chmod */ }
|
|
195
197
|
for (const line of contents.split(/\r?\n/u)) {
|
|
196
|
-
|
|
198
|
+
// Shell-style: an optional `export ` prefix, and a trailing `# comment`
|
|
199
|
+
// after an unquoted value or a closing quote. Keeping the comment made
|
|
200
|
+
// `MODE="auto" # enable` a value no reader accepts.
|
|
201
|
+
const match = line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=(.*?)\s*$/u);
|
|
197
202
|
if (!match || !allowed.has(match[1]) || process.env[match[1]] !== undefined) continue;
|
|
198
|
-
|
|
199
|
-
process.env[match[1]] = value;
|
|
203
|
+
process.env[match[1]] = envFileValue(match[2]);
|
|
200
204
|
}
|
|
201
205
|
return path;
|
|
202
206
|
} catch (error) {
|
|
@@ -205,32 +209,72 @@ export function loadSupervisorEnvironment(): string | undefined {
|
|
|
205
209
|
}
|
|
206
210
|
}
|
|
207
211
|
|
|
208
|
-
|
|
212
|
+
const reportedSettings = new Set<string>();
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* An unusable value keeps the default, but never silently: `DEADLINE_MS=10d`
|
|
216
|
+
* quietly running a 4h task is exactly the surprise an unattended run cannot
|
|
217
|
+
* afford. Each distinct rejected value is reported once per process.
|
|
218
|
+
*/
|
|
219
|
+
function rejectSetting(name: string | undefined, value: string, reason: string, fallback: unknown): void {
|
|
220
|
+
// An empty assignment (`KEY=`) is how an env file leaves a key unset.
|
|
221
|
+
if (!name || value.trim() === "") return;
|
|
222
|
+
const key = `${name}=${value}`;
|
|
223
|
+
if (reportedSettings.has(key)) return;
|
|
224
|
+
reportedSettings.add(key);
|
|
225
|
+
console.warn(`pi-claude-supervisor: ignoring ${name}=${JSON.stringify(String(redactSensitive(value)))} (${reason}); using ${String(fallback)}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The value of one env-file assignment: quoted text verbatim, or an unquoted word before any ` #` comment. */
|
|
229
|
+
export function envFileValue(raw: string): string {
|
|
230
|
+
// `KEY= # note` is an empty value with a comment, as in a shell; only a
|
|
231
|
+
// `#` right after the `=` (`KEY=#abc`) belongs to the value.
|
|
232
|
+
if (/^\s+#/u.test(raw)) return "";
|
|
233
|
+
raw = raw.trimStart();
|
|
234
|
+
const quoted = raw.match(/^(?:"([^"]*)"|'([^']*)')(?:\s+#.*)?$/u);
|
|
235
|
+
if (quoted) return quoted[1] ?? quoted[2] ?? "";
|
|
236
|
+
// A comment needs whitespace before its `#`, as in a shell: `KEY=#abc`
|
|
237
|
+
// is the value `#abc` (a webhook secret may well start with one).
|
|
238
|
+
return raw.replace(/\s+#.*$/u, "").trim();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function readBoolean(value: string | undefined, fallback: boolean, name?: string): boolean {
|
|
209
242
|
if (value === undefined) return fallback;
|
|
210
243
|
if (/^(?:1|true|yes|on)$/iu.test(value.trim())) return true;
|
|
211
244
|
if (/^(?:0|false|no|off)$/iu.test(value.trim())) return false;
|
|
245
|
+
rejectSetting(name, value, "expected 1/0, true/false, yes/no or on/off", fallback);
|
|
212
246
|
return fallback;
|
|
213
247
|
}
|
|
214
248
|
|
|
215
|
-
function readBoundedInteger(value: string | undefined, fallback: number, minimum: number, maximum: number): number {
|
|
249
|
+
function readBoundedInteger(value: string | undefined, fallback: number, minimum: number, maximum: number, name?: string): number {
|
|
216
250
|
if (value === undefined) return fallback;
|
|
217
251
|
const parsed = Number(value);
|
|
218
|
-
|
|
252
|
+
if (Number.isSafeInteger(parsed) && parsed >= minimum && parsed <= maximum) return parsed;
|
|
253
|
+
rejectSetting(name, value, `expected an integer from ${minimum} to ${maximum}`, fallback);
|
|
254
|
+
return fallback;
|
|
219
255
|
}
|
|
220
256
|
|
|
221
257
|
/** Like readBoundedInteger, but the literal "0" is always honored as an opt-out below the normal minimum. */
|
|
222
|
-
function readBoundedIntegerWithZeroOptOut(value: string | undefined, fallback: number, minimum: number, maximum: number): number {
|
|
258
|
+
function readBoundedIntegerWithZeroOptOut(value: string | undefined, fallback: number, minimum: number, maximum: number, name?: string): number {
|
|
223
259
|
if (value !== undefined && value.trim() === "0") return 0;
|
|
224
|
-
return readBoundedInteger(value, fallback, minimum, maximum);
|
|
260
|
+
return readBoundedInteger(value, fallback, minimum, maximum, name);
|
|
225
261
|
}
|
|
226
262
|
|
|
227
|
-
/**
|
|
228
|
-
function
|
|
263
|
+
/** A duration (`20m`, `1h30m`, or plain milliseconds) within bounds; anything else keeps the fallback. */
|
|
264
|
+
function readBoundedDuration(value: string | undefined, fallback: number, minimum: number, maximum: number, name?: string): number {
|
|
229
265
|
if (value === undefined) return fallback;
|
|
230
266
|
const parsed = parseDurationMs(value);
|
|
267
|
+
if (parsed !== undefined && parsed >= minimum && parsed <= maximum) return parsed;
|
|
268
|
+
rejectSetting(name, value, `expected a duration from ${formatDurationMs(minimum)} to ${formatDurationMs(maximum)}`, formatDurationMs(fallback));
|
|
269
|
+
return fallback;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Like readBoundedIntegerWithZeroOptOut, but also accepts a duration suffix (`8h`, `90m`); out-of-range values keep the fallback. */
|
|
273
|
+
function readBoundedDurationWithZeroOptOut(value: string | undefined, fallback: number, minimum: number, maximum: number, name?: string): number {
|
|
274
|
+
if (value === undefined) return fallback;
|
|
231
275
|
// "0", "0m", "0h": any zero duration is the opt-out, not a below-minimum typo.
|
|
232
|
-
if (
|
|
233
|
-
return
|
|
276
|
+
if (parseDurationMs(value) === 0) return 0;
|
|
277
|
+
return readBoundedDuration(value, fallback, minimum, maximum, name);
|
|
234
278
|
}
|
|
235
279
|
|
|
236
280
|
function readTrimmedString(value: string | undefined): string | undefined {
|
|
@@ -262,10 +306,12 @@ function readPermissionAuthority(value: string | undefined): PermissionAuthority
|
|
|
262
306
|
return normalized === "policy" || normalized === "decision-worker" ? normalized : "hybrid";
|
|
263
307
|
}
|
|
264
308
|
|
|
265
|
-
function readPositiveNumber(value: string | undefined): number | undefined {
|
|
309
|
+
function readPositiveNumber(value: string | undefined, name?: string): number | undefined {
|
|
266
310
|
if (value === undefined || value.trim() === "") return undefined;
|
|
267
311
|
const parsed = Number(value);
|
|
268
|
-
|
|
312
|
+
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
313
|
+
rejectSetting(name, value, "expected a positive number", "no limit");
|
|
314
|
+
return undefined;
|
|
269
315
|
}
|
|
270
316
|
|
|
271
317
|
/** How automatic tmux supervision drives Claude: the real TUI through hooks (default) or the stream-json bridge. */
|
|
@@ -275,10 +321,10 @@ export function tmuxMode(env: NodeJS.ProcessEnv = process.env): "interactive" |
|
|
|
275
321
|
|
|
276
322
|
/** Exit the interactive Worker and its tmux session once a task completes; default keeps it open for the operator. */
|
|
277
323
|
export function closeWorkerOnCompletion(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
278
|
-
return readBoolean(env.PI_CLAUDE_SUPERVISOR_CLOSE_WORKER_ON_COMPLETION, false);
|
|
324
|
+
return readBoolean(env.PI_CLAUDE_SUPERVISOR_CLOSE_WORKER_ON_COMPLETION, false, "PI_CLAUDE_SUPERVISOR_CLOSE_WORKER_ON_COMPLETION");
|
|
279
325
|
}
|
|
280
326
|
|
|
281
327
|
/** Install the user-level Claude Code hook entries automatically when the interactive tmux mode is configured. */
|
|
282
328
|
export function autoInstallHooks(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
283
|
-
return readBoolean(env.PI_CLAUDE_SUPERVISOR_AUTO_INSTALL_HOOKS, true);
|
|
329
|
+
return readBoolean(env.PI_CLAUDE_SUPERVISOR_AUTO_INSTALL_HOOKS, true, "PI_CLAUDE_SUPERVISOR_AUTO_INSTALL_HOOKS");
|
|
284
330
|
}
|