@staix/agent-hub 0.12.19 → 0.12.20

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 (66) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +3 -3
  3. package/docs/agent-notes/adapters.md +1 -0
  4. package/docs/agent-notes/bus.md +1 -0
  5. package/docs/agent-notes/daemon.md +2 -1
  6. package/docs/agent-notes/models.md +3 -0
  7. package/docs/agent-notes/tasks.md +3 -2
  8. package/docs/events.md +4 -1
  9. package/docs/operations.md +413 -17
  10. package/docs/quickstart.md +1 -1
  11. package/docs/security.md +17 -10
  12. package/docs/smoke.md +58 -0
  13. package/docs/specs/2026-09-19-agent-hub-design.md +399 -3
  14. package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
  15. package/docs/specs/2026-09-20-upgrade-recovery-design.md +287 -0
  16. package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
  17. package/docs/verified.json +13 -13
  18. package/package.json +1 -1
  19. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  20. package/plugins/agent-hub/server.js +63 -25
  21. package/src/adapters/claude-channel.ts +61 -20
  22. package/src/adapters/codex-appserver.ts +3 -0
  23. package/src/adapters/local-worker.ts +4 -0
  24. package/src/cli/console-state.ts +325 -102
  25. package/src/cli/console.ts +44 -23
  26. package/src/cli/help.ts +120 -0
  27. package/src/cli/identity.ts +3 -3
  28. package/src/cli/launch.ts +2 -0
  29. package/src/cli/main.ts +171 -75
  30. package/src/cli/preview.ts +1 -0
  31. package/src/cli/reset.ts +194 -0
  32. package/src/cli/status-lines.ts +9 -5
  33. package/src/cli/terminal-recovery.ts +140 -36
  34. package/src/cli/upgrade-runtime.ts +274 -56
  35. package/src/cli/upgrade.ts +366 -41
  36. package/src/hub/ask.ts +1 -1
  37. package/src/hub/board.ts +15 -7
  38. package/src/hub/bus.ts +60 -4
  39. package/src/hub/child-process.ts +1 -0
  40. package/src/hub/conductor.ts +38 -6
  41. package/src/hub/control-client.ts +5 -3
  42. package/src/hub/daemon.ts +56 -9
  43. package/src/hub/events.ts +7 -1
  44. package/src/hub/execution-budget.ts +15 -6
  45. package/src/hub/hub-tools.ts +4 -4
  46. package/src/hub/inference.ts +136 -1
  47. package/src/hub/lifecycle.ts +7 -1
  48. package/src/hub/peers.ts +2 -0
  49. package/src/hub/recovery-store.ts +51 -3
  50. package/src/hub/report.ts +43 -1
  51. package/src/hub/restart.ts +25 -0
  52. package/src/hub/routing.ts +48 -16
  53. package/src/hub/snapshots.ts +3 -2
  54. package/src/hub/tasks.ts +236 -83
  55. package/src/local/deny.ts +2 -1
  56. package/src/models/mlx.ts +11 -8
  57. package/src/models/ollama.ts +2 -2
  58. package/src/models/relay.ts +190 -42
  59. package/src/models/route/relay-selector.ts +43 -31
  60. package/src/models/route/runtime.ts +17 -7
  61. package/src/models/route/signals.ts +12 -1
  62. package/src/models/route/stage.ts +57 -3
  63. package/src/omniroute/client.ts +1 -1
  64. package/src/ui/index.html +1 -0
  65. package/templates/AGENTS.block.md +1 -1
  66. package/templates/routing.toml +8 -0
@@ -163,3 +163,290 @@ The target coordinator accepts authenticated protocol 9, 10 and 11 sources, then
163
163
  uses protocol 11 after startup. Older supported managers are refreshed onto the
164
164
  current implementation. The real 0.6.4/protocol-9 transition and the 0.12.0/protocol-10
165
165
  development-hub transition are recorded in `docs/smoke.md`.
166
+
167
+ ### Unmanaged Claude sessions (#206)
168
+
169
+ Amended 2026-10-09. An attached Claude session is managed when a live `ahub claude`
170
+ launcher is recorded for the running daemon instance (`terminal-recovery.json`, its
171
+ launcher identity checked with the shared `processSignature`) and the session record
172
+ (`claude-session.json`) was written by that launch. Managed sessions are closed and
173
+ resumed in a new terminal as before.
174
+
175
+ Any other attached Claude session is unmanaged: a plain `claude`, an `ahub claude`
176
+ outside Orca, or one whose launcher has ended. Its session record cannot be bound to
177
+ the attached session, so the plan drops the id (no output names it) and lists the
178
+ peer under `reconnectOnly`: no terminal is inspected, closed or relaunched, and the
179
+ source roster check compares its membership only. Before starting the target the
180
+ coordinator writes an operation-fenced waiver (`recovery-waivers.json`, mode 0600),
181
+ so the restored daemon does not require the saved session id for that peer; release
182
+ still requires it to reattach, which the coordinator waits for up to 90 seconds
183
+ (three times the plugin's longest reconnect backoff). Reconnect-only requires the
184
+ source control protocol to equal the target's; otherwise the plan names a blocker
185
+ whose next action is to end the session or relaunch it with `ahub claude` in Orca.
186
+ A live launcher whose record was written by another launch is a blocker, never a
187
+ target. Refused `upgrade --yes` and `restart --yes`, and every dry-run, print each
188
+ blocker on stderr before the final line.
189
+
190
+ The channel's hello carries no launch id, so a plain `claude` that took the peer id
191
+ from a live managed session is classified as managed. Telling them apart needs the
192
+ launch id at hello, which is a protocol change and is not claimed here. A live
193
+ upgrade with an unmanaged session attached has not been run.
194
+
195
+ ### Partial operations (#215)
196
+
197
+ Amended 2026-10-09, before implementation.
198
+
199
+ Expired preparation. The source hold lapses after ten minutes. When a project is
200
+ still `prepared` (terminal effects may already be recorded) and its hold is gone,
201
+ resume re-prepares the same source instead of reporting "source is no longer
202
+ prepared". The running daemon must be the planned source instance: a replaced
203
+ daemon or a source held by another operation is refused, with nothing prepared,
204
+ closed or stopped. The coordinator prepares again under the same operation id,
205
+ waits for readiness within the preparation bound (on timeout it aborts only its
206
+ own hold) and checks the roster again. A peer whose terminal this operation
207
+ already closed must stay detached; every other peer must keep its planned
208
+ conversation. Effect receipts are never reset, so no terminal is closed twice,
209
+ and the exclusive runner claim and instance fencing are unchanged. The
210
+ re-prepared source records a closed peer as offline, so the target daemon does
211
+ not require that peer itself; the coordinator still requires every originally
212
+ attached peer back before release. A roster change while effects are recorded
213
+ names the peer and the choices below instead of asking for a new plan.
214
+
215
+ Native resume viability. A Codex thread counts as resumable only when a rollout
216
+ file naming it exists under `sessions/` of the store its restoration uses (the
217
+ launcher's captured `CODEX_HOME`, else `~/.codex`); an app-server thread id
218
+ alone proves nothing. The plan blocks a Codex peer without one, the coordinator
219
+ checks again before closing its terminal (closing nothing when it fails) and
220
+ before creating the replacement.
221
+
222
+ Launcher failure. Orca's `terminal create --command` types the command into a
223
+ login shell, which outlives it, so the terminal never exits with the launcher
224
+ and its exit is no evidence. The evidence is the hub's own: `ahub codex`,
225
+ `ahub claude` and `ahub pi --mode tui` record their launcher (pid and process
226
+ signature) in `terminal-recovery.json` for the terminal they run in, before
227
+ they start the native agent. A replacement is awaited in 5-second slices of
228
+ Orca's `tui-idle` wait (Orca 1.4.223 answers a timed-out wait with exit 1 and
229
+ `{"ok":false,"error":{"code":"timeout"}}`, which counts as not yet). Between
230
+ slices, and once the terminal reads idle (an idle terminal may be the shell the
231
+ launcher returned to), a recorded launcher that is gone means the launch failed,
232
+ recognized within one slice. A launcher whose identity cannot be read is unknown
233
+ and is waited for, never taken as gone. A handle Orca reports
234
+ `terminal_handle_stale` means the terminal is gone only when no recorded
235
+ launcher may still run and Orca no longer lists the terminal's incarnation;
236
+ otherwise the create stays uncertain. A launcher that died before recording
237
+ itself is not distinguished from a slow one, and whether Orca reports the bare
238
+ login shell as TUI-idle is not verified: if it does, the create stops at once as
239
+ not mappable to the session; if not, after the 10-minute readiness bound. Either
240
+ way it blocks as needing manual verification with the receipt `pending`, and the
241
+ next resume, finding no live launcher and no attached session, records `failed`. Resume settles a `pending` or `failed` receipt by what is live
242
+ (the table below): an attached planned (or accepted) session is the
243
+ restoration, a launcher that may run is waited for, and only nothing live makes
244
+ it `failed` or, for `failed`, launches again. No terminal is created while a
245
+ recorded launcher for the peer may run on the target or its session is attached.
246
+ A launch that failed, or a thread found not resumable before the launch, is
247
+ receipted `restored:<peer>` = `failed` and never counts as restored. A Codex
248
+ store that cannot be read (other than missing) is unknown and blocks the plan,
249
+ the close and the create rather than counting as not resumable. Claude's
250
+ zero-turn rule is unchanged; Pi resume viability is not checked.
251
+
252
+ Failed restoration. The operation blocks naming the peer, its session or thread
253
+ id and the choices. `resume` launches a failed peer again once its cause is
254
+ fixed; a thread that is still not resumable fails again with the same choices.
255
+
256
+ Status. `ahub recovery status <id>` reports the receipt (phase, step, update
257
+ time, error); whether a runner process holds the operation now (`runner.state`
258
+ `running` with its pid, or `none`); `stale` when the receipt says `running` but
259
+ no runner holds it; each project's phase and effect receipts (`closed:<peer>`,
260
+ `restored:<peer>` as `done`, `pending` or `failed`); whether the shared plugin
261
+ and the global CLI were installed; any lost continuity and disposition; and
262
+ `next`, the commands that apply now. It never contains task or message text.
263
+ `resume` does not start a second runner while one is alive.
264
+
265
+ Disposition. Two human-only choices, each with a required `--reason`, run from
266
+ an ordinary terminal (agent shells are refused by the CLI identity gate), claim
267
+ the runner (refusing while one is alive) and append to the receipt's audit:
268
+
269
+ - `ahub recovery dispose <id> --fresh-session <peer> --reason <text>` applies
270
+ only to a Codex or Claude peer whose restoration is receipted `failed`; Pi is
271
+ refused, because a restored hub refills a Pi start from its recorded resume.
272
+ Like reconnect-only (#206), it needs a target that reads
273
+ `recovery-waivers.json`: staging refuses a target without it when a
274
+ reconnect-only session is planned, and the fresh launch is refused on one. Per affected project
275
+ it records the lost session or thread id (`fresh`) and schedules `resume`,
276
+ which writes an operation-fenced waiver so the restored daemon accepts a new
277
+ session for that peer, launches the peer without a resume id in a new
278
+ terminal, records the id the daemon then reports, and verifies everything
279
+ else as before. The operation may then complete; its status keeps the lost
280
+ continuity.
281
+ - `ahub recovery dispose <id> --stop-and-archive --reason <text>` abandons the
282
+ operation. It inspects every project before acting. Every unverified project
283
+ must read as running, stopped or missing (`disposeRefusal`): any other reading
284
+ (unavailable, starting, stopping, incompatible, or an inspection that failed)
285
+ refuses the whole disposition, and a project whose directory is gone is
286
+ recorded as such (nothing to stop or archive). A
287
+ source this operation holds and has not committed gets its hold aborted and
288
+ keeps running. A target this operation started and has not released (a
289
+ running daemon in phase `restored` under this operation id, other than the
290
+ source, whether or not the receipt recorded its instance) is stopped by its
291
+ live instance through the lifecycle stop, which verifies the instance and
292
+ waits until its manifest and registry claim are gone, and is then inspected
293
+ again. A daemon this operation does not own, or one already released, is left
294
+ running. Each project's outcome is recorded as it happens; a disposition that
295
+ stops partway keeps the lock, and resume and `--fresh-session` then refuse
296
+ until `--stop-and-archive` is run again and finishes. A stopped project's committed
297
+ snapshot of this operation moves to `restart.abandoned.<hash>.json`, which is
298
+ never replayed, so an ordinary `up` works again. Queued envelopes are not
299
+ delivered from that file; a hub with a delivery journal (protocol 10 and
300
+ later) reloads the queues it persisted in `hub.db` on its next start.
301
+ Replacement terminals the operation created stay open, attached to nothing
302
+ until a hub runs again. Only then is the operation
303
+ recorded `cancelled` with its disposition (per-project outcome, plugin and CLI
304
+ state) and the lock released. It is never recorded as completed, the global
305
+ CLI is not promoted, and terminals it closed stay closed. A project whose
306
+ target ran is started again with the target's CLI. The archived reset proposed
307
+ in #214 can follow: no lock or replayable snapshot is left in its way.
308
+ - `abort` stays the escape for a preflight without effects; its refusal names
309
+ the disposition. It also cancels a prepared project whose source still runs as
310
+ the same instance after its hold lapsed, which was never committed. A roster
311
+ change found while no effects are recorded offers `abort` exactly when
312
+ `abortRefusal` allows it (this operation's lock refuses a new upgrade until it
313
+ is cancelled).
314
+
315
+ #### Receipts, evidence and next actions
316
+
317
+ Resume, abort, dispose, the `next` list of `status` and every error follow this
318
+ table. A `failed` receipt stays `failed` until the relaunch replaces it with
319
+ `pending`, so a refusal on the way (an unreadable store, a launch found live, a
320
+ target without waivers) keeps its choices. `--fresh-session` is neither offered
321
+ nor accepted when the target cannot read recovery waivers; `status` then says
322
+ why in its own `freshSession` field, since `next` holds commands only. A runner
323
+ record that cannot be read shows as `unknown` in `status`, never as no runner;
324
+ while it reads so, resume, abort and dispose are all refused (the runner claim
325
+ cannot tell its owner), so `next` names only `status` again. Otherwise `status` and every error that lists choices build them with one function
326
+ (`nextActions`), in this order: resume (which also launches a failed peer
327
+ again), unless it can never get past what is live (`resumeBlocked`): a
328
+ recorded final refusal (staging refused the fixed target or preserved source), a
329
+ missing project directory, a hub that speaks another control protocol, a
330
+ runtime other than the one the receipt expects (a replaced source; a target not
331
+ fenced to this operation, another instance or another version), a stopped
332
+ pending source, a prepared source of a #215 coordinator that stopped with no
333
+ commit request, a target that stopped after it started (restarting it is not
334
+ supported in this release: its peers would have to be relaunched against the
335
+ new instance), or any other stopped runtime without this operation's unreleased
336
+ restart snapshot to start from (unavailable, starting or stopping is waited
337
+ for). Then abort where `abortRefusal` allows it; a fresh session for each failed
338
+ Codex or Claude restoration, only where resume is offered (resume launches the
339
+ new session, and dispose refuses `--fresh-session` on the same predicate); and
340
+ last stop-and-archive where `disposeRefusal` allows it, or else `wait until
341
+ <project>'s hub settles, then <c> recovery status <id>`. `test/upgrade.test.ts`
342
+ holds `next` to the commands with a fake driver for one project with no peers:
343
+ ten receipt states (pending, prepared, prepared with a sent commit, prepared by
344
+ an older coordinator, prepared with a closed terminal, stopped, started, started
345
+ with a failed Codex restoration, peers restored, a partway stop-and-archive)
346
+ crossed with ten live states (as planned, replaced, held by another operation,
347
+ crashed, crashed with the snapshot kept, unavailable, starting, stopping,
348
+ incompatible, missing); the older coordinator's operation is run there by the
349
+ current runner, and its fakes never fail start, restore or verify (terminals,
350
+ Orca and peers are covered by their own tests, not by this one). Abort, stop-and-archive and `--fresh-session codex` are offered
351
+ exactly when the command accepts them on a twin fixture, and where resume is
352
+ offered it makes progress or its error names a step a person takes first. An error gives only its own step (the
353
+ last column below) and the runner ends it with that list (`next actions: ...`),
354
+ read from the same sources as `status`, so it names abort exactly when
355
+ `abortRefusal` allows it, the same as `status`, and never
356
+ "make a new plan" alone: this operation's lock refuses a new one. `<c>` is the operation's own coordinator, printed in full as
357
+ `bun <preserved source>/src/cli/main.js`: during an upgrade the global `ahub`
358
+ may still be the older release, whose `recovery` lacks these commands.
359
+ "Effects" means any project past `prepared` or any terminal receipt in the whole
360
+ operation. Stop-and-archive (`<c> recovery dispose <id> --stop-and-archive
361
+ --reason <text>`) is offered, as the last `next` entry, exactly when
362
+ `disposeRefusal` allows it; the rows give what else applies.
363
+
364
+ A recorded launcher is the `terminal-recovery.json` row for that peer on the
365
+ target instance. It is live when its process signature matches, gone when the
366
+ pid no longer exists (ESRCH) or now belongs to another process, and unknown when
367
+ the pid exists but its identity cannot be read. Unknown is never treated as gone. At planning, an
368
+ unknown Claude launcher blocks: whether the attached session is managed cannot
369
+ be told.
370
+ Restore reads this evidence in one place: the target's report, which counts only
371
+ when the target runs as the expected instance, then the recorded launcher. A
372
+ target that does not, a launcher that cannot be read, or a launcher record file
373
+ that cannot be read or parsed is unknown and blocks without changing a receipt;
374
+ the record file is written atomically (temp + rename), and a launch is not recorded
375
+ over a record file that cannot be read (that would erase the other launchers'
376
+ records). `ahub codex` and `ahub pi --mode tui` launched by the operation that
377
+ holds the lock record themselves before the hub's start round trip. Whether a
378
+ target reads waivers is one check, used by staging, restore, `next` and dispose. A truncated Orca
379
+ inventory never shows a terminal as gone. While a stop-and-archive is recorded,
380
+ `resume` is refused by the running release, whichever coordinator started the
381
+ operation, and the runner keeps the disposition's own error. Abort and `next` decide with one predicate
382
+ (`abortRefusal`). `status`, abort and the runner's errors read the same live
383
+ state: every project not yet verified, its source or its target. A manifest
384
+ whose pid no longer exists reads as stopped, whatever protocol it names; probing
385
+ it would read unavailable forever. A stopped runtime also reports whose
386
+ unreleased restart snapshot its state directory holds.
387
+ An attached session is the target's report of that peer online with a thread
388
+ (Codex) or session (Claude, Pi) id.
389
+
390
+ | Receipt | Live evidence | Resume | Other actions | Next action text |
391
+ | --- | --- | --- | --- | --- |
392
+ | 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` |
393
+ | project `prepared`, hold ours | roster as planned | roster checked again, then close and commit | abort if no effects | none (it proceeds) |
394
+ | 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>` |
395
+ | project `prepared`, hold lapsed (same source instance) | roster as planned | re-prepare, check roster, close, commit | abort if no effects | none |
396
+ | project `prepared`, hold lapsed | roster changed | as the two rows above | as above | as above |
397
+ | project `pending` | source replaced, stopped or missing | blocks, nothing touched; not offered | abort if no effects, dispose | `source runtime changed` |
398
+ | 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` |
399
+ | project `prepared` | source replaced | blocks, nothing touched; not offered | abort if no effects (cancels and leaves that daemon alone), dispose | stop-and-archive |
400
+ | 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` |
401
+ | any project not verified | project directory missing | blocks; not offered | abort as `abortRefusal` decides, dispose | `the project directory is missing` |
402
+ | 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) |
403
+ | 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>` |
404
+ | project `prepared`, no `commitSent`, operation of a #215 coordinator | source stopped (crashed before any commit request) | blocks; the phase stays `prepared` (nothing was committed, so there is nothing to start from); not offered | abort if no effects, dispose | `<c> recovery abort <id>` without effects, else stop-and-archive (the lock refuses starting that hub by hand) |
405
+ | project `prepared` | source unavailable, starting, stopping or not inspected | as above when it reads again | none: abort (its hold may still stand) and stop-and-archive are refused until it reads | `<c> recovery status <id>` once it answers |
406
+ | project `prepared`, operation of an older coordinator (no `commitSent` written) | source not running, or not inspected | as its own runner does | dispose once it reads running, stopped or missing; abort neither offered nor accepted (it may have committed) | `<c> recovery status <id>` |
407
+ | project `started` or `peers-restored` | target stopped (a crash or reboot after it started), with or without its snapshot | blocks; not offered: restarting it is not supported in this release | dispose; no fresh session | `the target stopped after it started; restarting it is not supported in this release` |
408
+ | project `stopped` | target stopped without this operation's snapshot (never committed) | blocks; not offered | dispose | `the target is stopped and this operation's restart snapshot is gone` |
409
+ | project `stopped`, `started` or `peers-restored` | target running unfenced, as another instance or another version | blocks; not offered | dispose | `daemon is not owned by this recovery operation` (or `daemon instance changed`, `target daemon version mismatch`) |
410
+ | 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 |
411
+ | 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 |
412
+ | disposition recorded, first act not finished | any | refused | abort refused; stop-and-archive where `disposeRefusal` allows it | as the disposition row below |
413
+ | `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>` |
414
+ | `closed:<peer>` done | peer attached again | blocks | dispose | `end that <peer> session, then <c> recovery resume <id>` |
415
+ | `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 |
416
+ | `restored:<peer>` `pending` or `failed` | another session attached | blocks | dispose | `end that <peer> session and close its terminal, then <c> recovery resume <id>` |
417
+ | `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>` |
418
+ | `restored:<peer>` `pending` | no session, launcher gone or never recorded | receipted `failed`, blocks | fresh session (Codex, Claude), dispose | the failed-restoration choices below |
419
+ | `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 |
420
+ | `restored:codex` absent | rollout missing | receipted `failed`, blocks, no terminal created | fresh session, dispose | the failed-restoration choices below |
421
+ | any, before close or create | Codex store unreadable | blocks, nothing closed or created | dispose | `make <store> readable, then <c> recovery resume <id>` |
422
+ | `restored:<peer>` absent | the planned session attached | recorded as restored | none | none |
423
+ | `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>` |
424
+ | 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>` |
425
+ | `fresh` recorded for a peer | none live | launches it without a resume id, records the new id | dispose | none |
426
+ | 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>` |
427
+ | `completed` or `cancelled` | any | nothing to do | none | none |
428
+
429
+ The failed-restoration choices are `<c> recovery dispose <id> --fresh-session
430
+ <peer> --reason <text>` (Codex or Claude; records the lost session and resumes;
431
+ if the original session attaches after all, the recorded loss is cleared) and
432
+ stop-and-archive. When the operation's own coordinator predates these commands
433
+ (it has no `dispose`, refuses abort and resume on a lapsed hold and prints no
434
+ `next`), `<c>` names the running release for every action: abort, status and
435
+ dispose run in that process, while resume still runs the operation's own runner
436
+ and its `next` entry says what that runner cannot do. Stop-and-archive stops a
437
+ target that speaks an older control protocol at that protocol (a `kill` fenced
438
+ by its instance), then waits for its manifest and registry claim as the
439
+ lifecycle stop does. The disposition and its audit are written before the first
440
+ act. A recovery launch of `ahub codex` records its launcher before the hub's
441
+ `start` round trip; an ordinary launch records it after, so a refused start never
442
+ replaces the record of a Codex already running.
443
+
444
+ A Codex thread with no rollout is not blocked at the plan only when the hub saw
445
+ Codex start it (`native_thread` with `fresh`, logged by the adapter when it
446
+ adopts a thread) and logged no Codex `turn_start` while it was the adopted
447
+ thread (Codex writes the rollout with the first message). The plan then lists
448
+ it under `freshStart`, says so, and it restarts as a new session with nothing to
449
+ lose. Detaching forgets nothing; a thread whose start the log does not show
450
+ (resumed, an older hub, a pruned log) is unsure and stays a plan blocker, as
451
+ does one with turns. A planned fresh start still needs the store to show the
452
+ 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": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
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": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
15
15
  "paths": [
16
16
  "src/",
17
17
  "templates/"
18
18
  ]
19
19
  },
20
20
  "docs/operations.md": {
21
- "verifiedAgainst": "25d6ffe00c490a3690fc81782a45e62bfb383803",
21
+ "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
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": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
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": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
47
47
  "paths": [
48
48
  "src/adapters/",
49
49
  "src/pi/",
@@ -56,7 +56,7 @@
56
56
  ]
57
57
  },
58
58
  "docs/agent-notes/benchmarks.md": {
59
- "verifiedAgainst": "8b7d7254f1bb5602ec5d658e7814e236f8fb43e7",
59
+ "verifiedAgainst": "84a3c404d3fe3e293495009cffa11b204d0d3523",
60
60
  "paths": [
61
61
  "scripts/benchmarks/",
62
62
  "test/benchmarks/",
@@ -68,7 +68,7 @@
68
68
  ]
69
69
  },
70
70
  "docs/agent-notes/budget.md": {
71
- "verifiedAgainst": "25d6ffe00c490a3690fc81782a45e62bfb383803",
71
+ "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
72
72
  "paths": [
73
73
  "src/hub/budget.ts",
74
74
  "src/cli/statusline-tee.ts",
@@ -80,7 +80,7 @@
80
80
  ]
81
81
  },
82
82
  "docs/agent-notes/bus.md": {
83
- "verifiedAgainst": "25d6ffe00c490a3690fc81782a45e62bfb383803",
83
+ "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
84
84
  "paths": [
85
85
  "src/hub/bus.ts",
86
86
  "src/hub/envelope.ts",
@@ -92,7 +92,7 @@
92
92
  ]
93
93
  },
94
94
  "docs/agent-notes/daemon.md": {
95
- "verifiedAgainst": "25d6ffe00c490a3690fc81782a45e62bfb383803",
95
+ "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
96
96
  "paths": [
97
97
  "src/hub/daemon.ts",
98
98
  "src/hub/control-client.ts",
@@ -118,7 +118,7 @@
118
118
  ]
119
119
  },
120
120
  "docs/agent-notes/local-worker.md": {
121
- "verifiedAgainst": "4b44b92dd2dc4b5515b6e87627fe4bdfcd6742c3",
121
+ "verifiedAgainst": "84a3c404d3fe3e293495009cffa11b204d0d3523",
122
122
  "paths": [
123
123
  "src/adapters/local-worker.ts",
124
124
  "src/local/",
@@ -127,7 +127,7 @@
127
127
  ]
128
128
  },
129
129
  "docs/agent-notes/models.md": {
130
- "verifiedAgainst": "8b7d7254f1bb5602ec5d658e7814e236f8fb43e7",
130
+ "verifiedAgainst": "ab2925f7848a2eb848e9f0ab664df297c2c0f5fb",
131
131
  "paths": [
132
132
  "src/models/",
133
133
  "src/hub/inference.ts",
@@ -136,7 +136,7 @@
136
136
  ]
137
137
  },
138
138
  "docs/agent-notes/tasks.md": {
139
- "verifiedAgainst": "4b44b92dd2dc4b5515b6e87627fe4bdfcd6742c3",
139
+ "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
140
140
  "paths": [
141
141
  "src/hub/tasks.ts",
142
142
  "src/hub/board.ts",
@@ -151,7 +151,7 @@
151
151
  ]
152
152
  },
153
153
  "docs/agent-notes/tests.md": {
154
- "verifiedAgainst": "25d6ffe00c490a3690fc81782a45e62bfb383803",
154
+ "verifiedAgainst": "f18ba89fe7f41d3ff11c87ecdd02886386787227",
155
155
  "paths": [
156
156
  "test/",
157
157
  "scripts/check.sh",
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.20",
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.20",
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",