@staix/agent-hub 0.12.21 → 0.12.22

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 (44) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +13 -6
  3. package/docs/agent-notes/adapters.md +6 -3
  4. package/docs/agent-notes/benchmarks.md +1 -1
  5. package/docs/agent-notes/bus.md +2 -1
  6. package/docs/agent-notes/daemon.md +4 -0
  7. package/docs/agent-notes/local-worker.md +2 -0
  8. package/docs/agent-notes/tasks.md +1 -1
  9. package/docs/agent-notes/tests.md +2 -0
  10. package/docs/events.md +13 -4
  11. package/docs/operations.md +253 -22
  12. package/docs/quickstart.md +6 -3
  13. package/docs/security.md +9 -6
  14. package/docs/smoke.md +61 -2
  15. package/docs/specs/2026-09-19-agent-hub-design.md +38 -5
  16. package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -8
  17. package/docs/verified.json +21 -15
  18. package/package.json +1 -1
  19. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  20. package/plugins/agent-hub/server.js +1 -1
  21. package/src/adapters/acp.ts +10 -4
  22. package/src/adapters/codex-appserver.ts +27 -22
  23. package/src/adapters/local-worker.ts +44 -10
  24. package/src/adapters/pi.ts +158 -19
  25. package/src/cli/help.ts +6 -5
  26. package/src/cli/identity.ts +2 -2
  27. package/src/cli/launch.ts +182 -7
  28. package/src/cli/main.ts +137 -18
  29. package/src/cli/recovery-package.ts +17 -0
  30. package/src/cli/upgrade-interactive.ts +297 -0
  31. package/src/cli/upgrade-runtime.ts +56 -2
  32. package/src/cli/upgrade.ts +27 -10
  33. package/src/hub/bus.ts +5 -3
  34. package/src/hub/daemon.ts +145 -60
  35. package/src/hub/events.ts +2 -1
  36. package/src/hub/peers.ts +2 -2
  37. package/src/hub/recovery-store.ts +38 -0
  38. package/src/hub/tasks.ts +32 -15
  39. package/src/local/deny.ts +48 -1
  40. package/src/local/sandbox.ts +6 -3
  41. package/src/local/tools.ts +25 -6
  42. package/src/pi/extension.ts +43 -20
  43. package/src/pi/tool-receipts.ts +17 -1
  44. package/src/ui/index.html +15 -11
package/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ 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.22
8
+
9
+ - Restore the captured Codex approval policy once on a resumed thread after detached ask, show unknown-policy guidance in the CLI, and record direct native Claude child identity so verified crash cleanup can retire its settings file (#270).
10
+ - `ahub upgrade` and `ahub restart` are interactive on a terminal when neither `--dry-run` nor `--yes` is given: a readable plan (each peer's fate and whether it runs as a TUI or headless, what is in progress, every blocker), a choice to end agents first (a TUI agent's terminal is closed; a headless agent is stopped by a hub that knows `peer_stop`, #278), one key to apply, the steps and readiness waits as they happen, and for an open operation the choices `status` lists under `next`, run on the spot: resume, cancel, a fresh session, end (stop-and-archive) and a reset with its dry run first. A runner that only waits for its source can be cancelled when its claim's process signature still matches. Enter opens the operation's menu during the progress view; Ctrl+C leaves and never stops the runner. `--to` defaults to the latest release for a terminal run and a dry run (`--yes` still needs it), and a target newer than the CLI is handed to that release's own coordinator. `ahub recovery status|resume|abort|dispose` take the operation that holds the lock when the id is left out, and bare `ahub recovery` on a terminal opens its screen. With a flag or through a pipe, output and exit codes are unchanged, except that the dry-run plan gains `source.recovery.waiting` while the hub reports something in progress (#272).
11
+ - Add human-only `ahub stop <peer>` and console `peer_stop` for hub-owned headless ACP, Pi and local runtimes; preserve the hub and other peers, hold interrupted deliveries for review, refuse native terminals and recovery-held mutations, and suppress automatic restart after a requested stop (#278).
12
+ - `review()` and `done()` no longer throw after the approval or done board write when a following publish or notify fails: every such call goes through the one `releaseNotice` guard, which turns a failed publish into a console notice, drops a failed notify and returns the board write's result (#243).
13
+ - Distinguish expired Pi/local approvals, end a turn after two unanswered requests through the budget abort path with Pi staying attached (an expiry counts only while no person answers, and an always-cache grant is not an answer), withdraw aborted requests before late answers, show private approval counts, run a person's managed Pi shell without another approval using its actual exit status, and never let a tool past the stop grace block Pi teardown (#253).
14
+ - The seatbelt profile refuses writes that plant `.git` or `.agenthub` through the code points HFS+ ignores, in the name and in `config`/`hooks`/`commondir` alike, at any depth, and refuses the `.git` name itself, closing the rename, symlink, gitfile and commondir routes to a planted hook or config; the cost is that `git init`, `git clone` and `git worktree add` no longer run inside the sandbox (add, commit, stash, checkout and gc still do), and neither do `git worktree remove`, `git worktree prune` of a stale record, `git clean -ffd` with a nested repository, or `rm -rf` of a directory that holds a `.git`. `.gitignore` and `.github/` stay writable, `docs/security.md` says the ignored-code-point fold is HFS+-only, and the dead `hasSegment` export is gone (#270).
15
+ - Control protocol stays 16: `peer_stop` is a new console-role request that an older hub answers as unknown, the `recovery` `inspect` reply lists more readiness blockers, and the `permission` reply can carry a `note`. Events schema stays 1. A running 0.12.21 hub is upgraded with the 0.12.22 coordinator; that live upgrade is still pending (`docs/smoke.md`).
16
+
7
17
  ## 0.12.21
8
18
 
9
19
  - 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).
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.21, control protocol 16. Durable delivery records distinguish queued
7
+ Status: 0.12.22, 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,14 +28,20 @@ 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.21 && ahub setup
31
+ bun add -g github:STAIxBWLB/agent-hub#v0.12.22 && ahub setup
32
32
  ```
33
33
 
34
34
  The installed commands remain `ahub` and `agent-hub`.
35
35
 
36
- When upgrading an existing project, run `ahub init` to refresh its managed agent
37
- instructions and `ahub setup` to refresh the Claude plugin. Restart the daemon
38
- and agent sessions so both load the update.
36
+ To upgrade while hubs are running, run `ahub upgrade` in a terminal. It takes
37
+ the latest release, shows the plan for every running project, applies it on
38
+ one key and follows it: the operation starts the new hub, installs the Claude
39
+ plugin and the global CLI, and restores or restarts the agent sessions as the
40
+ plan says. The [operations guide](docs/operations.md#upgrade-and-crash-recovery)
41
+ has the details, and the one-time start from an older CLI. With no hub running
42
+ there is nothing to carry over: install the new version as above and run
43
+ `ahub setup`. Afterwards run `ahub init` in a project to refresh its managed
44
+ agent instructions.
39
45
 
40
46
  - [Quickstart](docs/quickstart.md): install, first session, the local worker, the commands of a working day
41
47
  - [Operations guide](docs/operations.md): managed launchers, task/review flow, receipts, shutdown and recovery
@@ -100,7 +106,8 @@ only. `--backend dgx` or `--backend mlx` explicitly pins a Pi session's backend.
100
106
  Pi connects to an authenticated relay with model aliases `dgx/coding`,
101
107
  `dgx/fast`, and `mlx/fast`; upstream gateway credentials stay in the hub.
102
108
  Its managed tools use the existing path guards, shell sandbox and terminal
103
- approval flow. Keep `ahub console` open to approve write/edit/shell operations.
109
+ approval flow. Keep `ahub console` open to approve write/edit/shell operations:
110
+ a request nobody answers expires, and two in a row end Pi's turn.
104
111
  Tool-call receipts survive daemon restart; an interrupted operation with an
105
112
  unknown outcome must be reconciled before repeating it. Cancelling a Pi turn
106
113
  does not automatically hand its task to a cloud peer.
@@ -8,12 +8,12 @@ Scope: the Codex, ACP and Pi adapters and the Pi extension (turn `generation` an
8
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.
9
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
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.
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/requested-stop/recovery/startup/active-tool fences and never fall back fresh.
12
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.
13
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
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
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.
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. Keep the captured native policy and the restoration debt per thread (`nativeApprovalPolicies`, `overriddenApprovals`) across detach and `thread/start` or `thread/resume`; clear a debt only on a successful response to its restoring `turn/start` while the mode is `ask` (`settlePermissionRestore`), so a rejected turn keeps it. While a thread owes a restoration with a known baseline, in any mode, never replace that baseline: a `thread/resume` response and the TUI's own `turn/start` can both echo the sticky override, and the restoring turn sends the kept baseline whatever the TUI supplied. That protection is one turn: after the restoration succeeded, a policy the TUI sends is taken as the person's choice, captured and passed through, because the proxy cannot tell a repeated echo from one. A detached proxy's `clearPermissionMode` is never refused; if the native policy is unknown, return a CLI-visible note that the thread keeps its last policy. Attached refusal directs the person to close the TUI, then run `ahub permission codex ask`.
17
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
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`).
19
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.
@@ -23,6 +23,9 @@ Scope: the Codex, ACP and Pi adapters and the Pi extension (turn `generation` an
23
23
  - A watchdog-cancelled turn still reports later. Anything a turn does on completion must check it is still the current turn (`turn` generation in `acp.ts`, `activeTurns` in `codex-appserver.ts`). A rejected prompt or abnormal prompt end reports through the adapter's `onTurnFailure` (same shape in `acp.ts` and `pi.ts`), even when ACP streamed partial text; a stale cancelled turn never reports it.
24
24
  - Native owner teardown must handle a lost shutdown acknowledgement using verified process identity. Pending launches must be revocable, and active tools/streaming output must count as watchdog activity.
25
25
  - Pi owner identity is one shared contract, `processSignature` in `src/pi/process-signature.ts`; the startup claim check, the extension's claim, the TUI owner monitor, `recoveryReady` and the verified teardown all go through it. Its `ps` read pins LC_ALL=C and TZ=UTC (the `processTable` contract) because `lstart` follows the reader's timezone and locale, and a hub on UTC with a Pi child on system time otherwise hashes different strings for the same process and refuses the valid owner (#174). Strict pid + start + command identity stays; never accept PID-only or a fuzzy command match.
26
- - Return the shared `toolResultFailed` verdict as `/tool`'s `failed`; map only `failed === true` to Pi's `isError`. Preserve text and the older bridge's absent-flag behavior.
26
+ - Return the shared `toolResultFailed` verdict as `/tool`'s `failed`; map only `failed === true` to Pi's `isError`. Preserve text and the older bridge's absent-flag behavior. The one exception is a person's `!command` whose process exit was observed: its `failed` is `exitCode !== 0` and the reply carries `exitCode`, from `onProcessExit` through `packPiShellResult`, never parsed from the output (#253).
27
+ - Pi's bridge executes `/tool` only for the verified owner (`verifiedOwner()`: the claimed pid with its `processSignature`; a headless owner is pinned from the spawned process once `get_state` proved its session) with the current `sessionId` and turn `generation`, and admits `/budget` only for that owner and generation, checked again after the awaited admission. Refuse anything else before execution. `tool_abort` aborts that pending call, or fences its id when it arrives before the call (64 ids a turn, then every further call of that turn). A person's `!command` (`purpose: "idle_user_bash"`) is taken only in TUI mode from an idle owner on its one-use reservation, and it alone skips `piPermit`: never widen that to a model's bash call (#253).
28
+ - Pi and local approvals (#253): `permit` answers a `ToolApproval` (contract: `local-worker.md`) and `requestPermission` (`src/hub/daemon.ts`) tells the adapter's `ApprovalObserver` what settled it. An expiry counts toward the stop only as `source: "expired"` with `eligibleExpiry` not false (no person answered a request of that peer while it was pending); `person` resets the count, `automatic` (a mode grant, Pi's `always`) never does, and a new turn starts it over. The second one ends the turn, never the peer: local throws `ApprovalWaitStop`, Pi sends `abort_budget` with `cause: "approval"`, and both settle the delivery `completed` with the reason; a Pi whose extension did not announce `approvalTurnAbort` goes offline with the delivery held. `requestPermission` deletes a request from `permissions` before it runs an observer, resolves or reports, and an aborted signal closes it as `cancelled`: a late answer must find nothing to grant, and a failed event, notice or console push never strands the caller.
29
+ - `stop(reason?)` (`src/hub/peers.ts`): a reason marks a person's requested stop (`peer_stop`, rule: `daemon.md`). Fence before the first await (the adapter's stopping flag, or `offline`) so `deliver()` and buffered native callbacks do nothing more, hold the in-flight delivery as `needs_review` with that reason, and end `offline`. A requested stop never completes, escalates or reports the turn it cut (`AcpPeer` bumps `turn`; `PiPeer.fail` skips the failure message and `onTurnFailure` while `requestedStopReason` is set). `PiPeer.stop()` waits for aborted tools only up to `stopGraceMs`: a tool past it never blocks the teardown, and the stop throws `PiToolSettlementFailure` once the owner is down.
27
30
  - Spawn a native peer with `peerChildEnv(peer, source)` so its own `AGENTHUB_PEER_ID` replaces inherited agent markers. Sandbox tool environments carry their executing peer too. Preserve the native shell environment policy; Codex's `CODEX_THREAD_ID` supplies the fallback after filtering. Before changing marker detection or launcher environments, read `docs/verification/2026-10-09-agent-shell-t0.md` for pinned source and live-probe limits.
28
31
  - Emit Pi's ceiling event at the pre-effect rejection boundary (`src/pi/ceiling.ts`), before `agent_end`. Accept only a valid current-session/current-generation signal during a running turn; retain the first signal and attach it to that turn's failure. Never classify a ceiling from free text.
@@ -8,5 +8,5 @@ Scope: the CooperBench runners, their fixtures, teardown and recovery. Read befo
8
8
  - A latched active-window terminal failure carries the #175 bounded diagnosis: the terminal class comes only from the pinned Qwen build's exact loop-protection message contract (anything else stays `unknown`), the active-window tool trace is count-only and phase/generation-fenced like the latch (setup probes and teardown never count), tool categories come from the protocol kinds and the #138 canonical binding (`canonicalMcpToolName` in `src/adapters/acp.ts`), and the native guard's threshold, predicate and no-op signals are declared unavailable, never derived from event totals. The diagnosis explains the latched failure and never changes it: no retry, no continuation, no guard relaxation.
9
9
  - Classify Pi's ceiling only from the adapter's validated producer signal bound to the latched peer/active generation. Keep shared budgets and Qwen's guard distinct; diagnostics never change the frozen end cause.
10
10
  - Read terminal coverage only through bounded `terminal_coverage` metadata reads, rebinding the audit, ledger and manifest to the seal index. Require complete sealed state, all audit checks, plan/cell/count reconciliation and zero recorded live owners. Report recorded verification separately from fresh audit; missing or inconsistent evidence is unavailable.
11
- - Publish Pi benchmark descriptors from `benchPiToolProblem`; keep production schemas unchanged and omit solo peer messaging. Attest every bootstrap tool publication against declared native/schema/source pins before scored generation. Fail/defer missing, mixed, malformed, truncated or changed publications; retain only bounded names/counts/hashes. Declare a new study condition before any changed collection; never rewrite historical artifacts or select retries by score.
11
+ - Publish Pi benchmark descriptors from `benchPiToolProblem`; keep production schemas unchanged and omit solo peer messaging. Attest every bootstrap tool publication against declared native/schema/source pins before scored generation. Fail/defer missing, mixed, malformed, truncated or changed publications; retain only bounded names/counts/hashes. Declare a new study condition before any changed collection; never rewrite historical artifacts or select retries by score. The driver's Qwen profile and Pi tool sandbox are `profile()` from `src/local/sandbox.ts`: a change to that profile (#270's refusal of the `.git` name is one) is such a changed condition.
12
12
  - The benchmark runner's Claude trust entry: a write still `pending` in the runner's own process never landed, so it is `not_written` on every path (contained or not, its temp file removed or left), and neither the runner nor the recovery touches an entry then. Only a dead runner's `pending` leaves that question to the recovery, which takes back only an entry exactly as the runner would have written it, and none while the runner's temp file is still there (the rename never happened). `changed_concurrently` is the user's entry and is never taken back. The runner's settlement is `settleTrust` in `scripts/benchmarks/teardown.ts`, tested as a table: change it there, never inline.
@@ -4,6 +4,7 @@
4
4
 
5
5
  Scope: delivery, digests and condensation, reply addressing, priority and limits. Read before editing `src/hub/bus.ts`, `src/hub/envelope.ts`, `src/hub/limits.ts`, `src/hub/delivery-journal.ts`, `src/hub/inference.ts` (digest condensation), or how an adapter addresses a reply or sets its priority.
6
6
 
7
+ - Lifecycle `deliveryHeld` fences both drain/pull and important steering before owner teardown awaits; retain newly queued work for an explicit restart without altering manual, budget or conductor pauses.
7
8
  - A failed digest is retried one envelope at a time, so a poison envelope cannot take its neighbours down with it.
8
9
  - 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
10
  - `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.
@@ -12,7 +13,7 @@ Scope: delivery, digests and condensation, reply addressing, priority and limits
12
13
  - 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
14
  - 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
15
  - `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
+ - 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, the lifecycle `deliveryHeld` fence) 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
17
  - `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
18
  - `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
19
  - 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`).
@@ -3,6 +3,7 @@
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
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
+ - `peer_stop` is console-only and stops only a hub-owned headless ACP, Pi or local runtime through its adapter. Refuse native-terminal peers and recovery-held mutations; serialize with starts and permission changes, and refuse a recovery `prepare` while a stop runs. An admitted stop holds delivery through `BusOptions.deliveryHeld` before any teardown await (never a console pause that resume can undo, or a global recovery hold that blocks unrelated peers) and withdraws the peer's pending approvals before the adapter stops. A requested stop holds an in-flight delivery as `needs_review` with an ids-only stop reason, suppresses restart/escalation, and never resolves that delivery automatically.
6
7
  - `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
8
  - 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
9
  - State files that clients read (`status.json`, `control-token`) are written after the port is bound, and `status.json` via temp file + rename.
@@ -17,5 +18,8 @@ Scope: the control WS and its protocol, state files, the Claude channel's reconn
17
18
  - 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
19
  - 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
20
  - 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.
21
+ - The interactive screens (`src/cli/upgrade-interactive.ts`, #272) change an operation only by running an existing command (`recovery resume|abort|dispose`, `reset`) through `io.run`, and build their menu from `nextChoices`, whose text is `nextActions`: never call `abortRecovery`, `disposeRecovery` or a driver from a screen, and never offer a choice `next` does not have. The one addition is `cancellableWait` (a runner still at `prepare` with no effect): read the receipt again at the key press, stop only a runner `signedRunner` names (a claim without a matching process signature is never signalled), then run `recovery abort`, which decides again. Agents are ended (`endPlannedPeer`) only before an operation holds the lock, and only after the lock, the hub instance, the attached session and the terminal's handle and incarnation were read again: never on the plan as it was drawn. Ctrl+C leaves the screens and the terminal is not read after it; a prompt answers an interrupt with null, never text. What a hub says it waits for (`recovery.waiting`, the daemon's `blockers`) is shown, never decided on.
22
+ - `upgrade()` (`src/cli/main.ts`, #272) never coordinates a target newer than its own `VERSION`: it hands over to that release's coordinator (`bun x --package @staix/agent-hub@<to> ahub upgrade --to <to>` with the same flags), and `AGENTHUB_UPGRADE_HANDOVER=1` keeps the process it started from handing over again. `--to` passes `exactVersion` before it reaches a package spec or the registry. `latestRelease` supplies it only for a terminal run or a dry run: `--yes`, and an apply through a pipe, still name the release.
20
23
  - 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
24
  - 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).
25
+ - `ahub claude` (`src/cli/launch.ts`, #270) publishes `claude-launch.json` for every launch against a running hub, atomically and without a lock: hooks, permission status, session observation and recovery read its `launchId`, so never make it wait for native-proof state. The native child's pid and signature go only into the private `claude-native-<binding hash>.json`, keyed by the exact launch record, written once under `claude-launch.lock` and removed at that child's own exit; never put them in `claude-launch.json`, where a late callback of an earlier launch could replace a newer record. Crash cleanup of a previous launch's settings file (`cleanupStaleClaudeSettings`) needs the launcher gone by `processLiveness` and that native proof, and for a `direct-child` proof the native pid absent from an available `processTable`: a direct child may exec in place, so its signature certifies nothing. A script or interpreter gets no proof and keeps its settings file.
@@ -4,11 +4,13 @@ Scope: the hub-native local worker, its tools, path guard, denylist and seatbelt
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.
7
+ - The seatbelt profile refuses the `.git` name itself at any depth (creation, rename, symlink, gitfile), `config` and `hooks` directly under it, `commondir` at any depth below it or below an external git dir, and `.agenthub` anywhere in the project, every name folded against the 16 code points HFS+ ignores (#270). An external git dir's `config`/`hooks` rules cover only its own children plus the hooks directory's contents: at any depth they would deny refs named `config` or `hooks` in a worktree or submodule project. The fold is a group alternation, never a character class (the seatbelt regex engine is byte-oriented and a quantifier after a multi-byte class member binds to its last byte), and every pattern keeps its `^` anchor (an unanchored starred alternation does not match). Folded literals never contain the project root: an SBPL string literal dies at 1024 bytes. The cost: `git init`, `git clone` and `git worktree add` do not run inside the sandbox, and neither do `git worktree remove`, `git worktree prune` of a stale record, `git clean -ffd` with a nested repository, or `rm -rf` of a directory holding a `.git`.
7
8
  - SBPL strings go through `sbplString` (the plain `"..."` form). In the raw `#"..."` form a backslash escapes nothing, so a `"` in a path ends the literal and the whole profile fails to parse.
8
9
  - One denylist, `src/local/deny.ts`: the path guard, the seatbelt profile and the memory filter all read it. Seatbelt sees absolute paths, so `local.deny` entries are anchored under the project root (a bare `private/` once denied all of `/private/var`).
9
10
  - `guardPath` walks with `lstat`: `existsSync` follows symlinks, so a dangling link looked like a new file and the write landed at its target.
10
11
  - git arguments never pass through `guardPath`; `gitArgsProblem` refuses absolute paths, `..`, `--no-index` and denylisted `rev:path` forms.
11
12
  - 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
13
  - After a tool with side effects ran, a failed turn is reported, never redelivered.
14
+ - `ctx.permit` answers a `ToolApproval` (`true`, `false`, `"expired"`, `"aborted"`, #253): only `true` permits an effect, so never test it for truthiness. A tool returns `approvalError`'s text for the rest: a denial, the expiry (which tells the model not to retry) or the withdrawal. A turn that two unanswered approvals ended (`ApprovalWaitStop`; counting rule: `adapters.md`) completes its missing tool results, settles its delivery `completed` with the reason and is never handed back for replay, also when no tool had a side effect.
13
15
  - 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
16
  - 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).
@@ -2,7 +2,7 @@
2
2
 
3
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
- - 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.
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: a publish or notify that follows a `review()` or `done()` board write goes through the one `releaseNotice` guard, which turns a failed publish into a console notice, drops a failed notify and never retries (#231, #243). Of the escalation a repeated `changes_requested` triggers, only `escalate()`'s own notice and "moved" message are guarded: the assignment inside it (`assignOwner`) still throws after its board write, and must not be wrapped before #276 decides, because the #231 ready-offer retry and the #254 uncertain-receipt test depend on that throw.
6
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
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.
@@ -7,4 +7,6 @@ Scope: tests, fakes and the test gate. Read before adding or changing a test or
7
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
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
9
  - A free-port probe binds `hostname: "127.0.0.1"`, matching the loopback server that takes the port (#232).
10
+ - `ahub upgrade` without `--to` asks the registry (`npm view`) and hands a target newer than the CLI to `bun x` (#272): a test that reaches either puts a fake `npm` first on `PATH` and a recording stand-in for `process.execPath`, as `test/upgrade-cli.test.ts` does. The interactive screens are tested through a scripted `ScreenIO`; a CLI test that needs them marks `process.stdin` and `process.stdout` as terminals in a wrapper script.
11
+ - A request that a test expects a stop or shutdown to cut gets its rejection handler where the request is created, never after the awaited teardown: a late `catch` races the reset and the run fails on an unhandled rejection.
10
12
  - 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/events.md CHANGED
@@ -25,7 +25,10 @@ marked `private: true`, and PII tasks `pii: true`.
25
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 |
26
26
  | `stuck` | `peer`, `task` (task id), `category` (`repetition`, `false_progress`, `drift`, `desperation`, `capability_gap`), `streak`, `latched`: an escalation verdict; it recommends considering reassignment and never reassigns automatically |
27
27
  | `capability` | `peer`, `state` (`verified` or `lost`), `via`: a peer's context path for facts |
28
- | `native_turn_end` | `peer`: Claude's Stop hook, the end of its turn (Codex's is its `turn_end`); quiescence evidence for an integration |
28
+ | `native_turn_end` | `peer`, `id` (an opaque hash that names the completed turn): the end of a Claude turn, recorded once the hub finds the turn its Stop hook reported completed in the native transcript (Codex's is its `turn_end`); quiescence evidence for an integration |
29
+ | `supervision_turn` | `peer`, `turn` (the hub turn a supervision notice was delivered into, or `supervision-<uuid>` when none was open), `tokens` (when the adapter reported any for it), `ms` (from that turn's start, or from the delivery when none was open, to its native end): a completed native turn that received supervision, which `ahub report` counts; a failed delivery or turn, a peer that went offline or a changed native session records none |
30
+ | `conduct` | `peer` (the conductor, or `user` when the console releases a conductor hold), `action`, `task` (when the action names or creates one), `target` (the peer an assignment, start, hold or release names): one action of the conductor that ran, the structured twin of the console's `conductor` notice. `action` is a tool name without its `hub_` prefix: a conductor tool (`status`, `task_show`, `task_assign`, `task_escalate`, `peer_start`, `peer_hold`, `peer_release`), or one of the conductor peer's own task tools, which are not conductor tools (`task_propose`, `task_accept`, `task_decline`, `task_done`, `review`, `checkpoint`, `remember`). The role-free reads and redirects (a task's owner or reviewer with `hub_task_show`, its proposer with `hub_task_assign`) record none, and neither does `hub_task_list` |
31
+ | `agent_cli` | `peer` (`unknown` when the agent markers were invalid), `command` (the command and, where it has one, its subcommand, from a closed list; `unknown` otherwise; never an argument), `refused` (the CLI refused it before connecting: a human-only command, or an invalid identity; a later refusal by the hub is not recorded here): an `ahub` command run from an agent session. The hub reads these from the `cli-audit/` outbox, so `at` is when it read the record |
29
32
  | `hook_stats` | `peer`, `n` (facts hook calls in the turn, its Stop included), `startupMs` and `maxStartupMs` (the hook processes' start-up and connect time, summed and the largest), `hubMs` (the hub's own time for them): at Claude's Stop (issue #108) |
30
33
  | `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 |
31
34
  | `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) |
@@ -34,21 +37,27 @@ marked `private: true`, and PII tasks `pii: true`.
34
37
  | `turn_start` | `peer`, `turn` (`<peer>#<hub run>.<n>`, unique across restarts). A turn follows the adapter: pausing a busy peer does not end it |
35
38
  | `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 |
36
39
  | `tokens` | `peer`, `n` (tokens added since the previous report), optional numeric `task`, `attribution` (`delivery`, `single_open`, `unattributed`), `pii: true` for an attributed PII task |
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. |
40
+ | `usage` | `peer`, `source` (`omniroute`, `claude_transcript`), opaque `id`, optional `measuredAt` (provider/source time), the `requestedModel`, `servedModel` and `provider` labels, and any provider-reported counters (`inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheWriteTokens`, `totalTokens`); optional numeric `task`, `attribution` (`delivery`, `single_open`, `unattributed`), `pii: true` for an attributed PII task. Missing counters stay unknown. |
38
41
  | `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`) |
39
42
  | `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
43
  | `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) |
44
+ | `permission` | `id`, `peer`, `event` (`requested`, then one of `answered`, `expired`, `cancelled`) and, on the closing event, `latencyMs`, `surface` (`console`, `dashboard`, `terminal`: where a person answered; absent when the hub closed the request) and `option` (the kind of the option chosen: `allow_once`, `allow_always`, `reject_once`, `reject_always`): one approval request relayed to the console and how it ended. `expired` is `approvals.timeout_s` without an answer. `cancelled` with a `surface` is a person's deny in the console or by `ahub permit <id> deny` (a deny on the dashboard is `answered` with a reject option); without one, the hub withdrew the request (its call or turn ended, the peer was stopped, or the hub is stopping). Never the title or the tool |
41
45
  | `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 |
46
+ | `context_pressure` | `peer`, `source` (`claude_statusline`, `codex_token_usage`), `measuredAt` (epoch ms of the reading), `used` (the fraction of the context window in use, 0 to 1), `window` (its size in tokens, or null): a fresh reading of a peer's native context reached `context.gate`. Recorded once per crossing, until a reading below the gate or a new native session rearms it, and not while the peer is paused or offline or the hub is in recovery or stopping |
42
47
  | `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) |
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) |
48
+ | `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), `turns` (the two turn ids, when two overlapping turns' snapshots both hold the change) |
44
49
 
45
50
  Token usage by adapter:
46
51
 
47
- - Kimi (ACP `usage_update`): recorded.
52
+ - Kimi (ACP `usage_update`, and the usage a `session/prompt` result carries): recorded when it holds a checked
53
+ total or an input/output pair. The `{used, size}` shape Kimi sends is context occupancy and is not recorded
54
+ (issue #285 tracks showing Kimi's usage).
48
55
  - Codex (app-server `thread/tokenUsage/updated`): recorded as the growth of the thread's running total, so
49
56
  compaction estimates, usage-limit refreshes and the replay to a reattaching connection add nothing. A thread
50
57
  started under the hub counts from zero; a resumed thread's first update is its history and only sets the
51
58
  baseline. The model call of a compaction itself is real usage and counts.
59
+ - Pi: the assistant usage its extension forwards at each `message_end`, tool-loop messages included, is recorded as
60
+ increments, each usage id once; a message without a usable count adds nothing.
52
61
  - Claude native transcript usage is optional and keyed by an opaque hash of session and message identity; streamed records with the same message id count once. The status line tee still carries quota percentages only.
53
62
  - The local worker records optional counters returned by OmniRoute. Requested route/model and gateway-reported served model/provider are separate fields; an alias is never treated as a served model.
54
63
  - `ahub report` deduplicates usage records by peer, source and id. Coverage counts distinguish calls with provider usage from calls where usage was absent. Token counters are provider-reported values; the report never derives a price or treats missing spend as zero. Estimated price and measured provider spend remain unknown unless a future source reports them.