@staix/agent-hub 0.12.19 → 0.12.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +7 -6
  3. package/docs/agent-notes/adapters.md +10 -2
  4. package/docs/agent-notes/bus.md +6 -2
  5. package/docs/agent-notes/daemon.md +10 -3
  6. package/docs/agent-notes/local-worker.md +3 -1
  7. package/docs/agent-notes/models.md +3 -0
  8. package/docs/agent-notes/tasks.md +6 -3
  9. package/docs/agent-notes/tests.md +3 -2
  10. package/docs/bench.md +143 -0
  11. package/docs/events.md +6 -2
  12. package/docs/operations.md +655 -34
  13. package/docs/quickstart.md +12 -10
  14. package/docs/research.md +92 -0
  15. package/docs/security.md +59 -17
  16. package/docs/smoke.md +137 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +586 -16
  18. package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
  19. package/docs/specs/2026-09-20-upgrade-recovery-design.md +345 -0
  20. package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
  21. package/docs/verified.json +29 -18
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +65 -26
  25. package/src/adapters/acp.ts +67 -6
  26. package/src/adapters/claude-channel.ts +61 -20
  27. package/src/adapters/codex-appserver.ts +70 -2
  28. package/src/adapters/local-worker.ts +8 -3
  29. package/src/adapters/pi.ts +58 -4
  30. package/src/cli/bench.ts +287 -0
  31. package/src/cli/console-state.ts +531 -110
  32. package/src/cli/console.ts +59 -26
  33. package/src/cli/facts-hook.ts +50 -8
  34. package/src/cli/help.ts +130 -0
  35. package/src/cli/identity.ts +11 -5
  36. package/src/cli/launch.ts +98 -17
  37. package/src/cli/main.ts +325 -88
  38. package/src/cli/preview.ts +14 -3
  39. package/src/cli/reset.ts +194 -0
  40. package/src/cli/status-lines.ts +10 -5
  41. package/src/cli/terminal-recovery.ts +196 -40
  42. package/src/cli/upgrade-runtime.ts +327 -60
  43. package/src/cli/upgrade.ts +442 -45
  44. package/src/hub/ask.ts +1 -1
  45. package/src/hub/bench.ts +211 -0
  46. package/src/hub/board.ts +15 -7
  47. package/src/hub/bus.ts +79 -8
  48. package/src/hub/child-process.ts +1 -0
  49. package/src/hub/conductor.ts +54 -21
  50. package/src/hub/config-trust.ts +1 -0
  51. package/src/hub/control-client.ts +9 -4
  52. package/src/hub/daemon.ts +401 -48
  53. package/src/hub/delivery-journal.ts +6 -6
  54. package/src/hub/events.ts +9 -2
  55. package/src/hub/execution-budget.ts +15 -6
  56. package/src/hub/hub-tools.ts +4 -4
  57. package/src/hub/inference.ts +136 -1
  58. package/src/hub/lifecycle.ts +13 -12
  59. package/src/hub/manager.ts +16 -14
  60. package/src/hub/peers.ts +2 -0
  61. package/src/hub/permission-mode.ts +44 -0
  62. package/src/hub/recovery-store.ts +68 -8
  63. package/src/hub/registry.ts +36 -22
  64. package/src/hub/report.ts +43 -1
  65. package/src/hub/research.ts +255 -0
  66. package/src/hub/restart.ts +25 -0
  67. package/src/hub/routing.ts +48 -16
  68. package/src/hub/snapshots.ts +3 -2
  69. package/src/hub/tasks.ts +295 -115
  70. package/src/hub/tool-refusal.ts +7 -0
  71. package/src/hub/ui.ts +4 -1
  72. package/src/local/deny.ts +2 -1
  73. package/src/local/tools.ts +14 -10
  74. package/src/models/mlx.ts +11 -8
  75. package/src/models/ollama.ts +2 -2
  76. package/src/models/relay.ts +190 -42
  77. package/src/models/route/relay-selector.ts +43 -31
  78. package/src/models/route/runtime.ts +17 -7
  79. package/src/models/route/signals.ts +12 -1
  80. package/src/models/route/stage.ts +57 -3
  81. package/src/omniroute/client.ts +1 -1
  82. package/src/pi/process-signature.ts +23 -0
  83. package/src/pi/tool-receipts.ts +7 -1
  84. package/src/ui/index.html +84 -6
  85. package/src/ui/task-progress.ts +33 -0
  86. package/templates/AGENTS.block.md +1 -1
  87. package/templates/routing.toml +8 -0
@@ -18,6 +18,8 @@ Issue: #25. Pi is a managed project peer for implementation, edits, tests, summa
18
18
  - Apple Silicon setup pins Python 3.12, `mlx-lm==0.31.3`, and `Qwen/Qwen3-8B-MLX-4bit` revision `383413e909f3bc5303ce195ebbdf0339c5a1a2a3`.
19
19
  - The MLX input estimate is capped at 16,000 tokens; decode and prompt concurrency are one. DGX's default input estimate cap is 262,144 tokens.
20
20
  - The relay authenticates every call, rejects browser origins and unknown model aliases, and preserves streamed bytes. It records the requested route and model reported by the backend. Per request it also journals sanitized identity and lifecycle evidence (#139): resolved alias, upstream-configured model, observed provider and served model with their source (gateway header, generation SSE event, or local MLX configuration), outcome (completed, cancelled, failed), duration, and a confirmed-mismatch flag. A request cancelled before any identification stays explicitly unidentified; a backend's mutable last-served label is never a request's own evidence, and a primary/auxiliary role stays unknown without native evidence. Records carry no prompts, tools, keys or Access headers.
21
+ - Load and cooldowns (#199, amended 2026-10-09). A `hub/auto` request bound for MLX keeps exactly its two candidates, MLX then its DGX fallback; load and cooldowns only reorder them, and a fixed alias (`mlx/fast`, `pi_backend = "mlx"`) is never reordered. The fallback goes first when MLX is cooling down, unless the fallback is cooling down or its own last dispatch failed in any way (an error status or a failed stream included) within 30 s and it has not succeeded since. Load moves are opt-in: with `[pi] efficient_wait_ms` set (below the 120 s slot wait; read when the hub first starts Pi), a movable request whose MLX slot is still busy after it, and an enforced tool loop pinned to the fallback, put the fallback first under the same health rule, and only while the request runs under no execution budget, where a move could spend the model call or the time MLX needs (budget-aware moves from the admission's remaining units are the upgrade path). Under #197 `enforce` a load move happens only at a user turn. A moved attempt gets a 15 s deadline to its response headers, after the gateway lookup, and is then abandoned for MLX; a reorder for a cooling MLX keeps the full request deadline. The bound ends with the headers: a stream that stalls or fails after them cannot fall back to MLX, and a failed one marks the fallback failing so later requests do not move. Each relay alias cools down after three consecutive transport or startup failures outside a cooldown (a failure more than twice the cap after the last counted one starts the count over), for 30 s doubling up to 5 min; a busy MLX slot and timeouts cut short by the execution budget never count, and any HTTP answer, a success or an error status, proves the transport works and clears the streak and the cooldown. A cooling DGX alias with no alternative is still tried. An alias that fails before it has a status row (gateway or key unavailable) gets one, so its cooldown shows. Both reorders are invisible to PII, which never reaches the relay (Pi refuses PII work). A load move journals no MLX attempt unless MLX then serves as the fallback. Route events record `source: "load"` or `"cooldown"` and `stateless` for a request without a session key; `events.jsonl` records `cooldown` start and end, and `ahub status` shows the cooldown on the backend line. Before the relay first starts MLX, startup decides as before. The live wait-time measurement on a mixed workload (#199 AC5) is open.
22
+ - Decision, 2026-10-09: load moves are off unless `efficient_wait_ms` is set, a deviation from the issue's "default about 500 ms". Reason: the move is unmeasured (AC5 is open), its bound covers only the response headers, and each review round found another way an unmeasured move could make a request worse than main. Revisit with the AC5 measurement.
21
23
  - MLX may fall back to DGX before response streaming begins. Failed non-PII tasks can escalate to configured cloud peers with an explicit warning to reconcile partial effects.
22
24
  - Runtime ownership includes process start identity. Project shutdown releases its relay, while explicit `ahub models stop` controls the shared MLX process.
23
25
 
@@ -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
@@ -163,3 +166,345 @@ The target coordinator accepts authenticated protocol 9, 10 and 11 sources, then
163
166
  uses protocol 11 after startup. Older supported managers are refreshed onto the
164
167
  current implementation. The real 0.6.4/protocol-9 transition and the 0.12.0/protocol-10
165
168
  development-hub transition are recorded in `docs/smoke.md`.
169
+
170
+ ### Unmanaged Claude sessions (#206)
171
+
172
+ Amended 2026-10-09. An attached Claude session is managed when a live `ahub claude`
173
+ launcher is recorded for the running daemon instance (`terminal-recovery.json`, its
174
+ launcher identity checked with the shared `processSignature`) and the session record
175
+ (`claude-session.json`) was written by that launch. Managed sessions are closed and
176
+ resumed in a new terminal as before.
177
+
178
+ Any other attached Claude session is unmanaged: a plain `claude`, an `ahub claude`
179
+ outside Orca, or one whose launcher has ended. Its session record cannot be bound to
180
+ the attached session, so the plan drops the id (no output names it) and lists the
181
+ peer under `reconnectOnly`: no terminal is inspected, closed or relaunched, and the
182
+ source roster check compares its membership only. Before starting the target the
183
+ coordinator writes an operation-fenced waiver (`recovery-waivers.json`, mode 0600),
184
+ so the restored daemon does not require the saved session id for that peer; release
185
+ still requires it to reattach, which the coordinator waits for up to 90 seconds
186
+ (three times the plugin's longest reconnect backoff). Reconnect-only requires the
187
+ source control protocol to equal the target's; otherwise the plan names a blocker
188
+ whose next action is to end the session or relaunch it with `ahub claude` in Orca.
189
+ A live launcher whose record was written by another launch is a blocker, never a
190
+ target. Refused `upgrade --yes` and `restart --yes`, and every dry-run, print each
191
+ blocker on stderr before the final line.
192
+
193
+ The channel's hello carries no launch id, so a plain `claude` that took the peer id
194
+ from a live managed session is classified as managed. Telling them apart needs the
195
+ launch id at hello, which is a protocol change and is not claimed here. A live
196
+ upgrade with an unmanaged session attached has not been run.
197
+
198
+ ### Partial operations (#215)
199
+
200
+ Amended 2026-10-09, before implementation.
201
+
202
+ Expired preparation. The source hold lapses after ten minutes. When a project is
203
+ still `prepared` (terminal effects may already be recorded) and its hold is gone,
204
+ resume re-prepares the same source instead of reporting "source is no longer
205
+ prepared". The running daemon must be the planned source instance: a replaced
206
+ daemon or a source held by another operation is refused, with nothing prepared,
207
+ closed or stopped. The coordinator prepares again under the same operation id,
208
+ waits for readiness within the preparation bound (on timeout it aborts only its
209
+ own hold) and checks the roster again. A peer whose terminal this operation
210
+ already closed must stay detached; every other peer must keep its planned
211
+ conversation. Effect receipts are never reset, so no terminal is closed twice,
212
+ and the exclusive runner claim and instance fencing are unchanged. The
213
+ re-prepared source records a closed peer as offline, so the target daemon does
214
+ not require that peer itself; the coordinator still requires every originally
215
+ attached peer back before release. A roster change while effects are recorded
216
+ names the peer and the choices below instead of asking for a new plan.
217
+
218
+ Native resume viability. A Codex thread counts as resumable only when a rollout
219
+ file naming it exists under `sessions/` of the store its restoration uses (the
220
+ launcher's captured `CODEX_HOME`, else `~/.codex`); an app-server thread id
221
+ alone proves nothing. The plan blocks a Codex peer without one, the coordinator
222
+ checks again before closing its terminal (closing nothing when it fails) and
223
+ before creating the replacement.
224
+
225
+ Launcher failure. Orca's `terminal create --command` types the command into a
226
+ login shell, which outlives it, so the terminal never exits with the launcher
227
+ and its exit is no evidence. The evidence is the hub's own: `ahub codex`,
228
+ `ahub claude` and `ahub pi --mode tui` record their launcher (pid and process
229
+ signature) in `terminal-recovery.json` for the terminal they run in, before
230
+ they start the native agent. A replacement is awaited in 5-second slices of
231
+ Orca's `tui-idle` wait (Orca 1.4.223 answers a timed-out wait with exit 1 and
232
+ `{"ok":false,"error":{"code":"timeout"}}`, which counts as not yet). Between
233
+ slices, and once the terminal reads idle (an idle terminal may be the shell the
234
+ launcher returned to), a recorded launcher that is gone means the launch failed,
235
+ recognized within one slice. A launcher whose identity cannot be read is unknown
236
+ and is waited for, never taken as gone. A handle Orca reports
237
+ `terminal_handle_stale` means the terminal is gone only when no recorded
238
+ launcher may still run and Orca no longer lists the terminal's incarnation;
239
+ otherwise the create stays uncertain. A launcher that died before recording
240
+ itself is not distinguished from a slow one, and whether Orca reports the bare
241
+ login shell as TUI-idle is not verified: if it does, the create stops at once as
242
+ not mappable to the session; if not, after the 10-minute readiness bound. Either
243
+ way it blocks as needing manual verification with the receipt `pending`, and the
244
+ next resume, finding no live launcher and no attached session, records `failed`. Resume settles a `pending` or `failed` receipt by what is live
245
+ (the table below): an attached planned (or accepted) session is the
246
+ restoration, a launcher that may run is waited for, and only nothing live makes
247
+ it `failed` or, for `failed`, launches again. No terminal is created while a
248
+ recorded launcher for the peer may run on the target or its session is attached.
249
+ A launch that failed, or a thread found not resumable before the launch, is
250
+ receipted `restored:<peer>` = `failed` and never counts as restored. A Codex
251
+ store that cannot be read (other than missing) is unknown and blocks the plan,
252
+ the close and the create rather than counting as not resumable. Claude's
253
+ zero-turn rule is unchanged; Pi resume viability is not checked.
254
+
255
+ Failed restoration. The operation blocks naming the peer, its session or thread
256
+ id and the choices. `resume` launches a failed peer again once its cause is
257
+ fixed; a thread that is still not resumable fails again with the same choices.
258
+
259
+ Status. `ahub recovery status <id>` reports the receipt (phase, step, update
260
+ time, error); whether a runner process holds the operation now (`runner.state`
261
+ `running` with its pid, or `none`); `stale` when the receipt says `running` but
262
+ no runner holds it; each project's phase and effect receipts (`closed:<peer>`,
263
+ `restored:<peer>` as `done`, `pending` or `failed`); whether the shared plugin
264
+ and the global CLI were installed; any lost continuity and disposition; and
265
+ `next`, the commands that apply now. It never contains task or message text.
266
+ `resume` does not start a second runner while one is alive.
267
+
268
+ Disposition. Two human-only choices, each with a required `--reason`, run from
269
+ an ordinary terminal (agent shells are refused by the CLI identity gate), claim
270
+ the runner (refusing while one is alive) and append to the receipt's audit:
271
+
272
+ - `ahub recovery dispose <id> --fresh-session <peer> --reason <text>` applies
273
+ only to a Codex or Claude peer whose restoration is receipted `failed`; Pi is
274
+ refused, because a restored hub refills a Pi start from its recorded resume.
275
+ Like reconnect-only (#206), it needs a target that reads
276
+ `recovery-waivers.json`: staging refuses a target without it when a
277
+ reconnect-only session is planned, and the fresh launch is refused on one. Per affected project
278
+ it records the lost session or thread id (`fresh`) and schedules `resume`,
279
+ which writes an operation-fenced waiver so the restored daemon accepts a new
280
+ session for that peer, launches the peer without a resume id in a new
281
+ terminal, records the id the daemon then reports, and verifies everything
282
+ else as before. The operation may then complete; its status keeps the lost
283
+ continuity.
284
+ - `ahub recovery dispose <id> --stop-and-archive --reason <text>` abandons the
285
+ operation. It inspects every project before acting. Every unverified project
286
+ must read as running, stopped or missing (`disposeRefusal`): any other reading
287
+ (unavailable, starting, stopping, incompatible, or an inspection that failed)
288
+ refuses the whole disposition, and a project whose directory is gone is
289
+ recorded as such (nothing to stop or archive). A
290
+ source this operation holds and has not committed gets its hold aborted and
291
+ keeps running. A target this operation started and has not released (a
292
+ running daemon in phase `restored` under this operation id, other than the
293
+ source, whether or not the receipt recorded its instance) is stopped by its
294
+ live instance through the lifecycle stop, which verifies the instance and
295
+ waits until its manifest and registry claim are gone, and is then inspected
296
+ again. A daemon this operation does not own, or one already released, is left
297
+ running. Each project's outcome is recorded as it happens; a disposition that
298
+ stops partway keeps the lock, and resume and `--fresh-session` then refuse
299
+ until `--stop-and-archive` is run again and finishes. A stopped project's committed
300
+ snapshot of this operation moves to `restart.abandoned.<hash>.json`, which is
301
+ never replayed, so an ordinary `up` works again. Queued envelopes are not
302
+ delivered from that file; a hub with a delivery journal (protocol 10 and
303
+ later) reloads the queues it persisted in `hub.db` on its next start.
304
+ Replacement terminals the operation created stay open, attached to nothing
305
+ until a hub runs again. Only then is the operation
306
+ recorded `cancelled` with its disposition (per-project outcome, plugin and CLI
307
+ state) and the lock released. It is never recorded as completed, the global
308
+ CLI is not promoted, and terminals it closed stay closed. A project whose
309
+ target ran is started again with the target's CLI. The archived reset proposed
310
+ in #214 can follow: no lock or replayable snapshot is left in its way.
311
+ - `abort` stays the escape for a preflight without effects; its refusal names
312
+ the disposition. It also cancels a prepared project whose source still runs as
313
+ the same instance after its hold lapsed, which was never committed. A roster
314
+ change found while no effects are recorded offers `abort` exactly when
315
+ `abortRefusal` allows it (this operation's lock refuses a new upgrade until it
316
+ is cancelled).
317
+
318
+ #### Receipts, evidence and next actions
319
+
320
+ Resume, abort, dispose, the `next` list of `status` and every error follow this
321
+ table. A `failed` receipt stays `failed` until the relaunch replaces it with
322
+ `pending`, so a refusal on the way (an unreadable store, a launch found live, a
323
+ target without waivers) keeps its choices. `--fresh-session` is neither offered
324
+ nor accepted when the target cannot read recovery waivers; `status` then says
325
+ why in its own `freshSession` field, since `next` holds commands only. A runner
326
+ record that cannot be read shows as `unknown` in `status`, never as no runner;
327
+ while it reads so, resume, abort and dispose are all refused (the runner claim
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
334
+ (`nextActions`), in this order: resume (which also launches a failed peer
335
+ again), unless it can never get past what is live (`resumeBlocked`): a
336
+ recorded final refusal (staging refused the fixed target or preserved source), a
337
+ missing project directory, a hub that speaks another control protocol, a
338
+ runtime other than the one the receipt expects (a replaced source; a target not
339
+ fenced to this operation, another instance or another version), a stopped
340
+ pending source, a prepared source of a #215 coordinator that stopped with no
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
346
+ Codex or Claude restoration, only where resume is offered (resume launches the
347
+ new session, and dispose refuses `--fresh-session` on the same predicate); and
348
+ last stop-and-archive where `disposeRefusal` allows it, or else `wait until
349
+ <project>'s hub settles, then <c> recovery status <id>`. `test/upgrade.test.ts`
350
+ holds `next` to the commands with a fake driver for one project with no peers:
351
+ ten receipt states (pending, prepared, prepared with a sent commit, prepared by
352
+ an older coordinator, prepared with a closed terminal, stopped, started, started
353
+ with a failed Codex restoration, peers restored, a partway stop-and-archive)
354
+ crossed with ten live states (as planned, replaced, held by another operation,
355
+ crashed, crashed with the snapshot kept, unavailable, starting, stopping,
356
+ incompatible, missing); the older coordinator's operation is run there by the
357
+ current runner, and its fakes never fail start, restore or verify (terminals,
358
+ Orca and peers are covered by their own tests, not by this one). Abort, stop-and-archive and `--fresh-session codex` are offered
359
+ exactly when the command accepts them on a twin fixture, and where resume is
360
+ offered it makes progress or its error names a step a person takes first. An error gives only its own step (the
361
+ last column below) and the runner ends it with that list (`next actions: ...`),
362
+ read from the same sources as `status`, so it names abort exactly when
363
+ `abortRefusal` allows it, the same as `status`, and never
364
+ "make a new plan" alone: this operation's lock refuses a new one. `<c>` is the operation's own coordinator, printed in full as
365
+ `bun <preserved source>/src/cli/main.js`: during an upgrade the global `ahub`
366
+ may still be the older release, whose `recovery` lacks these commands.
367
+ "Effects" means any project past `prepared` or any terminal receipt in the whole
368
+ operation. Stop-and-archive (`<c> recovery dispose <id> --stop-and-archive
369
+ --reason <text>`) is offered, as the last `next` entry, exactly when
370
+ `disposeRefusal` allows it; the rows give what else applies.
371
+
372
+ A recorded launcher is the `terminal-recovery.json` row for that peer on the
373
+ target instance. It is live when its process signature matches, gone when the
374
+ pid no longer exists (ESRCH) or now belongs to another process, and unknown when
375
+ the pid exists but its identity cannot be read. Unknown is never treated as gone. At planning, an
376
+ unknown Claude launcher blocks: whether the attached session is managed cannot
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.
386
+ Restore reads this evidence in one place: the target's report, which counts only
387
+ when the target runs as the expected instance, then the recorded launcher. A
388
+ target that does not, a launcher that cannot be read, or a launcher record file
389
+ that cannot be read or parsed is unknown and blocks without changing a receipt;
390
+ the record file is written atomically (temp + rename), and a launch is not recorded
391
+ over a record file that cannot be read (that would erase the other launchers'
392
+ records). `ahub codex` and `ahub pi --mode tui` launched by the operation that
393
+ holds the lock record themselves before the hub's start round trip. Whether a
394
+ target reads waivers is one check, used by staging, restore, `next` and dispose. A truncated Orca
395
+ inventory never shows a terminal as gone. While a stop-and-archive is recorded,
396
+ `resume` is refused by the running release, whichever coordinator started the
397
+ operation, and the runner keeps the disposition's own error. Abort and `next` decide with one predicate
398
+ (`abortRefusal`). `status`, abort and the runner's errors read the same live
399
+ state: every project not yet verified, its source or its target. A manifest
400
+ whose pid no longer exists reads as stopped, whatever protocol it names; probing
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
413
+ unreleased restart snapshot its state directory holds.
414
+ An attached session is the target's report of that peer online with a thread
415
+ (Codex) or session (Claude, Pi) id.
416
+
417
+ | Receipt | Live evidence | Resume | Other actions | Next action text |
418
+ | --- | --- | --- | --- | --- |
419
+ | project `pending` or `prepared`, no effects | roster changed | blocks | abort where `abortRefusal` allows it, dispose | `end that <peer> session` or `restore <peer>'s original session` `before resuming, or end this operation: a new plan can be made once it is cancelled or ended` |
420
+ | project `prepared`, hold ours | roster as planned | roster checked again, then close and commit | abort if no effects | none (it proceeds) |
421
+ | project `prepared`, hold ours | roster changed, effects | blocks, hold kept | dispose | `end that <peer> session` (joined after the plan, or its terminal was closed) or `restore <peer>'s original session`, `then <c> recovery resume <id>` |
422
+ | project `prepared`, hold lapsed (same source instance) | roster as planned | re-prepare, check roster, close, commit | abort if no effects | none |
423
+ | project `prepared`, hold lapsed | roster changed | as the two rows above | as above | as above |
424
+ | project `pending` | source replaced, stopped or missing | blocks, nothing touched; not offered | abort if no effects, dispose | `source runtime changed` |
425
+ | project `pending` or `prepared` | source unavailable, starting or stopping | blocks until it answers | abort as `abortRefusal` decides; stop-and-archive refused until it reads | `wait until it answers` |
426
+ | project `prepared` | source replaced | blocks, nothing touched; not offered | abort if no effects (cancels and leaves that daemon alone), dispose | stop-and-archive |
427
+ | project `pending` or `prepared` | source held by another operation | blocks, nothing touched, until that operation ends | abort if no effects (leaves that hold alone), dispose | `wait until that operation ends or its hold lapses` |
428
+ | any project not verified | project directory missing | blocks; not offered | abort as `abortRefusal` decides, dispose | `the project directory is missing` |
429
+ | any project not verified | hub speaks another control protocol (incompatible) | blocks; not offered | abort as `abortRefusal` decides; stop-and-archive refused | `wait until <project>'s hub settles` (end it by hand: operations guide) |
430
+ | project `prepared` with `commitSent` (a per-project receipt flag written right before the commit request; `step` is rewritten on every resume and is not evidence) | any (the commit may have been sent) | continues from the commit; a stopped source starts the target only from this operation's snapshot, so without it resume is not offered | dispose; abort is neither offered nor accepted | `<c> recovery resume <id>` |
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) |
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 |
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>` |
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>` |
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` |
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`) |
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 |
440
+ | any, at staging | the protocol probe did not answer (killed, exit 1) before the first staging | blocks, not final | dispose; abort if no effects | `resume again once it can run`; once staged, the checks are not run again |
441
+ | disposition recorded, first act not finished | any | refused | abort refused; stop-and-archive where `disposeRefusal` allows it | as the disposition row below |
442
+ | `closed:<peer>` `pending` | Orca still lists the terminal | blocks | dispose | `close Orca terminal <handle> (the login shell it runs in) by hand, then <c> recovery resume <id>` |
443
+ | `closed:<peer>` done | peer attached again | blocks | dispose | `end that <peer> session, then <c> recovery resume <id>` |
444
+ | `restored:<peer>` `pending` or `failed` | session attached with the planned id (or an accepted new one: fresh choice, planned fresh start, zero-turn Claude) | recorded as restored | none: `--fresh-session` is neither offered nor accepted while the inspection shows the planned session attached; when it cannot tell (not inspected, target unreadable), the `failed` receipt alone decides, and a chosen fresh session whose original then attaches is cleared on resume while its audit entry stays | none |
445
+ | `restored:<peer>` `pending` or `failed` | another session attached | blocks | dispose | `end that <peer> session and close its terminal, then <c> recovery resume <id>` |
446
+ | `restored:<peer>` `pending` or `failed` | no session, recorded launcher live or unknown | blocks | dispose | `wait until it attaches, or end it and close terminal <handle>, then <c> recovery resume <id>` |
447
+ | `restored:<peer>` `pending` | no session, launcher gone or never recorded | receipted `failed`, blocks | fresh session (Codex, Claude), dispose | the failed-restoration choices below |
448
+ | `restored:<peer>` `failed` | no session, launcher gone or never recorded | launches again (fresh if chosen) | fresh session (Codex, Claude), dispose | the failed-restoration choices below |
449
+ | `restored:codex` absent | rollout missing | receipted `failed`, blocks, no terminal created | fresh session, dispose | the failed-restoration choices below |
450
+ | any, before close or create | Codex store unreadable | blocks, nothing closed or created | dispose | `make <store> readable, then <c> recovery resume <id>` |
451
+ | `restored:<peer>` absent | the planned session attached | recorded as restored | none | none |
452
+ | `restored:<peer>` absent | another session attached, or a recorded launcher live | blocks, no terminal created | dispose | `end that <peer> session and close its terminal` (or `wait until it attaches, or end it and close terminal <handle>`), `then <c> recovery resume <id>` |
453
+ | any receipt being settled or launched | target hub not running as the expected instance (unavailable, starting or stopping; a stopped or other target is settled by the rows above), or a launcher or launcher record file that cannot be read | blocks; no receipt changes, nothing created | dispose once the target reads running or stopped | `wait until the target answers` (or wait for / end the launcher, or inspect the record file and move it aside), `then <c> recovery resume <id>` |
454
+ | `fresh` recorded for a peer | none live | launches it without a resume id, records the new id | dispose | none |
455
+ | disposition recorded, not finished | any | refused | stop-and-archive only | `rerun <c> recovery dispose <id> --stop-and-archive --reason <text>` where `disposeRefusal` allows it, else `wait until <project>'s hub settles, then <c> recovery status <id>` |
456
+ | `completed` or `cancelled` | any | nothing to do | none | none |
457
+
458
+ The failed-restoration choices are `<c> recovery dispose <id> --fresh-session
459
+ <peer> --reason <text>` (Codex or Claude; records the lost session and resumes;
460
+ if the original session attaches after all, the recorded loss is cleared) and
461
+ stop-and-archive. When the operation's own coordinator predates these commands
462
+ (it has no `dispose`, refuses abort and resume on a lapsed hold and prints no
463
+ `next`), `<c>` names the running release for every action: abort, status and
464
+ dispose run in that process, while resume still runs the operation's own runner
465
+ and its `next` entry says what that runner cannot do. Stop-and-archive stops a
466
+ target that speaks an older control protocol at that protocol (a `kill` fenced
467
+ by its instance), then waits for its manifest and registry claim as the
468
+ lifecycle stop does. The disposition and its audit are written before the first
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
479
+ `start` round trip; an ordinary launch records it after, so a refused start never
480
+ replaces the record of a Codex already running.
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
+
502
+ A Codex thread with no rollout is not blocked at the plan only when the hub saw
503
+ Codex start it (`native_thread` with `fresh`, logged by the adapter when it
504
+ adopts a thread) and logged no Codex `turn_start` while it was the adopted
505
+ thread (Codex writes the rollout with the first message). The plan then lists
506
+ it under `freshStart`, says so, and it restarts as a new session with nothing to
507
+ lose. Detaching forgets nothing; a thread whose start the log does not show
508
+ (resumed, an older hub, a pruned log) is unsure and stays a plan blocker, as
509
+ does one with turns. A planned fresh start still needs the store to show the
510
+ rollout missing: an unreadable store blocks the close and the create.
@@ -306,4 +306,14 @@ related: 261004-review-agent-hub-switchyard-comparison.md, 261004-plan-agent-hub
306
306
  - Existing sidecar routes remain optional and backward compatible; `[hub_routes]` never enters generated sidecar TOML. The sidecar retirement decision remains evidence-driven.
307
307
  - Each required phase is a separate reviewable commit and PR. No release or production configuration changes are implied.
308
308
 
309
+ ## Amendment: session-aware stay/switch (#197, 2026-10-09)
310
+
311
+ - The stage tier is no longer re-decided in isolation on every call. `planSwitch(pin, fresh, turn, cost)` sits next to `selectStage` in `src/models/route/stage.ts`; `hub/auto` and local `stage` routes both reach it through `stayOrSwitch`, so they record the same trace.
312
+ - Rules: a new session (the relay's `routeSessionKey`, the local route scope) or a compaction starts a fresh pin; a hard override escalates on any turn; a tool-result turn stays on the pinned tier; other changes wait for a user turn; a de-escalation whose conversation exceeds `max_switch_prefill_tokens` stays; a tier whose backend cannot hold the conversation is never chosen. Nothing summarizes, trims or rewrites the forwarded conversation.
313
+ - `hold_turns` is unchanged: the stage hold still counts calls, and enforced, it can only extend past the next user turn, never end inside a tool loop.
314
+ - Enforced, a clean test pass inside a tool loop does not de-escalate: it ends the stage hold, but the efficient tier waits for the next user turn. A dimension-driven escalation that a tool loop defers is not remembered by the planner: it reaches the next user turn only while `selectStage`'s hold still holds the capable tier, so a loop longer than the hold drops it. Hard overrides are never deferred: the planner reads pickTier's override predicate on its own (`hardOverride`), so a repeated failure or critical severity during a capable hold still escalates at once.
315
+ - `routing.toml` top-level `stay_switch = "off" | "shadow" | "enforce"`, default `shadow`, which changes no routing decision. Local routes see no context limits (OmniRoute publishes none to the hub), so only `hub/auto` filters by fit.
316
+ - Route events gain `turnType` (the issue's `turn`: the local `turn` field already holds the turn id), `prefillTokens` (omitted on PII routes, as a private envelope omits its size; a PII route's planner gets no prefill bound, so no plan or reason depends on the size), `staySwitch`, `plan` and `reason`. `ahub report` counts model changes per session and inside tool loops per peer and route; sessions are counted from pins, so they are unknown while `stay_switch` is `off`.
317
+ - Deferred: joining provider cache counters to switches (the relay has no usage events), and switching the default to `enforce` until the measurement in #197 AC6.
318
+
309
319
  Implementation tracking: [#124](https://github.com/STAIxBWLB/agent-hub/issues/124), core [#125](https://github.com/STAIxBWLB/agent-hub/issues/125), local [#126](https://github.com/STAIxBWLB/agent-hub/issues/126), Pi [#127](https://github.com/STAIxBWLB/agent-hub/issues/127), observations [#128](https://github.com/STAIxBWLB/agent-hub/issues/128).
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "documents": {
4
4
  "README.md": {
5
- "verifiedAgainst": "25d6ffe00c490a3690fc81782a45e62bfb383803",
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": "25d6ffe00c490a3690fc81782a45e62bfb383803",
14
+ "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
15
15
  "paths": [
16
16
  "src/",
17
17
  "templates/"
18
18
  ]
19
19
  },
20
20
  "docs/operations.md": {
21
- "verifiedAgainst": "25d6ffe00c490a3690fc81782a45e62bfb383803",
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": "25d6ffe00c490a3690fc81782a45e62bfb383803",
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": "25d6ffe00c490a3690fc81782a45e62bfb383803",
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": "8b7d7254f1bb5602ec5d658e7814e236f8fb43e7",
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": "25d6ffe00c490a3690fc81782a45e62bfb383803",
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": "25d6ffe00c490a3690fc81782a45e62bfb383803",
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": "25d6ffe00c490a3690fc81782a45e62bfb383803",
98
+ "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
96
99
  "paths": [
97
100
  "src/hub/daemon.ts",
98
101
  "src/hub/control-client.ts",
@@ -114,20 +117,22 @@
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": "4b44b92dd2dc4b5515b6e87627fe4bdfcd6742c3",
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": {
130
- "verifiedAgainst": "8b7d7254f1bb5602ec5d658e7814e236f8fb43e7",
135
+ "verifiedAgainst": "ab2925f7848a2eb848e9f0ab664df297c2c0f5fb",
131
136
  "paths": [
132
137
  "src/models/",
133
138
  "src/hub/inference.ts",
@@ -136,7 +141,7 @@
136
141
  ]
137
142
  },
138
143
  "docs/agent-notes/tasks.md": {
139
- "verifiedAgainst": "4b44b92dd2dc4b5515b6e87627fe4bdfcd6742c3",
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": "25d6ffe00c490a3690fc81782a45e62bfb383803",
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.19",
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.19",
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",