@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
@@ -812,8 +812,15 @@ session modes (`default`, `plan`, `auto`, `yolo`) but nothing per server or tool
812
812
  Loaded non-default Kimi modes are reset for project ask.
813
813
  - Codex overlays on-request/never on every outgoing turn/start without changing
814
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
815
+ turn; rejected restoration remains pending. Restoration debt and captured
816
+ policies are per thread and survive proxy detach and thread/start or thread/resume.
817
+ A known restoring turn retains its baseline even if the TUI echoes the resumed
818
+ response's sticky override, in any hub permission mode. After restoration is
819
+ admitted, a later repeated TUI policy is indistinguishable from an explicit
820
+ native choice and passes through; only the restoring turn is protected.
821
+ Unknown native baseline refuses restoration while attached, with instructions
822
+ to close the TUI and run `ahub permission codex ask`; detached ask succeeds and
823
+ returns a CLI-visible note about the old thread's unchanged native policy. Actual native unattended flags, not broker-wide flags, govern
817
824
  the refusal to change a bypassed session.
818
825
  - Claude ask-when-needed allows only Read/Edit/Write/MultiEdit/NotebookEdit/LS
819
826
  and bounded Glob/Grep targets resolved with realPath beneath project root,
@@ -823,8 +830,17 @@ session modes (`default`, `plan`, `auto`, `yolo`) but nothing per server or tool
823
830
  Merged/generated settings are 0600 state files passed by path, never inline
824
831
  in argv. Preview writes no settings file. The owning launcher removes its settings file
825
832
  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.
833
+ dead wrapper and native identities permit crash fallback cleanup. Direct binary
834
+ launches publish the native child PID/signature while running, bound to the exact
835
+ launch record; crash cleanup additionally requires its PID absent from a verified
836
+ process table. Script/interpreter native ownership remains unknown. Without
837
+ separate native identity proof, prior files remain for manual inspection. Every
838
+ launch atomically publishes its managed hook/session record without depending
839
+ on the native-proof lock. Exact-binding native proof is stored separately, so
840
+ an old callback cannot replace a later launch record. The populated signed
841
+ native-proof lock waits up to 250 ms for a live owner; an unknown/busy lock
842
+ disables only native identity/crash cleanup with exact-path guidance. Managed
843
+ hooks keep working for the current launch.
828
844
  - Permission-only Claude PreToolUse never drives busy state. Only the former
829
845
  observation purposes retain native turn bookkeeping. Hook identity/purpose
830
846
  are launch/session bound; unverified hook status reads unverified.
@@ -2075,7 +2091,7 @@ acts on the stopped state directory.
2075
2091
  ## Amendment: proven Pi tool refusals and idle exit recovery (issues #254, #255)
2076
2092
 
2077
2093
  - 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.
2094
+ - 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. Replacement teardown says the owner stopped for a new Pi owner even if replacement startup is refused. Internal teardown notices direct inspection and `ahub pi`, without claiming a person requested them. Replacement, failed-start and active-turn guidance takes precedence over the automatic-start-disabled notice. Console notices name the next action or automatic-start state; headless code 0 is still an exit and settles active deliveries as uncertain.
2079
2095
  - 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
2096
  ## Amendment: benchmarks (issue #251)
2081
2097
 
@@ -2104,3 +2120,20 @@ acts on the stopped state directory.
2104
2120
  settings for the rest.
2105
2121
  - The dashboard snapshot carries run summaries and per-arm measures only. `bench run` is a person's (identity gate,
2106
2122
  wherever `--json` stands); reading results is not. Schema and commands: [bench](../bench.md).
2123
+
2124
+
2125
+ ## Amendment: Pi/local approval expiry and cancellation (issue #253)
2126
+
2127
+ - An unanswered Pi/local approval returns an explicit expiry result: no person answered, do not retry that call, hand off or stop. It is not an answered denial. Two consecutive expiry results in the same native Pi turn or local worker turn end that turn with a console notice. An expiry counts toward the streak only when no person answered a request of that peer while it was pending (a per-peer answer epoch makes a parallel batch safe for an answering person); a grant served from the always cache or a permission mode is not an answer and does not reset the streak, and an actual new turn resets it. The turn ends through the same abort path an execution-budget stop uses, so Pi stays attached and the sender is told the approval reason. Supported Pi and local complete that correlated delivery after the reason reply, allowing the next queued message without an operator release. Pi clears the turn-only approval state at settlement and failure, so idle human shells remain usable and later failures cannot inherit its reason; a delayed approval abort rechecks that the same turn is still running. An extension that cannot abort a turn (one that does not advertise `approvalTurnAbort`) leaves Pi offline instead, with `ahub pi` restart and held-delivery settlement guidance. The second result remains an expiry tool failure before the abort. Approval stops do not invoke automatic safe replay or inference handoff.
2128
+ - Tool cancellation reaches the central approval registry. Settlement atomically removes the request and timer/abort listener before publishing closure or resolving. Late answers fail, cannot populate Pi's exact-tool always cache, and do not hold recovery waiting for approvals. Tool and budget continuations retain their captured session/generation; an admission response rechecks the owner and controller before changing a timer or recording a stop, and an old extension callback never aborts a later turn. Normal tool requests carry the session and generation captured before budget admission; the bridge refuses missing/stale lineage before registry, receipt or effect work, with restart guidance for an older extension. Spawned headless owners are pinned by the actual child process signature and its proven RPC session; TUI owners require the native claim. A Pi native abort is scoped to the verified owner, session, turn generation and call id; it withdraws only that call. Early native aborts fence a bounded set of call ids, with overflow failing closed for the remainder of that running turn. A managed tool that does not settle within the stop grace does not block the teardown: the stop goes on (shutdown, owner teardown, process stop) and reports the failure at the end.
2129
+ - A person's TUI shell command uses the existing authenticated idle reservation, session/owner/generation validation, sandbox and run-budget admission. It bypasses hub approval only in that trusted context and returns the sandbox's structured exit code through the receipt and native extension. Model tool arguments or printed output cannot establish human origin or exit status.
2130
+ - The dashboard counts waiting Pi/local approvals and directs the person to the console or `ahub tail` / `ahub permit`. It keeps those rows deny-only and never reveals private titles.
2131
+
2132
+ ## Amendment: requested headless peer stop (issue #278)
2133
+
2134
+ - A console-role `{ t: "peer_stop", peer }` request stops a hub-owned headless ACP, Pi or local runtime through its adapter's owned-runtime teardown. Success is `{ ok: true, state: "offline" }`; refusal or incomplete stop is `{ ok: false, error }` naming the reason and next action. New requests already receive an unknown-request reply on older hubs, so `PROTOCOL` stays unchanged.
2135
+ - Refuse agent/tools-role callers, unknown or offline peers, Claude/Codex and Pi TUI owners, and recovery/shutdown-held mutations. Starts, requested stops and permission changes are fenced per peer; recovery preparation waits until requested stops finish. A TUI is ended in its terminal, or by the upgrade plan's verified terminal binding.
2136
+ - A requested stop marks each in-flight delivery `needs_review` with a fixed ids-only stop reason, cancels pending approvals, and fences stale native completion/failure callbacks. It never completes or implicitly redelivers interrupted work, invokes failure escalation, or triggers Pi's idle auto-restart. The operator inspects effects and explicitly resolves the delivery before replay. Queued unattempted work remains for the next manual start under existing holds/pauses.
2137
+ - `ahub stop <peer>` is human-only in `classifyPeerCommand`, prints the returned state, and reports an older hub's unknown request as requiring an upgrade, with a nonzero exit. The upgrade plan's headless `endPlannedPeer` uses the same console request; the daemon does not close a native terminal.
2138
+
2139
+ - Requested stop admission also holds per-peer drain, pull and steering before any teardown await, without changing operator/budget pauses. Arriving work remains queued for explicit restart. Idle Pi captures live empty-session proof before stopping; a first accepted prompt with unpersisted history is not silently recreated as empty.
@@ -17,16 +17,24 @@ their matching CLI; the new coordinator never guesses a PID to terminate.
17
17
 
18
18
  ## Commands
19
19
 
20
- - `ahub upgrade --to <exact-version> --dry-run`: inspect the running registered
21
- projects, release identity, native session mappings and blockers.
22
- - `ahub upgrade --to <exact-version> [--yes]`: review the plan, revalidate it and
23
- schedule an independent recovery process. `--yes` accepts the displayed scope;
24
- it never overrides blockers.
20
+ - `ahub upgrade [--to <exact-version>] --dry-run`: inspect the running registered
21
+ projects, release identity, native session mappings and blockers. Without
22
+ `--to` the target is the registry's latest release (#272).
23
+ - `ahub upgrade [--to <exact-version>] [--yes]`: review the plan, revalidate it and
24
+ schedule an independent recovery process. `--yes` accepts the displayed scope
25
+ and needs `--to`; it never overrides blockers. On a terminal without either
26
+ flag the review, the apply and the progress are one interactive session (see
27
+ "Interactive screens").
28
+ - A target newer than the running CLI is handed to that release's own
29
+ coordinator (`bun x --package @staix/agent-hub@<version> ahub upgrade --to
30
+ <version>` with the same flags), named on stderr first and confirmed on a
31
+ terminal (#272).
25
32
  - `ahub --project <path|id> restart [--dry-run] [--yes]`: recover one project using
26
33
  the current package, without changing the global installation or plugin.
27
- - `ahub recovery status|resume <operation-id>`: read redacted progress or resume
28
- the recorded operation after checking live ownership.
29
- - `ahub recovery abort <operation-id>`: cancel a preflight and release its lock
34
+ - `ahub recovery status|resume [<operation-id>]`: read redacted progress or resume
35
+ the recorded operation after checking live ownership. Without an id every
36
+ `recovery` command takes the operation that holds the machine's lock (#272).
37
+ - `ahub recovery abort [<operation-id>]`: cancel a preflight and release its lock
30
38
  only before a runtime has stopped or a terminal mutation has been attempted.
31
39
 
32
40
  Upgrade uses all affected running registrations. Stopped projects stay stopped.
@@ -508,3 +516,71 @@ lose. Detaching forgets nothing; a thread whose start the log does not show
508
516
  (resumed, an older hub, a pruned log) is unsure and stays a plan blocker, as
509
517
  does one with turns. A planned fresh start still needs the store to show the
510
518
  rollout missing: an unreadable store blocks the close and the create.
519
+
520
+ ### Interactive screens (#272)
521
+
522
+ `ahub upgrade`, `ahub restart` and bare `ahub recovery` on a terminal are a
523
+ front end to the commands above, never a second coordinator.
524
+
525
+ - The screens (`src/cli/upgrade-interactive.ts`) read the plan, the receipt, the
526
+ runner claim and the live inspections, and change an operation only by running
527
+ `recovery resume|abort|dispose` or `reset` as a child process, with the
528
+ operation's own coordinator (`recoveryArgv`, the argv form of
529
+ `recoveryCommand`). Each command decides its own refusal.
530
+ - The operation screen's choices are `nextChoices`, the structured form of the
531
+ `next` list: `nextActions` is its text, and the invariant test that holds
532
+ `next` to what the commands accept also holds the two together.
533
+ - One choice is not in `next`: cancelling while a runner only waits for its
534
+ source (`cancellableWait`: phase `running`, step `prepare:<project>`, no
535
+ effect receipt, no commit request). The screen stops only a runner that
536
+ `signedRunner` names: a claim whose recorded process signature still matches.
537
+ `recoveryRunner` answers an unsigned claim (a coordinator of 0.12.20 or older)
538
+ from its bare pid, which is enough to wait for and never enough to signal, so
539
+ cancel is not offered for it. The receipt is read again at the key press, and
540
+ `recovery abort` then reads what is live once more. A runner killed after it
541
+ recorded an effect leaves an operation that abort refuses and resume
542
+ continues, as after any runner crash.
543
+ - The plan screen can end attached agents before the operation exists (user
544
+ request, 2026-10-10: choose to end headless or TUI agents during an upgrade).
545
+ A TUI agent is ended by closing the terminal the plan bound to its session; a
546
+ headless agent by the console-role request `peer_stop` to its hub (#278),
547
+ which an older hub answers as unknown. The plan is not acted on as it was
548
+ drawn: `endPlannedPeer` reads again the lock, the hub instance and the peer's
549
+ attachment, and for a TUI agent the attached session and the terminal's
550
+ handle and incarnation (`inspectTerminals` for that one session); any
551
+ difference ends nothing. `closeTerminal` itself compares only the terminal's
552
+ handle, incarnation, worktree and root. An ended agent is offline in the next
553
+ plan, so it is neither waited for nor restored. Nothing is ended once an
554
+ operation holds the lock: the coordinator's roster check reads a planned peer
555
+ that left as a changed source. With several projects in the plan an agent is
556
+ named `<project id>/<peer id>`, and a bare name attached in more than one
557
+ selects none. A Ctrl+C between two agents leaves the rest alone.
558
+ - A Ctrl+C while `apply` checks the plan again creates no operation.
559
+ - Ctrl+C leaves the screens and stops nothing; the terminal is not read again
560
+ after it, because a parent process that died on the same signal (`bun x`, a
561
+ hand-over) may have returned the terminal to the shell. While a progress view
562
+ runs, a line (Enter) opens the operation screen instead. A prompt answers an
563
+ interrupt or the end of input with no value, never with text.
564
+ - A follow that starts right after a command scheduled a runner (resume, a
565
+ fresh session) waits for that runner's first write, up to 5 seconds: until
566
+ then the receipt still shows the blocked state it was scheduled from. It
567
+ starts from the receipt as the command left it (a fresh-session choice writes
568
+ the receipt itself before it schedules the runner).
569
+ - The daemon's `recovery inspect` lists every condition `recoveryReady()` waits
570
+ for in `blockers` (task commands, completion checks, Pi calls and a release
571
+ in flight, an unsettled Pi, besides busy peers, approvals, startup and a
572
+ budget transition). The roster identity comparison is not listed. A hub that
573
+ is not ready and names no cause is shown as such.
574
+ - The progress view prints the receipt's steps and, during `prepare`, the
575
+ source's own readiness blockers. `inspectRecovery` copies them into
576
+ `Inspection.recovery.waiting` for display only; `planFingerprint` leaves
577
+ `recovery` out, so they never change a plan's identity.
578
+ - Without `--to` the target is `npm view @staix/agent-hub version`. `--yes`
579
+ keeps requiring `--to`, so an unattended apply names its release. `--to` is
580
+ checked as an exact version before it reaches the registry or a package spec.
581
+ - The dry-run plan gains `projects[].source.recovery.waiting` when the hub
582
+ reports something in progress; nothing else in the non-interactive output
583
+ changes.
584
+ - Out of scope: a hub-side interrupt of a running turn (the running source may
585
+ be older and would not know the request), and answering approvals from the
586
+ screens.
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "documents": {
4
4
  "README.md": {
5
- "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
5
+ "verifiedAgainst": "146db2d192b3dfc129fd9fef2dd24452aa7f64ae",
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": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
14
+ "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
15
15
  "paths": [
16
16
  "src/",
17
17
  "templates/"
18
18
  ]
19
19
  },
20
20
  "docs/operations.md": {
21
- "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
21
+ "verifiedAgainst": "146db2d192b3dfc129fd9fef2dd24452aa7f64ae",
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": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
38
+ "verifiedAgainst": "146db2d192b3dfc129fd9fef2dd24452aa7f64ae",
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": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
46
+ "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
47
47
  "paths": [
48
48
  "src/adapters/",
49
49
  "src/pi/",
@@ -55,11 +55,12 @@
55
55
  "src/hub/daemon.ts",
56
56
  "src/hub/permission-mode.ts",
57
57
  "src/hub/tool-refusal.ts",
58
- "src/cli/identity.ts"
58
+ "src/cli/identity.ts",
59
+ "src/local/tools.ts"
59
60
  ]
60
61
  },
61
62
  "docs/agent-notes/benchmarks.md": {
62
- "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
63
+ "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
63
64
  "paths": [
64
65
  "scripts/benchmarks/",
65
66
  "test/benchmarks/",
@@ -71,7 +72,7 @@
71
72
  ]
72
73
  },
73
74
  "docs/agent-notes/budget.md": {
74
- "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
75
+ "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
75
76
  "paths": [
76
77
  "src/hub/budget.ts",
77
78
  "src/cli/statusline-tee.ts",
@@ -83,7 +84,7 @@
83
84
  ]
84
85
  },
85
86
  "docs/agent-notes/bus.md": {
86
- "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
87
+ "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
87
88
  "paths": [
88
89
  "src/hub/bus.ts",
89
90
  "src/hub/envelope.ts",
@@ -95,7 +96,7 @@
95
96
  ]
96
97
  },
97
98
  "docs/agent-notes/daemon.md": {
98
- "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
99
+ "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
99
100
  "paths": [
100
101
  "src/hub/daemon.ts",
101
102
  "src/hub/control-client.ts",
@@ -118,11 +119,15 @@
118
119
  "src/cli/console-state.ts",
119
120
  "src/cli/identity.ts",
120
121
  "src/cli/identity-audit.ts",
121
- "src/hub/registry.ts"
122
+ "src/hub/registry.ts",
123
+ "src/cli/main.ts",
124
+ "src/cli/launch.ts",
125
+ "src/hub/child-process.ts",
126
+ "src/hub/bus.ts"
122
127
  ]
123
128
  },
124
129
  "docs/agent-notes/local-worker.md": {
125
- "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
130
+ "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
126
131
  "paths": [
127
132
  "src/adapters/local-worker.ts",
128
133
  "src/local/",
@@ -141,7 +146,7 @@
141
146
  ]
142
147
  },
143
148
  "docs/agent-notes/tasks.md": {
144
- "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
149
+ "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
145
150
  "paths": [
146
151
  "src/hub/tasks.ts",
147
152
  "src/hub/board.ts",
@@ -158,7 +163,7 @@
158
163
  ]
159
164
  },
160
165
  "docs/agent-notes/tests.md": {
161
- "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
166
+ "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
162
167
  "paths": [
163
168
  "test/",
164
169
  "scripts/check.sh",
@@ -172,7 +177,8 @@
172
177
  "src/hub/child-process.ts",
173
178
  "src/switchyard/sidecar.ts",
174
179
  "src/local/sandbox.ts",
175
- "src/models/mlx.ts"
180
+ "src/models/mlx.ts",
181
+ "src/cli/upgrade-interactive.ts"
176
182
  ]
177
183
  }
178
184
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-hub",
3
- "version": "0.12.21",
3
+ "version": "0.12.22",
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.21",
3
+ "version": "0.12.22",
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",
@@ -15404,7 +15404,7 @@ class ControlClient {
15404
15404
  // package.json
15405
15405
  var package_default = {
15406
15406
  name: "@staix/agent-hub",
15407
- version: "0.12.21",
15407
+ version: "0.12.22",
15408
15408
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
15409
15409
  license: "MIT",
15410
15410
  type: "module",
@@ -128,6 +128,7 @@ export class AcpPeer extends BasePeer {
128
128
  private readonly toolTitles = new Map<string, string>(); // the title a call was announced with, per call id (#138)
129
129
  private primed = false;
130
130
  private turn = 0; // generation: a prompt cancelled by the watchdog must not touch the turn that followed it
131
+ private stopping = false;
131
132
  private activeDeliveryId: string | undefined;
132
133
  private deliveryAccepted = false;
133
134
 
@@ -144,6 +145,7 @@ export class AcpPeer extends BasePeer {
144
145
  }
145
146
 
146
147
  async start(): Promise<void> {
148
+ this.stopping = false;
147
149
  const [bin, ...args] = this.opts.cmd;
148
150
  // Its own process group, stopped as a whole (#115, as Codex's in #113): an agent CLI may be a launcher with a native child.
149
151
  const proc = spawn(bin!, args, { cwd: this.opts.cwd, env: peerChildEnv(this.id, { ...process.env, ...(this.opts.env ?? {}) }), stdio: ["pipe", "pipe", "pipe"], detached: true });
@@ -222,18 +224,21 @@ export class AcpPeer extends BasePeer {
222
224
  this.permissionMode = mode;
223
225
  }
224
226
 
225
- async stop(): Promise<void> {
226
- if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "ACP session stopped before settlement" });
227
+ async stop(reason?: string): Promise<void> {
228
+ this.stopping = true;
229
+ if (reason !== undefined) this.turn++; // a requested stop cannot escalate or complete the cancelled turn
230
+ if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: reason ?? "ACP session stopped before settlement" });
227
231
  this.activeDeliveryId = undefined;
228
232
  const proc = this.proc;
229
- if (!proc) return;
233
+ if (!proc) { this.setState("offline"); return; }
230
234
  await stopOwnedProcess(proc, { group: true }); // also when it exited: what it left in its group fails the stop
231
235
  if (this.proc === proc) this.proc = undefined;
236
+ this.setState("offline");
232
237
  }
233
238
 
234
239
  /** Resolves once the prompt is in flight; the turn result arrives on its own. */
235
240
  async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
236
- if (this.state !== "idle") {
241
+ if (this.state !== "idle" || this.stopping) {
237
242
  if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} is ${this.state}` });
238
243
  throw new Error(`${this.id} is ${this.state}`);
239
244
  }
@@ -313,6 +318,7 @@ export class AcpPeer extends BasePeer {
313
318
  }
314
319
 
315
320
  private onLine(line: string): void {
321
+ if (this.stopping) return; // buffered native callbacks cannot recreate approvals after peer_stop
316
322
  let msg: any;
317
323
  try {
318
324
  msg = JSON.parse(line);
@@ -63,9 +63,9 @@ const TRACKED = new Set(["thread/start", "thread/resume"]);
63
63
  */
64
64
  export class CodexPeer extends BasePeer {
65
65
  private permissionMode: PermissionMode;
66
- private nativeApprovalPolicy: unknown;
67
- private approvalOverridden = false;
68
- private readonly restoringApprovals = new Set<string | number>();
66
+ private readonly nativeApprovalPolicies = new Map<string, unknown>();
67
+ private readonly overriddenApprovals = new Set<string>();
68
+ private readonly restoringApprovals = new Map<string | number, string>();
69
69
  private proc: ChildProcess | undefined;
70
70
  private server: Server<Link> | undefined;
71
71
  private link: Link | undefined; // the connection that owns the current thread
@@ -191,7 +191,6 @@ export class CodexPeer extends BasePeer {
191
191
  this.pending.set(id, {
192
192
  deliveryId, kind: "deliver",
193
193
  resolve: (result) => {
194
- if (this.restoringApprovals.delete(id) && this.permissionMode === "ask") this.approvalOverridden = false;
195
194
  this.primed = true;
196
195
  this.injected = replyParent(envs);
197
196
  this.answering = [...envs];
@@ -388,44 +387,50 @@ export class CodexPeer extends BasePeer {
388
387
 
389
388
  /** Reconcile the daemon's current choice after startup, before the returned proxy URL has a TUI. */
390
389
  setStartupPermissionMode(mode: PermissionMode): void {
391
- if (this.link || this.state !== "offline" || this.approvalOverridden) throw new Error("codex startup mode reconciliation requires a fresh unattached proxy");
390
+ if (this.link || this.state !== "offline" || this.overriddenApprovals.size) throw new Error("codex startup mode reconciliation requires a fresh unattached proxy");
392
391
  this.permissionMode = mode;
393
392
  }
394
393
 
395
- /**
396
- * The hub's choice was cleared while no TUI is attached: this proxy sends no override from now on. Returns false
397
- * when an override it already sent cannot be undone on that thread (its native policy was never reported): a thread
398
- * started or resumed through the proxy afterwards reports its own policy, and that one is left alone.
399
- */
394
+ /** Clear the overlay while detached, retaining each thread's restoration debt across a resume. */
400
395
  clearPermissionMode(): boolean {
401
- const restorable = !this.approvalOverridden || this.nativeApprovalPolicy !== undefined;
396
+ const restorable = [...this.overriddenApprovals].every(thread => this.nativeApprovalPolicies.has(thread));
402
397
  this.permissionMode = "ask";
403
398
  return restorable;
404
399
  }
405
400
 
406
401
  async setPermissionMode(mode: PermissionMode): Promise<void> {
407
402
  if (!this.link || this.link.up.readyState !== WebSocket.OPEN) throw new Error("codex permission mode unavailable: attach the TUI through ahub codex first");
408
- if (mode === "ask" && this.approvalOverridden && this.nativeApprovalPolicy === undefined) throw new Error("codex cannot restore ask: native approval policy unavailable; restart the Codex session");
403
+ if (mode === "ask" && [...this.overriddenApprovals].some(thread => !this.nativeApprovalPolicies.has(thread))) throw new Error("codex cannot restore ask: native approval policy unavailable; close the TUI, then run ahub permission codex ask before you restart the Codex session with ahub codex");
409
404
  this.permissionMode = mode;
410
405
  }
411
406
 
412
407
  private applyPermissionPolicy(params: Record<string, unknown>, id: string | number): Record<string, unknown> {
408
+ const thread = typeof params.threadId === "string" ? params.threadId : this.threadId;
413
409
  if (this.permissionMode !== "ask") {
414
- this.approvalOverridden = true;
410
+ this.overriddenApprovals.add(thread);
415
411
  return { ...params, approvalPolicy: this.permissionMode === "never-ask" ? "never" : "on-request" };
416
412
  }
417
- if (this.permissionMode === "ask" && this.approvalOverridden) {
418
- this.restoringApprovals.add(id);
419
- return { ...params, approvalPolicy: this.nativeApprovalPolicy };
413
+ if (this.overriddenApprovals.has(thread) && this.nativeApprovalPolicies.has(thread)) {
414
+ this.restoringApprovals.set(id, thread);
415
+ return { ...params, approvalPolicy: this.nativeApprovalPolicies.get(thread) };
420
416
  }
421
417
  return params;
422
418
  }
423
419
 
420
+ private settlePermissionRestore(id: string | number, failed: boolean): void {
421
+ const thread = this.restoringApprovals.get(id);
422
+ this.restoringApprovals.delete(id);
423
+ if (thread !== undefined && !failed && this.permissionMode === "ask") this.overriddenApprovals.delete(thread);
424
+ }
425
+
424
426
  private fromTui(link: Link, raw: string): void {
425
427
  const msg = parse(raw);
426
428
  if (msg?.method === "turn/start" && msg.params && link === this.link) {
427
- if (msg.params.approvalPolicy != null) this.nativeApprovalPolicy = msg.params.approvalPolicy;
428
- if (this.permissionMode !== "ask" || this.approvalOverridden) {
429
+ const thread = typeof msg.params.threadId === "string" ? msg.params.threadId : this.threadId;
430
+ // A resumed TUI can echo the sticky hub overlay. The restoring turn must use the kept baseline.
431
+ const restoring = this.overriddenApprovals.has(thread) && this.nativeApprovalPolicies.has(thread);
432
+ if (msg.params.approvalPolicy != null && !restoring) this.nativeApprovalPolicies.set(thread, msg.params.approvalPolicy);
433
+ if (this.permissionMode !== "ask" || this.overriddenApprovals.has(thread)) {
429
434
  // Native turn overrides persist: ask restores the captured native policy once on successful admission.
430
435
  msg.params = this.applyPermissionPolicy(msg.params, msg.id);
431
436
  raw = JSON.stringify(msg);
@@ -441,6 +446,7 @@ export class CodexPeer extends BasePeer {
441
446
  if (this.state === "busy") this.touch();
442
447
  if (!msg) return void link.tui.send(raw);
443
448
 
449
+ if (msg.id !== undefined && !msg.method) this.settlePermissionRestore(msg.id, !!msg.error);
444
450
  if (typeof msg.id === "number" && msg.id < 0 && !msg.method) {
445
451
  const p = this.pending.get(msg.id);
446
452
  this.pending.delete(msg.id);
@@ -448,14 +454,13 @@ export class CodexPeer extends BasePeer {
448
454
  else p?.resolve(msg.result);
449
455
  return; // ours: the TUI never asked for it
450
456
  }
451
- if (msg.id !== undefined && !msg.method && this.restoringApprovals.delete(msg.id) && !msg.error && this.permissionMode === "ask") this.approvalOverridden = false;
452
457
  if (typeof msg.result?.userAgent === "string") this.version = /^[^/\s]+\/(\d+\.\d+\.\d+(?:-[\w.]+)?)/.exec(msg.result.userAgent)?.[1] ?? this.version;
453
458
  const tracked = msg.id !== undefined && !msg.method ? link.tracked.get(msg.id) : undefined;
454
459
  if (tracked && link.tracked.delete(msg.id)) {
455
460
  if (typeof msg.result?.thread?.id === "string") {
456
- this.nativeApprovalPolicy = msg.result?.approvalPolicy;
457
- this.approvalOverridden = false;
458
- this.restoringApprovals.clear();
461
+ // A resumed response may report the sticky hub override, not the original native choice.
462
+ const thread = msg.result.thread.id;
463
+ if (!this.overriddenApprovals.has(thread) && msg.result.approvalPolicy != null) this.nativeApprovalPolicies.set(thread, msg.result.approvalPolicy);
459
464
  this.adopt(link, msg.result.thread.id, tracked === "thread/start");
460
465
  }
461
466
  }
@@ -8,7 +8,7 @@ import { renderDigest, replyAudience, replyParent, STANDING_INSTRUCTION, USER, t
8
8
  import { CONDUCTOR_TOOL_NAMES, CONDUCTOR_TOOLS, TASK_TOOL_NAMES, TASK_TOOLS } from "../hub/hub-tools.ts";
9
9
  import { BasePeer } from "../hub/peers.ts";
10
10
  import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
11
- import { runTool, toolResultFailed, TOOL_SCHEMAS, touchedPaths, type ToolContext } from "../local/tools.ts";
11
+ import { runTool, toolResultFailed, TOOL_SCHEMAS, touchedPaths, ApprovalWaitStop, type ToolApproval, type ApprovalProvenance, type ToolContext } from "../local/tools.ts";
12
12
  import type { Capture } from "../memory/capture.ts";
13
13
  import type { ChatMessage, ChatResult, OmniRoute, ToolCall } from "../omniroute/client.ts";
14
14
  import { safeModelLabel } from "../omniroute/usage.ts";
@@ -32,6 +32,7 @@ export interface LocalOptions {
32
32
  onRouteOutcome?: (event: RouteLabelEvent) => void;
33
33
  /** The daemon turn id created synchronously on busy, shared with turn_start/end. */
34
34
  turnId?: () => string | undefined;
35
+ onApprovalStop?: (reason: string) => void;
35
36
  onTool?: (observation: ToolObservation, task?: string) => void;
36
37
  tools: { deny: string[]; permit: ToolContext["permit"]; bashNetwork?: SandboxNetwork; readAllow?: string[] };
37
38
  capture?: Capture;
@@ -85,6 +86,9 @@ export class LocalPeer extends BasePeer {
85
86
  private abort: AbortController | undefined;
86
87
  private budgetTimer?: ReturnType<typeof setTimeout>;
87
88
  private budgetStopReason = "";
89
+ private approvalExpiries = 0;
90
+ private approvalAnswerEpoch: number | undefined;
91
+ private approvalStopReason = "";
88
92
  private activeDeliveryId: string | undefined;
89
93
  private readonly sandboxProfile: string; // built once: profile() spawns git and must stay off the per-call path
90
94
  /** What served the last call, for `ahub status`. */
@@ -127,15 +131,15 @@ export class LocalPeer extends BasePeer {
127
131
  this.setState("idle");
128
132
  }
129
133
 
130
- async stop(): Promise<void> {
134
+ async stop(reason?: string): Promise<void> {
131
135
  this.endRouteTurn(this.labelTurnId, "failed");
132
- if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "turn stopped before settlement" });
136
+ if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: reason ?? "turn stopped before settlement" });
133
137
  this.activeDeliveryId = undefined;
134
138
  this.turn++;
135
139
  clearTimeout(this.budgetTimer); this.budgetTimer = undefined;
136
140
  this.abort?.abort();
141
+ this.setState("offline"); // fence direct delivery before the bounded memory request awaits
137
142
  await this.opts.capture?.end();
138
- this.setState("offline");
139
143
  }
140
144
 
141
145
  /** Resolves once the turn is claimed; a turn that cannot reach any model hands the envelopes back through onFailed. */
@@ -146,6 +150,7 @@ export class LocalPeer extends BasePeer {
146
150
  }
147
151
  const turn = ++this.turn;
148
152
  this.budgetStopReason = "";
153
+ this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = "";
149
154
  clearTimeout(this.budgetTimer); this.budgetTimer = undefined;
150
155
  this.activeDeliveryId = deliveryId;
151
156
  this.abort = new AbortController();
@@ -172,6 +177,14 @@ export class LocalPeer extends BasePeer {
172
177
  })
173
178
  .catch((e: Error) => {
174
179
  if (turn !== this.turn) return; // aborted by the watchdog or stop(): nothing to report, nothing was committed
180
+ if (e instanceof ApprovalWaitStop) {
181
+ this.completeMissingToolResults(msgs, "not run: turn ended after unanswered approvals");
182
+ msgs.push({ role: "assistant", content: e.message });
183
+ if (!policy?.pii) this.commit(msgs);
184
+ this.onMessage?.(e.message, reply);
185
+ if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "completed", reason: e.message });
186
+ return; // even without effects, an unanswered approval must not trigger safe replay
187
+ }
175
188
  if (this.budgetStopReason && !(e instanceof ExecutionBudgetStop)) e = new ExecutionBudgetStop(this.budgetStopReason);
176
189
  this.opts.log?.(`[${this.id}] turn failed: ${e.message}`);
177
190
  if (e instanceof ExecutionBudgetStop) {
@@ -263,6 +276,7 @@ export class LocalPeer extends BasePeer {
263
276
  if (SIDE_EFFECTS.has(call.function.name) && !output.startsWith("error:")) progress.sideEffects++;
264
277
  const result: ChatMessage = { role: "tool", tool_call_id: call.id, content: output, is_error: toolResultFailed(name, output) };
265
278
  msgs.push(result); results.push(result);
279
+ if (this.approvalStopReason) { this.abort?.abort(); throw new ApprovalWaitStop(this.approvalStopReason); }
266
280
  this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
267
281
  // Hub tool arguments/results are not capture evidence; Tasks owns its screened memory writes.
268
282
  if (name !== "hub_send" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name)) capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
@@ -277,14 +291,34 @@ export class LocalPeer extends BasePeer {
277
291
  return {
278
292
  cwd: this.opts.cwd,
279
293
  deny: this.opts.tools.deny,
280
- permit: async (title, tool, _signal, path) => {
281
- if (turnSignal.aborted) return false;
282
- return new Promise<boolean>((resolve, reject) => {
283
- const finish = (allowed: boolean) => { turnSignal.removeEventListener("abort", onAbort); resolve(allowed); };
284
- const onAbort = () => finish(false);
294
+ permit: async (title, tool, _signal, canonicalTarget) => {
295
+ if (turnSignal.aborted) return "aborted";
296
+ const generation = this.turn;
297
+ let observed = false;
298
+ const observe = (provenance: ApprovalProvenance) => {
299
+ observed = true;
300
+ if (generation !== this.turn || turnSignal.aborted || this.approvalStopReason) return;
301
+ if (this.approvalAnswerEpoch !== provenance.answerEpoch) {
302
+ this.approvalExpiries = 0; this.approvalAnswerEpoch = provenance.answerEpoch;
303
+ }
304
+ if (provenance.source === "person") this.approvalExpiries = 0;
305
+ if (provenance.source === "expired" && provenance.eligibleExpiry !== false && ++this.approvalExpiries >= 2) {
306
+ this.approvalStopReason = "Local turn stopped after two unanswered approvals; no person answered. Do not retry the calls; inspect prior work before handoff or stopping.";
307
+ try { this.opts.onApprovalStop?.(this.approvalStopReason); } catch { /* stop remains authoritative */ }
308
+ }
309
+ };
310
+ const picked = await new Promise<ToolApproval>((resolve, reject) => {
311
+ const finish = (decision: ToolApproval) => { turnSignal.removeEventListener("abort", onAbort); resolve(decision); };
312
+ const onAbort = () => finish("aborted");
285
313
  turnSignal.addEventListener("abort", onAbort, { once: true });
286
- this.opts.tools.permit(title, tool, turnSignal, path).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
314
+ this.opts.tools.permit(title, tool, turnSignal, canonicalTarget, observe).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
287
315
  });
316
+ if (generation !== this.turn || (turnSignal.aborted && picked !== "expired")) return "aborted";
317
+ // Legacy permit callbacks can represent a person's denial without provenance. A true
318
+ // result may be automatic, so it must never reset the unanswered-request streak.
319
+ if (!observed && picked === false) observe({ source: "person", answerEpoch: (this.approvalAnswerEpoch ?? 0) + 1 });
320
+ if (!observed && picked === "expired") observe({ source: "expired", answerEpoch: this.approvalAnswerEpoch ?? 0, eligibleExpiry: true });
321
+ return picked;
288
322
  },
289
323
  sandboxProfile: this.sandboxProfile,
290
324
  sandboxEnv: { ...proxyEnv(this.opts.tools.bashNetwork ?? false), AGENTHUB_PEER_ID: this.id },