@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 +6 -0
- package/README.md +2 -2
- package/docs/operations.md +83 -9
- package/docs/quickstart.md +1 -1
- package/docs/security.md +3 -1
- package/docs/smoke.md +36 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +76 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +2 -2
- package/src/adapters/acp.ts +10 -2
- package/src/adapters/local-worker.ts +2 -2
- package/src/cli/main.ts +6 -4
- package/src/hub/board.ts +26 -0
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/crash.ts +104 -0
- package/src/hub/daemon.ts +112 -8
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/routing.ts +21 -3
- package/src/hub/tasks.ts +76 -5
- package/src/local/sandbox.ts +60 -9
- package/templates/config.json +4 -1
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.
|
|
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.
|
|
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`.
|
package/docs/operations.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Operations guide
|
|
2
2
|
|
|
3
|
-
This guide describes ahub 0.
|
|
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:
|
|
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.
|
|
387
|
-
bunx --package @staix/agent-hub@0.
|
|
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)
|
|
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.
|
|
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.
|
|
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.
|
|
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):
|
package/docs/quickstart.md
CHANGED
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
|
|
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": "agent-hub",
|
|
3
|
-
"version": "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.
|
|
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
|
];
|
package/src/adapters/acp.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
697
|
-
|
|
698
|
-
|
|
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]));
|
package/src/hub/config-trust.ts
CHANGED
package/src/hub/crash.ts
ADDED
|
@@ -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
|
-
|
|
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))
|
|
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 (
|
|
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 */ } }
|
package/src/hub/hub-tools.ts
CHANGED
|
@@ -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
|
];
|
package/src/hub/routing.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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)
|
|
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})`);
|
package/src/local/sandbox.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
package/templates/config.json
CHANGED
|
@@ -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
|
}
|