@staix/agent-hub 0.9.0 → 0.10.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 CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  Issue and pull request numbers in the entries for 0.7.7 and earlier refer to the previous repository, archived on 2026-09-30 when this repository's history was rewritten; the one exception is the open smoke-check issue, formerly #12, which moved here as #1. Numbers in newer entries refer to this repository.
4
4
 
5
+ ## 0.10.0
6
+
7
+ - Review checklists and review outcomes: review requests ask the reviewer to map signatures and call sites to the plan, read the check result and list unmet items (`hub_review {unmet}`); the hub records approved, caught, contradicted and escalated reviews per implementer, reviewer and class, shown by `ahub task show` and `ahub route explain`, and `review.adaptive` (off by default) orders reviewers by that record, after idle before busy and ahead of quota. A record counts tasks, a catch belongs to the owner whose work was caught, and only a failure on the same file or symbol contradicts an approval (#35).
8
+ - Recovery after an unplanned stop: the hub keeps each attached peer's session identity while it runs; a hub started after a crash reports what happened to each peer in `ahub status`, resumes Kimi (ACP `session/load`), a headless Pi and the local worker (on its recorded route) when `recovery.auto_resume_after_crash` is on, and gives each peer a notice of its deliveries left in `needs_review` with its next delivery. A stop someone asked for, even one past the shutdown deadline, is not taken for a crash (#37).
9
+ - The local worker's sandbox starts from deny default: commands may run and read the system, toolchain and project directories (and the selected Xcode or Command Line Tools dir) and write the project and temp, nothing else; `/Applications`, `/nix`, `/Volumes` and `/Users/Shared` now need `local.read_allow`; `local.sandbox: "allow-default"` (machine-local) keeps the profile of 0.9 and earlier for one release; with network on, the public CA bundles stay readable for TLS. Per-peer `capabilities` (`propose`, `assign`, `remember`, `important`) are enforced by the daemon, a malformed entry grants nothing and every refusal is logged, under deny-default the worker's commands can no longer reach the LaunchServices, CoreServices or SecurityServer brokers, and a test pins that a peer can never answer a permission request (#39).
10
+
5
11
  ## 0.9.0
6
12
 
7
13
  - Early conflict detection: in a git work tree, a turn that changes a file another owner's open task changed earlier warns both owners (and the console, and `events.jsonl`), once per file and task, marked concurrent when another peer worked meanwhile. `ahub check-path` and the PreToolUse hook template `templates/claude-hooks.json` give Claude the same warning before an edit, without blocking it (#32).
package/README.md CHANGED
@@ -4,7 +4,7 @@ Native multi-agent hub for one developer's machine: Claude Code, Codex, Kimi Cod
4
4
  hub-owned local-LLM worker collaborate as peers in independent project directories, with
5
5
  task-aware model routing (Switchyard) in front of a self-hosted gateway (OmniRoute).
6
6
 
7
- Status: 0.9.0, control protocol 10. Durable delivery records distinguish queued
7
+ Status: 0.10.0, control protocol 10. Durable delivery records distinguish queued
8
8
  work from uncertain execution. The [smoke checklist](docs/smoke.md) records
9
9
  verified paths and remaining prerequisites.
10
10
 
@@ -28,7 +28,7 @@ cd <your project> && ahub init && ahub up && ahub tail
28
28
  Or install the same version from GitHub:
29
29
 
30
30
  ```bash
31
- bun add -g github:STAIxBWLB/agent-hub#v0.9.0 && ahub setup
31
+ bun add -g github:STAIxBWLB/agent-hub#v0.10.0 && ahub setup
32
32
  ```
33
33
 
34
34
  The installed commands remain `ahub` and `agent-hub`.
@@ -1,6 +1,6 @@
1
1
  # Operations guide
2
2
 
3
- This guide describes ahub 0.9.0 and control protocol 10. Live verification
3
+ This guide describes ahub 0.10.0 and control protocol 10. Live verification
4
4
  results and remaining prerequisites are recorded separately in [the smoke ledger](smoke.md).
5
5
 
6
6
  ## Install and start
@@ -27,11 +27,38 @@ what the hub runs, which files it sends as credentials, where task text goes,
27
27
  or how far the local worker's sandbox reaches (`kimi_cmd`, `codex_bin`,
28
28
  `pi.cmd`, `checks`, `mlx.bin`, `mlx.runtimeDir`, `mlx.modelPath`, `omniroute.urls`,
29
29
  `omniroute.access_hosts`, the `omniroute` key files, `memory.worker_url`,
30
- `local.read_allow`, `local.bash_network`) are machine-local: they apply only
31
- from a file git confirms nobody committed. Put them in
30
+ `local.read_allow`, `local.bash_network`, `local.sandbox`) are machine-local:
31
+ they apply only from a file git confirms nobody committed. Put them in
32
32
  `.agenthub/config.local.json` (`ahub init` adds it to `.gitignore`), which is
33
33
  read after `config.json`; outside a git repository they keep their defaults, and
34
34
  an empty value always means the default.
35
+
36
+ The local worker's commands run under a sandbox that starts from deny default
37
+ (0.10): they may run and read the system, toolchain and project directories and
38
+ the selected Xcode or Command Line Tools dir (`xcode-select -p`), write the
39
+ project and temp, and nothing else. With `local.bash_network` on they may also
40
+ read the public CA bundles, which the `*.pem` key deny would otherwise hide.
41
+ `"local": { "sandbox": "allow-default" }` in `config.local.json` brings back the
42
+ profile of 0.9 and earlier for one release, should a toolchain need a path the
43
+ new one lacks; please report it. Newly closed outside home: `/Applications`
44
+ (an app's bundled CLI), `/nix`, `/Volumes` and `/Users/Shared`. A toolchain
45
+ there, or elsewhere in your home (a CI tool cache, a version manager the profile
46
+ does not list), needs its directory in `local.read_allow`, for example
47
+ `"/nix"` or `"~/.pixi"`.
48
+
49
+ `capabilities` in `.agenthub/config.json` narrows what a peer may do with the
50
+ hub's tools: list a peer and it keeps only the capabilities named, from
51
+ `propose` (`hub_task_propose`), `assign` (proposing with another peer as owner),
52
+ `remember` (the `hub_remember` tool; the notes the hub itself keeps of done
53
+ summaries and review verdicts are not gated) and `important` (`[IMPORTANT]`
54
+ messages). For example `"capabilities": { "local": ["propose", "remember"] }`.
55
+ A peer that is not listed keeps all of them, a listed peer whose value is not a
56
+ list gets none, and an unknown capability name grants nothing; `hub.log` says
57
+ so for each, and logs every refusal (`capabilities:`). A refused tool call says
58
+ which capability is missing; an `[IMPORTANT]` turn answer without the
59
+ capability goes out as status, with a note to the sender.
60
+ Approvals are never a capability: only the console (and the dashboard) answers a
61
+ permission request.
35
62
  A committed value is ignored with a line in `hub.log`, a note from `ahub
36
63
  codex` and `ahub models`, and a row in `ahub doctor`.
37
64
 
@@ -87,6 +114,21 @@ external side effect happened unless the task's refs and a live readback show
87
114
  that effect. The hub can reassign after repeated review changes according to
88
115
  the routing configuration.
89
116
 
117
+ A review request carries a checklist: map the changed signatures and call sites
118
+ to the task's plan (without one, to its detail, which the request then
119
+ includes), read the check result, and list what is
120
+ unmet (`hub_review` takes `unmet`, `ahub review ... --unmet <item>`). The hub
121
+ records how each review turned out, per implementer, reviewer and class:
122
+ approved; caught (changes were requested and the owner's redo was approved);
123
+ contradicted (within a week, work on the same file or symbol failed its check
124
+ or review); escalated (after the reviewer asked for changes). A record counts
125
+ tasks, not verdicts. `ahub task show <id>` lists a task's outcomes and `ahub
126
+ route explain` shows each reviewer's record with the implementer. With
127
+ `"review": { "adaptive": true }` in `.agenthub/config.json`, reviewers with at
128
+ least `min_reviews` (5) reviews of that implementer in the class are ordered by
129
+ how their reviews held up, ahead of quota but after idle before busy; off by
130
+ default, which leaves assignment as it was.
131
+
90
132
  An agent can claim work nobody assigned it by proposing a task with itself as
91
133
  owner; without a class, and with no model to name one, the claim is filed as
92
134
  `implement`. A claim or an accept can carry a plan: the files, symbols and
@@ -383,8 +425,8 @@ source and carries every recovery fix released up to it. Protocol 8 and older
383
425
  project directory, without replacing the global CLI first:
384
426
 
385
427
  ```bash
386
- bunx --package @staix/agent-hub@0.9.0 ahub upgrade --to 0.9.0 --dry-run
387
- bunx --package @staix/agent-hub@0.9.0 ahub upgrade --to 0.9.0 --yes
428
+ bunx --package @staix/agent-hub@0.10.0 ahub upgrade --to 0.10.0 --dry-run
429
+ bunx --package @staix/agent-hub@0.10.0 ahub upgrade --to 0.10.0 --yes
388
430
  ```
389
431
 
390
432
  | Running now | Coordinator to use |
@@ -407,25 +449,26 @@ no blocker, and `--yes` stops at staging ("target protocol requires a newer
407
449
  coordinator") with an operation left to clear by `ahub recovery abort <id>`. An
408
450
  older 0.7.x CLI may lack recovery fixes released after it. The
409
451
  [smoke ledger](smoke.md) records dry-runs from real 0.6.4 and 0.7.5 hubs (issue
410
- #75); an applied upgrade was last proven with the 0.7.0 coordinator.
452
+ #75), an applied upgrade with the 0.7.0 coordinator, and one from a running
453
+ 0.8.1 hub with tasks and a budget pause with the 0.9.0 coordinator.
411
454
 
412
455
  The coordinator verifies and retains the exact target package, preserves its
413
456
  own source, and promotes the global CLI only after restored projects pass
414
457
  readback.
415
458
 
416
- Once the installed CLI is 0.9.0, review the current project or all registered
459
+ Once the installed CLI is 0.10.0, review the current project or all registered
417
460
  projects first:
418
461
 
419
462
  ```bash
420
463
  ahub restart --dry-run
421
- ahub upgrade --to 0.9.0 --dry-run
464
+ ahub upgrade --to 0.10.0 --dry-run
422
465
  ```
423
466
 
424
467
  Apply only after reviewing the plan:
425
468
 
426
469
  ```bash
427
470
  ahub restart --yes
428
- ahub upgrade --to 0.9.0 --yes
471
+ ahub upgrade --to 0.10.0 --yes
429
472
  ahub recovery status <operation-id>
430
473
  ahub recovery resume <operation-id>
431
474
  ahub recovery abort <operation-id>
@@ -448,6 +491,14 @@ block: `limits` (12 messages a minute per sender, 6 per recipient, 6
448
491
  `[IMPORTANT]` an hour, 120 s repeats) and `budget.wait_max_min` (30). Set them
449
492
  to 0 to opt out.
450
493
 
494
+ 0.10.0 changes every project's local worker: its commands run under the
495
+ deny-default sandbox described above, and a toolchain outside the listed
496
+ directories needs `local.read_allow`; `"local": { "sandbox": "allow-default" }`
497
+ in `config.local.json` restores the old profile for one release. Off unless set:
498
+ `review.adaptive`, `recovery.auto_resume_after_crash` and `capabilities`. A hub
499
+ before 0.10.0 keeps no session record, so a crash of one is not reported as such
500
+ by the next start.
501
+
451
502
  The 0.7.0 transition stages the verified package and runs a retained
452
503
  coordinator from the source tree. It accepts a verified protocol-9 source and
453
504
  moves to a protocol-10 target. The source journal, queued envelopes, tasks,
@@ -469,6 +520,29 @@ Do not run an upgrade with an incompatible active protocol, an unverified
469
520
  terminal binding, or an unresolved operation lock. Dry-run performs no package,
470
521
  plugin, daemon, or terminal mutation.
471
522
 
523
+ ### After an unplanned stop
524
+
525
+ While it runs, the hub keeps each attached peer's session identity in
526
+ `.agenthub/state/sessions.json` (ids and launch options, no message text); a stop
527
+ removes it as it begins. When a hub starts and finds the file, the previous run
528
+ died (`kill -9`, a crash, a lost machine), and `ahub status` and the console say
529
+ what happened to each peer. Hubs before 0.10.0 kept no such record, so a
530
+ crash of one is not reported this way.
531
+
532
+ - Deliveries that were in flight are in `needs_review` (`ahub queue list`), as
533
+ before. When a peer next attaches, its next delivery starts with a notice that
534
+ lists them by id, sender and task (`[pii]` for a PII task), never their text.
535
+ - Kimi, Pi and the local worker run inside the hub, so they died with it. With
536
+ `"recovery": { "auto_resume_after_crash": true }` in `.agenthub/config.json` the
537
+ hub starts them again: Kimi loads its recorded session (ACP `session/load`), Pi
538
+ resumes its session file, and the local worker starts without its history, on
539
+ its recorded route (or pinned model). Off by default: the report then says
540
+ what to start. A Pi that ran in a terminal (`--mode tui`) is never started by
541
+ the hub; the report gives the command. With `pi.auto_start` and no resume,
542
+ Pi starts on a fresh session as usual.
543
+ - Codex's app-server died with the hub; run `ahub codex` again. Claude Code's
544
+ plugin reconnects by itself while that session is open.
545
+
472
546
  ## Evidence and limits
473
547
 
474
548
  The 0.6.4 release has real measurements in [the smoke checklist](smoke.md):
@@ -17,7 +17,7 @@ ahub setup # installs the Claude Code channel plu
17
17
  Or use the matching GitHub release:
18
18
 
19
19
  ```bash
20
- bun add -g github:STAIxBWLB/agent-hub#v0.9.0
20
+ bun add -g github:STAIxBWLB/agent-hub#v0.10.0
21
21
  ahub setup
22
22
  ```
23
23
 
package/docs/security.md CHANGED
@@ -11,7 +11,9 @@ agent-hub connects agents that can each run commands. This page says what the hu
11
11
  - **Telemetry holds no bodies.** `.agenthub/state/events.jsonl` (issue #40) records envelope ids, routing and sizes, task ids and states, overlapping paths and token counts. It never records a message body, a task title or detail, and marks private (PII) envelopes and tasks as such. It stays on the machine; `ahub export` only prints it.
12
12
  - **Snapshots stay in your repository.** Per-turn snapshots (issue #33) are git objects in the project's own object store, written through a temporary index; nothing is referenced, pushed or copied elsewhere, and `git gc` prunes them. They hold what the work tree held, including untracked files that are not ignored, so keep secrets in ignored files. They carry the repository's own permissions, and nothing caps their disk use but `git gc`. A turn of a peer holding an open PII task is not snapshotted; a PII file left in the project is snapshotted by later turns like any other file. `ahub undo` restores only files whose current content is exactly what the turn left.
13
13
  - **The edit hook reads, never decides.** `ahub check-path --hook` (issue #32) reads hub.db and returns context for Claude and a line for you; it sets no permission decision, so your permission rules stay in charge. It names other owners' task ids, titles and states, which then reach Claude's model; PII tasks are left out.
14
- - **The local worker is boxed in.** Paths are resolved through symlinks and must stay inside the project; a secrets denylist (`.env*`, keys, credential files, the hub's own state) applies to its file tools, its git arguments and its memory capture alike; `.git` and `.agenthub` are not writable. Writes, edits, shell commands and mutating git wait for approval, and the approver sees what will be written or run, with control characters escaped. Everything it executes runs under the macOS sandbox, attended or not: no writes outside the project, its git dir and temp; the home directory is unreadable except toolchains; no `.git/hooks` or `.git/config` writes; no network, loopback included. Without the sandbox there is no `bash` tool.
14
+ - **The session record holds identities only.** `.agenthub/state/sessions.json` (issue #37, mode 600) keeps each attached peer's recovery metadata: launch options, session and thread ids, Pi's session file path. No message or task text; loss notices name deliveries by id, sender and public task title.
15
+ - **The local worker is boxed in.** Paths are resolved through symlinks and must stay inside the project; a secrets denylist (`.env*`, keys, credential files, the hub's own state) applies to its file tools, its git arguments and its memory capture alike; `.git` and `.agenthub` are not writable. Writes, edits, shell commands and mutating git wait for approval, and the approver sees what will be written or run, with control characters escaped. Everything it executes runs under the macOS sandbox, attended or not. Since 0.10 the profile starts from deny default (issue #39): commands run and read only the system, toolchain and project directories (and the project's git dir, the selected developer dir and temp; with network on, the public CA bundles); no writes outside the project, its git dir and temp; no `.git/hooks` or `.git/config` writes; no network, loopback included, unless `local.bash_network`. `local.sandbox: "allow-default"`, machine-local, brings back the profile of 0.9 and earlier for one release. The user's temp dir is still readable in both: a command can read another tool's leftovers there. Without the sandbox there is no `bash` tool.
16
+ - **Capabilities are enforced, not suggested.** `capabilities` (issue #39) is checked by the daemon where task operations and messages arrive, so a peer cannot get round it by phrasing. A peer can never answer a permission request: the control link takes `permit` from the console role only, and a message that quotes a permit command is just text. Both bind the hub's own tool paths: a vendor agent with its own shell in the project (Codex, Claude, Kimi) can read `.agenthub/state/control-token` and connect as the console, which only the local worker's and Pi's sandbox prevents.
15
17
  - **PII has an enforced path.** A task matching `signals.pii_patterns` goes to `local` or to nobody; its text is absent from other peers' envelopes, the console stream, the log and the board listing; the console user reviews it; `local` answers such a turn to the console only, keeps it out of its history, refuses it when the only gateway is off campus, and may not save notes or spin off tasks during it. Nothing about it is sent to claude-mem, whose observer is a cloud model.
16
18
  - **Secrets stay where they are read.** The gateway key and Cloudflare Access values are read inside the gateway client and go only into request headers: never into logs, errors, envelopes, tool output, memory, or the generated Switchyard config (the key travels by environment variable name). Subscription logins of Claude, Codex and Kimi are never proxied or pooled.
17
19
  - **The hub's own model calls are fenced.** Digest condensation and task triage read agent-written text as data; their output is capped text framed as untrusted, or a value checked against a closed list. It is never used as a route, a peer id, a tool call or an instruction.
package/docs/smoke.md CHANGED
@@ -901,3 +901,39 @@ peers attached.
901
901
  - Run 1, before the hook was logged: Claude answered NONE. Whether the hook
902
902
  did not fire or the model left the reminder out was not determined; that run
903
903
  also had no stdin redirect (`< /dev/null`), which runs 2 and 3 had.
904
+
905
+ ## Recovery after an unplanned stop (issue #37, 2026-10-01)
906
+
907
+ - AC1 baseline, released 0.7.11 in a scratch project, the fake ACP agent standing
908
+ in for the Kimi binary (`kimi_cmd`), `kill -9` of the daemon:
909
+ - the ACP child exited with the daemon;
910
+ - `ahub status` failed to reach the stale manifest's port;
911
+ - `ahub up` started a hub with no peers attached; nothing recorded Kimi's
912
+ session, so it could only start a new one.
913
+ - The same run with this branch and `auto_resume_after_crash` on: `ahub up`
914
+ reported the crash and `kimi resumed: ... session s1 ... (ACP session/load)`,
915
+ and Kimi was idle on its recorded session id.
916
+ - Pending, needs real accounts: Kimi 2.x (does it offer `loadSession`, and does
917
+ the resumed session keep its context), Pi with a real session file, and Codex
918
+ and Claude reattachment after `kill -9`.
919
+
920
+ ## 0.8.1 to 0.9.0 attended upgrade (2026-10-01)
921
+
922
+ A scratch project (a git work tree whose `.agenthub/config.json` was written by
923
+ the 0.8.1 `ahub init`) ran a 0.8.1 hub with two proposed tasks, one with a path
924
+ and a detail, and an open budget pause: a peer had attached once and gone
925
+ offline, and `ahub budget set` fed it a 95% reading. No completion check was
926
+ configured or running, and no peer was attached at the upgrade.
927
+
928
+ - `bunx --package @staix/agent-hub@0.9.0 ahub upgrade --to 0.9.0 --dry-run`
929
+ exited 0: one project, source 0.8.1, protocol 10, no blockers.
930
+ - `--yes` scheduled the operation, which completed in about 10 s with the
931
+ project `verified`.
932
+ - After release the hub ran 0.9.0 under a new instance id. Both tasks were on
933
+ the board unchanged, now with `deps: []` (the column the target adds on open),
934
+ the budget pause kept its reset time, and the `outcomes` table was created.
935
+ - The coordinator promoted the global CLI to 0.9.0, and `ahub setup --yes`
936
+ installed the 0.9.0 plugin.
937
+ - For about two minutes after the publish step logged `+ @staix/agent-hub@0.9.0`,
938
+ `npm view` still showed `latest: 0.8.1`; the registry caught up without any
939
+ action.
@@ -1016,3 +1016,79 @@ session modes (`default`, `plan`, `auto`, `yolo`) but nothing per server or tool
1016
1016
  suppression. Limits are off in `DEFAULT_CONFIG` and on with any project config
1017
1017
  (12/min per sender, 6/min per recipient, 6 important an hour, 120 s repeats).
1018
1018
 
1019
+ ## Amendment: review checklists and outcomes (issue #35)
1020
+
1021
+ - A review request carries a checklist: map the changed signatures and call
1022
+ sites to the task's plan, or without a plan to the task detail, which the
1023
+ request then includes; the result of the class's check, or that none ran; and
1024
+ `hub_review`'s `unmet`, one item each, which is appended to the verdict note.
1025
+ - Review outcomes live in a `reviews` table in hub.db: implementer, reviewer,
1026
+ class, kind, task, time. Kinds: `approved`; `caught` (each reviewer who asked
1027
+ for changes on the current owner's work, which was then approved);
1028
+ `contradicted` (an approval, within seven days, of a task on the same file or
1029
+ symbol as one whose check failed or whose review asked for changes, a
1030
+ directory or `.` not counting; once per approval; console approvals and PII
1031
+ tasks are not judged); `escalated` (the reviewer had asked for changes on the
1032
+ work that was escalated, so not an escalation of unreviewed work).
1033
+ - A reviewer's record with an implementer in a class counts tasks: n = the tasks
1034
+ it reviewed (approved, caught or escalated), held = (n - contradicted tasks) /
1035
+ n. `assign()` takes the records as input and always shows them in its trace;
1036
+ with `review.adaptive` it orders reviewer candidates that have at least
1037
+ `min_reviews` by held, others keep their place, and the implementer is never
1038
+ a candidate. The record is applied after quota, so the order is idle before
1039
+ busy, then the record, then quota.
1040
+
1041
+ ## Amendment: recovery after an unplanned stop (issue #37)
1042
+
1043
+ - The continuous record is `sessions.json` (instance id, time, and each attached
1044
+ peer's `recoveryMetadata()`), rewritten when a peer's state changes and
1045
+ removed when a stop of the run that wrote it begins (a stop that then runs past
1046
+ the shutdown deadline is still not a crash). Found at start, with no
1047
+ controlled-restart state in play, it means the previous run crashed; the new
1048
+ run takes the record over at once, so its own clean stop removes it even when
1049
+ no peer attaches. A controlled restart's target removes any record it finds.
1050
+ - Limits: a second crash before the peers attach loses their loss notices (the
1051
+ journal rows stay in `needs_review`, shown by `ahub queue list`), and the first
1052
+ attach rewrites the record with only the attached peers. A Pi in TUI mode is
1053
+ reported with its command, not resumed: the CLI runs its terminal.
1054
+ - Resume goes through the same start path as `ahub kimi` / `ahub pi` / `ahub
1055
+ local`: Kimi with `sessionId` (ACP `session/load`, refused when the agent does
1056
+ not offer `loadSession`), Pi with its session file, the local worker afresh.
1057
+ Codex and Claude are reported, not resumed: the TUI and the Claude session live
1058
+ outside the hub. The issue's Codex `thread/resume` needs the TUI, so the report
1059
+ names the thread instead.
1060
+ - Loss notices use the bus preface, not an envelope: a peer's `needs_review`
1061
+ rows block its later deliveries, so a notice queued behind them would arrive
1062
+ only after they are resolved anyway; the preface leads that next delivery. Only
1063
+ rows still in `needs_review` when the peer attaches are listed.
1064
+ - `recovery.auto_resume_after_crash` is off by default, also with a project
1065
+ config: an automatic start spends quota the user did not ask for.
1066
+
1067
+ ## Amendment: deny-default sandbox and capabilities (issue #39)
1068
+
1069
+ - The deny-default profile allows exec and reads of the system directories
1070
+ (`/usr`, `/bin`, `/sbin`, `/System`, `/Library`, `/opt`, `/private/etc`, the
1071
+ dyld and timezone databases), the toolchain directories in home, `read_allow`,
1072
+ the project, its external git dirs, the selected developer dir (`xcode-select
1073
+ -p`) and temp; writes as before; a short list of mach services (directory
1074
+ lookups, logging, notifications), plus name resolution and TLS trust when
1075
+ network is on, the trust being the public CA bundles allowed by exact path
1076
+ after the denies (the `*.pem` key deny matches them); no brokers that act
1077
+ outside the sandbox (LaunchServices, SecurityServer). The denies at the end (credential
1078
+ stores, the denylist, `.agenthub`, git hooks and config) are shared by both
1079
+ bases.
1080
+ - The issue's allowlist proxy for `local.bash_network` is not built here: with
1081
+ the flag on, network is allowed as in 0.9 and earlier. It is left for a
1082
+ follow-up issue.
1083
+ - `local.sandbox` ("deny-default" | "allow-default") is machine-local, since
1084
+ "allow-default" widens the sandbox.
1085
+ - Capabilities: `propose`, `assign` (a proposal naming another peer as owner),
1086
+ `remember`, `important`. A peer not listed in `capabilities` has all of them,
1087
+ which keeps today's behaviour, and a listed peer whose value is not a list has
1088
+ none; the console user and the hub are never limited. `remember` gates the
1089
+ `hub_remember` tool only, not the notes the hub keeps of done summaries and
1090
+ review verdicts. Every refusal, a malformed entry and an unknown capability
1091
+ name are logged.
1092
+ `important` is checked where messages are admitted, the others in the task
1093
+ operations, so in-process tools (the local worker, Pi) are covered too.
1094
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-hub",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hub",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Channel between Claude Code and the agent-hub daemon: peer messages from Codex, Kimi and the local worker arrive as channel events; hub_send replies.",
5
5
  "author": {
6
6
  "name": "Young Joon Lee",
@@ -15727,7 +15727,7 @@ class ControlClient {
15727
15727
  // package.json
15728
15728
  var package_default = {
15729
15729
  name: "@staix/agent-hub",
15730
- version: "0.9.0",
15730
+ version: "0.10.0",
15731
15731
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
15732
15732
  license: "MIT",
15733
15733
  type: "module",
@@ -15820,7 +15820,7 @@ var TASK_TOOLS = [
15820
15820
  tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer.", { id, reason: str }, ["id"]),
15821
15821
  tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
15822
15822
  tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
15823
- tool("hub_review", "Give your verdict on a task you were asked to review. Two changes_requested in a row move the task to another peer.", { id, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str }, ["id", "verdict"]),
15823
+ tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result, and list what is unmet. Two changes_requested in a row move the task to another peer.", { id, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" } }, ["id", "verdict"]),
15824
15824
  tool("hub_checkpoint", "Answer a checkpoint request from the hub (your quota window is nearly used up): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str }, ["summary"]),
15825
15825
  tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (claude-mem); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter.", { text: str, title: str, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id }, ["text"])
15826
15826
  ];
@@ -22,6 +22,8 @@ export interface AcpOptions {
22
22
  cmd: string[];
23
23
  /** Coordinator-visible selected model only; contains no prompts or command arguments. */
24
24
  launchModel?: string;
25
+ /** Load this earlier session (ACP `session/load`) instead of starting a new one: crash recovery, issue #37. */
26
+ resumeSessionId?: string;
25
27
  cwd: string;
26
28
  /** Optional launch environment; recovery authority is always removed before spawn. */
27
29
  env?: NodeJS.ProcessEnv;
@@ -92,11 +94,17 @@ export class AcpPeer extends BasePeer {
92
94
  createInterface({ input: proc.stdout }).on("line", (line) => this.onLine(line));
93
95
 
94
96
  const handshake = async () => {
95
- await this.request("initialize", {
97
+ const init = await this.request("initialize", {
96
98
  protocolVersion: 1,
97
99
  clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
98
100
  });
99
- return this.request("session/new", { cwd: this.opts.cwd, mcpServers: this.opts.mcpServers ?? [] });
101
+ const resume = this.opts.resumeSessionId;
102
+ if (!resume) return this.request("session/new", { cwd: this.opts.cwd, mcpServers: this.opts.mcpServers ?? [] });
103
+ // The agent replays the session as updates while it loads; they arrive before the peer is idle, so none of
104
+ // them is taken for an answer.
105
+ if (!init?.agentCapabilities?.loadSession) throw new Error(`${this.id} cannot load an earlier session (the agent offers no loadSession)`);
106
+ await this.request("session/load", { sessionId: resume, cwd: this.opts.cwd, mcpServers: this.opts.mcpServers ?? [] });
107
+ return { sessionId: resume };
100
108
  };
101
109
  const timeout = new Promise<never>((_, reject) => {
102
110
  setTimeout(() => reject(new Error(`${this.id} did not complete the ACP handshake within ${HANDSHAKE_MS / 1000} s`)), HANDSHAKE_MS).unref();
@@ -17,7 +17,7 @@ export interface LocalOptions {
17
17
  route?: string;
18
18
  /** Model id sent straight to OmniRoute when the sidecar is absent, unhealthy or fails a call. */
19
19
  fixedModel: string;
20
- tools: { deny: string[]; permit: ToolContext["permit"]; bashNetwork?: boolean; readAllow?: string[] };
20
+ tools: { deny: string[]; permit: ToolContext["permit"]; bashNetwork?: boolean; readAllow?: string[]; sandbox?: "deny-default" | "allow-default" };
21
21
  capture?: Capture;
22
22
  /** Runs a hub task tool (hub_task_*, hub_review, hub_remember) as this peer. Absent = the tools are not offered. */
23
23
  taskTool?: (name: string, args: Record<string, unknown>, turn: { pii: boolean }) => Promise<string>;
@@ -67,7 +67,7 @@ export class LocalPeer extends BasePeer {
67
67
  private readonly opts: LocalOptions,
68
68
  ) {
69
69
  super(id, opts.watchdogMs);
70
- this.sandboxProfile = profile(opts.cwd, opts.tools.bashNetwork ?? false, opts.tools.readAllow, opts.tools.deny);
70
+ this.sandboxProfile = profile(opts.cwd, opts.tools.bashNetwork ?? false, opts.tools.readAllow, opts.tools.deny, opts.tools.sandbox === "allow-default" ? "allow" : "deny");
71
71
  }
72
72
 
73
73
  recoveryMetadata(): Record<string, unknown> {
package/src/cli/main.ts CHANGED
@@ -73,7 +73,7 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
73
73
  ahub task propose [--class <c> | <class>] <title...> [--owner <peer>] [--path <p>]... [--after <id>]... [--urgent] [--detail <text>]
74
74
  ahub task show|escalate <id> full task with history (PII text included) / hand it to the next peer in escalate_to
75
75
  ahub task assign <id> <peer> give a task to a peer yourself
76
- ahub review <id> approved|changes_requested [note...]
76
+ ahub review <id> approved|changes_requested [note...] [--unmet <item>]...
77
77
  ahub remember <text...> save a note to the memory all agents share
78
78
  ahub ask [--remember] <question...> answer from the task board, shared memory and this run's log, with the ids it rests on
79
79
  ahub route explain <id> why a task went where it went
@@ -693,9 +693,10 @@ const commands: Record<string, () => Promise<void> | void> = {
693
693
  },
694
694
 
695
695
  review: async () => {
696
- const [id, verdict, ...note] = args;
697
- if (!id || !verdict) fail("usage: ahub review <id> approved|changes_requested [note...]");
698
- console.log(await taskOp("hub_review", { id: Number(id), verdict, note: note.join(" ") }));
696
+ const { many, rest } = takeFlags(args, [], ["--unmet"]);
697
+ const [id, verdict, ...note] = rest;
698
+ if (!id || !verdict) fail("usage: ahub review <id> approved|changes_requested [note...] [--unmet <item>]...");
699
+ console.log(await taskOp("hub_review", { id: Number(id), verdict, note: note.join(" "), ...(many["--unmet"]?.length ? { unmet: many["--unmet"] } : {}) }));
699
700
  },
700
701
 
701
702
  remember: async () => console.log(await taskOp("hub_remember", { text: freeText(args, "ahub remember <text>") })),
@@ -744,6 +745,7 @@ const commands: Record<string, () => Promise<void> | void> = {
744
745
  if (args.includes("--json")) return console.log(JSON.stringify(status, null, 2));
745
746
  console.log(`hub pid ${status.pid}, control 127.0.0.1:${status.controlPort}, ${status.cwd}`);
746
747
  if (status.deliveryError) console.log(` delivery storage: ${status.deliveryError}; dispatch is stopped`);
748
+ for (const line of (status as { crash?: string[] }).crash ?? []) console.log(` crash recovery: ${line}`);
747
749
  const peers = Object.entries(status.peers as Record<string, PeerRow>);
748
750
  for (const [id, p] of peers) console.log(peerLine(id, p));
749
751
  const models = (status as any).models?.backends as BackendRow[] | undefined;
package/src/hub/board.ts CHANGED
@@ -74,6 +74,11 @@ export class Board {
74
74
  // Who did well or badly at which class, for demotion (issue #36). Only peers, classes and times: no task text.
75
75
  this.db.run("CREATE TABLE IF NOT EXISTS outcomes (peer TEXT NOT NULL, class TEXT NOT NULL, ok INTEGER NOT NULL, at INTEGER NOT NULL)");
76
76
  this.db.run("CREATE INDEX IF NOT EXISTS outcomes_class_at ON outcomes (class, at)");
77
+ // How reviews turned out, per (implementer, reviewer, class) (issue #35): approved, contradicted later, caught, escalated.
78
+ this.db.run("CREATE TABLE IF NOT EXISTS reviews (implementer TEXT NOT NULL, reviewer TEXT NOT NULL, class TEXT NOT NULL, kind TEXT NOT NULL, task INTEGER NOT NULL, at INTEGER NOT NULL)");
79
+ // ponytail: kept for good (a record is the whole history); prune by age if it ever grows large.
80
+ this.db.run("CREATE INDEX IF NOT EXISTS reviews_class ON reviews (class)");
81
+ this.db.run("CREATE INDEX IF NOT EXISTS reviews_task ON reviews (task)");
77
82
  // Boards from before issues #31 and #34 lack these columns; existing rows get the defaults.
78
83
  const have = new Set((this.db.query("PRAGMA table_info(tasks)").all() as { name: string }[]).map((c) => c.name));
79
84
  for (const [col, empty] of [["plan", "{}"], ["deps", "[]"]] as const) {
@@ -135,11 +140,32 @@ export class Board {
135
140
  return this.db.query("SELECT peer, ok, at FROM outcomes WHERE class = ? AND at >= ?").all(cls, since) as { peer: PeerId; ok: number; at: number }[];
136
141
  }
137
142
 
143
+ recordReview(r: Omit<ReviewOutcome, "at">, at = Date.now()): void {
144
+ this.db.query("INSERT INTO reviews (implementer, reviewer, class, kind, task, at) VALUES (?, ?, ?, ?, ?, ?)").run(r.implementer, r.reviewer, r.class, r.kind, r.task, at);
145
+ }
146
+
147
+ /** One task's review outcomes, or a class's (for reviewer choice). */
148
+ reviews(where: { task: number } | { class: TaskClass }): ReviewOutcome[] {
149
+ return ("task" in where
150
+ ? this.db.query("SELECT * FROM reviews WHERE task = ? ORDER BY at").all(where.task)
151
+ : this.db.query("SELECT * FROM reviews WHERE class = ? ORDER BY at").all(where.class)) as ReviewOutcome[];
152
+ }
153
+
138
154
  close(): void {
139
155
  this.db.close();
140
156
  }
141
157
  }
142
158
 
159
+ export interface ReviewOutcome {
160
+ implementer: PeerId;
161
+ reviewer: PeerId;
162
+ class: TaskClass;
163
+ /** approved; contradicted (a later check failure or changes requested on the same places); caught (changes requested, then the redo passed); escalated */
164
+ kind: "approved" | "contradicted" | "caught" | "escalated";
165
+ task: number;
166
+ at: number;
167
+ }
168
+
143
169
  function parse(row: Record<string, unknown>): Task {
144
170
  const out = { ...row } as Record<string, unknown>;
145
171
  for (const col of JSON_COLS) out[col] = JSON.parse(String(row[col]));
@@ -24,6 +24,7 @@ export const MACHINE_LOCAL = [
24
24
  "memory.worker_url",
25
25
  "local.read_allow",
26
26
  "local.bash_network",
27
+ "local.sandbox", // "allow-default" widens what the worker's commands may do
27
28
  ] as const;
28
29
 
29
30
  /**
@@ -0,0 +1,104 @@
1
+ import { chmodSync, existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { JournalDelivery } from "./delivery-journal.ts";
4
+
5
+ /**
6
+ * Unplanned-crash recovery (issue #37). While the hub runs it keeps `sessions.json`: each attached peer's session
7
+ * identity (the adapters' recovery metadata: ids and launch options, never message text). A clean stop removes the
8
+ * file, so finding it at start means the previous run died.
9
+ */
10
+ export interface SessionRecord {
11
+ peer: string;
12
+ /** The adapter's recovery metadata: `launch`, and `sessionId` / `threadId` / `sessionFile` where it has one. */
13
+ meta: Record<string, unknown>;
14
+ }
15
+ export interface SessionsFile {
16
+ instanceId: string;
17
+ at: number;
18
+ peers: SessionRecord[];
19
+ }
20
+
21
+ const fileOf = (stateDir: string) => join(stateDir, "sessions.json");
22
+
23
+ export function writeSessions(stateDir: string, s: SessionsFile): void {
24
+ const tmp = `${fileOf(stateDir)}.tmp`;
25
+ writeFileSync(tmp, `${JSON.stringify(s)}\n`, { mode: 0o600 });
26
+ chmodSync(tmp, 0o600);
27
+ renameSync(tmp, fileOf(stateDir));
28
+ }
29
+
30
+ export function readSessions(stateDir: string): SessionsFile | undefined {
31
+ if (!existsSync(fileOf(stateDir))) return undefined;
32
+ try {
33
+ const s = JSON.parse(readFileSync(fileOf(stateDir), "utf8")) as SessionsFile;
34
+ return Array.isArray(s.peers) ? s : undefined;
35
+ } catch {
36
+ return undefined; // cut short by the crash: nothing to resume from
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Only the run that wrote it removes it: a stop of an older instance must not erase a newer run's record. Without an
42
+ * instance id it goes whatever wrote it (a controlled restart, which has its own record of the peers).
43
+ */
44
+ export function removeSessions(stateDir: string, instanceId?: string): void {
45
+ if (instanceId === undefined || readSessions(stateDir)?.instanceId === instanceId) rmSync(fileOf(stateDir), { force: true });
46
+ }
47
+
48
+ const str = (v: unknown) => (typeof v === "string" && v ? v : undefined);
49
+
50
+ /**
51
+ * What can come back after a crash. `resume` holds the start arguments for peers the hub launches itself (Kimi through
52
+ * ACP `session/load`, Pi through its session file, the local worker afresh); the others say what the user has to do.
53
+ */
54
+ export function crashPlan(records: SessionRecord[]): { peer: string; resume?: Record<string, string>; how: string }[] {
55
+ return records.map(({ peer, meta }) => {
56
+ const launch = (meta.launch && typeof meta.launch === "object" ? meta.launch : {}) as Record<string, unknown>;
57
+ const model = str(launch.model);
58
+ switch (peer) {
59
+ case "claude":
60
+ return { peer, how: "claude: the Claude Code plugin reconnects by itself while that session is still open" };
61
+ case "codex":
62
+ return { peer, how: `codex: its app-server died with the hub; run ahub codex again${str(meta.threadId) ? ` (its conversation was thread ${meta.threadId})` : ""}` };
63
+ case "kimi": {
64
+ const sessionId = str(meta.sessionId);
65
+ if (!sessionId) return { peer, how: "kimi: no session id was recorded; start it again with ahub kimi" };
66
+ return { peer, resume: { sessionId, ...(model ? { model } : {}) }, how: `kimi: session ${sessionId} can be loaded again (ACP session/load)` };
67
+ }
68
+ case "pi": {
69
+ const sessionFile = str(meta.sessionFile) ?? str(launch.sessionFile);
70
+ if (!sessionFile) return { peer, how: "pi: no session file was recorded; start it again with ahub pi" };
71
+ // A terminal Pi is run by the CLI that launched it, not by the hub: nothing here could start it again.
72
+ if (str(launch.mode) === "tui") return { peer, how: `pi: it ran in a terminal; start it again with ahub pi --mode tui --session-file ${sessionFile}` };
73
+ const args: Record<string, string> = { sessionFile };
74
+ for (const k of ["mode", "backend", "model"] as const) if (str(launch[k])) args[k] = str(launch[k])!;
75
+ return { peer, resume: args, how: `pi: its session file can be resumed (${sessionFile})` };
76
+ }
77
+ case "local": {
78
+ // `model` is also recorded as the route's fallback, and a model given at start pins it: pass one or the other.
79
+ const route = str(launch.route);
80
+ const args: Record<string, string> = route ? { route } : model ? { model } : {};
81
+ return { peer, resume: args, how: "local: starts again without its history (the worker keeps none across a hub stop)" };
82
+ }
83
+ default:
84
+ return { peer, how: `${peer}: reconnects by itself if its client is still running` };
85
+ }
86
+ });
87
+ }
88
+
89
+ /**
90
+ * The loss notice for one peer: the deliveries to it that the crash left in needs_review, with their senders and the
91
+ * tasks they were about. `taskTitle` must return the public title (`#n [pii]` for a PII task). No message text.
92
+ */
93
+ export function lossNotice(lost: JournalDelivery[], taskTitle: (id: number) => string | undefined): string {
94
+ const lines = lost.map((d) => {
95
+ const from = [...new Set(d.originals.map((e) => e.from))].join(", ");
96
+ const tasks = [...new Set(d.originals.map((e) => Number(e.refs?.task)).filter((n) => Number.isInteger(n) && n > 0))].map((n) => taskTitle(n) ?? `#${n}`);
97
+ return `- delivery ${d.id} from ${from}${tasks.length ? `, about task ${tasks.join(", ")}` : ""}`;
98
+ });
99
+ return [
100
+ "The hub stopped unexpectedly and was started again. These deliveries to you were in flight when it stopped, so whether you acted on them is not known; the console has since marked each one completed, retried or discarded:",
101
+ ...lines,
102
+ "Check your work against them before you go on.",
103
+ ].join("\n");
104
+ }
package/src/hub/daemon.ts CHANGED
@@ -42,6 +42,8 @@ import { MemoryClient, workerUrl } from "../memory/client.ts";
42
42
  import { VERSION } from "../version.ts";
43
43
  import { projectChain, recallFor } from "../memory/recall.ts";
44
44
  import { conflictsOf } from "./conflicts.ts";
45
+ import { crashPlan, lossNotice, readSessions, removeSessions, writeSessions, type SessionsFile } from "./crash.ts";
46
+ import type { JournalDelivery } from "./delivery-journal.ts";
45
47
  import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
46
48
  import { changedPaths, repoOf, snapshot, Turns } from "./snapshots.ts";
47
49
  import { archiveRestartSnapshot, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
@@ -60,7 +62,8 @@ export interface HubConfig {
60
62
  omniroute: OmniRouteConfig;
61
63
  pi: { enabled: boolean; auto_start: boolean; cmd: string[]; backend: "auto" | "dgx" | "mlx"; dgx_coding: string; dgx_fast: string; max_steps: number };
62
64
  mlx: Pick<MlxOptions, "provider" | "host" | "runtimeDir" | "modelPath" | "port" | "model" | "sourceModel" | "contextWindow" | "maxInputTokens" | "maxTokens" | "maxConcurrency">;
63
- local: { deny: string[]; bash_network: boolean; max_steps: number; read_allow: string[] };
65
+ /** `sandbox`: "deny-default" (issue #39), or "allow-default", the profile of 0.9 and earlier, kept for one release. */
66
+ local: { deny: string[]; bash_network: boolean; max_steps: number; read_allow: string[]; sandbox: "deny-default" | "allow-default" };
64
67
  /** Pending permission requests: how long they wait, and whether the desktop is told (issue #5). */
65
68
  approvals: { timeout_s: number; notify: boolean };
66
69
  /** An owner offline this long loses its open tasks back to routing; 0 turns it off (issue #6). */
@@ -71,6 +74,12 @@ export interface HubConfig {
71
74
  snapshots: { enabled: boolean; keep: number };
72
75
  /** Per-sender rate limits and repeat suppression for what agents send (issue #38). */
73
76
  limits: LimitsConfig;
77
+ /** Reviewer choice from recorded review outcomes, once a reviewer has `min_reviews` of an implementer (issue #35). */
78
+ review: { adaptive: boolean; min_reviews: number };
79
+ /** After an unplanned stop, start Kimi, Pi and the local worker again with their recorded sessions (issue #37). */
80
+ recovery: { auto_resume_after_crash: boolean };
81
+ /** Per peer, the hub-tool capabilities it has (issue #39); a peer not listed has all of them. */
82
+ capabilities: Record<string, string[]>;
74
83
  /** Machine-local fields a config file set but git could not vouch for, and why (issue #17). */
75
84
  ignored?: string[];
76
85
  }
@@ -88,7 +97,7 @@ export const DEFAULT_CONFIG: HubConfig = {
88
97
  omniroute: DEFAULT_OMNIROUTE,
89
98
  pi: { enabled: false, auto_start: false, cmd: ["pi"], backend: "auto", dgx_coding: "coding", dgx_fast: "fast", max_steps: 30 },
90
99
  mlx: { provider: "ollama", model: "agenthub-fast-mlx:4b-8k", sourceModel: "qwen3.5:4b-mlx", contextWindow: 8192, maxInputTokens: 6000, maxTokens: 2048, maxConcurrency: 1 },
91
- local: { deny: [], bash_network: false, max_steps: 30, read_allow: [] },
100
+ local: { deny: [], bash_network: false, max_steps: 30, read_allow: [], sandbox: "deny-default" },
92
101
  // Off here, so tests and a hub without a config file stay silent; a project's config defaults it on for macOS.
93
102
  approvals: { timeout_s: 120, notify: false },
94
103
  tasks: { release_after_min: 30 },
@@ -96,6 +105,9 @@ export const DEFAULT_CONFIG: HubConfig = {
96
105
  // Off here like approvals.notify, so tests (whose cwd is this repository) write no objects; a project's config turns it on.
97
106
  snapshots: { enabled: false, keep: 20 },
98
107
  limits: DEFAULT_LIMITS,
108
+ review: { adaptive: false, min_reviews: 5 },
109
+ recovery: { auto_resume_after_crash: false },
110
+ capabilities: {},
99
111
  };
100
112
 
101
113
  export { stateDirFor };
@@ -106,7 +118,7 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
106
118
 
107
119
  /** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
108
120
  const CONFIG_FILES = ["config.json", "config.local.json"] as const;
109
- const CONFIG_BLOCKS = ["memory", "roles", "budget", "inference", "omniroute", "local", "pi", "approvals", "tasks", "checks", "snapshots", "limits", "mlx"];
121
+ const CONFIG_BLOCKS = ["memory", "roles", "budget", "inference", "omniroute", "local", "pi", "approvals", "tasks", "checks", "snapshots", "limits", "review", "recovery", "capabilities", "mlx"];
110
122
 
111
123
  export function loadConfig(cwd: string): HubConfig {
112
124
  const ignored: string[] = [];
@@ -151,6 +163,9 @@ export function loadConfig(cwd: string): HubConfig {
151
163
  checks: { ...DEFAULT_CONFIG.checks, ...file.checks },
152
164
  snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
153
165
  limits: { ...PROJECT_LIMITS, ...file.limits }, // on with any project config (issue #38)
166
+ review: { ...DEFAULT_CONFIG.review, ...file.review },
167
+ recovery: { ...DEFAULT_CONFIG.recovery, ...file.recovery },
168
+ capabilities: { ...file.capabilities },
154
169
  mlx,
155
170
  ...(ignored.length ? { ignored } : {}),
156
171
  };
@@ -280,9 +295,35 @@ export async function startDaemon(opts: DaemonOptions) {
280
295
  : undefined;
281
296
  if ((restartFilePresent && !restored) || (recoveryOperation && !restored)) throw new Error("restart state is unreadable, missing, or does not match this project and recovery operation");
282
297
 
298
+ // A session record left by a run that never shut down means it crashed (issue #37). A controlled restart has its own.
299
+ const crashed = !recoveryOperation && !restartFilePresent ? readSessions(opts.stateDir) : undefined;
300
+ // A controlled restart's source may have been cut short before it removed its record: this run is not a crash, and
301
+ // a record left now would make the next ordinary start look like one.
302
+ if (recoveryOperation || restartFilePresent) try { removeSessions(opts.stateDir); } catch { /* nothing to remove */ }
303
+ const autoResume = config.recovery.auto_resume_after_crash === true; // a string "false" is not a yes
304
+ const startedAt = Date.now();
305
+ /** What crash recovery did or asks the user to do, for `ahub status`. */
306
+ const crashReport: string[] = [];
307
+
283
308
  // The hub's own model calls (digest condensation, task triage) are wired below, once the gateway client exists.
284
309
  let inference: Inference | undefined;
285
310
  const journal = new DeliveryJournal({ file: join(opts.stateDir, "hub.db"), projectRoot: opts.cwd, projectId, instanceId, operationId: recoveryOperation });
311
+ // What the crash left in flight, per recipient: opening the journal just marked these needs_review.
312
+ const lost = new Map<PeerId, JournalDelivery[]>();
313
+ if (crashed) for (const d of journal.list()) if (d.state === "needs_review" && d.reason === "daemon stopped during delivery" && d.updatedAt >= startedAt) lost.set(d.peer, [...(lost.get(d.peer) ?? []), d]);
314
+ // Capabilities (issue #39): enforced here and in taskOp, never by role text alone. Unlisted peers keep everything.
315
+ // A listed peer whose value is not a list gets nothing: whoever listed it meant to narrow it.
316
+ const may = (peer: PeerId, cap: "propose" | "assign" | "remember" | "important"): boolean => {
317
+ if (peer === USER || peer === HUB || !Object.hasOwn(config.capabilities, peer)) return true;
318
+ const list = config.capabilities[peer];
319
+ return Array.isArray(list) && list.includes(cap);
320
+ };
321
+ const CAPABILITIES = ["propose", "assign", "remember", "important"];
322
+ for (const [peer, list] of Object.entries(config.capabilities)) {
323
+ if (!PEER_ID.test(peer)) log(`capabilities.${peer} is not a peer id; ignored (capabilities is an object of lists, one per peer)`);
324
+ else if (!Array.isArray(list)) log(`capabilities.${peer} is not a list: ${peer} gets no capabilities`);
325
+ else for (const c of list) if (!CAPABILITIES.includes(c)) log(`capabilities.${peer}: ${JSON.stringify(c)} is not a capability (${CAPABILITIES.join(", ")}); it grants nothing`);
326
+ }
286
327
  // Agents only: the console user and the hub itself are never limited (issue #38).
287
328
  // A typo such as "12/min" would read as 0, which turns a limit off without a word: the project default instead.
288
329
  for (const k of Object.keys(config.limits)) if (!(k in PROJECT_LIMITS)) log(`limits.${k} is not a known limit; ignored`);
@@ -296,6 +337,10 @@ export async function startDaemon(opts: DaemonOptions) {
296
337
  const admit = (env: Envelope, parent?: string): string | undefined => {
297
338
  // [FYI] is recorded and costs nobody a turn: nothing to limit.
298
339
  if (env.from === USER || env.from === HUB || env.from === DIGEST || env.priority === "fyi") return undefined;
340
+ if (env.priority === "important" && !may(env.from, "important")) {
341
+ log(`capabilities: ${env.from} may not send important messages`);
342
+ return `${env.from} may not send important messages (no "important" in capabilities.${env.from}): send it without [IMPORTANT]`;
343
+ }
299
344
  const refused = limiter.admit(env.from, env.to, env.priority, env.body, parent);
300
345
  if (refused) log(`limits: ${env.from}: ${refused}`);
301
346
  return refused;
@@ -410,6 +455,7 @@ export async function startDaemon(opts: DaemonOptions) {
410
455
  },
411
456
  triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
412
457
  quota: (): ReturnType<Budget["headroom"]> => budget.headroom(), // budget is built below; this runs at assignment time
458
+ review: config.review,
413
459
  });
414
460
  board.onChange = (t, h) => event({ type: "task", id: t.id, event: h.event, by: h.by, state: t.state, owner: t.owner, reviewer: t.reviewer, class: t.class, pii: tasks.isPii(t) });
415
461
  // ---- budget relay -------------------------------------------------------------------------------------------
@@ -570,6 +616,16 @@ export async function startDaemon(opts: DaemonOptions) {
570
616
  // console reads a PII task's text deliberately, with `ahub task show <id>`.
571
617
  const onPrem = inProcess && by === "local";
572
618
  const line = (t: { id: number; state: string; owner: PeerId | null; reviewer: PeerId | null }) => `task #${t.id}: ${t.state}, owner ${t.owner ?? "none"}, reviewer ${t.reviewer ?? "none"}`;
619
+ const need = (cap: "propose" | "assign" | "remember", what: string) => {
620
+ if (may(by, cap)) return;
621
+ log(`capabilities: ${by} may not ${what} (${op})`);
622
+ throw new Error(`${by} may not ${what} (no "${cap}" in capabilities.${by} in .agenthub/config.json)`);
623
+ };
624
+ if (op === "hub_task_propose") {
625
+ need("propose", "propose tasks");
626
+ if (typeof a.owner === "string" && a.owner && a.owner !== by) need("assign", "hand tasks to other peers");
627
+ }
628
+ if (op === "hub_remember") need("remember", "save notes to shared memory");
573
629
  switch (op) {
574
630
  case "hub_task_propose": {
575
631
  const t = await tasks.propose(by, a);
@@ -588,7 +644,7 @@ export async function startDaemon(opts: DaemonOptions) {
588
644
  return tasks.isChecking(t.id) ? `${line(t)}; its check is queued or running, and the result comes as a task message` : line(t);
589
645
  }
590
646
  case "hub_review":
591
- return line(await tasks.review(by, a.id, a.verdict, a.note));
647
+ return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet));
592
648
  case "hub_remember":
593
649
  return tasks.remember(by, a);
594
650
  case "hub_checkpoint": {
@@ -604,7 +660,7 @@ export async function startDaemon(opts: DaemonOptions) {
604
660
  if (by !== USER) throw new Error(`${op} is a console command`);
605
661
  switch (op) {
606
662
  case "task_show":
607
- return JSON.stringify(board.get(Number(a.id)) ?? `no task #${a.id}`, null, 2);
663
+ return JSON.stringify(board.get(Number(a.id)) ? { ...board.get(Number(a.id)), reviews: board.reviews({ task: Number(a.id) }) } : `no task #${a.id}`, null, 2);
608
664
  case "task_assign":
609
665
  return line(await tasks.assignTo(a.id, String(a.peer)));
610
666
  case "task_escalate":
@@ -735,6 +791,7 @@ export async function startDaemon(opts: DaemonOptions) {
735
791
  return now;
736
792
  };
737
793
  const status = () => ({
794
+ ...(crashReport.length ? { crash: crashReport } : {}),
738
795
  projectId,
739
796
  instanceId,
740
797
  version: VERSION,
@@ -833,6 +890,36 @@ export async function startDaemon(opts: DaemonOptions) {
833
890
  }
834
891
  };
835
892
 
893
+ // Session identities of the attached peers, kept current for crash recovery (issue #37); a clean stop removes them.
894
+ let sessionsWritten = "";
895
+ const recordSessions = () => {
896
+ if (stopping) return;
897
+ const peers = [...bus.peers.values()].filter((p) => p.state !== "offline").map((p) => {
898
+ let meta: Record<string, unknown> = {};
899
+ try { meta = (p as { recoveryMetadata?: () => Record<string, unknown> }).recoveryMetadata?.() ?? {}; } catch { /* not ready yet: the id alone */ }
900
+ return { peer: p.id, meta };
901
+ });
902
+ const text = JSON.stringify(peers);
903
+ if (text === sessionsWritten) return;
904
+ sessionsWritten = text;
905
+ try { writeSessions(opts.stateDir, { instanceId, at: Date.now(), peers }); } catch (error) { log(`session record not written: ${(error as Error).message}`); }
906
+ };
907
+ const recoverAfterCrash = async (prev: SessionsFile) => {
908
+ const report = (line: string) => (crashReport.push(line), notify(`crash recovery: ${line}`));
909
+ const lostCount = [...lost.values()].reduce((n, l) => n + l.length, 0);
910
+ report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
911
+ for (const step of crashPlan(prev.peers)) {
912
+ if (!step.resume || !autoResume) {
913
+ const fresh = step.peer === "pi" && config.pi.enabled && config.pi.auto_start ? "; pi.auto_start starts a fresh session" : "";
914
+ report(`${step.how}${step.resume ? " (recovery.auto_resume_after_crash is off)" : ""}${fresh}`);
915
+ continue;
916
+ }
917
+ const r = await startPeer(step.peer, step.resume as Parameters<typeof startPeer>[1]).catch((e: Error) => ({ ok: false, error: e.message }));
918
+ report(r.ok ? `${step.peer} resumed: ${step.how}` : `${step.peer} not resumed (${String(r.error)}); ${step.how}`);
919
+ }
920
+ writeStatus();
921
+ };
922
+
836
923
  bus.tap((e) => {
837
924
  e = redact(e);
838
925
  uiEvents.push({ seq: ++uiSequence, event: e });
@@ -873,6 +960,13 @@ export async function startDaemon(opts: DaemonOptions) {
873
960
  }
874
961
  if (e.state === "offline") offlineSince.set(e.peer, offlineSince.get(e.peer) ?? Date.now());
875
962
  else offlineSince.delete(e.peer);
963
+ // After a crash, a peer's first attach brings the loss notice: it leads its next delivery (issue #37).
964
+ if (e.state !== "offline" && lost.has(e.peer)) {
965
+ const still = lost.get(e.peer)!.filter((d) => { try { return journal.get(d.id)?.state === "needs_review"; } catch { return false; } });
966
+ lost.delete(e.peer);
967
+ if (still.length) bus.preface(e.peer, lossNotice(still, (id) => { const t = board.get(id); return t ? tasks.publicTitle(t) : undefined; }));
968
+ }
969
+ recordSessions();
876
970
  }
877
971
  else if (e.t === "undeliverable" || e.t === "overflow") {
878
972
  log(e.t === "undeliverable" ? `UNDELIVERABLE to ${e.peer} after retries: ${e.env.id} from ${e.env.from}` : `OVERFLOW ${e.peer}: dropped ${e.env.id} from ${e.env.from}`);
@@ -1023,6 +1117,7 @@ export async function startDaemon(opts: DaemonOptions) {
1023
1117
  const kimi = new AcpPeer("kimi", {
1024
1118
  cmd,
1025
1119
  ...(args.model ? { launchModel: args.model } : {}),
1120
+ ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
1026
1121
  cwd: opts.cwd,
1027
1122
  watchdogMs: config.watchdog_ms,
1028
1123
  onPermission,
@@ -1082,7 +1177,7 @@ export async function startDaemon(opts: DaemonOptions) {
1082
1177
  let piReply: Envelope | undefined;
1083
1178
  const ctx: ToolContext = {
1084
1179
  cwd: opts.cwd, deny: config.local.deny,
1085
- sandboxProfile: profile(opts.cwd, config.local.bash_network, config.local.read_allow, config.local.deny),
1180
+ sandboxProfile: profile(opts.cwd, config.local.bash_network, config.local.read_allow, config.local.deny, config.local.sandbox === "allow-default" ? "allow" : "deny"),
1086
1181
  permit: (title) => onPermission({ peer: "pi", title, options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" }] }).then((picked) => picked === "allow" && pi.acceptingTools && bus.peers.get("pi") === pi),
1087
1182
  send: (text, to) => {
1088
1183
  if (to?.some((id) => !bus.peers.has(id) && id !== USER)) return "error: unknown peer";
@@ -1173,7 +1268,7 @@ export async function startDaemon(opts: DaemonOptions) {
1173
1268
  omni,
1174
1269
  ...(sidecar && route ? { sidecar, route } : {}),
1175
1270
  fixedModel: args.model ?? routing.local.fixed_model,
1176
- tools: { deny: config.local.deny, bashNetwork: config.local.bash_network, readAllow: config.local.read_allow, permit },
1271
+ tools: { deny: config.local.deny, bashNetwork: config.local.bash_network, readAllow: config.local.read_allow, sandbox: config.local.sandbox, permit },
1177
1272
  ...(capture ? { capture } : {}),
1178
1273
  taskTool: (name, a, turn) => taskOp("local", name, a, true, turn.pii),
1179
1274
  turnPolicy: (envs) => tasks.turnPolicy(envs),
@@ -1619,6 +1714,8 @@ export async function startDaemon(opts: DaemonOptions) {
1619
1714
  }
1620
1715
  async function stopOnce(): Promise<void> {
1621
1716
  stopping = true;
1717
+ // A stop someone asked for is not a crash, even if it then runs past the shutdown deadline: forget the sessions now.
1718
+ try { removeSessions(opts.stateDir, instanceId); } catch { /* the state dir is gone */ }
1622
1719
  checksClosed = true;
1623
1720
  for (const kill of runningChecks) kill();
1624
1721
  log("hub stopping");
@@ -1674,7 +1771,14 @@ export async function startDaemon(opts: DaemonOptions) {
1674
1771
  writeStatus();
1675
1772
  log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
1676
1773
  ready = true;
1677
- if (config.pi.enabled && config.pi.auto_start && !recoveryActive()) void startPeer("pi", {}).catch((error) => log(`Pi auto-start failed: ${error.message}`));
1774
+ if (crashed) {
1775
+ // This run owns the record now, so its clean stop removes it even if no peer attaches to rewrite it.
1776
+ try { if (readSessions(opts.stateDir)?.instanceId === crashed.instanceId) writeSessions(opts.stateDir, { ...crashed, instanceId, at: Date.now() }); } catch (error) { log(`session record not adopted: ${(error as Error).message}`); }
1777
+ void recoverAfterCrash(crashed).catch((error) => log(`crash recovery failed: ${(error as Error).message}`));
1778
+ }
1779
+ // Skipped only when crash recovery itself starts Pi again on its recorded session.
1780
+ const piResumes = !!crashed && autoResume && crashPlan(crashed.peers).some((s) => s.peer === "pi" && s.resume);
1781
+ if (config.pi.enabled && config.pi.auto_start && !recoveryActive() && !piResumes) void startPeer("pi", {}).catch((error) => log(`Pi auto-start failed: ${error.message}`));
1678
1782
  return { bus, token, port: server.port as number, stop, stopped: new Promise<void>((r) => (onStop = r)) };
1679
1783
  } finally {
1680
1784
  if (!ready) for (const cleanup of startupCleanup.reverse()) { try { cleanup(); } catch { /* preserve startup error */ } }
@@ -40,7 +40,7 @@ export const TASK_TOOLS: HubTool[] = [
40
40
  tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer.", { id, reason: str }, ["id"]),
41
41
  tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
42
42
  tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
43
- tool("hub_review", "Give your verdict on a task you were asked to review. Two changes_requested in a row move the task to another peer.", { id, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str }, ["id", "verdict"]),
43
+ tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result, and list what is unmet. Two changes_requested in a row move the task to another peer.", { id, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" } }, ["id", "verdict"]),
44
44
  tool("hub_checkpoint", "Answer a checkpoint request from the hub (your quota window is nearly used up): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str }, ["summary"]),
45
45
  tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (claude-mem); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter.", { text: str, title: str, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id }, ["text"]),
46
46
  ];
@@ -135,6 +135,9 @@ export function assign(
135
135
  now?: number;
136
136
  /** Peers demoted for this task's class, with their recent failure weight (issue #36). */
137
137
  demoted?: Record<PeerId, number>;
138
+ /** Review record per implementer, then per reviewer, for this class (issue #35); followed only when `adaptive`. */
139
+ reviews?: Record<PeerId, Record<PeerId, { score: number; n: number }>>;
140
+ adaptive?: { min: number };
138
141
  } = {},
139
142
  ): Assignment {
140
143
  const policy = routing.classes[task.class];
@@ -177,10 +180,25 @@ export function assign(
177
180
  return out;
178
181
  };
179
182
  /** Demoted peers go behind the rest for this class (owners only); each group is then ordered by quota. */
180
- const rank = (ok: PeerId[], role: "owner" | "reviewer"): PeerId[] => {
183
+ /** Reviewers with enough recorded reviews of this owner's work swap places by how those reviews held up. */
184
+ const byRecord = (list: PeerId[], owner: PeerId | undefined): PeerId[] => {
185
+ const record = owner ? opts.reviews?.[owner] : undefined;
186
+ if (!record || !Object.keys(record).length) return list;
187
+ trace.push(` review record with ${owner} in ${task.class}: ${Object.entries(record).map(([r, s]) => `${r} ${s.n} reviews, ${Math.round(s.score * 100)}% held`).join("; ")}${opts.adaptive ? "" : " (review.adaptive is off)"}`);
188
+ if (!opts.adaptive) return list;
189
+ const known = (p: PeerId) => (record[p] && record[p].n >= opts.adaptive!.min ? record[p].score : undefined);
190
+ const slots = list.flatMap((p, i) => (known(p) === undefined ? [] : [i]));
191
+ const sorted = slots.map((i) => list[i]!).sort((a, b) => known(b)! - known(a)!);
192
+ const out = [...list];
193
+ slots.forEach((i, k) => (out[i] = sorted[k]!));
194
+ return out;
195
+ };
196
+ const rank = (ok: PeerId[], role: "owner" | "reviewer", owner?: PeerId): PeerId[] => {
181
197
  const down = role === "owner" ? ok.filter((p) => opts.demoted?.[p]) : [];
182
198
  if (down.length) trace.push(` demoted for ${task.class}: ${down.map((p) => `${p} (${opts.demoted![p]!.toFixed(1)} recent failures)`).join(", ")}`);
183
- return [...byDrain(ok.filter((p) => !down.includes(p))), ...byDrain(down)];
199
+ const ranked = [...byDrain(ok.filter((p) => !down.includes(p))), ...byDrain(down)];
200
+ // The review record comes after quota, so it decides among reviewers that have one: idle, then record, then quota.
201
+ return role === "reviewer" ? byRecord(ranked, owner) : ranked;
184
202
  };
185
203
 
186
204
  const pick = (list: PeerId[], role: "owner" | "reviewer", not?: PeerId): PeerId | undefined => {
@@ -190,7 +208,7 @@ export function assign(
190
208
  trace.push(` ${role} candidate ${peer}: ${why ? `skipped, ${why}` : states[peer]}`);
191
209
  if (!why) ok.push(peer);
192
210
  }
193
- const ranked = rank(ok, role);
211
+ const ranked = rank(ok, role, not);
194
212
  // Demotion applies to local and Pi too: a demoted one loses its place ahead of the cloud peers.
195
213
  const localTier = ranked.filter((p) => (p === LOCAL || p === PI) && !(role === "owner" && opts.demoted?.[p]));
196
214
  if (localTier.length) return localTier.find((p) => states[p] === "idle") ?? localTier[0]; // local/Pi stays ahead of an idle cloud peer
package/src/hub/tasks.ts CHANGED
@@ -27,6 +27,8 @@ export interface TasksDeps {
27
27
  recordOverlap?: (task: number, owner: PeerId, others: { task: number; owner: PeerId; paths: string[]; symbols?: string[] }[]) => void;
28
28
  /** Quota per peer from fresh readings (issue #36): routing drains the windows that reset soonest first. */
29
29
  quota?: () => Record<PeerId, { headroom: number; resetsAt?: number }>;
30
+ /** Reviewer choice from recorded review outcomes (issue #35); off unless the project config turns it on. */
31
+ review?: { adaptive: boolean; min_reviews: number };
30
32
  /** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
31
33
  triage?: { classify: (title: string, detail: string) => Promise<TaskClass | undefined>; onCampus: () => Promise<boolean> };
32
34
  }
@@ -37,6 +39,8 @@ const ESCALATE_AFTER = 2;
37
39
  // ponytail: fixed constants; make them config when someone needs to tune them.
38
40
  const DEMOTION_HALF_LIFE_MS = 24 * 3_600_000;
39
41
  const DEMOTE_AT = 1.5;
42
+ /** An approval counts as contradicted when work on the same places fails within this window (issue #35). */
43
+ const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
40
44
  const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
41
45
  /** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
42
46
  const QUIET_EVENTS = new Set(["answer", "reviewer changed"]);
@@ -122,11 +126,56 @@ export class Tasks {
122
126
  return Object.fromEntries([...sums].filter(([, s]) => s.bad >= DEMOTE_AT && s.bad > s.good).map(([p, s]) => [p, s.bad]));
123
127
  }
124
128
 
125
- /** What assignment weighs besides states and policy: quota and demotion. */
129
+ /** How each reviewer's reviews of each implementer's work in a class held up (issue #35). */
130
+ reviewRecord(cls: TaskClass): Record<PeerId, Record<PeerId, { score: number; n: number }>> {
131
+ // Counted per task: a review that asked for changes and then approved is one review, not two.
132
+ const c = new Map<string, { reviewed: Set<number>; contradicted: Set<number> }>();
133
+ for (const r of this.d.board.reviews({ class: cls })) {
134
+ const key = `${r.implementer}\0${r.reviewer}`;
135
+ const s = c.get(key) ?? { reviewed: new Set<number>(), contradicted: new Set<number>() };
136
+ (r.kind === "contradicted" ? s.contradicted : s.reviewed).add(r.task);
137
+ c.set(key, s);
138
+ }
139
+ const out: Record<PeerId, Record<PeerId, { score: number; n: number }>> = {};
140
+ for (const [key, s] of c) {
141
+ const [implementer, reviewer] = key.split("\0") as [PeerId, PeerId];
142
+ const n = s.reviewed.size;
143
+ if (n) (out[implementer] ??= {})[reviewer] = { score: (n - [...s.contradicted].filter((t) => s.reviewed.has(t)).length) / n, n };
144
+ }
145
+ return out;
146
+ }
147
+
148
+ /** Who asked for changes on the current owner's work: requests made before the task changed hands were about someone else's. */
149
+ private requestedChanges(task: Task): Set<PeerId> {
150
+ const since = task.history.findLastIndex((h) => ["assigned", "escalated", "reassigned", "unassigned"].includes(h.event));
151
+ return new Set(task.history.slice(since + 1).filter((h) => h.event === "changes_requested").map((h) => h.by));
152
+ }
153
+
154
+ /** What assignment weighs besides states and policy: quota, demotion and the review record. */
126
155
  private weights(cls: TaskClass) {
127
156
  const now = Date.now();
128
157
  const quota = this.d.quota?.();
129
- return { now, demoted: this.demoted(cls, now), ...(quota ? { quota } : {}) };
158
+ return { now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
159
+ }
160
+
161
+ /**
162
+ * Work on these places failed (a check failure, or changes requested): approvals of other tasks on the same places
163
+ * within the window were contradicted. Each approval counts once.
164
+ */
165
+ private contradict(failed: Task): void {
166
+ if (this.isPii(failed)) return; // its places are left out everywhere else too
167
+ const now = Date.now();
168
+ const mine = this.places(failed);
169
+ for (const t of this.d.board.list("approved")) {
170
+ if (t.id === failed.id || !t.owner || this.isPii(t)) continue;
171
+ const approval = [...t.history].reverse().find((h) => h.event === "approved");
172
+ if (!approval || now - approval.at > CONTRADICTION_WINDOW_MS || approval.by === USER) continue;
173
+ const theirs = this.places(t);
174
+ // Blame needs the same file or symbol: a directory or `.` would contradict every approval under it.
175
+ const same = mine.paths.some((p) => theirs.paths.includes(p)) || mine.symbols.some((x) => theirs.symbols.includes(x));
176
+ if (!same || this.d.board.reviews({ task: t.id }).some((r) => r.kind === "contradicted")) continue;
177
+ this.d.board.recordReview({ implementer: t.owner, reviewer: approval.by, class: t.class, kind: "contradicted", task: t.id });
178
+ }
130
179
  }
131
180
 
132
181
  async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown }): Promise<Task> {
@@ -482,6 +531,7 @@ export class Tasks {
482
531
  }
483
532
  this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim());
484
533
  if (task.owner) this.d.board.recordOutcome(task.owner, task.class, false);
534
+ this.contradict(task);
485
535
  this.d.notify(`task ${this.publicTitle(task)}: its check failed (${outcome}); it stays with ${task.owner ?? by}`);
486
536
  this.tell(task, `Task #${id}: its check failed.\n$ ${outcome}${result.tail ? `\n${result.tail}` : ""}\nFix it and call hub_task_done again.`, pii);
487
537
  }
@@ -531,19 +581,37 @@ export class Tasks {
531
581
  const r = task.refs;
532
582
  const last = [...task.history].reverse().find((h) => h.event === "done");
533
583
  const where = [r.branch ? `branch ${r.branch}` : "", r.commit ? `commit ${r.commit}` : "", r.paths?.length ? `paths ${r.paths.join(", ")}` : ""].filter(Boolean).join("; ");
534
- const body = `Review task #${task.id} [${task.class}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${last?.note ?? "(no summary)"}\n${where ? `Where: ${where}\n` : ""}${why ? `${why}\n` : ""}Give your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note}.`;
584
+ // A checklist that maps the change to its contract (issue #35): reviewers who check against the written plan catch more.
585
+ const plan = planText(task.plan);
586
+ const check = [...task.history].reverse().find((h) => h.event === "check passed");
587
+ const checklist = [
588
+ "Checklist:",
589
+ `- Map the changed signatures and call sites to ${plan ? `the plan (${plan})` : task.detail ? "the task detail above" : "the task title"}, and name each one that does not match.`,
590
+ `- ${check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : "No check ran for this class: run the affected tests yourself."}`,
591
+ `- List what is unmet in hub_review's unmet, one item each.`,
592
+ ].join("\n");
593
+ const body = `Review task #${task.id} [${task.class}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${last?.note ?? "(no summary)"}\n${where ? `Where: ${where}\n` : ""}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet}.`;
535
594
  this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, task: String(task.id) }, ...(this.isPii(task) ? { private: true } : {}) }));
536
595
  }
537
596
 
538
- async review(by: PeerId, id: unknown, verdict: unknown, note?: string): Promise<Task> {
597
+ async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown): Promise<Task> {
539
598
  const task = this.need(id);
540
599
  this.mine(task, by, "reviewer");
541
600
  if (verdict !== "approved" && verdict !== "changes_requested") throw new Error('verdict must be "approved" or "changes_requested"');
542
601
  if (task.state !== "in_review") throw new Error(`task #${task.id} is ${task.state}: cannot move to ${verdict} before its owner calls hub_task_done`);
543
602
  const pii = this.isPii(task);
603
+ const items = textList(unmet);
604
+ if (items.length) note = `${note ?? ""}\nUnmet: ${items.join("; ")}`.trim();
544
605
  if (verdict === "approved") {
545
606
  const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0 }, note);
546
- if (next.owner) this.d.board.recordOutcome(next.owner, next.class, true);
607
+ if (next.owner) {
608
+ this.d.board.recordOutcome(next.owner, next.class, true);
609
+ this.d.board.recordReview({ implementer: next.owner, reviewer: by, class: next.class, kind: "approved", task: next.id });
610
+ // Changes requested on this owner's work and the redo passed: those reviews caught something.
611
+ for (const r of this.requestedChanges(task)) {
612
+ this.d.board.recordReview({ implementer: next.owner, reviewer: r, class: next.class, kind: "caught", task: next.id });
613
+ }
614
+ }
547
615
  this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`);
548
616
  this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${note}` : ""}`, pii);
549
617
  await this.releaseDependents(next);
@@ -551,6 +619,7 @@ export class Tasks {
551
619
  }
552
620
  const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note);
553
621
  if (rejected.owner) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
622
+ this.contradict(rejected);
554
623
  this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`);
555
624
  if (rejected.rejections >= ESCALATE_AFTER) {
556
625
  const moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`);
@@ -573,6 +642,8 @@ export class Tasks {
573
642
  // The hub's own escalations are not counted: after repeated changes_requested each one already was, and after a
574
643
  // Pi inference failure the backend failed, not the work. An escalation by hand counts on its own.
575
644
  if (from && by !== HUB) this.d.board.recordOutcome(from, task.class, false);
645
+ // Only a reviewer that asked for changes on this work saw it fail: not an escalation of unreviewed work (a Pi failure).
646
+ if (from && task.reviewer && task.reviewer !== USER && this.requestedChanges(task).has(task.reviewer)) this.d.board.recordReview({ implementer: from, reviewer: task.reviewer, class: task.class, kind: "escalated", task: task.id });
576
647
  const next = await this.assignOwner(task, by, { candidates: list, event: "escalated", note: `${why}; from ${from ?? "none"}`, context: why });
577
648
  if (next.owner && next.owner !== from) {
578
649
  this.d.notify(`task ${this.publicTitle(next)} escalated from ${from} to ${next.owner} (${why})`);
@@ -18,7 +18,19 @@ const q = sbplString;
18
18
  * no network unless asked. In SBPL the last matching rule wins, so the denies come last.
19
19
  */
20
20
  /** Toolchains and git identity: the only parts of the home directory a sandboxed command may read besides the project. */
21
- const HOME_READABLE = [".bun", ".cargo", ".rustup", ".local", ".npm", ".cache", ".pyenv", ".nvm", ".deno", "go", ".gitconfig", ".config/git", "Library/Caches"];
21
+ const HOME_READABLE = [".bun", ".cargo", ".rustup", ".local", ".npm", ".cache", ".pyenv", ".nvm", ".deno", "go", ".gitconfig", ".config/git", "Library/Caches", ".volta", ".asdf", ".nodenv", ".rbenv", ".sdkman", "Library/Application Support/fnm", "Library/pnpm"];
22
+
23
+ /**
24
+ * Public CA bundles: the name denies (`*.pem` for keys) also match these, and TLS needs them once network is on.
25
+ * Allowed after the denies, by exact path.
26
+ */
27
+ const CA_BUNDLES = ["/private/etc/ssl/cert.pem", "/opt/homebrew/etc/ca-certificates/cert.pem", "/opt/homebrew/etc/openssl@3/cert.pem", "/usr/local/etc/ca-certificates/cert.pem", "/usr/local/etc/openssl@3/cert.pem"];
28
+
29
+ /** The selected Xcode or Command Line Tools dir: the `/usr/bin` shims (git, clang, make, python3) run what is in it. */
30
+ function developerDir(): string | undefined {
31
+ const out = spawnSync("xcode-select", ["-p"], { encoding: "utf8" });
32
+ return out.status === 0 && out.stdout.trim() ? out.stdout.trim() : undefined;
33
+ }
22
34
 
23
35
  /** A submodule or worktree keeps its git dir outside the project; git needs it, minus the parts that execute or reconfigure. */
24
36
  function externalGitDirs(root: string): string[] {
@@ -28,28 +40,67 @@ function externalGitDirs(root: string): string[] {
28
40
  return [...new Set(dirs)].filter((d) => !d.startsWith(`${root}/`));
29
41
  }
30
42
 
31
- export function profile(cwd: string, network: boolean, readAllow: string[] = [], deny: string[] = []): string {
43
+ /** The system directories a deny-default profile lets commands read and run from: dyld, frameworks, toolchains. */
44
+ const SYSTEM_READABLE = ["/usr", "/bin", "/sbin", "/System", "/Library", "/opt", "/private/etc", "/private/var/db/timezone", "/private/var/db/dyld"];
45
+ /**
46
+ * Directory lookups (users, groups), logging and notifications; plus name resolution and TLS trust when network is on.
47
+ * No brokers that act outside the sandbox: LaunchServices would let `open` start a browser with network, and
48
+ * SecurityServer would answer Keychain queries the credential-path denies are there to stop.
49
+ */
50
+ const MACH_SERVICES = ["com.apple.system.opendirectoryd.libinfo", "com.apple.system.DirectoryService.libinfo_v1", "com.apple.system.logger", "com.apple.system.notification_center"];
51
+ const NETWORK_MACH_SERVICES = ["com.apple.dnssd.service", "com.apple.trustd", "com.apple.trustd.agent", "com.apple.networkd"];
52
+
53
+ /**
54
+ * `base` "deny" (issue #39) starts from `(deny default)` and allows only what commands need: running and reading the
55
+ * system, toolchain and project directories, writing the project and temp. "allow" is the profile of 0.9 and earlier,
56
+ * kept for one release behind `local.sandbox: "allow-default"`. The denies at the end apply to both.
57
+ */
58
+ export function profile(cwd: string, network: boolean, readAllow: string[] = [], deny: string[] = [], base: "deny" | "allow" = "deny"): string {
32
59
  const home = homedir();
33
60
  const inHome = (p: string) => (p.startsWith("~/") ? join(home, p.slice(2)) : p);
34
61
  const root = realPath(cwd);
35
62
  const tmp = realPath(tmpdir());
36
63
  const gitDirs = externalGitDirs(root);
37
64
  const creds = [".ssh", ".aws", ".gnupg", ".config/gh", ".config/gcloud", ".kube", ".docker", ".netrc", ".npmrc", ".omniroute", ".claude", ".codex", ".kimi-code", "Library/Keychains"];
65
+ const dev = developerDir();
66
+ const readable = [root, ...HOME_READABLE.map((p) => join(home, p)), ...readAllow.map(inHome), ...gitDirs, ...(dev ? [dev] : [])];
67
+ const subpaths = (paths: string[]) => paths.map((p) => `(subpath ${q(p)})`).join(" ");
68
+ const globals = (names: string[]) => names.map((n) => `(global-name ${q(n)})`).join(" ");
69
+ const start = base === "deny"
70
+ ? [
71
+ "(deny default)",
72
+ "(allow process-fork)",
73
+ // Runs from the system, toolchain and project directories only; a script runs through an allowed interpreter.
74
+ `(allow process-exec ${subpaths([...SYSTEM_READABLE, ...readable, tmp, "/private/tmp"])})`,
75
+ "(allow signal (target same-sandbox))",
76
+ "(allow process-info* (target same-sandbox))",
77
+ "(allow sysctl-read)",
78
+ `(allow mach-lookup ${globals([...MACH_SERVICES, ...(network ? NETWORK_MACH_SERVICES : [])])})`,
79
+ '(allow ipc-posix-shm-read-data ipc-posix-shm-read-metadata (ipc-posix-name "apple.shm.notification_center"))',
80
+ "(allow file-read-metadata)",
81
+ `(allow file-read* (literal "/") ${subpaths([...SYSTEM_READABLE, tmp, "/private/tmp", "/dev"])} ${subpaths(readable)})`,
82
+ '(allow file-ioctl (regex #"^/dev/"))',
83
+ ...(network ? ["(allow network*)"] : []),
84
+ ]
85
+ : [
86
+ "(allow default)",
87
+ ...(network ? [] : ["(deny network*)"]),
88
+ // Home is default-deny for reads: whatever a command reads can end up in the model's answer, and that answer
89
+ // is shared with agents that run on cloud subscriptions (~/.claude.json, app tokens, browser profiles ...).
90
+ `(deny file-read* (subpath ${q(home)}))`,
91
+ `(allow file-read-metadata (subpath ${q(home)}))`,
92
+ `(allow file-read* ${subpaths(readable)})`,
93
+ ];
38
94
  return [
39
95
  "(version 1)",
40
- "(allow default)",
41
- ...(network ? [] : ["(deny network*)"]),
42
- // Home is default-deny for reads: whatever a command reads can end up in the model's answer, and that answer
43
- // is shared with agents that run on cloud subscriptions (~/.claude.json, app tokens, browser profiles ...).
44
- `(deny file-read* (subpath ${q(home)}))`,
45
- `(allow file-read-metadata (subpath ${q(home)}))`,
46
- `(allow file-read* (subpath ${q(root)}) ${[...HOME_READABLE.map((p) => join(home, p)), ...readAllow.map(inHome), ...gitDirs].map((p) => `(subpath ${q(p)})`).join(" ")})`,
96
+ ...start,
47
97
  "(deny file-write*)",
48
98
  `(allow file-write* (subpath ${q(root)}) (subpath ${q(tmp)}) (subpath "/private/tmp") (regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`,
49
99
  // Inside cwd: nothing that runs later outside the sandbox, nothing that reconfigures the hub.
50
100
  `(deny file-write* (subpath ${q(join(root, ".agenthub"))}) ${[join(root, ".git"), ...gitDirs].map((d) => `(subpath ${q(join(d, "hooks"))}) (literal ${q(join(d, "config"))})`).join(" ")})`,
51
101
  `(deny file-read* file-write* ${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")})`,
52
102
  `(deny file-read* file-write* ${denyRegexes(root, deny).join(" ")})`,
103
+ ...(network ? [`(allow file-read* ${CA_BUNDLES.map((p) => `(literal ${q(p)})`).join(" ")})`] : []),
53
104
  ].join("\n");
54
105
  }
55
106
 
@@ -23,10 +23,13 @@
23
23
  "cf_client_id_file": "",
24
24
  "cf_client_secret_file": ""
25
25
  },
26
- "local": { "deny": [], "bash_network": false, "max_steps": 30, "read_allow": [] },
26
+ "local": { "deny": [], "bash_network": false, "max_steps": 30, "read_allow": [], "sandbox": "deny-default" },
27
27
  "approvals": { "timeout_s": 120 },
28
28
  "tasks": { "release_after_min": 30 },
29
29
  "checks": { "timeout_s": 600 },
30
30
  "snapshots": { "enabled": true, "keep": 20 },
31
+ "review": { "adaptive": false, "min_reviews": 5 },
32
+ "recovery": { "auto_resume_after_crash": false },
33
+ "capabilities": {},
31
34
  "limits": { "sender_per_min": 12, "pair_per_min": 6, "important_per_hour": 6, "repeat_window_s": 120 }
32
35
  }