@staix/agent-hub 0.12.19 → 0.12.21

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.
Files changed (87) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +7 -6
  3. package/docs/agent-notes/adapters.md +10 -2
  4. package/docs/agent-notes/bus.md +6 -2
  5. package/docs/agent-notes/daemon.md +10 -3
  6. package/docs/agent-notes/local-worker.md +3 -1
  7. package/docs/agent-notes/models.md +3 -0
  8. package/docs/agent-notes/tasks.md +6 -3
  9. package/docs/agent-notes/tests.md +3 -2
  10. package/docs/bench.md +143 -0
  11. package/docs/events.md +6 -2
  12. package/docs/operations.md +655 -34
  13. package/docs/quickstart.md +12 -10
  14. package/docs/research.md +92 -0
  15. package/docs/security.md +59 -17
  16. package/docs/smoke.md +137 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +586 -16
  18. package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
  19. package/docs/specs/2026-09-20-upgrade-recovery-design.md +345 -0
  20. package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
  21. package/docs/verified.json +29 -18
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +65 -26
  25. package/src/adapters/acp.ts +67 -6
  26. package/src/adapters/claude-channel.ts +61 -20
  27. package/src/adapters/codex-appserver.ts +70 -2
  28. package/src/adapters/local-worker.ts +8 -3
  29. package/src/adapters/pi.ts +58 -4
  30. package/src/cli/bench.ts +287 -0
  31. package/src/cli/console-state.ts +531 -110
  32. package/src/cli/console.ts +59 -26
  33. package/src/cli/facts-hook.ts +50 -8
  34. package/src/cli/help.ts +130 -0
  35. package/src/cli/identity.ts +11 -5
  36. package/src/cli/launch.ts +98 -17
  37. package/src/cli/main.ts +325 -88
  38. package/src/cli/preview.ts +14 -3
  39. package/src/cli/reset.ts +194 -0
  40. package/src/cli/status-lines.ts +10 -5
  41. package/src/cli/terminal-recovery.ts +196 -40
  42. package/src/cli/upgrade-runtime.ts +327 -60
  43. package/src/cli/upgrade.ts +442 -45
  44. package/src/hub/ask.ts +1 -1
  45. package/src/hub/bench.ts +211 -0
  46. package/src/hub/board.ts +15 -7
  47. package/src/hub/bus.ts +79 -8
  48. package/src/hub/child-process.ts +1 -0
  49. package/src/hub/conductor.ts +54 -21
  50. package/src/hub/config-trust.ts +1 -0
  51. package/src/hub/control-client.ts +9 -4
  52. package/src/hub/daemon.ts +401 -48
  53. package/src/hub/delivery-journal.ts +6 -6
  54. package/src/hub/events.ts +9 -2
  55. package/src/hub/execution-budget.ts +15 -6
  56. package/src/hub/hub-tools.ts +4 -4
  57. package/src/hub/inference.ts +136 -1
  58. package/src/hub/lifecycle.ts +13 -12
  59. package/src/hub/manager.ts +16 -14
  60. package/src/hub/peers.ts +2 -0
  61. package/src/hub/permission-mode.ts +44 -0
  62. package/src/hub/recovery-store.ts +68 -8
  63. package/src/hub/registry.ts +36 -22
  64. package/src/hub/report.ts +43 -1
  65. package/src/hub/research.ts +255 -0
  66. package/src/hub/restart.ts +25 -0
  67. package/src/hub/routing.ts +48 -16
  68. package/src/hub/snapshots.ts +3 -2
  69. package/src/hub/tasks.ts +295 -115
  70. package/src/hub/tool-refusal.ts +7 -0
  71. package/src/hub/ui.ts +4 -1
  72. package/src/local/deny.ts +2 -1
  73. package/src/local/tools.ts +14 -10
  74. package/src/models/mlx.ts +11 -8
  75. package/src/models/ollama.ts +2 -2
  76. package/src/models/relay.ts +190 -42
  77. package/src/models/route/relay-selector.ts +43 -31
  78. package/src/models/route/runtime.ts +17 -7
  79. package/src/models/route/signals.ts +12 -1
  80. package/src/models/route/stage.ts +57 -3
  81. package/src/omniroute/client.ts +1 -1
  82. package/src/pi/process-signature.ts +23 -0
  83. package/src/pi/tool-receipts.ts +7 -1
  84. package/src/ui/index.html +84 -6
  85. package/src/ui/task-progress.ts +33 -0
  86. package/templates/AGENTS.block.md +1 -1
  87. package/templates/routing.toml +8 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,44 @@ Issue and pull request numbers in the entries for 0.7.7 and earlier refer to the
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.12.21
8
+
9
+ - Console and dashboard show shared whole-board task progress, waiting dependencies and four-stage tracks; the dashboard adds a host-scoped System/Light/Dark choice with pre-paint initialization and hashed inline CSP (#246).
10
+ - `ahub bench` follow-ups: a task id such as `constructor` no longer breaks reports, an attempt whose measure wait ran out or was interrupted in its last poll records no measures, the fingerprint lists only attached peers, a run returns to a branch that shares its name with a tag, and a suite ref deleted during the run is reported (#265).
11
+ - `ahub bench`: run a suite of tasks against the attached peers in a project kept for benchmarks, record pass, fail, timeout and the #247 measures per attempt in `~/.agenthub/bench/`, and compare arms with bootstrap intervals (`bench run|list|status|report|compare|export`); the dashboard gains a Benchmarks section (#251).
12
+ - Kimi can start or resume with an operator-selected `permission_modes.kimi` default, setting ACP `yolo` for `ask-when-needed` or `auto` for `never-ask` before its first prompt; unavailable or refused modes fail startup with a reason. Non-Kimi ACP sessions retain their native mode as unmanaged. Kimi 2.1.1 benign live checks accepted both ids and completed file edits and harmless shells with no ACP approval requests; risky-action behavior remains unverified (#240).
13
+ - Add human-only `ahub permission` and a console Peers `m` chooser for running Claude, Codex, Kimi, Pi and local, with explicit confirmation for `never-ask`, status/event visibility and project defaults capped at ask-when-needed until a source-labelled console confirmation enables never-ask at each hub start. Native sandboxes remain unchanged; `ask` restores native approval handling (#242).
14
+ - Return proven pre-effect Pi task/conductor refusals as their exact errors and completed receipts; preserve uncertain effects, storage failures and unproven legacy pending receipts (#254).
15
+ - Log each Pi exit cause and last tool name without arguments, show the next action, and resume an unexpectedly idle headless Pi on its verified session at most once in 60 seconds under `pi.auto_start`, with no fresh fallback (#255).
16
+ - Opt-in research records (`"research": { "enabled": true }`): one record per approved task in `~/.agenthub/research/`, across projects, with outcome, review rounds, rework, failed checks, tokens and time per peer and model, built only from `events.jsonl` (never task or message text). `ahub research` reports success, first-pass, rework and check-failure rates and tokens and wall time per task; `ahub research export` writes JSONL or CSV; `ahub research backfill` builds records from existing events; `ahub task label` adds a person's later verdict (#247).
17
+ - Preserve empty committed queue entries for offline peers during journal-backed recovery, so preservation verification does not require them to reconnect. Durable queues and delivery receipts remain authoritative; stale non-empty snapshot queues are never replayed (#234).
18
+ - Preserve every pause and queue array when a delivery resolution is refused or its journal write fails; roll back only the recipient queue and preface (#229).
19
+ - Retry a dependent task released while no peer can take it or its ready/assignment write fails; consume its per-run offer only after assignment (#231).
20
+ - Test port probes bind 127.0.0.1 to match the Codex servers, and failed daemon starts show their error in assertion diffs (#232).
21
+ - Reuse Git fixture templates and restore sealed-study mutations between subtests to reduce export test process spawns without changing its 20 s timeout (#233).
22
+ - Judge a recorded hub manifest, registry claim, recovery runner and manager owner by its process signature as well as its pid, through one helper, so a pid that a reboot handed to another process (one we may not even signal) no longer wedges recovery, `ahub up` or `ahub reset`. `status.json` (file only), the registry claim, the runner record and the manager owner now carry the signature, daemons stop writing `hub.pid`, and recovery reads the registry's live claim instead of the plan's frozen copy. Records written by 0.12.20 and older keep the bare-pid answer; the operations guide gives the manual clear for such a claim (#226).
23
+ - A stop-and-archive recorded on an operation whose coordinator predates #215 marks its receipt `schema: 2`, which every older runner and abort refuse before they take the lock, so an older global `ahub recovery resume` can no longer advance an operation being abandoned; checked against the real v0.12.19 source (#227).
24
+ - A `terminal-recovery.json` row that fails validation is unknown for the launcher it may describe, never "no launcher", and the next `ahub codex`, `ahub claude` or `ahub pi` writes it back unchanged; launchers recorded by 0.12.12 or older are also read by their unpinned signature (#228).
25
+ - `ahub recovery resume` restarts a target hub that stopped after it started (a crash, an OOM kill, a reboot) from the operation's snapshot, once per resume: it records the restart (status shows `restarts`), closes the terminals the dead hub's peers were restored into, relaunches those peers against the new hub (a session the operator had accepted by its own id), and treats a launcher of the dead hub or a session attached without an id as live, never as nothing attached. Without the snapshot, or for an operation of an older coordinator, stop-and-archive stays the way out (#225).
26
+ - Keep local-worker hub tool arguments and results out of claude-mem capture; ordinary turns see PII task stubs, and only PII turns may read full local board rows (#230).
27
+ - Console stream lines color peer names, the `task` keyword, numeric values and issue/task references separately, keeping ordinary words and message bodies at the default foreground and preserving plain output and terminal geometry (#239).
28
+
29
+ ## 0.12.20
30
+
31
+ - `ahub help` groups the commands into sections, starts every description at one column and wraps at word boundaries to the terminal width (80 to 100 columns), colored with the console palette only on a terminal without `TERM=dumb` or `NO_COLOR`. `--help`, `-h` and `help <command>` work, and `ahub <command> -h` (or `--help`) given as the only argument prints that command's help instead of running it, except for claude and codex, which pass it to the agent; an unknown command prints a one-line hint instead of the full list. The console shares the word-aware wrapping, which also covers the wrapping item of #213: the help overlay, approval titles, details and stream lines break at word boundaries. A continuation line starts four columns deeper than its source line (at most half the width), so wrapped peer text in the console stream never starts at column 0 or 2, where headers and event lines start; a wrapped line that starts like a hub header keeps its `> ` marker, now counted in its width (#212).
32
+ - A Claude session started without `ahub claude` (`AGENTHUB_CHANNEL=1`) has no channel evidence and attaches tools-only: it declares no channel and never reports a delivery `accepted`; its messages stay queued and `hub_inbox` reads them, one push delivery's worth at a time, as `completed` deliveries, under the same holds as pushes. It stands by instead of taking the peer from an `ahub claude` session, while `ahub claude` takes the peer from it with pushes. `ahub status`, the console and the dashboard name the state and the next action, `ahub claude` (#205).
33
+ - Control protocol 16 (hello `channel`, `inbox`). The upgrade coordinator accepts protocol-15 sources (0.12.17 through 0.12.19); the live upgrade from a running protocol-15 hub is still pending.
34
+ - Let a task's current owner and reviewer read its public view with history through `hub_task_show`, so a reviewer sees the done summary and check line; PII tasks stay stubs and other peers stay refused (#208).
35
+ - Keep `owner` with `after` as a reserved owner offered the task first once it is ready, falling back with a notice when that peer is offline, paused, declined or excluded by PII; let a proposer redirect its own unaccepted task; `ahub route explain` shows the reservation (#207).
36
+ - Add session-aware stay/switch for Pi's `hub/auto` and hub stage routes: a hard override escalates at once, a tool loop keeps its tier, other changes wait for a user turn, and a de-escalation over `max_switch_prefill_tokens` stays; no route summarizes or trims the history. Top-level `stay_switch` in `routing.toml` is `off`, `shadow` (default: route events record the planner's verdict, routing unchanged) or `enforce`; `ahub report` counts model changes per session and inside tool loops. Enforce stays opt-in until measured (#197).
37
+ - Cool a relay alias down after three consecutive transport or startup failures (30 s doubling to 5 min; a busy slot and budget-cut timeouts never count, and any HTTP answer ends it), and try the DGX fallback first, keeping MLX behind it, while MLX cools down on `hub/auto`. Opt-in with `[pi] efficient_wait_ms`: an efficient `hub/auto` request whose MLX slot stays busy past it tries `dgx/fast` first, for at most 15 s to its response headers before MLX takes over, never under an execution budget. A stream that fails after its headers is not retried. `ahub status` and `cooldown` events show it (#199).
38
+ - Events schema stays 1: route events gain optional fields and sources, and `cooldown` (#199), `pii_screen` (#198) and `native_thread` (#215) are new event types.
39
+ - `ahub console` separates header, body and footer with ASCII rules and brackets the active tab, which counts pending approvals or `needs_review` deliveries. Peers, Approvals, Tasks and Queue are aligned tables with header rows, `-` for zero counters, human durations and relative resets; Enter shows labeled fields instead of JSON. The Approvals panel shows the selected request's title, numbered allow options and `d deny`. The footer lists only the keys that act in the current mode, panel and state, `?` shows a key table grouped by panel, and notices clear at the next key or after about ten seconds. Untrusted text can no longer look like a hub line: `v` in the stream frames an approval title as its push does, titles in the Approvals panel and details sit in their value column, command output starts at column 4, and a cut line ending in `[agent-hub` no longer overflows the width. Peers show `tools-only` when a status carries #205's field, and a context reading in the console shows local time instead of an ISO time; `ahub tail` is unchanged (#213).
40
+ - `ahub reset [--all] [--yes]` returns a project's hub to a clean state. Without `--yes` it lists, by id and count and never with task or message text, what it would settle, clear, archive and keep, and changes nothing. With `--yes` it stops a running hub as `ahub kill` does; the runtime reset then discards every queued and needs_review delivery with reason `reset` through the journal (one `resolution_history` entry each), clears manual, budget and conductor holds and drops the session pointers, keeping the board, logs, audit, recovery records and execution budgets. `--all` moves the state directory to a git-ignored `.agenthub/archive/state-<UTC time>/` (0700) and starts an empty one holding only `project.json`, so the project id and registration stay; the local worker's and Pi's denylist and turn snapshots leave `.agenthub/archive` out, and `--all` archives a state directory it cannot read as it is. A crashed hub's leftover manifest does not block a reset; `--all` refuses a symlinked archive directory or `.gitignore`. Human-only; refused while an upgrade or recovery operation is open or for a hub without a matching registration; claude-mem is never touched (#214).
41
+ - Add an opt-in model PII screen (`signals.pii_screen = "local"` in `routing.toml`, default `off`): a new task is held back until the on-device model, or the gateway only while on campus, labels it `pii` or `clear` with a closed category; no verdict (timeout, off campus, an answer outside the list, text over 6000 UTF-8 bytes) counts as PII, and a task left that way with no owner is screened again on the release timer, at most 10 times per hub run and never while its text is over the limit. A taken, failing or still-loading on-device model hands the screen to the campus gateway. The screen joins the pattern checks on done summaries, review notes, unmet items, budget hand-offs and `hub_remember`; withheld history entries stay withheld. History and the new `pii_screen` event record the source and category, never the text; `scripts/pii-screen-eval.ts` measures it on a synthetic Korean and English fixture set against a 90% recall bound (#198).
42
+ - Plan an attached Claude session that no live `ahub claude` launcher recorded as reconnect-only: its stale `claude-session.json` is never a recovery target or shown, nothing in its terminal is closed or relaunched, and an operation-fenced waiver lets the restored hub accept it once its plugin reconnects. Refused `upgrade --yes`, `restart --yes` and every dry-run print each blocker on stderr (#206).
43
+ - Re-prepare the verified source after an expired preparation lease, keeping effect receipts; refuse a replaced daemon, a source held by another operation and a changed peer with the next action. Check Codex resume viability against the launcher's session store before closing and relaunching. A restoration launcher that recorded itself and whose process is gone, or whose terminal Orca no longer lists, is receipted failed within one 5-second wait (an Orca wait timeout counts as not yet; an unreadable launcher counts as running); resume settles a pending or failed launch by what is live, and no terminal is created beside a launcher that may run. `abort` also cancels a prepared source whose hold lapsed. `ahub recovery status` reports the live runner, stale receipts, effects and next commands; `ahub recovery dispose <id> --fresh-session <codex or claude> --reason <text>` records a lost conversation and resumes, and `ahub recovery dispose <id> --stop-and-archive --reason <text>` abandons the operation, releasing the lock only after its own holds and targets are released or stopped (a target of an older coordinator is stopped at its own control protocol). A Codex thread with no rollout that the hub saw start and saw no turn on (from a new `native_thread` event) restarts as a new session, with the plan saying so. Next actions and errors name the operation's own coordinator, since the global CLI may be the older release mid-upgrade; reconnect-only and new sessions need a target that reads recovery waivers. A daemon that crashed and left its manifest behind reads as stopped, not unavailable, so it no longer wedges abort, resume and stop-and-archive; `next` offers resume and `--fresh-session` only where resume can get past what is live, and stop-and-archive only where dispose accepts it; a target that dies after it started is not restarted in this release (stop-and-archive is the way out) (#215).
44
+
7
45
  ## 0.12.19
8
46
 
9
47
  - Pi's write, edit, bash and git write approvals offer `Always allow <tool> until Pi restarts`: later calls of that tool by the running Pi skip the prompt, still under the path guard and sandbox. The grant is in memory only, logged by tool name, and the dashboard can still only deny (#209).
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.12.19, control protocol 15. Durable delivery records distinguish queued
7
+ Status: 0.12.21, control protocol 16. 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
28
28
  Or install the same version from GitHub:
29
29
 
30
30
  ```bash
31
- bun add -g github:STAIxBWLB/agent-hub#v0.12.19 && ahub setup
31
+ bun add -g github:STAIxBWLB/agent-hub#v0.12.21 && ahub setup
32
32
  ```
33
33
 
34
34
  The installed commands remain `ahub` and `agent-hub`.
@@ -68,7 +68,7 @@ an N-peer message bus and attaches each agent through its native control surface
68
68
 
69
69
  | Peer | Inbound | Outbound | Busy handling |
70
70
  | --- | --- | --- | --- |
71
- | Claude Code | channel plugin push (`notifications/claude/channel`) | MCP tools | queued by Claude Code, grouped on next turn |
71
+ | Claude Code | channel plugin push (`notifications/claude/channel`) under `ahub claude`; otherwise tools-only, read with `hub_inbox` | MCP tools | queued by Claude Code, grouped on next turn; tools-only: queued at the hub |
72
72
  | Codex | app-server `turn/start` (idle) / `turn/steer` (busy) | proxy intercepts `item/agentMessage` | steer or queue |
73
73
  | Kimi Code | ACP `session/prompt` | ACP `session/update` stream | queue (`turn.agent_busy`) |
74
74
  | Pi | RPC or managed native TUI extension | hub-approved tools | queued until `agent_settled` |
@@ -138,13 +138,14 @@ Bun + TypeScript. Single daemon per project directory, loopback only.
138
138
  ## Local dashboard
139
139
 
140
140
  With a running daemon, `ahub ui` opens a local dashboard for messages, peer states
141
- and queues, the task board, budget windows and pending approvals. Use
142
- `ahub ui --no-open` to print a single-use link when the browser cannot be opened.
141
+ and queues, the task board, budget windows, pending approvals and this machine's
142
+ benchmark runs. Use `ahub ui --no-open` to print a single-use link when the
143
+ browser cannot be opened.
143
144
  Open the link within 60 seconds; the browser session lasts one hour.
144
145
 
145
146
  The dashboard can send console messages, propose and assign tasks, pause or resume
146
147
  peers, and answer approvals. Budget pauses still require the terminal override.
147
- Private envelopes and PII tasks stay redacted. Local-worker tool details and
148
+ Private envelopes and PII tasks stay redacted. Local-worker and Pi tool details and
148
149
  allow decisions stay in `ahub tail` / `ahub permit`; the page can deny them.
149
150
  No dashboard port is opened until requested. See [the session security model](docs/security.md#local-dashboard-sessions-issue-6).
150
151
 
@@ -1,13 +1,21 @@
1
1
  # Native adapters and spawned agents
2
2
 
3
- Scope: the Codex, ACP and Pi adapters and the Pi extension (turn `generation` and `activity` events), peer turn state, approvals, and the agent processes the hub spawns and stops. Read before editing `src/adapters/`, `src/pi/`, `src/hub/peers.ts`, `src/hub/child-process.ts`, `src/hub/lifecycle.ts` (shutdown deadline), `src/memory/capture.ts` (session end) or the process-tree stop in `scripts/benchmarks/teardown.ts`, or before adding a native peer.
3
+ Scope: the Codex, ACP and Pi adapters and the Pi extension (turn `generation` and `activity` events), peer turn state, approvals, and the agent processes the hub spawns and stops. Read before editing `src/adapters/`, `src/pi/`, `src/hub/peers.ts`, `src/hub/permission-mode.ts`, `src/hub/tool-refusal.ts`, `src/hub/child-process.ts`, `src/hub/lifecycle.ts` (shutdown deadline), `src/memory/capture.ts` (session end) or the process-tree stop in `scripts/benchmarks/teardown.ts`, or before adding a native peer.
4
4
 
5
5
  - Codex: the adapter never sends its own `initialize`. It is a proxy; hub requests use negative ids and their responses must not reach the TUI.
6
+ - Codex reports each thread it adopts, started or resumed (`onThread`, the `native_thread` event), before any turn on it starts: the upgrade planner (`codexZeroTurn` in `src/cli/upgrade-runtime.ts`, #215) gives each codex `turn_start` to the thread adopted last before it and plans a thread it saw start with no turn (and no rollout) as a fresh start.
6
7
  - Codex 0.154.0 `agentMessage` items carry `text` and `phase` (not `content[]`); only the last non-`commentary` message of a turn is shared.
7
8
  - Approval titles are agent-written text shown to a person: the daemon escapes control characters, and write/edit/bash show what will be written or run.
8
9
  - An ACP permission request may carry no `rawInput` (Kimi 2.0.1): the arguments were on the earlier `tool_call` update, which `acp.ts` remembers by `toolCallId`. Kimi 2.1.1 sends no `rawInput` before the answer at all: the argument JSON streams as `content` text on `tool_call_update`, and only complete JSON counts. A payload that still cannot be resolved, or is too long to show whole (marked `[cut, N chars]`), withholds its `allow_always` option - never render a bare tool name as if it described the call.
10
+ - Tag a Pi hub-tool refusal only at a proven pre-effect guard with `PreEffectToolRefusal`; return its exact error and settle its receipt. Never classify a generic/post-effect/storage failure or a legacy pending receipt from error text or current board state.
11
+ - Report each Pi owner exit once, with code/signal (unknown for an unobserved TUI exit), turn/tool activity and only the last tool name. Auto-restart only a current, started, unexpectedly idle headless owner on its verified persisted session, once per 60 s; preserve shutdown/recovery/startup/active-tool fences and never fall back fresh.
9
12
  - Pi's `always` grant (#209) is keyed by `executeTool`'s tool name, never parsed from the approval title, and lives only in that Pi start's closure. Never persist it, share it with the local worker, or let the dashboard give it.
10
- - Auto-approval covers the hub's own tools by exact `mcp__agent-hub__<name>` title and only ever picks `allow_once`. Identity comes from the permission request title or, when an agent titles the request with the argument JSON (Qwen), from the announced `tool_call` title bound to the call id and resolved against the session's configured MCP servers, never from payload text, a prefix or an unrelated display title. Never widen it to a prefix, and never set Kimi's session mode to `auto` or `yolo` instead: those approve everything.
13
+ - Auto-approval covers the hub's own tools by exact `mcp__agent-hub__<name>` title and only ever picks `allow_once`. Identity comes from the permission request title or, when an agent titles the request with the argument JSON (Qwen), from the announced `tool_call` title bound to the call id and resolved against the session's configured MCP servers, never from payload text, a prefix or an unrelated display title. Never widen it to a prefix or replace it with an agent-selected session mode. Set Kimi `yolo`/`auto` only for its verified native identity and an operator mode; config never-ask waits for a console y, and other ACP agents stay unmanaged on fresh/resumed starts without setting/resetting modes or failing over their reported mode; refuse runtime changes until their map exists; await the advertised mode change after session new/load and before a prompt, and fail startup on refusal. Use the shared `KIMI_MODE_IDS` mapping in `src/hub/permission-mode.ts`.
14
+ - Permission modes (#242) are a person's and live in the daemon's memory only: `permission` and `permission_default` are console-role control messages, `ahub permission` is `console` in `classifyPeerCommand`, and no hub tool, conductor tool or dashboard action sets one. Never write a runtime choice to a restart snapshot.
15
+ - A peer whose mode lives in the native agent (Kimi, Codex) joins the bus only through `reconcileNativeStart` (`src/hub/daemon.ts`): after `start()` the daemon's current mode is applied, then `bus.add` (it can drain at once), then the state `start()` reached is replayed. Every mode change runs inside `permissionChange`, one per peer at a time.
16
+ - Codex takes a mode as an `approvalPolicy` override on `turn/start`, and the override persists on the thread: pass the hub's and the TUI's `turn/start` alike through `applyPermissionPolicy`, and return to `ask` only by sending the captured native policy once. With none captured, refuse while the TUI is attached; never just stop sending the override. A detached proxy's `clearPermissionMode` is never refused and logs that the thread keeps its last policy.
17
+ - An unanswered `session/set_mode` leaves the native mode unknown, not unchanged: `AcpPeer` invalidates the turn, goes offline, stops its process group and refuses further changes until restarted. Never report the old or the requested mode after a timeout.
18
+ - A mode grant in `piPermit` or the local worker's `permit` (`src/hub/daemon.ts`) answers as the prompt path does: allowed only while that owner is still the attached one and the call was not aborted. `ask-when-needed` covers `PI_EDIT_TOOLS` on a `grantablePath` only (path rule: `local-worker.md`).
11
19
  - Every agent the hub spawns (the Codex app-server, ACP agents such as Kimi, Pi) runs in its own process group and is stopped as one (`stopOwnedProcess(proc, { group: true })`, `trackGroup` after spawn): `codex` is a node launcher, and mid-turn its native app-server does not exit on SIGTERM within the grace period, so a SIGKILL to the launcher alone left the app-server running under init and holding the daemon alive (#113, #115). A new adapter that spawns an agent does the same.
12
20
  - To stop a process tree, freeze it (SIGSTOP) before reading what it contains, then SIGKILL, and call it done only when the table shows none of it (with no table at all, the stop can only ask the group itself, which cannot see groups below it). A process a read after the freeze shows for the first time is frozen and read again before anything is killed. A snapshot taken while the tree runs misses what it starts next: three review rounds of #113 found such a gap (groups of its own, an unreadable first read, a child started during the grace period).
13
21
  - Work that must survive `ahub kill` (claude-mem `session-end`) is awaited in `stop()`; fire-and-forget dies with the process.
@@ -7,9 +7,13 @@ Scope: delivery, digests and condensation, reply addressing, priority and limits
7
7
  - A failed digest is retried one envelope at a time, so a poison envelope cannot take its neighbours down with it.
8
8
  - An `important` envelope being steered is not in the queue while the steer is in flight; queue it first and an idle transition delivers it twice.
9
9
  - `replyParent()` decides what a reply answers (highest hop, never the `hub` preface). Use it for deliveries and steers alike, or the hop cap can be reset.
10
- - What a peer is handed (`out`) and what it stands for (`originals`) differ once a delivery is condensed: the bus keeps both, registers `out` so `reply_to` resolves, puts the originals back on any failure (a thrown `deliver` or a later `onFailed`), and counts no attempt when the peer merely got busy while the delivery was prepared. A delivery with an `important` envelope is never condensed.
11
- - A condensed digest is sent by `digest`, not `hub`: `replyParent()` skips `hub` items, and a reply to a digest must keep the highest hop of what it replaced. On a failed delivery the bus puts back the originals, never the digest.
10
+ - What a peer is handed (`out`) and what it stands for (`originals`) differ once a delivery is condensed: the bus keeps both, registers `out` so `reply_to` resolves, puts the originals back on a `failed_safe` receipt and holds any other failure (a thrown `deliver`, a later `onFailed`, a `needs_review` receipt) as `needs_review` with its originals, for `ahub queue resolve`, and counts no attempt when the peer merely got busy while the delivery was prepared. A delivery with an `important` envelope is never condensed.
11
+ - A condensed digest is sent by `digest`, not `hub`: `replyParent()` skips only the hub's `presence` item, and a reply to a digest must keep the highest hop of what it replaced. On a `failed_safe` delivery or a `retry` resolution the bus puts back the originals, never the digest.
12
12
  - Limits admit what is sent: the envelope `newEnvelope` builds (a reply inherits its parent's sender, `digest` resolves to the originals, `capPriority` applies), never the raw `to` and priority. Admit first and build later, and implicit replies all count as broadcasts.
13
13
  - A reply is addressed, never broadcast. An adapter that answers a delivery passes `to: replyAudience(envs)`; anything else with an `inReplyTo` inherits that envelope's sender. Leaving `to` empty fans the message out to every peer.
14
14
  - `digest` is not a peer: addressing a reply at the envelopes the peer was handed sends it nowhere once a delivery was condensed. The bus resolves `digest` back through `lastDelivery.originals`; anything else that reads a reply's `to` has to do the same.
15
+ - A pull-only peer (a Claude session without channel pushes, #205) is never drained; `Bus.pull` hands it what one push would take. Drain and pull share `held()` (recovery, an uncertain delivery, any pause) and `remember()` (so `reply_to` resolves), and a drain rechecks `deliverable()` after condensing because such a session can take the peer meanwhile. A pull is journaled `completed` in the same transaction as the bus snapshot, never `accepted`; a failed one puts back only that peer's queue and preface and never reloads the snapshot.
16
+ - `resolveDelivery` mutates queues in place; a refused or failed resolution puts back only that peer's queue and preface and keeps every pause. Never reload a snapshot on a live bus.
17
+ - `restore` takes queued work from the journal, never from the committed snapshot once the journal has state (an empty journal, revision 0, is seeded from the commit): from the commit only an empty queue key is carried over, for an offline peer whose key no delivery ever persisted (#234). Never resurrect a non-empty queue from it.
18
+ - The journal's owner check (`rejectLiveOwner` in `delivery-journal.ts`) judges the manifest pid with `processLiveness` and its `pidSignature`, never a bare probe (rule: `daemon.md`).
15
19
  - A hub-native peer (Pi, the local worker) does not get to call its own message `important`: `capPriority` caps it unless the delivery it answers held an `important` envelope addressed to it. Check the delivery, not `replyParent`, which ties on hop and takes the later item. Do not bypass it by setting `priority` in the adapter.
@@ -2,13 +2,20 @@
2
2
 
3
3
  Scope: the control WS and its protocol, state files, the Claude channel's reconnect, `ahub setup`, git snapshots, upgrade and recovery. Read before editing `src/hub/daemon.ts`, `src/hub/control-client.ts`, `src/adapters/claude-channel.ts`, `src/hub/restart.ts`, `src/hub/recovery-store.ts`, `src/hub/snapshots.ts`, `src/hub/manager.ts`, `src/hub/lifecycle.ts`, `src/hub/crash.ts`, `src/cli/setup.ts`, `src/cli/upgrade*.ts`, `src/cli/terminal-recovery.ts`, `src/cli/recovery-package.ts` or `src/cli/facts-hook.ts`, or before adding a native peer.
4
4
 
5
- - The plugin bundle is installed apart from the daemon. Any change to a control WS message shape bumps `PROTOCOL` in `control-client.ts`.
5
+ - The plugin bundle is installed apart from the daemon. Breaking a control WS message contract bumps `PROTOCOL` in `control-client.ts`; a new request answered as unknown or an optional field preserves it, as does additive optional public task metadata in `ui_snapshot` that older clients ignore (such as `deps` in #246).
6
6
  - `ahub setup` reads Claude Code's state from `--json` listings and takes one step at a time, re-reading after each; never match paths or names by substring.
7
7
  - A copy of the git index keeps the original's mtime (`snapshot()`): git's racy-entry check compares entries with the index file's time in whole seconds, and a fresh copy makes a same-size edit look clean.
8
8
  - State files that clients read (`status.json`, `control-token`) are written after the port is bound, and `status.json` via temp file + rename.
9
9
  - The channel's reconnect loop stops on closes a retry cannot fix (`TERMINAL_CLOSES` in `claude-channel.ts`); a new daemon close code that means "do not come back" belongs there, or two clients fight over it forever.
10
- - Close 4000 is the one close that ends on its own, so it is not in `TERMINAL_CLOSES`: the session stands by and reconnects only once `status.json` reports the peer offline and not `claiming`. Reconnecting blind would evict whoever holds the id now, and the two would trade it forever. `claim()` happens at hello and `attach()` only after the preface, so the peer reads as offline in between: the daemon writes the status file at the claim and the flag covers that window.
10
+ - Close 4000 is the one close that ends on its own, so it is not in `TERMINAL_CLOSES`: the session stands by and reconnects only once `status.json` reports the peer offline and not `claiming`. One exception (#205, `peerHeld` in `claude-channel.ts`): a session with channel pushes also reconnects when the holder is reported `toolsOnly` and not `claiming`, and takes the peer; the daemon refuses the reverse with 4000. Reconnecting blind would evict whoever holds the id now, and the two would trade it forever. `claim()` happens at hello and `attach()` only after the preface, so the peer reads as offline in between: the daemon writes the status file at the claim and the flag covers that window.
11
11
  - Adding a native peer requires testing the command emitted by the actual recovery driver, not only its inspection or launch helpers. Never let a generic non-Codex branch treat a new peer as Claude.
12
12
  - Cross-version recovery must use the authenticated source protocol for prepare/commit/abort and the target protocol after startup. Prove the transition against a real prior release before claiming compatibility.
13
13
  - A zero-turn Claude session has no transcript, so it can never be resumed: every identity gate that compares session ids (restore, coordinator verify, daemon readiness) must tolerate a fresh session while the transcript is absent and stay strict the moment one exists. The commit snapshot records `sessionPersisted` for the restored daemon, and snapshots too old to have it are re-derived from disk. Never treat "session id changed" as proof of a lost conversation without checking the transcript first.
14
- - The terminal-recovery launcher signature is the shared pinned `processSignature` (`src/pi/process-signature.ts`): a record written by one CLI invocation must compare equal when a later invocation re-reads the live launcher under another TZ/LC_ALL (#177). In-process `process.env` mutation does not reach Bun.spawnSync's default child environment, so cross-environment regressions are tested with real subprocesses.
14
+ - The terminal-recovery launcher signature is the shared pinned `processSignature` (`src/pi/process-signature.ts`): a record written by one CLI invocation must compare equal when a later invocation re-reads the live launcher under another TZ/LC_ALL (#177). In-process `process.env` mutation does not reach Bun.spawnSync's default child environment, so cross-environment regressions are tested with real subprocesses. A row written by 0.12.12 or older carries the unpinned signature, which `launcherState` also compares (#228). A launch-record row that fails validation is unknown for the peer it may belong to and is written back unchanged, never filtered out (#228).
15
+ - Judge every recorded owner pid (hub manifest, registry claim, recovery runner, manager owner, launcher record) with `processLiveness` in `src/pi/process-signature.ts`, never a bare `process.kill(pid, 0)`: after a reboot a recorded pid can belong to another process, even one we may not signal, and the bare probe wedged recovery and `ahub up` (#226). A writer records `processSignature(process.pid)` beside its pid; a registry claim stores it as `<instance id> <signature>` because an older daemon's claim rewrites the pid but not that column. Records without a signature keep the bare-pid answer; unknown is never gone.
16
+ - A restored daemon accepts a changed native session id (Kimi and local aside: they restart as fresh workers) in two ways only: the #64 rule for a Claude session that never persisted a transcript, and the operation-fenced `recovery-waivers.json`, which the coordinator writes wherever it accepts a session other than the planned one: a reconnect-only Claude (#206), an operator-chosen fresh session, the plan's `freshStart` of a Codex thread the hub saw start with no turn, or a zero-turn Claude it accepted (#215). Never waive on any other evidence. Re-preparation after an expired lease keeps every effect receipt, and `restored:<peer>` = `failed` never counts as restored. Judge a restoration launcher by its `terminal-recovery.json` record and process signature (and a `terminal_handle_stale` answer), never by the terminal's exit; an unreadable record is unknown, never "none".
17
+ - A target that stopped after it started is restarted only through `recordRestart` (`src/cli/upgrade.ts`), recorded before the new one starts and at most once per project per resume: each done `restored:<peer>` becomes `retired:<peer>`, whose terminal is closed (`closedRetired:<peer>`) or found absent before that peer is relaunched, and is never revalidated against the new instance. Launcher evidence is read for every instance in `restarts`, and a session attached without an id is unknown, never gone (#225).
18
+ - An operation receipt stays `schema: 1`. Only `abandoned()` writes 2, on an operation with a recorded `disposition` whose coordinator predates #215: in the same write as the disposition, or, for a disposition 0.12.20 recorded at 1, by the next command that holds the runner claim. `validateReceipt` accepts 2 only with a disposition (#227). Never write 2 on an operation that can still be resumed.
19
+ - Three checks read another release's source text for an exact line: `coordinatorCurrent` (`export async function disposeRecovery` in `src/cli/upgrade.ts`), `targetReadsWaivers` (`export function readRecoveryWaivers` in `src/hub/restart.ts`) and `coordinatorRestarts` (`export function recordRestart` in `src/cli/upgrade.ts`). Renaming, moving or un-exporting one makes this release read as an older one: keep those lines until a capability list replaces the checks.
20
+ - Recovery judges the registry claim as it is now (`inspectRecovery` re-reads `registeredProjects()`), never the plan's frozen copy; a registry that cannot be read is not inspected, never stopped (#226).
21
+ - The managed Claude hook (`src/cli/facts-hook.ts`) may only add `permissionDecision: "allow"` on `PreToolUse`, and for `ask-when-needed` only what `projectFileTool` accepts: a hub that is down, slow or refuses decides nothing. The daemon puts `permission` in a `facts` reply only for the hook of this instance's recorded `ahub claude` launch (the same `launchId`, not `--unattended`), and a permission-only hook (`hookPurpose`) never drives busy state (#242).
@@ -1,6 +1,6 @@
1
1
  # Local worker and sandbox
2
2
 
3
- Scope: the hub-native local worker, its tools, path guard, denylist and seatbelt sandbox. Read before editing `src/adapters/local-worker.ts`, `src/local/`, `src/memory/capture.ts` or `src/hub/facts.ts` (both read the denylist).
3
+ Scope: the hub-native local worker, its tools, path guard, denylist and seatbelt sandbox. Read before editing `src/adapters/local-worker.ts`, `src/local/`, `src/memory/capture.ts` or `src/hub/facts.ts` (both read the denylist), or the grant filter in `src/hub/permission-mode.ts`.
4
4
 
5
5
  - Nothing the local worker executes may run outside `sandboxedExec`; a new tool that spawns a process goes through it, and a tool that touches a path goes through `guardPath`.
6
6
  - The sandbox denies home reads by default (toolchain dirs, the project and its real git dir excepted) and all network, loopback included: claude-mem and the Codex app-server listen on loopback without auth. Tests that bind a local port therefore fail when the worker runs them, also with `local.bash_network: true` (the egress proxy keeps loopback closed, and the proxy variables send a test's plain-HTTP `fetch` to the proxy, which refuses it); that is the intended trade-off, and `"direct"` is the switch until 0.13.0 removes it.
@@ -10,3 +10,5 @@ Scope: the hub-native local worker, its tools, path guard, denylist and seatbelt
10
10
  - git arguments never pass through `guardPath`; `gitArgsProblem` refuses absolute paths, `..`, `--no-index` and denylisted `rev:path` forms.
11
11
  - A worker turn builds its messages in a local array and joins the history only as a whole. Never push to `history` mid-turn: one tool call without its result poisons every later request.
12
12
  - After a tool with side effects ran, a failed turn is reported, never redelivered.
13
+ - Hub tool calls (`hub_send`, `TASK_TOOL_NAMES`, `CONDUCTOR_TOOL_NAMES`) are never passed to `capture.observe`; `Tasks` alone decides what hub tool text reaches memory (#230).
14
+ - A tool that asks for approval passes its name to `ctx.permit`, and `write`/`edit` also the `guardPath` result, never the model's path string: `ask-when-needed` grants on that real path through `grantablePath` (`src/hub/permission-mode.ts`). `bash` and mutating `git` pass no path. After the awaited approval `write`/`edit` run `guardPath` again and refuse a target that changed (#242).
@@ -4,7 +4,10 @@ Scope: the model relay and its journal, the hub's own inference and its slots, O
4
4
 
5
5
  - The model relay journals per-request identity (`RelayRequestRecord`): a request's served model comes only from its own gateway header, its own generation SSE event (#137 heartbeat classification), or the locally validated MLX configuration. A backend's mutable last-served label is never a request's evidence, HTTP 200 plus the requested alias identifies nothing, and a request cancelled before identification stays explicitly unidentified (`identified: false`, `outcome: "cancelled"`). Journal records carry no prompts, tools, keys or Access headers.
6
6
  - Inference is optional and fail-open: it returns its input or `undefined` on any failure and backs off, so a delivery never waits on a dead model twice. Its output is only ever capped text or a value checked against a closed list.
7
+ - The PII screen (`screenPii`, #198) is the exception: it fails closed. Every failure (timeout, no or off-campus model, an answer outside `clear` / `pii <category>`, text over `SCREEN_MAX_BYTES` UTF-8 bytes, since a token is at least a byte) is `unknown`, which `Tasks` handles as PII. It never goes through the sidecar or the inference backoff, asks the gateway only after `onCampus()` and with `onCampusOnly`, takes the on-device generation slot under its own deadline (`acquire(abort.signal, 0)` first, one look and `MlxBusyError` when all are taken: a taken slot sends it to the campus gateway, and only off campus does it wait; on campus a failing or still-loading device hands over to the gateway after 60% of the deadline), sends temperature 0 and `reasoning_effort: "none"` on both paths so a reasoning model cannot spend its 32 tokens thinking, and never puts the text in a log line, an error or an event.
7
8
  - Secrets stay inside `OmniRoute`: never put the key or Access values in a log line, an error message, a return value or the generated Switchyard file (the key goes by env var name).
8
9
  - Switchyard's docs drift from the released binary. Any change to `switchyardToml` is checked with the real `switchyard-server --dry-run`, not only the stand-in in `test/fakes/`.
9
10
  - Shared inference slots must recover after a hub process dies, without evicting a live owner. Cancellation has to reach slot acquisition from the relay caller, not just exist in the helper signature.
11
+ - Stay/switch (#197): `hub/auto` and local stage routes apply `stay_switch` only through `stayOrSwitch` (`src/models/route/stage.ts`), never at a call site; `shadow` must not change a routing decision (the recorded-decision golden test in `test/route-relay.test.ts`). No route summarizes, trims or rewrites the forwarded conversation: a tier that cannot fit is filtered out. A PII route's planner never sees the conversation size (no prefill bound) and its event omits `prefillTokens`.
12
+ - Load and cooldowns (#199) only reorder a `hub/auto` request's candidates (the route's own backend, then its fallback, as on main), in one ordering step in the relay's fetch handler; a fixed alias is never reordered. Never remove a candidate. A candidate goes first only while it is neither cooling nor `failing` (any failure, an error status or a failed stream included, for 30 s or until a success; separate from the cooldown streak). Load and pin moves are opt-in (`efficientWaitMs` absent: none) until #199 AC5 measures them. Their attempt is bounded only up to its response headers (`MOVE_FIRST_BYTE_MS`, after `omni.base()`); a cut there marks `failing` without counting toward a cooldown, and a stream that fails after the headers cannot fall back, so do not claim a move never fails a request MLX would have served. None happens while the request runs under an execution budget (`underBudget`, a read that admits nothing; a throw counts as a budget). The selector only names `prefer`; it never picks a moved backend itself. They never consider PII because the relay serves only Pi, which refuses PII work before any model call (`selectModel` in `daemon.ts`); a relay caller that can carry PII must not get a reorder toward DGX without a positive `onCampus()`. Count a cooldown failure only for a transport or startup failure outside a cooldown that was neither cancelled, refused admission, a busy MLX slot (`MlxBusyError`: load, never failure), nor a deadline the relay cut itself (`journalEntry.cut`: `"budget"` for the execution budget's, `"move"` for a moved attempt's first byte, which only marks `failing`); any HTTP answer, whatever its status, ends the streak and the cooldown (`answered`).
10
13
  - MLX owner and generation identity read `ps lstart` with LC_ALL=C/TZ=UTC pinned (#177): unpinned, a record written under one environment reads as a foreign process under another, and the fail-closed paths would refuse the hub's own server.
@@ -1,9 +1,12 @@
1
1
  # Task board and hub tools
2
2
 
3
- Scope: the task flow, assignment, the state machine and the hub's MCP tools. Read before editing `src/hub/tasks.ts`, `src/hub/board.ts`, `src/hub/routing.ts`, `src/hub/task-sweep.ts`, `src/hub/conductor.ts`, `src/hub/supervision.ts` or `src/hub/hub-tools.ts`.
3
+ Scope: the task flow, assignment, the state machine and the hub's MCP tools. Read before editing `src/hub/tasks.ts`, `src/hub/board.ts`, `src/hub/routing.ts`, `src/hub/task-sweep.ts`, `src/hub/conductor.ts`, `src/hub/supervision.ts`, `src/hub/tool-refusal.ts` or `src/hub/hub-tools.ts`.
4
4
 
5
5
  - Tool callers are models: MCP `inputSchema` is not enforced on the way in. Normalize at the boundary (`cleanRefs`) before anything reaches the board, and never throw after a board write.
6
- - `assign()` never defaults to the task's current owner, or a decline can only come back to the decliner.
6
+ - `assign()` never defaults to the task's current owner, or a decline can only come back to the decliner. A reservation (#207) is the same risk: the reserved owner goes first only through `blocked()`, and a decline or an escalation for reason `rejections` or `manual` records the owner it left in the history entry's `from`, which `excluded()` reads, so no later reroute hands the task back through its reservation.
7
7
  - The state machine allows `in_progress -> approved` only for classes without a reviewer; `review()` checks `in_review` itself.
8
- - Conductor authority requires the explicit unique role before capability checks. Assignments preserve the acting peer. A peer releases only its own conductor hold; operator release and manual, quota and recovery holds remain separate paths.
8
+ - Conductor authority requires the explicit unique role before capability checks. The only exceptions run before that check in `Conductor.execute` and stay task-scoped: a task's current owner and reviewer read it with `hub_task_show` (#208), and its proposer redirects it with `hub_task_assign` while nobody ever accepted it and no person moved it (#207). Removing them strands reviewers that missed a review request. Assignments preserve the acting peer. A peer releases only its own conductor hold; operator release and manual, quota and recovery holds remain separate paths.
9
+ - The PII screen (#198) runs in `propose` before anything else reads the task (no board row, envelope, triage or brief before its verdict), and `unknown` is PII. A call that screens free text (`done`, `review`, `remember`) screens only after its own checks pass, and `done` and `review` run them again after the awaited screen, because the task can change meanwhile. A history entry marked `withheld` stays withheld in public views, completed notices and review requests, whatever `nameable()` says; `ahub ask` shows it only on campus, like a note that matches a PII pattern.
10
+ - `taskOp` (`src/hub/daemon.ts`) lists full PII rows only when `onPrem` holds: the in-process local worker inside a PII turn (#230). Every other caller, the local worker's ordinary turns included, gets stubs.
11
+ - A guard in `tasks.ts` or `conductor.ts` passes `preEffect` (throws `PreEffectToolRefusal`, `src/hub/tool-refusal.ts`) only before the operation's first board or hold write; a throw after a write, a readback failure or a wrapped error stays a plain `Error` (#254). The Pi side of the rule is in `adapters.md`.
9
12
  - Supervision reasons use structured enums, never history notes, check output or approval titles. Round signatures survive restart; later summaries count only newly joined tasks.
@@ -4,6 +4,7 @@ Scope: tests, fakes and the test gate. Read before adding or changing a test or
4
4
 
5
5
  - Tests that are not about batching build the bus with `batchMs: 0`; with the default 15 s window a lone status envelope looks like a lost message.
6
6
  - In Bun 1.3.14 a test timeout that fires while `Bun.spawnSync` runs can start the next test inside spawnSync's own event loop, where another spawnSync then spins at full CPU for good (trigger not pinned down; see README). Bun 1.4.2 still runs the next test inside the outer spawnSync's event loop. `scripts/check.sh` runs tests with a 20 s timeout and under `scripts/hang-watch.sh`; a test that spawns and reads the process table gets a timeout of its own.
7
- - A child process gets a scrubbed environment, so test knobs for fakes travel in a wrapper script, not in `process.env`.
8
- - Bind agent identity markers explicitly in native/CLI fixture children. The full gate starts the test runner without inherited agent markers for human CLI fixtures; never weaken production marker detection to make them pass.
7
+ - The Switchyard sidecar, sandboxed commands and the MLX server get a fixed environment (`PATH`, `HOME` and their own keys), so test knobs for their fakes travel in a wrapper script or argv, not in `process.env`; native peer children inherit the hub's environment minus the agent markers (`peerChildEnv`).
8
+ - Bind agent identity markers explicitly in native/CLI fixture children. The full gate starts the test runner without inherited agent markers and without an `ahub claude` session's hub variables (`AGENTHUB_PROJECT_DIR`, `AGENTHUB_STATE_DIR`, `AGENTHUB_INSTANCE_ID`, `AGENTHUB_LAUNCH_ID`); a new variable a native session exports and a fixture child reads joins that `env -u` list in `scripts/check.sh`. Never weaken production marker detection to make them pass.
9
+ - A free-port probe binds `hostname: "127.0.0.1"`, matching the loopback server that takes the port (#232).
9
10
  - Run `bun scripts/seeded-check.ts` before reporting the seeded-guard gate complete. Keep the six `scripts/seeds.json` cases sequential; require the same named test green before its seeded assertion fails. Seed rot, survivors, setup/compiler failures, timeout/watchdog termination and process leaks fail the gate. Never run the full seed runner from a unit test or mutate the source checkout; inspect preserved failed fixtures before removing them. CI runs this in the required Linux `seeded guards` job after the ordinary check, separately from `scripts/check.sh`.
package/docs/bench.md ADDED
@@ -0,0 +1,143 @@
1
+ # Benchmarks
2
+
3
+ `ahub bench` runs a fixed set of tasks against the agents attached to a hub and compares configurations (#251). A run
4
+ records each attempt's outcome and the #247 measures of the task (tokens, time, review rounds, rework), never the
5
+ suite's text, the verify command's output or the agents' words. CooperBench (`docs/cooperbench.md`) stays the
6
+ rigorous native study of this repository; `ahub bench` is for any project.
7
+
8
+ ## A bench project
9
+
10
+ Each attempt resets the work tree, so a run refuses every project but one kept for benchmarks: a git repository whose
11
+ `.agenthub/config.json` has
12
+
13
+ ```json
14
+ { "bench": { "enabled": true } }
15
+ ```
16
+
17
+ and that passes these checks before anything changes:
18
+
19
+ - the project is the root of its repository (a reset acts on the whole repository);
20
+ - nothing under `.agenthub/` is tracked, in the tree or in any ref the suite names (a checkout would replace the hub's own
21
+ files): `git rm -r --cached .agenthub`, ignore it, commit;
22
+ - every ref the suite names is a commit (it is pinned to that commit for the whole run, so a branch an agent moves
23
+ later changes nothing);
24
+ - the tree is clean (`.agenthub/` aside).
25
+
26
+ The reset also deletes ignored files (`git clean -x`): keep the suite file, hidden verify scripts and anything else the
27
+ run needs outside the tree, and let `setup` recreate what an attempt needs (dependencies, local settings).
28
+
29
+ Between attempts the runner checks out the task's pinned commit (`git checkout --force --detach <commit>`), runs
30
+ `git clean -ffdx -e /.agenthub` and confirms the tree is clean; a step that fails says why. A run that finishes returns
31
+ to the branch (or commit) it started on; a run that stops leaves the tree as its last attempt left it. Either way the
32
+ runner says so if a branch or tag the suite names moved or was deleted during the run (a ref given as a hash, relative
33
+ to another such as `main~1`, or by a name that is both a branch and a tag is not tracked).
34
+
35
+ ## Suite file
36
+
37
+ JSON, kept outside the bench project's tree (a relative path is read from the directory you run `ahub` in). Unknown
38
+ fields, a missing `verify` and duplicate ids are refused with the task and the field named.
39
+
40
+ ```json
41
+ {
42
+ "name": "parser-fixes",
43
+ "tasks": [
44
+ { "id": "csv-quote", "title": "Fix quoted commas in src/csv.ts", "detail": "parse('\"a,b\",c') must give two fields",
45
+ "class": "implement", "owner": "codex", "ref": "3f2c1aa", "setup": "bun install --frozen-lockfile",
46
+ "verify": "bun test test/csv.test.ts", "timeout_s": 1800 }
47
+ ]
48
+ }
49
+ ```
50
+
51
+ | Field | Meaning |
52
+ | --- | --- |
53
+ | `name` | the suite's label, kept in the records |
54
+ | `id` | the task's label, kept in the records |
55
+ | `title`, `detail` | what the hub proposes (never recorded) |
56
+ | `class` | the task class; `implement` when absent, since a hub without a model cannot triage |
57
+ | `owner` | the peer to propose it to; absent lets routing choose |
58
+ | `ref` | the commit the attempt starts from |
59
+ | `setup` | a shell command before the task is proposed (bounded, 5 min) |
60
+ | `verify` | a shell command run in the tree the agents left; exit 0 is a pass (bounded, 10 min) |
61
+ | `timeout_s` | 10 to 86400 seconds from the start of the attempt to approval |
62
+
63
+ `setup` and `verify` run in their own process group: at the bound, on Ctrl-C and when the command exits, everything in
64
+ that group is stopped, so nothing keeps writing into the next attempt's tree (a process that leaves the group, as a
65
+ daemon does, is not followed).
66
+
67
+ `name`, `id` and the arm label are stored; keep them free of anything private.
68
+
69
+ ## Running
70
+
71
+ Start the peers of the configuration under test (`ahub claude`, `ahub codex`, `ahub pi`, their models, permission
72
+ modes and `routing.toml`), then:
73
+
74
+ ```bash
75
+ ahub bench run ../suites/suite.json --arm baseline --repeat 5
76
+ ahub bench run ../suites/suite.json --arm ripwire-on --repeat 5 --tasks b,a,c # --tasks also sets the order
77
+ ```
78
+
79
+ Only a person can run a suite (agent shells are refused). Each attempt resets the tree to `ref`, runs `setup`, proposes
80
+ the task as the console does, waits until it is approved or `timeout_s` passes, then runs `verify`. Outcomes:
81
+
82
+ - `pass`: approved and `verify` exited 0;
83
+ - `fail`: approved and `verify` exited non-zero or ran out of time (`verify timeout`);
84
+ - `timeout`: not approved in time. The run stops there, because an agent may still be working in the tree the next
85
+ attempt would reset; settle the open task on the board before the next run;
86
+ - `error`: the reset, `setup` or the proposal failed, the hub stopped, or a person interrupted the run (the reason is
87
+ one of a closed list).
88
+
89
+ Ctrl-C stops the run at its next check, also inside `setup` or `verify`: the attempt in progress is recorded as
90
+ `error` (`interrupted`), a task still open on the board is named, and the run reads `stopped`. An interrupt after
91
+ `verify` has finished keeps that attempt's outcome and drops its measures. A run whose runner died without its end
92
+ record reads `interrupted`.
93
+
94
+ ## Store
95
+
96
+ `~/.agenthub/bench/<run id>.jsonl` (directory 0700, files 0600), one file per run, shared by every bench project on the
97
+ machine. Schema `agent-hub.bench/v1`:
98
+
99
+ - `run` header: suite name and SHA-256 of the suite file, arm, fingerprint (the peers attached at the start, with the requested model and
100
+ permission mode where the hub's status reports them: today the model for Pi only; the SHA-256 of `routing.toml`; the
101
+ hub version), task order, repeats, the runner's process.
102
+ - `attempt`: task id, repeat, outcome, error reason, verify exit code, hub task id, start, end, duration, and, for an
103
+ approved task, its #247 measures (tokens, wall and active time, review rounds, changes requested, failed checks, first
104
+ pass, turns, files changed, models). They are read once the turns started since the task's proposal have ended (the
105
+ runner waits only while their peers are still busy or paused, at most 10 minutes), so the turn that approved the task is
106
+ counted; that wait is not part of the attempt's duration. A timeout or an error has none, nor has an attempt whose
107
+ wait was interrupted, ran out with the turn still open, or could not read the hub's status while it was open.
108
+ - `end`: end time, and `stopped` (`timeout`, `interrupted`, `error`) when the run did not finish.
109
+
110
+ ## Reading results
111
+
112
+ ```bash
113
+ ahub bench list # runs on this machine
114
+ ahub bench status # the run in progress
115
+ ahub bench report <run> [--json] # per task and overall
116
+ ahub bench compare baseline ripwire-on [--suite parser-fixes] [--json]
117
+ ahub bench export --format csv
118
+ ```
119
+
120
+ Measures: pass rate and median wall time (over attempts that did not end in `error`), first-pass rate, median tokens
121
+ and mean rework (over approved attempts). `compare` takes run ids or arm labels (an arm means all its runs that are
122
+ over, a run a timeout stopped included, since each attempt carries its own outcome), sets the first as the baseline and
123
+ gives each other arm's difference with a 95% bootstrap interval (1000 resamples, fixed seed, so the same records give
124
+ the same interval). Below 5 attempts counted for a measure in either arm, its difference is marked inconclusive. Runs of
125
+ different suites, or of different versions of one suite file, are not compared unless you pass `--mixed`; name runs by
126
+ id to compare within one version. Agents may
127
+ read all of this. CSV columns: run, suite, arm, task, repeat, outcome, error, verifyExit, hubTask, startedAt, endedAt,
128
+ ms, tokens, wallMs, activeMs, reviewRounds, changesRequested, checkFailed, firstPass, turns, filesChanged, models.
129
+
130
+ The dashboard's Benchmarks section shows the latest runs with their progress (the attempt in progress, elapsed time and
131
+ attempts left while one runs) and, per suite with two or more arms, a bar per arm and measure with its number as text.
132
+
133
+ ## A fair comparison
134
+
135
+ - Change one thing per arm (a model, a permission mode, an add-on, a routing rule) and keep the suite, the refs and the
136
+ peers otherwise the same; the fingerprint records the routing file, the hub version and what the status reports of
137
+ each peer, so note the rest (models, permission modes, add-ons) in the arm label.
138
+ - Repeat: agents are not deterministic. Five attempts per task and arm is the floor for a comparison; more narrows the
139
+ interval.
140
+ - Randomize the order per run with `--tasks` (for example `--tasks "$(jq -r '.tasks[].id' ../suites/suite.json | shuf | paste -sd, -)"`)
141
+ and interleave the arms' runs, so quota, time of day and provider load do not line up with one arm.
142
+ - Keep `verify` independent of the agents' own tests where you can: a hidden test the agents never see measures the
143
+ outcome, not their opinion of it.
package/docs/events.md CHANGED
@@ -18,7 +18,8 @@ marked `private: true`, and PII tasks `pii: true`.
18
18
  | `quiet` | `id`, `from`, `peers`: an agent message held back from these members of a silent turn-free cohort; its other recipients got it (issue #107) |
19
19
  | `fact` | `peer`, `id` (the offer), `files` (files whose diff it carried), `plans`, `unknown` (files whose change it showed with attribution unknown), `named` (files under a named directory it named without a diff, those counted as "N more" or held back by the PII filter included), `bytes` (the injected text), `via` (`hook` for Claude, `steer` for Codex, `done` with an integration request), `ms` (the hub's time to build it), `hookMs` (the hook process's own start-up and connect time), `accepted` (whether app-server took the steer), `unanswered` (app-server did not answer it within 10 s: it may have gone in), `rttMs` (an accepted steer: from sending it to app-server's answer), `probe` (a context check), `coverage` (it named files earlier changes are not covered for): one fact offer (issue #108) |
20
20
  | `fact_ack` | `peer`, `id`, `via` (`hook` and `steer`: a readback found the offer in the native session; `done`: the next `hub_task_done`), `ms` (from the offer): an acknowledged offer, the only thing that moves a peer's view |
21
- | `route` | `peer`, `route`, `tier`, `source` (`override`, `dimensions`, `hold`, `classifier`, `default`), `score`, `ms`. Local decisions also carry `decision`, `turn`, optional numeric `task`, `pii`, and decision-time `severity`, `spinning`, `exploring`, `production`; no prompt text |
21
+ | `route` | `peer`, `route`, `tier`, `source` (`override`, `dimensions`, `hold`, `classifier`, `default`; for `hub/auto` also `load`: a busy MLX slot moved it to `dgx/fast` (opt-in, `[pi] efficient_wait_ms`), `cooldown`: a cooling MLX went to its fallback first, and `pin`: an enforced tool loop went to the backend it is pinned to, issue #199), `score`, `ms`. Local decisions also carry `decision`, `turn`, optional numeric `task`, `pii`, and decision-time `severity`, `spinning`, `exploring`, `production`. A `hub/auto` decision for a request without a session key carries `stateless: true` (`ahub report` then counts its sessions as unknown). `hub/auto` and hub stage decisions carry `turnType` (`tool_result`, `user`, `compaction`: what the call answers) and `prefillTokens` (estimated input a backend would prefill; absent on a PII route), and, unless `stay_switch` is `off`, `staySwitch` (`shadow`, `enforce`), `plan` (`stay`, `switch`) and `reason` (`new_pin`, `compaction`, `context_fit`, `same_tier`, `override`, `tool_loop`, `prefill_bound`, `user_turn`): the planner's verdict next to the tier taken (issue #197). No prompt text |
22
+ | `cooldown` | `peer`, `alias`, `event` (`start`, `end`), `failures` (consecutive transport or startup failures), `ms` (on `start`, how long the alias cools down): a relay backend alias after repeated failures (issue #199). An `end` is recorded at an HTTP answer (any status) or when the hub next checks the alias after it expired |
22
23
  | `route_outcome` | `peer`, `decision`, `turnId`, `turn` (`completed` or `failed`), optional `task`, `pii`, `latched`, optional `next` (`severity`, `tests`: `pass`/`fail`/`none`, `repeat`) and `advisor` (`approve`/`redo`/`failed`). Exactly once per local decision at turn settlement, including failure/cancellation |
23
24
  | `advisor` | `peer`, `route`, `trigger`, `verdict` (`approve`, `redo`, `failed`), `discardedChars`: an advisor check result; no transcript or feedback text |
24
25
  | `progress` | `peer`, `task` (task id), `severity`, `spinning`, `exploring`, `production`: normalized tool activity dimensions for an open task; coverage differs by peer and no commands or task text are recorded |
@@ -29,12 +30,15 @@ marked `private: true`, and PII tasks `pii: true`.
29
30
  | `cohort` | `id`, `event` (`formed`, `joined`, `lifted`), `silent`, `tasks`, `owners`: owners of overlapping tasks formed a cohort, it changed membership, or it stopped being silent (issue #107). Recorded in every regime; only a turn-free project's cohorts can be silent |
30
31
  | `split` | `task`, `where` (`routing`: routing chose the first owner of a task overlapping another owner's task not started yet, not an escalation, relay or reassignment, the record calibration reads; `cohort`: an overlap formed or changed a cohort), `verdict` (`split`, `single`, `unknown`), `single` (the peer that would finish both units alone soonest), `splitS`, `singleS`, `reason` (for `unknown`), `trace` (the inputs and steps: peer names, their profiles of versions and coordination, and numbers only): a shadow split prediction; it never changes the assignment (issue #109) |
31
32
  | `state` | `peer`, `state` |
33
+ | `native_thread` | `peer` (`codex`), `thread` (its native thread id), `fresh` (`true` when the TUI started the thread, `false` when it resumed one): each thread the Codex adapter adopts, before any turn on it. The upgrade planner reads it: a thread with no rollout that the hub saw start, with no `turn_start` since, is planned as a fresh start (issue #215) |
32
34
  | `turn_start` | `peer`, `turn` (`<peer>#<hub run>.<n>`, unique across restarts). A turn follows the adapter: pausing a busy peer does not end it |
33
35
  | `turn_end` | `peer`, `turn`, `ms`, `tokens` (when the adapter reported any during the turn), `files` and `snapshotMs` (when snapshots are on: how many files the turn changed, and the time both snapshots took); optional numeric `task`, `attribution` (`delivery`, `single_open`, `unattributed`) frozen at turn start, `pii: true` for an attributed PII task |
34
36
  | `tokens` | `peer`, `n` (tokens added since the previous report), optional numeric `task`, `attribution` (`delivery`, `single_open`, `unattributed`), `pii: true` for an attributed PII task |
35
37
  | `usage` | `peer`, `source`, opaque `id`, optional `measuredAt` (provider/source time), requested/served model and provider labels, and any provider-reported input/output/cache/total counters; optional numeric `task`, `attribution` (`delivery`, `single_open`, `unattributed`), `pii: true` for an attributed PII task. Missing counters stay unknown. |
36
- | `task` | `id`, `event` (the board history event, e.g. `proposed`, `assigned`, `done`, `check failed`, `blocked`, `ready`), `by`, `state`, `owner`, `reviewer`, `class`, `pii` |
38
+ | `task` | `id`, `event` (the board history event, e.g. `proposed`, `assigned`, `done`, `check failed`, `blocked`, `ready`), `by`, `state`, `owner`, `reviewer`, `class`, `pii`, and `reason` when the board recorded a move reason (`manual`, `budget`, `offline`, `idle`, `rejections`, `declined`, `inference_failed`, `delivery_failed`) |
37
39
  | `overlap` | `task`, `owner`, `others` (`task`, `owner`, `paths`, and `symbols` when plans name the same symbol; a name that matches a PII pattern is left out, so either list can be empty), the structured twin of the console notice |
40
+ | `permission_mode` | `peer`, `from`, `to` (`ask`, `ask-when-needed`, `never-ask`): a person set a peer's permission mode (`from` equals `to` when it was already in force) or confirmed a never-ask default (issue #242) |
41
+ | `pii_screen` | `task` (absent for a budget hand-off or a note not about a task), `item` (`task`, `summary`, `review note`, `handoff`, `note`), `label` (`pii`, `clear`), `source` (`regex`: a pattern matched and no call was made; `screen`; `unknown`: no verdict, handled as PII), `category` (`name`, `student_id`, `phone`, `address`, `grade`, `health`, `other`), `miss` (why there was no verdict: `too long`, `off campus`, `timeout`, `unreadable`, `failed`), `ms` (absent for `regex`): one PII screen verdict while `signals.pii_screen = "local"` (issue #198); never the text |
38
42
  | `quota` | `peer`, `windows` (`id`, `used`, `resetsAt`), `hard`, `measuredAt` (when the reading was taken, if not when it arrived: Claude's numbers come through a file) |
39
43
  | `conflict` | `peer`, `task` (its task in progress, when it had one), `other` (the other owner's open task), `owner`, `paths` (names that match a PII pattern left out, so it can be empty), `concurrent` (another peer worked during the turn) |
40
44