@staix/agent-hub 0.12.20 → 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 (60) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +6 -5
  3. package/docs/agent-notes/adapters.md +9 -2
  4. package/docs/agent-notes/bus.md +5 -2
  5. package/docs/agent-notes/daemon.md +8 -2
  6. package/docs/agent-notes/local-worker.md +3 -1
  7. package/docs/agent-notes/tasks.md +3 -1
  8. package/docs/agent-notes/tests.md +3 -2
  9. package/docs/bench.md +143 -0
  10. package/docs/events.md +2 -1
  11. package/docs/operations.md +271 -46
  12. package/docs/quickstart.md +12 -10
  13. package/docs/research.md +92 -0
  14. package/docs/security.md +45 -10
  15. package/docs/smoke.md +79 -0
  16. package/docs/specs/2026-09-19-agent-hub-design.md +194 -20
  17. package/docs/specs/2026-09-20-upgrade-recovery-design.md +68 -10
  18. package/docs/verified.json +28 -17
  19. package/package.json +1 -1
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +3 -2
  22. package/src/adapters/acp.ts +67 -6
  23. package/src/adapters/codex-appserver.ts +67 -2
  24. package/src/adapters/local-worker.ts +4 -3
  25. package/src/adapters/pi.ts +58 -4
  26. package/src/cli/bench.ts +287 -0
  27. package/src/cli/console-state.ts +226 -28
  28. package/src/cli/console.ts +16 -4
  29. package/src/cli/facts-hook.ts +50 -8
  30. package/src/cli/help.ts +10 -0
  31. package/src/cli/identity.ts +10 -4
  32. package/src/cli/launch.ts +96 -17
  33. package/src/cli/main.ts +159 -18
  34. package/src/cli/preview.ts +13 -3
  35. package/src/cli/status-lines.ts +2 -1
  36. package/src/cli/terminal-recovery.ts +83 -31
  37. package/src/cli/upgrade-runtime.ts +67 -18
  38. package/src/cli/upgrade.ts +81 -9
  39. package/src/hub/bench.ts +211 -0
  40. package/src/hub/bus.ts +19 -4
  41. package/src/hub/conductor.ts +22 -21
  42. package/src/hub/config-trust.ts +1 -0
  43. package/src/hub/control-client.ts +4 -1
  44. package/src/hub/daemon.ts +346 -40
  45. package/src/hub/delivery-journal.ts +6 -6
  46. package/src/hub/events.ts +2 -1
  47. package/src/hub/lifecycle.ts +8 -13
  48. package/src/hub/manager.ts +16 -14
  49. package/src/hub/permission-mode.ts +44 -0
  50. package/src/hub/recovery-store.ts +21 -9
  51. package/src/hub/registry.ts +36 -22
  52. package/src/hub/research.ts +255 -0
  53. package/src/hub/tasks.ts +70 -43
  54. package/src/hub/tool-refusal.ts +7 -0
  55. package/src/hub/ui.ts +4 -1
  56. package/src/local/tools.ts +14 -10
  57. package/src/pi/process-signature.ts +23 -0
  58. package/src/pi/tool-receipts.ts +7 -1
  59. package/src/ui/index.html +83 -6
  60. package/src/ui/task-progress.ts +33 -0
@@ -89,7 +89,8 @@ Facts below are tagged **verified** (measured on 2026-09-19 on the owner's Mac)
89
89
  7. Quota relay hands open tasks to the `local` peer first, then waits for the window
90
90
  reset for judgment-heavy classes. Task-level escalation on verification failure.
91
91
  8. Safety defaults: cross-peer text is framed as untrusted; permission prompts stay on
92
- unless `--unattended`; ports bind loopback only; Kimi ACP permission requests are
92
+ unless `--unattended` or an operator-selected permission mode (issues #240,
93
+ #242); ports bind loopback only; Kimi ACP permission requests are
93
94
  relayed to the hub console. Loopback is not enough on its own (any web page can open a
94
95
  WebSocket to 127.0.0.1), so the control WS requires a per-run token and both hub
95
96
  servers refuse requests that carry an `Origin` header (amended in M1).
@@ -97,8 +98,9 @@ Facts below are tagged **verified** (measured on 2026-09-19 on the owner's Mac)
97
98
  10. Shared memory reuses claude-mem as the single store; the hub never runs its own
98
99
  memory database. Native capture stays where it exists (Claude hooks, Codex plugin,
99
100
  Kimi transcript bridge); the hub captures only the local worker and adds
100
- cross-platform recall, task-scoped briefs and explicit shared notes. The bus still
101
- passes messages, never transcripts.
101
+ cross-platform recall, task-scoped briefs and explicit shared notes. Hub tool calls
102
+ are not captured; Tasks alone decides what screened tool text reaches memory.
103
+ The bus still passes messages, never transcripts.
102
104
 
103
105
  ## Design
104
106
 
@@ -279,9 +281,10 @@ peer. Peer ids claimed over the control WS must not be `user` or a hub-managed a
279
281
  injection is dropped for the same reason as `dynamicTools`.
280
282
  - PII (amended in M4): a task matching `signals.pii_patterns` is owned by `local` or by
281
283
  nobody; its envelopes are `private` (console tail and `hub.log` print a stub), lists show
282
- `[pii]` to everyone but `local`, `ahub task show` is the one place the console reads it; the
283
- reviewer is the console user; `local` answers such a turn to the console only, keeps it out
284
- of its history, refuses it when the only gateway is off campus (Cloudflare Access), and
284
+ `[pii]` to everyone but `local` inside a PII turn; `ahub task show` is the one place
285
+ the console reads it. The reviewer is the console user; `local` answers such a turn
286
+ to the console only, keeps it out of its history, refuses it when the only gateway
287
+ is off campus (Cloudflare Access), and
285
288
  nothing reaches claude-mem (no brief, no note, no capture), because claude-mem's observer is
286
289
  a cloud model (verified: `/api/health` reports `ai.provider: claude`).
287
290
 
@@ -486,7 +489,8 @@ inside a peer.
486
489
  ### Safety
487
490
 
488
491
  - Untrusted framing on all cross-peer text; standing instruction once per session.
489
- - `ahub claude` and `ahub codex` keep normal permission prompts. `--unattended` opts into
492
+ - `ahub claude` and `ahub codex` keep native permission defaults unless the person
493
+ selects a permission mode (issues #240, #242). `--unattended` opts into
490
494
  `--dangerously-skip-permissions` (Claude) and `--dangerously-bypass-approvals-and-sandbox`
491
495
  (Codex 0.154.0 documents this flag, not `--yolo`) and prints a warning.
492
496
  - Kimi and local tools: cwd-scoped, secrets denylist (a secrets directory, `.env*`, keys).
@@ -496,7 +500,7 @@ inside a peer.
496
500
  ### Configuration and state
497
501
 
498
502
  - `.agenthub/config.json` (roles, ports, filter tiers, watchdog), `.agenthub/routing.toml`.
499
- - `.agenthub/state/` gitignored: `hub.pid`, `status.json`, `control-token` (0600, per run),
503
+ - `.agenthub/state/` gitignored: `status.json` (with the daemon's pid and process signature, #226; daemons up to 0.12.20 also wrote `hub.pid`), `control-token` (0600, per run),
500
504
  `hub.db` (tasks, messages, budget; from M4), `hub.log`, `switchyard.toml`, `checkpoint.md`.
501
505
  - Env: `AGENTHUB_STATE_DIR`, `AGENTHUB_OMNIROUTE_URL`, `OMNIROUTE_API_KEY`,
502
506
  `AGENTHUB_SWITCHYARD_BIN`, `AGENTHUB_UNATTENDED`.
@@ -763,7 +767,8 @@ session modes (`default`, `plan`, `auto`, `yolo`) but nothing per server or tool
763
767
  `hub_send` or a task tool, are answered with their `allow_once` option by the
764
768
  hub, without a console prompt, and logged by tool name only. This matches the
765
769
  `approval_mode = "approve"` Codex already gets for the same tools. Every other
766
- request keeps the console path; `allow_always` is never chosen automatically.
770
+ request keeps the console path unless the person opts into the permission modes
771
+ below (issues #240, #242); `allow_always` is never chosen automatically.
767
772
  - For the other tools, the console payload falls back to the streamed argument
768
773
  text once it is a complete JSON object. A partial stream stays unresolved and
769
774
  keeps `allow_always` withheld.
@@ -776,6 +781,68 @@ session modes (`default`, `plan`, `auto`, `yolo`) but nothing per server or tool
776
781
  configured server of the same name, so only the hub's `agent-hub` server can
777
782
  produce the approved names.
778
783
 
784
+ ## Amendment: operator permission modes (issues #240, #242)
785
+
786
+ - Modes: ask leaves native decisions unchanged; ask-when-needed uses native
787
+ Kimi/Codex policies or scoped Claude/Pi/local grants; never-ask suppresses
788
+ prompts within each peer's existing execution boundaries.
789
+ - Runtime CLI permission changes are refused from ordinary agent shells.
790
+ Never-ask needs --yes; console mode selection needs a separate y. These
791
+ policy/role checks do not contain a hostile same-user shell: Kimi can clear
792
+ markers, use --yes or read the control token and impersonate the console.
793
+ The security and operations guides name that boundary explicitly.
794
+ - permission_modes defaults merge per peer across config files. Automatic
795
+ effects stop at ask-when-needed. Never-ask from an applied untracked config remains ask until
796
+ a person's source-labelled console y at hub start; no boot --yes applies it.
797
+ Git-tracked mode defaults are ignored and never offered for confirmation.
798
+ Every non-ask start logs mode and source (config file or runtime choice). Successful
799
+ runtime changes clear pending config confirmations; startup choices are reconciled
800
+ under the peer fence before a new adapter is exposed to bus delivery. Runtime changes and
801
+ confirmations remain daemon-local and expire on stop.
802
+ - Newly added native peers replay their non-offline state to drain queued work,
803
+ publish state telemetry and persist sessions. Offline/absent ask clears only
804
+ the hub choice. Unattended metadata is saved after successful startup, and
805
+ concurrent joined starts with different flags are refused. Refused default
806
+ confirmation remains handled until state changes; Esc defers without declining.
807
+ - Kimi mapping requires the actual Kimi Code CLI identity: yolo for
808
+ ask-when-needed, auto for never-ask, default for ask. Another ACP agent starts fresh or resumed as unmanaged: its reported mode is
809
+ untouched, and runtime changes are refused until its table exists. Set-mode replies precede prompts;
810
+ missing ids/refused startup changes fail. A timeout is unknown/offline and
811
+ the owned process group stops, so a late reply cannot restore a live peer.
812
+ Loaded non-default Kimi modes are reset for project ask.
813
+ - Codex overlays on-request/never on every outgoing turn/start without changing
814
+ sandbox fields. Ask restores a known native policy once after an accepted
815
+ turn; rejected restoration remains pending. Unknown native baseline refuses
816
+ restoration. Actual native unattended flags, not broker-wide flags, govern
817
+ the refusal to change a bypassed session.
818
+ - Claude ask-when-needed allows only Read/Edit/Write/MultiEdit/NotebookEdit/LS
819
+ and bounded Glob/Grep targets resolved with realPath beneath project root,
820
+ outside .agenthub/.git/.claude/.codex/.qwen/.kimi/.pi/.mcp.json. Missing/nonresolving or escaping targets,
821
+ protected case aliases, symlinks and uncertain wildcard/filter targets yield
822
+ no decision. Never-ask answers allow; ask and transport errors decide nothing.
823
+ Merged/generated settings are 0600 state files passed by path, never inline
824
+ in argv. Preview writes no settings file. The owning launcher removes its settings file
825
+ after native exit; earlier active or unknown launchers retain theirs. Verified
826
+ dead wrapper and native identities permit crash fallback cleanup. Without
827
+ separate native identity proof, prior files remain for manual inspection.
828
+ - Permission-only Claude PreToolUse never drives busy state. Only the former
829
+ observation purposes retain native turn bookkeeping. Hook identity/purpose
830
+ are launch/session bound; unverified hook status reads unverified.
831
+ - Pi/local read never asks. Ask-when-needed grants write/edit inside the project, outside the shared native
832
+ configuration segments (.claude/.codex/.qwen/.kimi/.pi/.mcp.json, compared folded and by the path within the
833
+ project) and never through a hard link; bash and mutating
834
+ git retain console approval. Never-ask grants once, retaining sandbox and
835
+ path guards; no mode gives allow-always. ACP tools have no hub sandbox and
836
+ the never-ask confirmation names that difference.
837
+ - Console-role permission and permission_default requests are additive;
838
+ ordinary tools/conductor/messages cannot change or confirm modes. Status
839
+ includes non-ask/unverified/unknown mode state and pending defaults; facts
840
+ replies include permission and authenticated projectRoot. Unknown requests
841
+ are answered, so PROTOCOL remains unchanged. permission_mode events retain
842
+ schema 1 and include only peer/from/to, never tool arguments.
843
+ - Live benign Kimi/Claude results and unverified Codex/Pi limits are recorded
844
+ in docs/smoke.md. No extra account prompts accompany these review corrections.
845
+
779
846
  ## Amendment: approval notifications and timeout (issue #5)
780
847
 
781
848
  - A waiting permission request raises one macOS notification (`osascript`) with
@@ -994,7 +1061,11 @@ session modes (`default`, `plan`, `auto`, `yolo`) but nothing per server or tool
994
1061
  awaited an approval. An approval is saved before its dependents are assigned,
995
1062
  so the daemon's 60 s release timer sweeps for ownerless tasks whose last event
996
1063
  is `blocked` or `ready` and that wait for nothing: each is offered once per hub
997
- run, once an attached peer can take it, outside recovery operations.
1064
+ run, once an attached peer can take it, outside recovery operations. An offer
1065
+ is used up only by an assignment, after an approval and on the timer alike;
1066
+ failed dependent reads, ready writes or assignments do not escape a saved
1067
+ approval. Their notices are best effort because notification can read the
1068
+ same unavailable board. An ownerless task stays eligible once a peer can take it.
998
1069
  - A recovery commit also waits for task operations in flight and for completion
999
1070
  checks queued or running: both can write the board after its integrity digest
1000
1071
  (a check the commit's stop kills records `check interrupted`).
@@ -1360,14 +1431,59 @@ approval. Deny is direct. Pending requests include expiry and are withdrawn by
1360
1431
  Panels expose Peers, Approvals, Tasks, Queue and Events with bounded polling and
1361
1432
  Unicode cell widths, fall back below 80x24, and restore terminal state on exit.
1362
1433
 
1434
+ Whole-board progress (#246) uses one pure `taskProgress` function shared by the
1435
+ console and hashed dashboard script. It reads only public id/state/deps fields;
1436
+ PII stubs count like other tasks. Waiting partitions proposed tasks whose public
1437
+ dependencies are not approved, including unknown dependencies. Approved/all gives
1438
+ the done fraction (zero on an empty board). Four stages are proposed, in progress,
1439
+ in review and approved; changes requested returns to stage two and is marked.
1440
+ The console Tasks panel has a 20-cell ASCII summary bar and fixed four-cell row
1441
+ meters, preserving width and color-off geometry. Cells and percentage round down;
1442
+ count labels use importance order changes requested, review, waiting, in progress,
1443
+ proposed, then unknown when nonzero, and drop only from the end. The stream count is in the fixed footer,
1444
+ from existing all-state status counts; it makes no board fetch and retains a
1445
+ row-one scroll region for terminal scrollback. Loading/failed reads never imply
1446
+ confirmed empty progress. Dashboard filters affect rows only, not totals. Its
1447
+ full-width stacked SVG and stages have text equivalents; the existing signature
1448
+ guard prevents unchanged snapshots from mutating the progress live region.
1449
+ System/Light/Dark choice is applied as `data-theme` before first paint, persists
1450
+ in a one-year host-scoped `SameSite=Strict` cookie through guarded access and
1451
+ follows OS CSS preference on System. Cookie port independence shares the choice
1452
+ between hub starts and projects. The shared model is injected before the page's
1453
+ own inline script/style CSP hashes, with no new control message or CLI JSON change.
1454
+ UI public rows expose optional public-view deps for waiting. This is additive
1455
+ metadata enrichment of the existing ui_snapshot payload, not a changed control
1456
+ message envelope/operation, so it does not bump PROTOCOL. Unknown task states
1457
+ count in an explicit unknown bucket with stage zero; no NaN or prototype-key
1458
+ updates are permitted. Approval fraction is derived from counts/total at display
1459
+ time, avoiding a redundant floating-ratio field.
1460
+
1363
1461
  Console semantic colors (issue #201) use spans with a fixed terminal-native
1364
1462
  palette: cyan information, bold cyan active/selected labels, green availability
1365
1463
  and success, yellow attention/waiting, red failure/intervention, and restrained
1366
1464
  bright-black metadata. Offline peers, ordinary titles, action details and body
1367
1465
  lines use the default foreground. Existing labels, selection markers and
1368
- confirmation prompts remain sufficient without color. Stream headers use event
1369
- structure for their tone; body text is never parsed for meaning or passed
1370
- through as terminal styling. Denial requests and observed expiry/cancellation
1466
+ confirmation prompts remain sufficient without color. Console stream headers
1467
+ color meaningful tokens only (#239; dated issue Decisions): Claude bright blue
1468
+ (94), Codex bright cyan (96), all other peers default; `task` magenta (35, never
1469
+ bold), numbers bold default foreground (1), task references magenta underlined
1470
+ (4;35), and other issue references underlined default foreground (4). No token
1471
+ SGR equals a state or peer SGR. Green/yellow/red remain states/approvals/failures,
1472
+ cyan remains navigation. Case-insensitive `task #N`, `Task #N`, `[task #N]` and
1473
+ `[review #N]` identify task references; unbracketed prose `review #N` remains an
1474
+ issue reference. Ordinary words keep default foreground;
1475
+ there is no hashing or user theme.
1476
+ Only hub-written peer slots, state words and structural markers receive their
1477
+ semantic tones; title words and markers cannot choose them. Numeric tokens have
1478
+ whole-token boundaries, so UUIDs and ISO identifiers stay plain. Legacy notices
1479
+ lack typed reference ranges: the task/review prefix heuristic is bounded by a
1480
+ `ponytail:` comment; other `#N` tokens use the issue tone until structured notice
1481
+ spans classify assign/which references. Header text crosses `terminalText` before tokenization. Wrapped headers normalize
1482
+ tabs to spaces first; plain headers retain their original tabs in both color modes. Wrapped spans retain the source classification. After a projection
1483
+ miss on the first physical header, only structural tones remain. A missed
1484
+ continuation and all later continuations are fully plain. Projection advances
1485
+ a span index through the source once, keeping long permission titles bounded. Bodies and every
1486
+ command-output line stay plain. Denial requests and observed expiry/cancellation
1371
1487
  are red; an answered remote closure lacks option-kind metadata and is not
1372
1488
  guessed to be a denial.
1373
1489
 
@@ -1654,7 +1770,10 @@ push `accepted` and show none of them.
1654
1770
  to the newest end of the cache) for `reply_to` like a push, and a read clears
1655
1771
  the peer's failure streak as a completed push does. If that journal write fails, only this peer's queue
1656
1772
  and preface are put back (every pause stays) and the bus stops with
1657
- `delivery journal unavailable`. A pull is not a native turn and is not
1773
+ `delivery journal unavailable`. A refused or failed `resolveDelivery` likewise
1774
+ puts back only that peer's queue and preface and keeps every pause. Queue
1775
+ contents change in place, preserving arrays held by a suspended drain; only
1776
+ the constructor and recovery restore reload a bus snapshot. A pull is not a native turn and is not
1658
1777
  counted as supervision. The row is completed at hand-out, before anything
1659
1778
  shows the model saw it: a reply lost on the way, a tool call cancelled with
1660
1779
  Esc or a plugin that dies loses the batch (a `ponytail:` ceiling in
@@ -1789,9 +1908,9 @@ acts on the stopped state directory.
1789
1908
  Checked, then renamed by path: a symlink swapped in between still wins
1790
1909
  (marked `ponytail:`; `renameat` on a held descriptor would close it).
1791
1910
  - Between stop and act: after `stopProject` the CLI takes the project's
1792
- registry claim (`Registry.claim` with its own pid), the claim a daemon must
1911
+ registry claim (`Registry.claim` with its own pid and process signature), the claim a daemon must
1793
1912
  hold to run, and checks that no manifest names a daemon whose pid is alive
1794
- or uncertain (`processAlive`, the rule `inspectProject` uses); either failing
1913
+ or uncertain (`processLiveness`, the rule `inspectProject` uses, #226); either failing
1795
1914
  means a hub started in between and nothing is reset. A reset's claim carries
1796
1915
  the instance prefix `reset-`, so losing the claim to a concurrent reset says
1797
1916
  so. A crashed daemon's manifest (`status.json`, `control-token`, `hub.pid`
@@ -1800,7 +1919,7 @@ acts on the stopped state directory.
1800
1919
  removes it and `--all` archives it. With the claim held, the CLI checks the
1801
1920
  machine's recovery lock again, so an upgrade or recovery that took it in
1802
1921
  between does not run beside the reset. The claim is released when the reset
1803
- ends; a reset that dies leaves a dead pid, which the next claim takes over.
1922
+ ends; a reset that dies leaves a dead or reused pid with its signature, which the next claim takes over.
1804
1923
  - Failures: the plan is read only for the dry run, so `--all --yes` archives a
1805
1924
  state directory it cannot read as it is (its dry run says what it could not
1806
1925
  read). A failure after the stop says how far it got. A runtime reset that
@@ -1849,9 +1968,8 @@ acts on the stopped state directory.
1849
1968
  text right after the pattern check and before triage. "Held as private" is met
1850
1969
  by holding the task back entirely: until the verdict it is not on the board, so
1851
1970
  no envelope, notice, triage call, brief or note of the hub's can read it. The
1852
- local worker's claude-mem capture of its own tool calls is outside this: in an
1853
- ordinary turn it sends a proposal's arguments whatever the verdict (an existing
1854
- gap for every hub tool, not closed here). Writing it as
1971
+ local worker never captures hub tool calls (#230), so a proposal's arguments
1972
+ cannot bypass the verdict through capture. Writing it as
1855
1973
  a PII task first was rejected: a PII task opening lifts every silent turn-free
1856
1974
  cohort for good and switches facts off (#107, #108), so every proposal would do
1857
1975
  that. The proposal's tool call waits for the verdict, as it already waits for
@@ -1930,3 +2048,59 @@ acts on the stopped state directory.
1930
2048
  before any measurement: the model itself labels at least 90% of the positives
1931
2049
  `pii` (an unknown verdict counts as a miss). The report goes to
1932
2050
  `docs/verification/`; the script exits 1 under the bound.
2051
+
2052
+ ## Amendment: research records (issue #247)
2053
+
2054
+ - Opt-in per project (`research.enabled`, off by default). When a task is approved, the daemon builds its record from
2055
+ the project's `events.jsonl` with the same function `ahub research backfill` uses (`taskRecords` in
2056
+ `src/hub/research.ts`), so live and backfilled records agree, and appends it to the machine-wide store
2057
+ `~/.agenthub/research/<sha256(project id)[0:16]>.jsonl`. Records hold ids, states, counts, tokens and times only,
2058
+ because the events do; schema and fields: [research](../research.md).
2059
+ - The board has no terminal state but `approved`, and it is final, so each approved task gets one record; a person's
2060
+ later verdict (`ahub task label`, including `abandoned`) is a separate label record bound to the task's proposal
2061
+ time. A task is keyed by project, id and proposal time, because `ahub reset --all` restarts the ids and the store
2062
+ keeps the older records. Measures (success, first pass, rework, check failures over checks run, tokens and wall time
2063
+ per task) are derived when reported, never stored.
2064
+ - The record counts everything attributed to the task, the turn that approved it included: that turn ends after the
2065
+ approval and its usage arrives later, so the writer waits until no open turn is attributed to the task (polled every
2066
+ 10 s, at most 30 minutes; a stopping hub writes once its peers have stopped). The first record written for a
2067
+ task is final; a label binds to the current board's task, else to the store's only record with that id, or with
2068
+ `--created` to a named one from before a reset. Task events carry the board's move `reason` (a closed
2069
+ list) so reassignments are counted by reason.
2070
+ - Recording never blocks or fails the task flow: the writer runs after the board event, and a failure is one
2071
+ hub.log line per run. ponytail: it reads the whole events file for each record; an incremental per-task index
2072
+ if that file grows large.
2073
+ - Agents may read the measures and export; backfill and labels are a person's (identity gate).
2074
+
2075
+ ## Amendment: proven Pi tool refusals and idle exit recovery (issues #254, #255)
2076
+
2077
+ - A trusted guard uses `PreEffectToolRefusal` only when that operation has performed no task or board effect; PII screening and its telemetry record may precede the refusal. Pi receives the same `error: <message>` refusal as the control client, and its session/call receipt is `done`. Initial task ownership/dependency/state checks and conductor entry peer/mode/assignment/escalation validation opt into the type; shared helpers default to ordinary errors, and conductor checks after an awaited release validation stay ordinary. Generic errors, board readback failures after a write, and receipt settlement write failures remain uncertain/pending and are never repeated. A legacy pending row has no pre-effect proof in its stored fingerprint/state/result, so startup preserves it; neither present board state nor a matching error message establishes historical refusal.
2078
+ - Each Pi owner reports one exit with cause, exit code or signal, whether startup completed, whether a turn/tool was active, whether teardown was expected, and the last validated tool name only. TUI shutdown/owner-loss and verified stop teardown lack observed OS status and record unknown. A successfully stopped claimed TUI owner reports once; a never-owned launch reports no invented exit. Internal teardown notices direct inspection and `ahub pi`, without claiming a person requested them. Console notices name the next action or automatic-start state; headless code 0 is still an exit and settles active deliveries as uncertain.
2079
+ - With `pi.auto_start`, an unexpected idle headless exit attempts its verified persisted session once in a 60 s window. The daemon owns the attempt/window across adapter replacements, blocks throughout an in-flight attempt, and rearms the 60 s window when that attempt settles. It checks current-owner identity, completed startup, turn/tool inactivity, start serialization, shutdown and recovery before and after awaited receipt drain/session capture. Missing or invalid persisted state leaves Pi offline with `ahub pi` guidance, with no fresh-session fallback. A second exit inside the window stays offline. Requested stops, stale owners, startup failures, active turns/tools and native terminal exits never trigger this automatic path; crash recovery's existing #66 behavior remains separate.
2080
+ ## Amendment: benchmarks (issue #251)
2081
+
2082
+ - `ahub bench` runs a person's suite file (tasks with a starting commit, an optional setup and a verify command)
2083
+ against the peers attached to a hub, in a project opted in with `bench.enabled`, because every attempt resets the work
2084
+ tree (`git checkout --force --detach <commit>` with HEAD checked against the pin, `git clean -ffdx -e /.agenthub`, then a
2085
+ clean-tree check). Before
2086
+ anything changes the runner requires the project to be the root of its repository, nothing tracked under `.agenthub/`
2087
+ (in any letter case) in the tree or in any suite ref, every ref to be a commit, which it pins for the run, and a
2088
+ clean tree. The runner is a console client: it proposes each
2089
+ task as the console does and polls the board, so the task flow is unchanged and no new control request exists.
2090
+ - `setup` and `verify` run in their own process group, bounded; the group is stopped at the bound, on an interrupt and
2091
+ when the command exits. An approved attempt's measures come from #247's `taskRecords` for the task proposed last with
2092
+ that id, read once the turns started since its proposal have ended (waiting only while their peers are busy or
2093
+ paused, at most 10 minutes, outside the attempt's duration); a timeout, an error, an interrupted wait, a wait that
2094
+ runs out with the turn still open, or a hub status that cannot be read while it is open leaves none, never partial
2095
+ ones. The fingerprint lists only the peers attached at the start.
2096
+ - Results go to `~/.agenthub/bench/<run>.jsonl` (ids, outcomes, exit codes, counts, times; never suite text or command
2097
+ output). A run's state comes from its end record or its runner's liveness (`processLiveness`). Comparisons are
2098
+ derived when asked: an arm is every run of it that is over (a stopped run included), runs of different suites or
2099
+ suite versions are not mixed unless asked, and each difference has a seeded 95% bootstrap interval, inconclusive
2100
+ below five attempts counted for that measure per arm (errors are not counted for pass rate or wall time).
2101
+ - A task that times out stops the run, because an agent may still work in the tree the next attempt would reset.
2102
+ ponytail: a board state to withdraw an open task would let the run continue. The fingerprint holds what the hub's
2103
+ status reports (a requested model for Pi, permission modes once #242 lands); ponytail: read each peer's launch
2104
+ settings for the rest.
2105
+ - The dashboard snapshot carries run summaries and per-arm measures only. `bench run` is a person's (identity gate,
2106
+ wherever `--json` stands); reading results is not. Schema and commands: [bench](../bench.md).
@@ -50,6 +50,9 @@ parents/hops, retries, dedupe state, prefaces, manual pauses and peer descriptor
50
50
  The existing task and budget databases remain authoritative. A mismatched or
51
51
  corrupt snapshot blocks startup. Snapshot restoration precedes peer attachment;
52
52
  release is idempotent and retires replayable snapshot state before delivery.
53
+ Journal-backed restoration retains missing empty queue entries from the committed
54
+ bus snapshot, including peers that remain offline. The journal stays authoritative
55
+ for queued work and receipts; a non-empty snapshot queue is never replayed over it.
53
56
  Release atomically moves that snapshot to a private per-operation archive before
54
57
  lifting the hold. Archives retain evidence for an uncertain release and are never
55
58
  automatically replayed. Preparation freezes the peer roster; a new unplanned peer
@@ -322,7 +325,12 @@ nor accepted when the target cannot read recovery waivers; `status` then says
322
325
  why in its own `freshSession` field, since `next` holds commands only. A runner
323
326
  record that cannot be read shows as `unknown` in `status`, never as no runner;
324
327
  while it reads so, resume, abort and dispose are all refused (the runner claim
325
- cannot tell its owner), so `next` names only `status` again. Otherwise `status` and every error that lists choices build them with one function
328
+ cannot tell its owner), so `next` names only `status` again. The runner row
329
+ carries its runner's process signature (#226): a row whose pid now belongs to
330
+ another process (after a reboot, for example) is no runner and the next claim
331
+ replaces it; one whose process exists but cannot be identified is `unknown`, and
332
+ the claim refuses it with that reason, never a raw `kill` error. A row an older
333
+ runner wrote has no signature and is judged by its pid alone, as before. Otherwise `status` and every error that lists choices build them with one function
326
334
  (`nextActions`), in this order: resume (which also launches a failed peer
327
335
  again), unless it can never get past what is live (`resumeBlocked`): a
328
336
  recorded final refusal (staging refused the fixed target or preserved source), a
@@ -330,11 +338,11 @@ missing project directory, a hub that speaks another control protocol, a
330
338
  runtime other than the one the receipt expects (a replaced source; a target not
331
339
  fenced to this operation, another instance or another version), a stopped
332
340
  pending source, a prepared source of a #215 coordinator that stopped with no
333
- commit request, a target that stopped after it started (restarting it is not
334
- supported in this release: its peers would have to be relaunched against the
335
- new instance), or any other stopped runtime without this operation's unreleased
336
- restart snapshot to start from (unavailable, starting or stopping is waited
337
- for). Then abort where `abortRefusal` allows it; a fresh session for each failed
341
+ commit request, a target that stopped after it started when the operation's
342
+ coordinator predates #225, or any other stopped runtime without this operation's
343
+ unreleased restart snapshot to start from (unavailable, starting or stopping is
344
+ waited for). A target that stopped after it started, with that snapshot, is
345
+ started again by resume (#225, below). Then abort where `abortRefusal` allows it; a fresh session for each failed
338
346
  Codex or Claude restoration, only where resume is offered (resume launches the
339
347
  new session, and dispose refuses `--fresh-session` on the same predicate); and
340
348
  last stop-and-archive where `disposeRefusal` allows it, or else `wait until
@@ -366,7 +374,15 @@ target instance. It is live when its process signature matches, gone when the
366
374
  pid no longer exists (ESRCH) or now belongs to another process, and unknown when
367
375
  the pid exists but its identity cannot be read. Unknown is never treated as gone. At planning, an
368
376
  unknown Claude launcher blocks: whether the attached session is managed cannot
369
- be told.
377
+ be told. A row that fails validation is never dropped and never read as "no
378
+ launcher" (#228): unless its readable `peer`, `projectRoot` or `instanceId` names
379
+ another launcher, it is unknown for that peer (it blocks binding, creation and the
380
+ plan's managed check), and `ahub <peer>` writes every such row back unchanged,
381
+ replacing only its own valid row. A launcher recorded by 0.12.12 or older hashed
382
+ `ps` in its own TZ and locale (the pin came with #177): when the pinned signature
383
+ differs, the signature computed the unpinned way in the reader's environment is
384
+ compared too, and a match is live; a reader in another environment still reads
385
+ such a launcher as gone.
370
386
  Restore reads this evidence in one place: the target's report, which counts only
371
387
  when the target runs as the expected instance, then the recorded launcher. A
372
388
  target that does not, a launcher that cannot be read, or a launcher record file
@@ -382,7 +398,18 @@ operation, and the runner keeps the disposition's own error. Abort and `next` de
382
398
  (`abortRefusal`). `status`, abort and the runner's errors read the same live
383
399
  state: every project not yet verified, its source or its target. A manifest
384
400
  whose pid no longer exists reads as stopped, whatever protocol it names; probing
385
- it would read unavailable forever. A stopped runtime also reports whose
401
+ it would read unavailable forever. So does a manifest whose pid now belongs to
402
+ another process (#226): from 0.12.21 `status.json` carries the daemon's process
403
+ signature (`pidSignature`, in the file only; the status reply is unchanged, so no
404
+ protocol bump), the registry claim carries it bound to the claiming instance, and
405
+ every ownership check (manifest, claim, runner, manager owner) goes through one
406
+ helper that reads live, gone or unknown. Recovery reads the claim from the
407
+ registry as it is now, never from the plan's frozen copy. Daemons no longer write
408
+ `hub.pid`; one an older daemon left is an unsigned legacy record. Records without
409
+ a signature give the answers they always did, so an unsigned claim whose pid was
410
+ reused still reads as starting: no command clears it (a hub started by 0.12.20 or
411
+ older that crashed before a reboot), and the operations guide gives the manual
412
+ clear after the process at that pid is checked. A stopped runtime also reports whose
386
413
  unreleased restart snapshot its state directory holds.
387
414
  An attached session is the target's report of that peer online with a thread
388
415
  (Codex) or session (Claude, Pi) id.
@@ -404,7 +431,9 @@ An attached session is the target's report of that peer online with a thread
404
431
  | project `prepared`, no `commitSent`, operation of a #215 coordinator | source stopped (crashed before any commit request) | blocks; the phase stays `prepared` (nothing was committed, so there is nothing to start from); not offered | abort if no effects, dispose | `<c> recovery abort <id>` without effects, else stop-and-archive (the lock refuses starting that hub by hand) |
405
432
  | project `prepared` | source unavailable, starting, stopping or not inspected | as above when it reads again | none: abort (its hold may still stand) and stop-and-archive are refused until it reads | `<c> recovery status <id>` once it answers |
406
433
  | project `prepared`, operation of an older coordinator (no `commitSent` written) | source not running, or not inspected | as its own runner does | dispose once it reads running, stopped or missing; abort neither offered nor accepted (it may have committed) | `<c> recovery status <id>` |
407
- | project `started` or `peers-restored` | target stopped (a crash or reboot after it started), with or without its snapshot | blocks; not offered: restarting it is not supported in this release | dispose; no fresh session | `the target stopped after it started; restarting it is not supported in this release` |
434
+ | project `started` or `peers-restored` | target stopped (a crash or reboot after it started), this operation's snapshot kept | records the restart (dead instance, time), retires the dead instance's done `restored:<peer>` receipts, moves the project back to `stopped` and starts the target from the snapshot; once per resume | dispose | `the target stopped after it started; resume restarts it from this operation's snapshot` (`stopped again after restart <n> in this resume` when it stops again) |
435
+ | project `started` or `peers-restored` | target stopped, snapshot gone | blocks; not offered | dispose | `this operation's restart snapshot is gone, so nothing can start it` |
436
+ | `retired:<peer>` (a done restoration of a dead target instance) | Orca lists its terminal, or cannot be read | closed under the close receipt rules (`closedRetired:<peer>` `pending` until Orca no longer lists it); an unreadable inventory changes nothing | dispose | `close Orca terminal <handle> by hand` or `check it in Orca`, `then <c> recovery resume <id>` |
408
437
  | project `stopped` | target stopped without this operation's snapshot (never committed) | blocks; not offered | dispose | `the target is stopped and this operation's restart snapshot is gone` |
409
438
  | project `stopped`, `started` or `peers-restored` | target running unfenced, as another instance or another version | blocks; not offered | dispose | `daemon is not owned by this recovery operation` (or `daemon instance changed`, `target daemon version mismatch`) |
410
439
  | any, at staging | preserved source or staged target changed, or (first staging of these bytes only) target protocol or recovery waivers refused | blocks; recorded as `final`, so resume is never offered again | abort if no effects, dispose | the refusal |
@@ -437,10 +466,39 @@ and its `next` entry says what that runner cannot do. Stop-and-archive stops a
437
466
  target that speaks an older control protocol at that protocol (a `kill` fenced
438
467
  by its instance), then waits for its manifest and registry claim as the
439
468
  lifecycle stop does. The disposition and its audit are written before the first
440
- act. A recovery launch of `ahub codex` records its launcher before the hub's
469
+ act. When the operation's coordinator predates #215, that same write sets the
470
+ receipt's `schema` to 2 (#227): an older runner never reads `disposition`, and an
471
+ older global `ahub recovery resume` still runs it, but every older runner and
472
+ abort refuses a schema other than 1 before it takes the lock or claims the
473
+ runner, so it exits having written nothing (an older `resume` still prints
474
+ `scheduled`; an older `status` still prints the receipt). A #215 coordinator's
475
+ operation keeps schema 1: its runner refuses a disposition itself, and a 0.12.20
476
+ CLI can still rerun its dispose. This release accepts schema 2 only with a
477
+ disposition, and the next abort or dispose that holds the runner claim moves a
478
+ schema-1 disposition 0.12.20 recorded on an older coordinator's operation to 2. A recovery launch of `ahub codex` records its launcher before the hub's
441
479
  `start` round trip; an ordinary launch records it after, so a refused start never
442
480
  replaces the record of a Codex already running.
443
481
 
482
+ #225: a target that stopped after it started is restarted only while its state
483
+ directory holds this operation's unreleased snapshot. The runner records the restart
484
+ in the project's receipt (`restarts`: the dead instance and the time) before acting,
485
+ moves each done `restored:<peer>` binding to `retired:<peer>`, clears the instance
486
+ and moves the project back to `stopped`, whose path starts the target from the
487
+ snapshot; the journal imports the snapshot only at revision 0, so the second start
488
+ continues from what the first wrote. `closed:<peer>`, `commitSent`, `fresh`, waivers
489
+ and the install flags stay. Restore closes each retired terminal (or finds it
490
+ absent) before relaunching that peer, and a peer whose retired session was an
491
+ accepted new one (fresh choice, fresh start, zero-turn Claude) is relaunched by that
492
+ id, which the operation-fenced waiver already covers, unless it has nothing to resume
493
+ (a Codex thread with no rollout, a Claude session with no transcript): that one starts
494
+ new under the waiver. Evidence reads the launch
495
+ records of every instance the operation started for the project, the dead ones
496
+ included, and a peer attached to the new instance without a session id is a session
497
+ of unknown identity, never "nothing attached". One restart per project per resume:
498
+ a target that stops again blocks with the restart count, so a crash loop never
499
+ spins unattended. Operations whose coordinator predates #225 keep refusing (their
500
+ own runner does), with stop-and-archive as the way out.
501
+
444
502
  A Codex thread with no rollout is not blocked at the plan only when the hub saw
445
503
  Codex start it (`native_thread` with `fresh`, logged by the adapter when it
446
504
  adopts a thread) and logged no Codex `turn_start` while it was the adopted
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "documents": {
4
4
  "README.md": {
5
- "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
5
+ "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
6
6
  "paths": [
7
7
  "package.json",
8
8
  "src/",
@@ -11,14 +11,14 @@
11
11
  ]
12
12
  },
13
13
  "docs/security.md": {
14
- "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
14
+ "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
15
15
  "paths": [
16
16
  "src/",
17
17
  "templates/"
18
18
  ]
19
19
  },
20
20
  "docs/operations.md": {
21
- "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
21
+ "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
22
22
  "paths": [
23
23
  "package.json",
24
24
  "src/",
@@ -35,7 +35,7 @@
35
35
  ]
36
36
  },
37
37
  "docs/quickstart.md": {
38
- "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
38
+ "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
39
39
  "paths": [
40
40
  "package.json",
41
41
  "src/",
@@ -43,7 +43,7 @@
43
43
  ]
44
44
  },
45
45
  "docs/agent-notes/adapters.md": {
46
- "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
46
+ "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
47
47
  "paths": [
48
48
  "src/adapters/",
49
49
  "src/pi/",
@@ -52,11 +52,14 @@
52
52
  "src/hub/lifecycle.ts",
53
53
  "src/memory/capture.ts",
54
54
  "scripts/benchmarks/teardown.ts",
55
- "src/hub/daemon.ts"
55
+ "src/hub/daemon.ts",
56
+ "src/hub/permission-mode.ts",
57
+ "src/hub/tool-refusal.ts",
58
+ "src/cli/identity.ts"
56
59
  ]
57
60
  },
58
61
  "docs/agent-notes/benchmarks.md": {
59
- "verifiedAgainst": "84a3c404d3fe3e293495009cffa11b204d0d3523",
62
+ "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
60
63
  "paths": [
61
64
  "scripts/benchmarks/",
62
65
  "test/benchmarks/",
@@ -68,7 +71,7 @@
68
71
  ]
69
72
  },
70
73
  "docs/agent-notes/budget.md": {
71
- "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
74
+ "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
72
75
  "paths": [
73
76
  "src/hub/budget.ts",
74
77
  "src/cli/statusline-tee.ts",
@@ -80,7 +83,7 @@
80
83
  ]
81
84
  },
82
85
  "docs/agent-notes/bus.md": {
83
- "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
86
+ "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
84
87
  "paths": [
85
88
  "src/hub/bus.ts",
86
89
  "src/hub/envelope.ts",
@@ -92,7 +95,7 @@
92
95
  ]
93
96
  },
94
97
  "docs/agent-notes/daemon.md": {
95
- "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
98
+ "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
96
99
  "paths": [
97
100
  "src/hub/daemon.ts",
98
101
  "src/hub/control-client.ts",
@@ -114,16 +117,18 @@
114
117
  "src/cli/console.ts",
115
118
  "src/cli/console-state.ts",
116
119
  "src/cli/identity.ts",
117
- "src/cli/identity-audit.ts"
120
+ "src/cli/identity-audit.ts",
121
+ "src/hub/registry.ts"
118
122
  ]
119
123
  },
120
124
  "docs/agent-notes/local-worker.md": {
121
- "verifiedAgainst": "84a3c404d3fe3e293495009cffa11b204d0d3523",
125
+ "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
122
126
  "paths": [
123
127
  "src/adapters/local-worker.ts",
124
128
  "src/local/",
125
129
  "src/memory/capture.ts",
126
- "src/hub/facts.ts"
130
+ "src/hub/facts.ts",
131
+ "src/hub/permission-mode.ts"
127
132
  ]
128
133
  },
129
134
  "docs/agent-notes/models.md": {
@@ -136,7 +141,7 @@
136
141
  ]
137
142
  },
138
143
  "docs/agent-notes/tasks.md": {
139
- "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
144
+ "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
140
145
  "paths": [
141
146
  "src/hub/tasks.ts",
142
147
  "src/hub/board.ts",
@@ -147,11 +152,13 @@
147
152
  "src/cli/main.ts",
148
153
  "src/cli/preview.ts",
149
154
  "src/hub/conductor.ts",
150
- "src/hub/supervision.ts"
155
+ "src/hub/supervision.ts",
156
+ "src/hub/tool-refusal.ts",
157
+ "src/hub/daemon.ts"
151
158
  ]
152
159
  },
153
160
  "docs/agent-notes/tests.md": {
154
- "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
161
+ "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
155
162
  "paths": [
156
163
  "test/",
157
164
  "scripts/check.sh",
@@ -161,7 +168,11 @@
161
168
  "scripts/ci-gates.mjs",
162
169
  "scripts/ci-reuse.mjs",
163
170
  ".github/workflows/check.yml",
164
- ".github/workflows/release.yml"
171
+ ".github/workflows/release.yml",
172
+ "src/hub/child-process.ts",
173
+ "src/switchyard/sidecar.ts",
174
+ "src/local/sandbox.ts",
175
+ "src/models/mlx.ts"
165
176
  ]
166
177
  }
167
178
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-hub",
3
- "version": "0.12.20",
3
+ "version": "0.12.21",
4
4
  "description": "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hub",
3
- "version": "0.12.20",
3
+ "version": "0.12.21",
4
4
  "description": "Channel between Claude Code and the agent-hub daemon: peer messages from Codex, Kimi and the local worker arrive as channel events; hub_send replies.",
5
5
  "author": {
6
6
  "name": "Young Joon Lee",
@@ -15306,7 +15306,8 @@ function readControl(stateDir) {
15306
15306
  instanceId: status.instanceId,
15307
15307
  cwd: status.cwd,
15308
15308
  protocol: status.protocol,
15309
- pid: status.pid
15309
+ pid: status.pid,
15310
+ ...typeof status.pidSignature === "string" && status.pidSignature ? { pidSignature: status.pidSignature } : {}
15310
15311
  };
15311
15312
  } catch {
15312
15313
  return;
@@ -15403,7 +15404,7 @@ class ControlClient {
15403
15404
  // package.json
15404
15405
  var package_default = {
15405
15406
  name: "@staix/agent-hub",
15406
- version: "0.12.20",
15407
+ version: "0.12.21",
15407
15408
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
15408
15409
  license: "MIT",
15409
15410
  type: "module",