@staix/agent-hub 0.12.22 → 0.12.23

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 (78) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +16 -8
  3. package/docs/agent-notes/adapters.md +10 -5
  4. package/docs/agent-notes/benchmarks.md +3 -0
  5. package/docs/agent-notes/budget.md +4 -0
  6. package/docs/agent-notes/daemon.md +8 -1
  7. package/docs/agent-notes/local-worker.md +3 -2
  8. package/docs/agent-notes/tasks.md +2 -1
  9. package/docs/agent-notes/tests.md +2 -0
  10. package/docs/events.md +7 -3
  11. package/docs/operations.md +232 -26
  12. package/docs/quickstart.md +9 -4
  13. package/docs/security.md +15 -6
  14. package/docs/smoke.md +72 -5
  15. package/docs/specs/2026-09-19-agent-hub-design.md +77 -10
  16. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  17. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  18. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  19. package/docs/verified.json +25 -16
  20. package/package.json +1 -1
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +14 -3
  23. package/src/adapters/acp-quota.ts +13 -0
  24. package/src/adapters/acp.ts +95 -13
  25. package/src/adapters/codex-appserver.ts +43 -13
  26. package/src/cli/bench.ts +2 -1
  27. package/src/cli/console-state.ts +28 -22
  28. package/src/cli/console.ts +20 -10
  29. package/src/cli/help.ts +19 -14
  30. package/src/cli/identity.ts +2 -2
  31. package/src/cli/init.ts +1 -1
  32. package/src/cli/main.ts +272 -159
  33. package/src/cli/output.ts +363 -0
  34. package/src/cli/preview.ts +2 -1
  35. package/src/cli/reset.ts +3 -0
  36. package/src/cli/status-lines.ts +0 -28
  37. package/src/cli/terminal-recovery.ts +27 -1
  38. package/src/cli/upgrade-runtime.ts +24 -7
  39. package/src/cli/upgrade.ts +23 -5
  40. package/src/hub/board.ts +2 -0
  41. package/src/hub/budget.ts +39 -7
  42. package/src/hub/bus.ts +10 -11
  43. package/src/hub/child-process.ts +11 -0
  44. package/src/hub/conductor.ts +23 -11
  45. package/src/hub/config-trust.ts +16 -5
  46. package/src/hub/conflicts.ts +2 -0
  47. package/src/hub/context-window.ts +8 -1
  48. package/src/hub/crash.ts +2 -1
  49. package/src/hub/daemon.ts +338 -23
  50. package/src/hub/db-timeout.ts +3 -0
  51. package/src/hub/delivery-journal.ts +2 -1
  52. package/src/hub/envelope.ts +1 -1
  53. package/src/hub/events.ts +2 -0
  54. package/src/hub/execution-budget.ts +2 -0
  55. package/src/hub/facts.ts +5 -4
  56. package/src/hub/git.ts +32 -0
  57. package/src/hub/hub-tools.ts +1 -1
  58. package/src/hub/lifecycle.ts +10 -4
  59. package/src/hub/manager.ts +18 -5
  60. package/src/hub/peers.ts +5 -3
  61. package/src/hub/permission-mode.ts +14 -0
  62. package/src/hub/project.ts +2 -1
  63. package/src/hub/recovery-error.ts +31 -0
  64. package/src/hub/recovery-store.ts +6 -5
  65. package/src/hub/registry.ts +2 -1
  66. package/src/hub/routing.ts +114 -13
  67. package/src/hub/settings.ts +392 -0
  68. package/src/hub/snapshots.ts +4 -2
  69. package/src/hub/start-mode.ts +48 -0
  70. package/src/hub/tasks.ts +129 -31
  71. package/src/hub/terminal-open.ts +64 -0
  72. package/src/hub/ui.ts +25 -13
  73. package/src/local/deny.ts +29 -7
  74. package/src/local/sandbox.ts +44 -6
  75. package/src/memory/recall.ts +2 -2
  76. package/src/models/mlx.ts +2 -1
  77. package/src/pi/tool-receipts.ts +2 -1
  78. package/src/ui/index.html +157 -10
@@ -0,0 +1,90 @@
1
+ # Agent-hub 0.12.23 verification
2
+
3
+ Release preparation for 0.12.23, performed on 2026-10-11 with Bun 1.4.2 on macOS. The release changes no file under `src/`, `test/` or `scripts/` relative to main at `94b489d`: it carries the version, the rebuilt plugin bundle, the changelog section, the document review below and the stamps in `docs/verified.json`. Production application is verified separately after publication.
4
+
5
+ ## What the release holds
6
+
7
+ Seventeen pull requests merged since `v0.12.22` (`deeae76..94b489d`): settings and start modes (#269, pull requests #290 and #292), the assignment delivery fixes (#276, #297), the sandbox follow-ups and the hub's one git helper (#281), Kimi's context reading (#285 phase 1), the Codex steer and dashboard start fixes (#296, #298), readable command output (#284, pull requests #291, #299 and #314), the SQLite busy timeouts and the named retryable restart outcome (#303, pull requests #305 and #313), the ACP cancellation wait (#304), the Kimi quota refusal pause and handoff (#306) the macOS timing fixtures (#287), and the console's Left and Right tab keys, Events colours and unrepeated context readings (#309, pull request #312).
8
+
9
+ Control protocol stays 16 (`PROTOCOL` in `src/hub/control-client.ts`) and the events schema stays 1 (`EVENTS_SCHEMA` in `src/hub/events.ts`). The new console-role requests (`settings_get`, `settings_set`, `settings_undo`, `settings_preview`, `peer_start`) are answered as unknown by an older hub, and the new fields are optional.
10
+
11
+ ## Changelog
12
+
13
+ Sixteen of the seventeen pull requests have their entry in the 0.12.23 section; #291 and #299 share the #284 entry, and #305 and #313 each have their own #303 entry. #314, merged while this release was prepared, corrects how the unreleased #284 output splits a file name that holds combining marks or joined emoji; it is a clause of the #284 entry. The #309 line arrived under Unreleased with the merge of #312 and was moved into the section. No entry describes work that is not merged. The section heading carries no date, as no earlier section does. A closing line records the protocol and schema.
14
+
15
+ ## Document review
16
+
17
+ `node scripts/check-docs.mjs` listed 13 stale documents at `bce5d00`. For each, the source commits since its stamp were read as the changed lines of `src/`, `scripts/`, `templates/`, `package.json` and `.github/`, with the commit messages, and the document was read against the code at `bce5d00`. All 13 are restamped; none is deferred. The corrections are in `88d4920`.
18
+
19
+ `origin/main` then moved by #314 (`76f1695`, `src/cli/output.ts` and its test) and was merged into the branch with a merge commit, `002abf4`. Its change was read against the five documents it made stale again: none describes the split at that level and nothing needed a correction.
20
+
21
+ `origin/main` moved once more by #312 (`94b489d`: `src/cli/console-state.ts`, `src/cli/console.ts` and the context line of the tail in `src/hub/daemon.ts`), merged in `e3313dd`. It made ten documents stale. Its own text in the operations guide ("Operator console and panels", "Native context readings") was read against the reducer, the paste branch and the tail's stamp and is accurate; README, the quickstart, the security guide and the six agent notes say nothing the change touches. No correction was needed and the ten are stamped at that merge commit.
22
+
23
+ | Document | Source commits | Changed | Stamp |
24
+ | --- | --- | --- | --- |
25
+ | `README.md` | 16 | yes | `e3313dd` |
26
+ | `docs/security.md` | 16 | yes | `e3313dd` |
27
+ | `docs/operations.md` | 17 | yes | `e3313dd` |
28
+ | `docs/quickstart.md` | 16 | yes | `e3313dd` |
29
+ | `docs/agent-notes/adapters.md` | 12 | scope line only | `e3313dd` |
30
+ | `docs/agent-notes/benchmarks.md` | 5 | no | `88d4920` |
31
+ | `docs/agent-notes/budget.md` | 9 | yes | `e3313dd` |
32
+ | `docs/agent-notes/bus.md` | 9 | no | `e3313dd` |
33
+ | `docs/agent-notes/daemon.md` | 12 | yes | `e3313dd` |
34
+ | `docs/agent-notes/local-worker.md` | 2 | scope line only | `88d4920` |
35
+ | `docs/agent-notes/models.md` | 1 | no | `88d4920` |
36
+ | `docs/agent-notes/tasks.md` | 12 | no | `e3313dd` |
37
+ | `docs/agent-notes/tests.md` | 17 | no | `e3313dd` |
38
+
39
+ README, the operations guide and the quickstart cover `package.json`, so their stamp has to follow the version commit (`62318e1`); the merge commit `e3313dd` does. Both stamps (`e3313dd` and `88d4920`) name commits of the release branch: the release pull request is merged with a merge commit, never squashed or rebased.
40
+
41
+ ### What changed
42
+
43
+ - `README.md`, `docs/quickstart.md`: the dashboard also starts a peer in its start mode and shows the settings, and what an ordinary session may change (#269). The unified dashboard's Start opens a TUI only through a terminal provider; both pages said TUIs are launched from a terminal only.
44
+ - `docs/security.md`:
45
+ - `terminal.open` is in `MACHINE_LOCAL` (`src/hub/config-trust.ts`) and was missing from the list of machine-local fields under "Trust boundaries".
46
+ - "A conductor may ... start supported headless peers" contradicted the page's own start-mode paragraph: `conductorStart` starts any startable peer in the mode a person set.
47
+ - The list of what the browser receives omitted the settings rows and the start plans (`settingsSnapshot`, `startPlans` in `src/hub/daemon.ts`).
48
+ - `docs/operations.md`: the authority paragraph of "Settings" omitted that an ordinary session may set a start mode back to `tui` (the registry's `floor`, `settingRefusal` in `src/hub/settings.ts`). The version, the protocol-16 source lists and the upgrade commands follow the release.
49
+ - `docs/agent-notes/daemon.md`: two rules the changes introduced and no note held. Every `new Database(` under `src/` is followed by its own named `busy_timeout`, enforced by `test/db-busy-timeout.test.ts` (#303). The manager's `start_peer` deadline is matched by the exact text `ControlClient.request` resolves with, and the reopen guard and the retry hold share `PEER_START_RETRY_MS` (#298).
50
+ - `docs/agent-notes/budget.md`: Kimi's occupancy is a context reading only, and a gate crossing sends a checkpoint request to Claude and Codex only (#285); a quota resume notice is published before the unread notices it replaces are withdrawn (#306).
51
+ - `AGENTS.md` and the scope lines of the daemon, adapters and local-worker notes name the files this release added (`start-mode.ts`, `terminal-open.ts`, `recovery-error.ts`, `db-timeout.ts`, `git.ts`), and `docs/verified.json` covers them, with `ui.ts` and `settings.ts` for the daemon note.
52
+
53
+ ### What was checked and found accurate
54
+
55
+ - `docs/operations.md`: "Command output" against `src/cli/help.ts` and the flag handling in `src/cli/main.ts`; "Settings" and "Start modes" against `src/hub/settings.ts`, `src/hub/start-mode.ts`, `src/hub/terminal-open.ts` and `hubStart`; the assignment delivery paragraph against `assignOwner` and `undeliveredNotice`; the Kimi quota paragraphs against `src/adapters/acp-quota.ts`, `Budget.report` and `Bus.failed`; the `database_busy` paragraph against `src/hub/recovery-error.ts` and `runRecovery`; the Kimi context reading against `acpContext` and the adapter's idle acceptance; the dashboard Start timings against `src/hub/manager.ts`. The ACP cancellation wait (#304) needs no operator text: the daemon's bus always has a journal, and the wait applies to a bus without one.
56
+ - `docs/security.md`: the settings and start-mode sections against the registry, `NEVER_EDITABLE`, the dashboard session window (`SETTINGS_MS`) and `personEnv`; the sandbox paragraph against `hubWriteRegexes` and `moduleGitDirs`.
57
+ - `adapters.md`: the steer rule (#296), the occupancy rule (#285), the cancellation wait (#304), the quota contract (#306), the `stop(reason)` return of journal-less work and the terminal environment rule (#269), each against `src/adapters/acp.ts`, `src/adapters/codex-appserver.ts` and `src/hub/child-process.ts`.
58
+ - `tasks.md`: the `assignOwner` rule against `src/hub/tasks.ts` at the head (#276, #297), and the overlay rule against `checkOverlay`.
59
+ - `bus.md`: the quota flag changes `Bus.failed` only for a paused peer and is stated in `budget.md`; nothing in the note is contradicted.
60
+ - `local-worker.md`: the submodule rules and the git helper against `src/local/deny.ts`, `src/local/sandbox.ts` and `src/hub/git.ts`.
61
+ - `benchmarks.md`: the two #304 paragraphs against the adapter's cancellation deadline. `models.md`: its one commit renames a busy-timeout constant.
62
+ - `tests.md`: against the workflow change, the ACP fake's held trailing updates and the harness of the new test files (`settings`, `start-modes`, `recovery-busy`, `hub-git`, `output-cli`).
63
+
64
+ ### What was not checked
65
+
66
+ - The 5,600 changed lines under `test/` were not read assertion by assertion: for `tests.md` the fakes, the helpers and the opening of each new test file were read.
67
+ - `src/ui/index.html` was read for the start controls only; the Settings section's rendering was not compared with the guides line by line.
68
+ - The table-rendering code of `src/cli/output.ts` was not compared with the wrapping and banding sentences of "Command output"; `docs/verification/2026-10-10-cli-output-phase1.md` and `-phase2.md` record that check.
69
+ - Sections of the operations guide that none of the merged commits touch in substance were not re-read in full.
70
+ - `docs/events.md`, `docs/smoke.md` and the design spec are outside `docs/verified.json` and were not reviewed.
71
+
72
+ ## Checks
73
+
74
+ All at the head of the branch after the merge of #312 and the last restamp, unless a line says otherwise.
75
+
76
+ - `node scripts/check-docs.mjs`: `docs: OK (0 stale documents)`.
77
+ - `bun x tsc --noEmit`: clean (exit 0).
78
+ - `bun scripts/build.mjs --check`: the bundle and the plugin manifest are fresh (exit 0).
79
+ - `scripts/check.sh`, admitted through `dot admit` and run without the agent session's environment markers, on the tree of the commit that holds this restamp (the merge `e3313dd` plus documents only): 1972 tests across 132 files in 311 s, 1972 pass, 0 fail, 26,811 expectations, no daemon leak; `check: OK`. This is the first full run on #312 and #314 together. An earlier run at `7ebd860`, before either merge, had 1953 pass and 0 fail.
80
+ - `git diff origin/main...HEAD -- src test scripts` is empty.
81
+ - CI at `e80c7af` (run 38104252746), first attempt: `check` on macOS (1972 pass, 0 fail, `check: OK`), `check` on Ubuntu (1941 pass, 31 skips, 0 fail, `check: OK`) and `seeded guards` (`seeded: OK (6 sequential guards, 13.237 s)`). The commit after `e80c7af` adds only this line; its own run is recorded on the pull request.
82
+
83
+ ## Not proven live (human-only)
84
+
85
+ No live leg was run for this release. `docs/smoke.md` lists what stays unverified against real agents, among it: the settings leg and the start-mode legs (#269), Kimi 2.1.1's `usage_update` arrival through the hub (#285), the native measurement owed for #304, and the interactive upgrade legs (#272). An upgrade of a running 0.12.22 hub to this release has not been run.
86
+
87
+ ## Left for the owner
88
+
89
+ - `test/upgrade-cli.test.ts` gained `withRestartDiagnostics` and the `AHUB_RESTART_DIAGNOSTICS_DIR` variable CI sets (#303). No note says a new installed-layout restart fixture should use it; whether that is a rule for `tests.md` is undecided.
90
+ - `docs/operations.md` still says "From an installed CLI older than 0.12.22, start it once by hand" and titles the start-mode migration "from 0.12.22 and earlier"; both are historical and were left.
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "documents": {
4
4
  "README.md": {
5
- "verifiedAgainst": "146db2d192b3dfc129fd9fef2dd24452aa7f64ae",
5
+ "verifiedAgainst": "e3313ddb1673edc91fb567793e42723d441e53f2",
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": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
14
+ "verifiedAgainst": "e3313ddb1673edc91fb567793e42723d441e53f2",
15
15
  "paths": [
16
16
  "src/",
17
17
  "templates/"
18
18
  ]
19
19
  },
20
20
  "docs/operations.md": {
21
- "verifiedAgainst": "146db2d192b3dfc129fd9fef2dd24452aa7f64ae",
21
+ "verifiedAgainst": "e3313ddb1673edc91fb567793e42723d441e53f2",
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": "146db2d192b3dfc129fd9fef2dd24452aa7f64ae",
38
+ "verifiedAgainst": "e3313ddb1673edc91fb567793e42723d441e53f2",
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": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
46
+ "verifiedAgainst": "e3313ddb1673edc91fb567793e42723d441e53f2",
47
47
  "paths": [
48
48
  "src/adapters/",
49
49
  "src/pi/",
@@ -56,11 +56,13 @@
56
56
  "src/hub/permission-mode.ts",
57
57
  "src/hub/tool-refusal.ts",
58
58
  "src/cli/identity.ts",
59
- "src/local/tools.ts"
59
+ "src/local/tools.ts",
60
+ "src/hub/start-mode.ts",
61
+ "src/hub/terminal-open.ts"
60
62
  ]
61
63
  },
62
64
  "docs/agent-notes/benchmarks.md": {
63
- "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
65
+ "verifiedAgainst": "88d49207d57a43e8beed8dbf95ad4a0283495b43",
64
66
  "paths": [
65
67
  "scripts/benchmarks/",
66
68
  "test/benchmarks/",
@@ -72,7 +74,7 @@
72
74
  ]
73
75
  },
74
76
  "docs/agent-notes/budget.md": {
75
- "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
77
+ "verifiedAgainst": "e3313ddb1673edc91fb567793e42723d441e53f2",
76
78
  "paths": [
77
79
  "src/hub/budget.ts",
78
80
  "src/cli/statusline-tee.ts",
@@ -84,7 +86,7 @@
84
86
  ]
85
87
  },
86
88
  "docs/agent-notes/bus.md": {
87
- "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
89
+ "verifiedAgainst": "e3313ddb1673edc91fb567793e42723d441e53f2",
88
90
  "paths": [
89
91
  "src/hub/bus.ts",
90
92
  "src/hub/envelope.ts",
@@ -96,7 +98,7 @@
96
98
  ]
97
99
  },
98
100
  "docs/agent-notes/daemon.md": {
99
- "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
101
+ "verifiedAgainst": "e3313ddb1673edc91fb567793e42723d441e53f2",
100
102
  "paths": [
101
103
  "src/hub/daemon.ts",
102
104
  "src/hub/control-client.ts",
@@ -123,21 +125,28 @@
123
125
  "src/cli/main.ts",
124
126
  "src/cli/launch.ts",
125
127
  "src/hub/child-process.ts",
126
- "src/hub/bus.ts"
128
+ "src/hub/bus.ts",
129
+ "src/hub/ui.ts",
130
+ "src/hub/settings.ts",
131
+ "src/hub/start-mode.ts",
132
+ "src/hub/terminal-open.ts",
133
+ "src/hub/recovery-error.ts",
134
+ "src/hub/db-timeout.ts"
127
135
  ]
128
136
  },
129
137
  "docs/agent-notes/local-worker.md": {
130
- "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
138
+ "verifiedAgainst": "88d49207d57a43e8beed8dbf95ad4a0283495b43",
131
139
  "paths": [
132
140
  "src/adapters/local-worker.ts",
133
141
  "src/local/",
134
142
  "src/memory/capture.ts",
135
143
  "src/hub/facts.ts",
136
- "src/hub/permission-mode.ts"
144
+ "src/hub/permission-mode.ts",
145
+ "src/hub/git.ts"
137
146
  ]
138
147
  },
139
148
  "docs/agent-notes/models.md": {
140
- "verifiedAgainst": "ab2925f7848a2eb848e9f0ab664df297c2c0f5fb",
149
+ "verifiedAgainst": "88d49207d57a43e8beed8dbf95ad4a0283495b43",
141
150
  "paths": [
142
151
  "src/models/",
143
152
  "src/hub/inference.ts",
@@ -146,7 +155,7 @@
146
155
  ]
147
156
  },
148
157
  "docs/agent-notes/tasks.md": {
149
- "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
158
+ "verifiedAgainst": "e3313ddb1673edc91fb567793e42723d441e53f2",
150
159
  "paths": [
151
160
  "src/hub/tasks.ts",
152
161
  "src/hub/board.ts",
@@ -163,7 +172,7 @@
163
172
  ]
164
173
  },
165
174
  "docs/agent-notes/tests.md": {
166
- "verifiedAgainst": "48fc0eebcf24cb2bbb0a3a1abc17530ef49ed395",
175
+ "verifiedAgainst": "e3313ddb1673edc91fb567793e42723d441e53f2",
167
176
  "paths": [
168
177
  "test/",
169
178
  "scripts/check.sh",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-hub",
3
- "version": "0.12.22",
3
+ "version": "0.12.23",
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.22",
3
+ "version": "0.12.23",
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",
@@ -15215,7 +15215,18 @@ import { join as join2 } from "path";
15215
15215
  // src/hub/project.ts
15216
15216
  import { existsSync, lstatSync, readFileSync, realpathSync } from "fs";
15217
15217
  import { basename, dirname, isAbsolute, join, resolve } from "path";
15218
+
15219
+ // src/hub/git.ts
15218
15220
  import { spawnSync } from "child_process";
15221
+ var SAFE_CONFIG = ["core.fsmonitor=false", "core.hooksPath=/dev/null", "diff.external=", "core.pager=cat", "credential.helper=", "core.askpass="];
15222
+ var hubGitArgv = (args) => ["git", "--no-pager", ...SAFE_CONFIG.flatMap((c) => ["-c", c]), ...args];
15223
+ var hubGitEnv = (env = process.env) => ({ ...env, GIT_TERMINAL_PROMPT: "0" });
15224
+ function hubGitSync(args, options) {
15225
+ const argv = hubGitArgv(args);
15226
+ return spawnSync(argv[0], argv.slice(1), { ...options, env: hubGitEnv(options.env ?? process.env) });
15227
+ }
15228
+
15229
+ // src/hub/project.ts
15219
15230
  var canonical = (path) => {
15220
15231
  const absolute = resolve(path);
15221
15232
  try {
@@ -15226,7 +15237,7 @@ var canonical = (path) => {
15226
15237
  }
15227
15238
  };
15228
15239
  function gitRoot(dir) {
15229
- const result = spawnSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
15240
+ const result = hubGitSync(["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
15230
15241
  if (result.status !== 0)
15231
15242
  return;
15232
15243
  const root = result.stdout.trim();
@@ -15404,7 +15415,7 @@ class ControlClient {
15404
15415
  // package.json
15405
15416
  var package_default = {
15406
15417
  name: "@staix/agent-hub",
15407
- version: "0.12.22",
15418
+ version: "0.12.23",
15408
15419
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
15409
15420
  license: "MIT",
15410
15421
  type: "module",
@@ -15509,7 +15520,7 @@ var CONDUCTOR_TOOLS = [
15509
15520
  tool("hub_task_show", "Read a task's public view and history, its done summary and check result included. PII tasks remain stubs. Only the conductor and the task's current owner and reviewer may use this tool.", { id }, ["id"]),
15510
15521
  tool("hub_task_assign", "Move a task to another peer, as the conductor, or redirect a task you proposed while it is proposed and nobody accepted it. A task that still waits gets the peer as its reserved owner. When you have an explicit capabilities list, the conductor needs assign capability for any assign, the proposer only to hand the task to another peer.", { id, peer: str }, ["id", "peer"]),
15511
15522
  tool("hub_task_escalate", "Escalate a task through the normal task flow, as the conductor. Requires assign capability when explicitly listed.", { id }, ["id"]),
15512
- tool("hub_peer_start", "Start local, kimi or headless pi. Claude, Codex and Pi TUI requests return a command for the person to run, without launching a terminal.", { peer: { type: "string", enum: ["local", "kimi", "pi", "claude", "codex"] }, mode: { type: "string", enum: ["headless", "tui"] } }, ["peer"]),
15523
+ tool("hub_peer_start", "Start local, kimi, pi, claude or codex in the start mode a person set for it; you cannot choose the mode. A headless peer starts at once. A TUI peer (claude, and pi and codex by default) opens in a terminal with a fixed command; when the hub cannot open a terminal the call fails with the command for the person to run.", { peer: { type: "string", enum: ["local", "kimi", "pi", "claude", "codex"] } }, ["peer"]),
15513
15524
  tool("hub_peer_hold", "Hold a peer's deliveries as the conductor. This hold is separate from the person's hold and budget pauses.", { peer: str }, ["peer"]),
15514
15525
  tool("hub_peer_release", "Release only the conductor hold you placed. Never lifts a person's hold or a budget pause.", { peer: str }, ["peer"])
15515
15526
  ];
@@ -0,0 +1,13 @@
1
+ /** Provider refusal observed from Kimi Code CLI 2.1.1 on 2026-10-11. No weekly/monthly or Qwen text is verified. */
2
+ export const KIMI_2_1_1_5H_REFUSAL_PREFIX = "Authentication required: 403 You've reached your 5-hour usage limit. Your quota will reset when the current 5-hour window ends.";
3
+ export interface ACPQuotaRefusal {
4
+ id: "5h";
5
+ used: 1;
6
+ windowMins: 300;
7
+ source: "kimi ACP usage-limit refusal";
8
+ }
9
+ /** Identity and turn/effect fencing are the caller's responsibility; an arbitrary 'quota' error is not a known refusal. */
10
+ export function acpQuotaRefusal(message: string): ACPQuotaRefusal | undefined {
11
+ if (!message.startsWith(KIMI_2_1_1_5H_REFUSAL_PREFIX)) return undefined;
12
+ return { id: "5h", used: 1, windowMins: 300, source: "kimi ACP usage-limit refusal" };
13
+ }
@@ -1,6 +1,7 @@
1
1
  import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
2
  import { createInterface } from "node:readline";
3
- import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
3
+ import { renderDigest, replyAudience, replyParent, sanitize, type Envelope, type PeerId } from "../hub/envelope.ts";
4
+ import { acpQuotaRefusal, type ACPQuotaRefusal } from "./acp-quota.ts";
4
5
  import { KIMI_MODE_IDS, type PermissionMode } from "../hub/permission-mode.ts";
5
6
  import { BasePeer } from "../hub/peers.ts";
6
7
  import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
@@ -66,6 +67,8 @@ export interface AcpOptions {
66
67
  /** Optional launch environment; recovery authority is always removed before spawn. */
67
68
  env?: NodeJS.ProcessEnv;
68
69
  watchdogMs?: number;
70
+ /** Bound for a journal-less watchdog cancellation to report its prompt; defaults to the 30 s ACP handshake cap. */
71
+ cancelWaitMs?: number;
69
72
  /** ACP stdio MCP servers for the session (the hub's task tools). */
70
73
  mcpServers?: { name: string; command: string; args: string[]; env: { name: string; value: string }[] }[];
71
74
  /** Appended to the standing instruction of the first delivery (role contract). */
@@ -75,7 +78,10 @@ export interface AcpOptions {
75
78
  /** The prompt rejected or ended without normal completion, even when it streamed partial text.
76
79
  * Same shape as Pi's onTurnFailure; a stale cancelled turn never reports. */
77
80
  onTurnFailure?: (envs: Envelope[], reason: string) => Promise<void> | void;
78
- onUsageDiagnostic?: (observation: ACPUsageDiagnostic) => void;
81
+ /** The sessionId is the adapter's own: the daemon binds a context reading to it, not to a lookup of its own (#285). */
82
+ onUsageDiagnostic?: (observation: ACPUsageDiagnostic, sessionId: string) => void;
83
+ /** Return true only when the current-session refusal has an authoritative quota pause; otherwise hold delivery. */
84
+ onQuotaRefusal?: (window: ACPQuotaRefusal) => boolean;
79
85
  /** Resolve with an optionId, or undefined to cancel. Absent = every request is cancelled.
80
86
  * A request whose payload could not be resolved is titled as such and carries no session-wide allow option. */
81
87
  onPermission?: (req: PermissionRequest) => Promise<string | undefined>;
@@ -130,7 +136,10 @@ export class AcpPeer extends BasePeer {
130
136
  private turn = 0; // generation: a prompt cancelled by the watchdog must not touch the turn that followed it
131
137
  private stopping = false;
132
138
  private activeDeliveryId: string | undefined;
139
+ private activeLegacy: { turn: number; envs: Envelope[] } | undefined;
133
140
  private deliveryAccepted = false;
141
+ private cancelWait: { turn: number; successor: number; timer: ReturnType<typeof setTimeout> } | undefined;
142
+ private promptActivity = false;
134
143
 
135
144
  constructor(
136
145
  id: PeerId,
@@ -226,6 +235,12 @@ export class AcpPeer extends BasePeer {
226
235
 
227
236
  async stop(reason?: string): Promise<void> {
228
237
  this.stopping = true;
238
+ this.clearCancelWait();
239
+ const legacy = this.activeLegacy;
240
+ this.activeLegacy = undefined;
241
+ // Journal-less work has no durable receipt: return it before a requested stop
242
+ // invalidates its generation, while stopping still prevents a drain.
243
+ if (legacy?.turn === this.turn) this.onFailed?.(legacy.envs);
229
244
  if (reason !== undefined) this.turn++; // a requested stop cannot escalate or complete the cancelled turn
230
245
  if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: reason ?? "ACP session stopped before settlement" });
231
246
  this.activeDeliveryId = undefined;
@@ -244,7 +259,9 @@ export class AcpPeer extends BasePeer {
244
259
  }
245
260
  const turn = ++this.turn;
246
261
  this.activeDeliveryId = deliveryId;
262
+ this.activeLegacy = deliveryId ? undefined : { turn, envs };
247
263
  this.deliveryAccepted = false;
264
+ this.promptActivity = false;
248
265
  this.chunks = [];
249
266
  this.setState("busy");
250
267
  // session/prompt answers only when the turn ends, so deliver resolves now and failures come back through onFailed.
@@ -252,6 +269,9 @@ export class AcpPeer extends BasePeer {
252
269
  prompt
253
270
  .then((result) => {
254
271
  if (turn !== this.turn) return; // superseded: these chunks belong to a later turn
272
+ // This prompt has reported. A synchronous observer stop must not return
273
+ // completed work as if its native request were still in flight.
274
+ if (this.activeLegacy?.turn === turn) this.activeLegacy = undefined;
255
275
  this.observeUsage(result, "prompt_result");
256
276
  this.primed = true;
257
277
  const body = this.chunks.join("").trim();
@@ -264,13 +284,35 @@ export class AcpPeer extends BasePeer {
264
284
  }
265
285
  })
266
286
  .catch((e: Error) => {
267
- this.opts.log?.(`[${this.id}] prompt failed: ${e.message}`);
268
- if (turn === this.turn) this.reportTurnFailure(envs, e.message); // a watchdog-cancelled turn reports stale, never as this turn's failure
269
- if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason: e.message });
270
- else if (!deliveryId) this.onFailed?.(envs);
287
+ if (turn !== this.turn) {
288
+ if (this.cancelWait?.turn === turn) this.opts.log?.(`[${this.id}] cancelled prompt rejected: ${sanitize(e.message)}`);
289
+ return; // cancelled prompt errors are discarded, like their late results
290
+ }
291
+ const reason = sanitize(e.message);
292
+ this.opts.log?.(`[${this.id}] prompt failed: ${reason}`);
293
+ const quota = !this.stopping && this.kimiAgent ? acpQuotaRefusal(e.message) : undefined;
294
+ if (quota) {
295
+ let paused = false;
296
+ try { paused = this.opts.onQuotaRefusal?.(quota) === true; }
297
+ catch (error) { this.opts.log?.(`[${this.id}] quota pause failed: ${sanitize(error instanceof Error ? error.message : String(error))}`); }
298
+ const safe = paused && !this.deliveryAccepted && !this.promptActivity;
299
+ if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: safe ? "failed_safe" : "needs_review", reason, ...(safe ? { quota: true as const } : {}) });
300
+ else if (!deliveryId && this.activeLegacy?.turn === turn) { this.activeLegacy = undefined; this.onFailed?.(envs, safe ? { quota: true } : undefined); }
301
+ return; // quota relay owns reassignment, not the ordinary turn-failure path
302
+ }
303
+ this.reportTurnFailure(envs, reason);
304
+ if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason });
305
+ else if (!deliveryId && this.activeLegacy?.turn === turn) { this.activeLegacy = undefined; this.onFailed?.(envs); }
271
306
  })
272
307
  .finally(() => {
273
- if (turn === this.turn && this.state === "busy") this.setState("idle");
308
+ const waiting = this.cancelWait;
309
+ if (waiting?.turn === turn) {
310
+ this.clearCancelWait();
311
+ if (!this.stopping && this.turn === waiting.successor && this.state === "busy") this.setState("idle");
312
+ return;
313
+ }
314
+ if (turn === this.turn && !this.stopping && this.state === "busy") this.setState("idle");
315
+ if (this.activeLegacy?.turn === turn) this.activeLegacy = undefined;
274
316
  if (turn === this.turn && this.activeDeliveryId === deliveryId) this.activeDeliveryId = undefined;
275
317
  });
276
318
  }
@@ -285,6 +327,10 @@ export class AcpPeer extends BasePeer {
285
327
  }
286
328
 
287
329
  protected override onWatchdog(): void {
330
+ if (this.state !== "busy") return;
331
+ if (this.cancelWait) { this.clearWatchdog(); return; }
332
+ const cancelledTurn = this.turn;
333
+ this.activeLegacy = undefined; // the cancelled prompt is discarded, never returned by a later stop
288
334
  const durable = !!this.activeDeliveryId;
289
335
  if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "ACP turn watchdog timeout" });
290
336
  this.activeDeliveryId = undefined;
@@ -293,12 +339,31 @@ export class AcpPeer extends BasePeer {
293
339
  // ACP updates carry only a session ID, not a turn ID. After a durable turn times out,
294
340
  // keep the adapter offline until it is restarted so late chunks cannot contaminate a new turn.
295
341
  if (durable) this.setState("offline");
296
- else super.onWatchdog();
342
+ else {
343
+ this.clearWatchdog(); // waiting for cancellation is not a second inactive model turn
344
+ const successor = this.turn;
345
+ const timer = setTimeout(() => {
346
+ const waiting = this.cancelWait;
347
+ if (!waiting || waiting.turn !== cancelledTurn || waiting.successor !== successor) return;
348
+ this.clearCancelWait();
349
+ if (!this.stopping && this.turn === successor) {
350
+ this.setState("offline"); // keep queued work until restart
351
+ this.opts.log?.(`[${this.id}] cancelled prompt did not report within the cancellation deadline; peer offline, queued work retained; stop and restart the peer to resume delivery`);
352
+ }
353
+ }, this.opts.cancelWaitMs ?? HANDSHAKE_MS);
354
+ timer.unref?.();
355
+ this.cancelWait = { turn: cancelledTurn, successor, timer };
356
+ }
357
+ }
358
+
359
+ private clearCancelWait(): void {
360
+ if (this.cancelWait) clearTimeout(this.cancelWait.timer);
361
+ this.cancelWait = undefined;
297
362
  }
298
363
 
299
364
  private observeUsage(value: unknown, source: ACPUsageDiagnostic["source"]): void {
300
365
  const observation = normalizeACPUsage(value, source);
301
- this.opts.onUsageDiagnostic?.(observation);
366
+ this.opts.onUsageDiagnostic?.(observation, this.sessionId);
302
367
  if (observation.availability === "known") this.opts.onTokens?.(observation.total!, this.sessionId);
303
368
  }
304
369
 
@@ -309,6 +374,7 @@ export class AcpPeer extends BasePeer {
309
374
  }
310
375
 
311
376
  private down(reason: string): void {
377
+ this.clearCancelWait();
312
378
  this.opts.log?.(`[${this.id}] ${reason}`);
313
379
  for (const p of this.pending.values()) p.reject(new Error(reason));
314
380
  this.pending.clear();
@@ -325,13 +391,27 @@ export class AcpPeer extends BasePeer {
325
391
  } catch {
326
392
  return; // not protocol output
327
393
  }
328
- if (this.state === "busy") this.touch();
394
+ if (this.state === "busy" && !this.cancelWait) this.touch();
329
395
 
330
396
  if (msg.method === "session/update") {
331
397
  if (msg.params?.sessionId && msg.params.sessionId !== this.sessionId) return;
332
- if (this.state !== "busy") return;
333
- this.acceptDelivery();
334
398
  const u = msg.params?.update;
399
+ // Occupancy is the session's, not the turn's: 2.1.1's source text emits usage_update after the prompt
400
+ // resolves, when the peer may already be idle (read from the source, not observed live; #285). So a
401
+ // context-used update is accepted while idle too, but then it must name the current session; in a turn the
402
+ // lenient session check above stands, as before. Every other update stays behind the busy gate, and a
403
+ // watchdog-cancelled turn's late chunks still lose their turn (the prompt result path is turn-fenced); an
404
+ // occupancy reading carries no turn state.
405
+ if (u?.sessionUpdate === "usage_update" && this.state !== "offline") {
406
+ const observation = normalizeACPUsage(u, "usage_update");
407
+ if (observation.shape === "context-used") {
408
+ if ((this.state === "busy" && !this.cancelWait) || msg.params?.sessionId === this.sessionId) this.opts.onUsageDiagnostic?.(observation, this.sessionId);
409
+ return;
410
+ }
411
+ }
412
+ if (this.state !== "busy" || this.cancelWait) return;
413
+ this.promptActivity = true;
414
+ this.acceptDelivery();
335
415
  if (u?.sessionUpdate === "agent_message_chunk" && u.content?.type === "text") this.chunks.push(u.content.text);
336
416
  // The permission request that follows may carry no `rawInput` (Kimi 2.0.1 does not), and then the
337
417
  // console would be asked to approve a bare tool name. Keep what the call said it would run (issue #31).
@@ -350,6 +430,8 @@ export class AcpPeer extends BasePeer {
350
430
  }
351
431
  else if (u?.sessionUpdate === "usage_update") this.observeUsage(u, "usage_update");
352
432
  } else if (msg.method === "session/request_permission") {
433
+ if (this.cancelWait) { this.send({ jsonrpc: "2.0", id: msg.id, result: { outcome: { outcome: "cancelled" } } }); return; }
434
+ if (this.state === "busy" && (!msg.params?.sessionId || msg.params.sessionId === this.sessionId)) this.promptActivity = true;
353
435
  void this.answerPermission(msg);
354
436
  } else if (msg.method && msg.id !== undefined) {
355
437
  this.send({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "method not supported by agent-hub" } });
@@ -401,7 +483,7 @@ export class AcpPeer extends BasePeer {
401
483
  } finally {
402
484
  clearInterval(alive); // also when there is no handler at all
403
485
  }
404
- const valid = options.some((o) => o.optionId === picked);
486
+ const valid = turn === this.turn && !this.stopping && options.some((o) => o.optionId === picked);
405
487
  this.send({
406
488
  jsonrpc: "2.0",
407
489
  id: msg.id,
@@ -37,7 +37,7 @@ export interface CodexOptions {
37
37
  onItem?: (item: any, nativeTurn?: string) => void;
38
38
  /** How often to ask app-server for the rate limits while a TUI is attached. */
39
39
  usagePollMs?: number;
40
- /** How long a fact's steer waits for app-server's answer (tests shorten it). */
40
+ /** Facts wait from send; durable steers wait only after their original turn ends (default 10 s). */
41
41
  steerTimeoutMs?: number;
42
42
  cwd: string;
43
43
  /** Optional launch environment; recovery authority is always removed before spawn. */
@@ -84,7 +84,7 @@ export class CodexPeer extends BasePeer {
84
84
  private freshThread = false;
85
85
  private readonly activeTurns = new Set<string>();
86
86
  private nextId = -1;
87
- private readonly pending = new Map<number, { resolve: (result?: any) => void; reject: (e: Error) => void; deliveryId?: string; kind?: "deliver" | "steer" }>();
87
+ private readonly pending = new Map<number, { resolve: (result?: any) => void; reject: (e: Error) => void; deliveryId?: string; kind?: "deliver" | "steer"; expectedTurnId?: string; afterTurn?: () => void }>();
88
88
  private usageTimer: ReturnType<typeof setInterval> | undefined;
89
89
  private injected: Envelope | undefined; // the hub envelope that started the current turn, if any
90
90
  private answering: Envelope[] = []; // everything delivered or steered into the current turn: who its answer is for
@@ -228,12 +228,35 @@ export class CodexPeer extends BasePeer {
228
228
  return Promise.reject(new Error(`${this.id} has no steerable turn`));
229
229
  }
230
230
  const id = this.nextId--;
231
+ const expectedEpoch = this.contextEpoch;
231
232
  this.steers.add(id);
232
233
  return new Promise<void>((resolve, reject) => {
234
+ let timer: ReturnType<typeof setTimeout> | undefined;
235
+ const settle = () => { clearTimeout(timer); this.steers.delete(id); };
236
+ const fail = (e: Error) => {
237
+ settle();
238
+ if (deliveryId) this.delivery({ id: deliveryId, state: this.knownRejection(e) ? "failed_safe" : "needs_review", reason: e.message });
239
+ reject(e);
240
+ };
233
241
  this.pending.set(id, {
234
- deliveryId, kind: "steer",
235
- resolve: () => {
236
- this.steers.delete(id);
242
+ deliveryId, kind: "steer", expectedTurnId,
243
+ afterTurn: deliveryId ? () => {
244
+ if (timer !== undefined) return;
245
+ timer = setTimeout(() => {
246
+ const pending = this.pending.get(id);
247
+ this.pending.delete(id);
248
+ pending?.reject(new Error("steer unanswered: RPC timeout"));
249
+ }, this.opts.steerTimeoutMs ?? 10_000);
250
+ timer.unref?.();
251
+ } : undefined,
252
+ resolve: (result) => {
253
+ // Native completion can precede the RPC answer. A late refusal is definitive; a late acceptance is uncertain.
254
+ if (deliveryId && (this.link !== link || this.contextEpoch !== expectedEpoch || !this.activeTurns.has(expectedTurnId) ||
255
+ (typeof result?.turnId === "string" && result.turnId !== expectedTurnId))) {
256
+ fail(new Error("steer accepted after its original native turn ended or changed"));
257
+ return;
258
+ }
259
+ settle();
237
260
  this.primed = true;
238
261
  // The turn now answers these too; the highest hop wins so a steer cannot reset the hop cap.
239
262
  this.injected = replyParent(this.injected ? [this.injected, ...envs] : envs);
@@ -244,11 +267,7 @@ export class CodexPeer extends BasePeer {
244
267
  }
245
268
  resolve();
246
269
  },
247
- reject: (e) => {
248
- this.steers.delete(id);
249
- if (deliveryId) this.delivery({ id: deliveryId, state: this.knownRejection(e) ? "failed_safe" : "needs_review", reason: e.message });
250
- reject(e);
251
- },
270
+ reject: fail,
252
271
  });
253
272
  const input = [{ type: "text", text: this.render(envs) }];
254
273
  link.up.send(JSON.stringify({ method: "turn/steer", id, params: { threadId: this.threadId, expectedTurnId, input } }));
@@ -299,6 +318,16 @@ export class CodexPeer extends BasePeer {
299
318
  }
300
319
  }
301
320
 
321
+ /** An ended native turn bounds only its unanswered durable steers; legacy steers retain their immediate retry. */
322
+ private endSteers(turnId: string | undefined): void {
323
+ for (const id of this.steers) {
324
+ const pending = this.pending.get(id);
325
+ if (!pending || pending.expectedTurnId !== turnId) continue;
326
+ if (pending.deliveryId) pending.afterTurn?.();
327
+ else { this.pending.delete(id); pending.reject(new Error("steer unanswered: turn completed")); }
328
+ }
329
+ }
330
+
302
331
  private addTurnDelivery(turnId: string, deliveryId: string): void {
303
332
  const ids = this.turnDeliveries.get(turnId) ?? new Set<string>();
304
333
  ids.add(deliveryId); this.turnDeliveries.set(turnId, ids); this.unboundDeliveries.delete(deliveryId);
@@ -363,8 +392,8 @@ export class CodexPeer extends BasePeer {
363
392
  this.contextEpoch++;
364
393
  this.activeTurns.clear();
365
394
  for (const p of this.pending.values()) {
366
- if (p.deliveryId) this.delivery({ id: p.deliveryId, state: "needs_review", reason: "Codex TUI detached" });
367
- p.reject(new Error("codex TUI detached"));
395
+ if (p.deliveryId && p.kind !== "steer") this.delivery({ id: p.deliveryId, state: "needs_review", reason: "Codex TUI detached" });
396
+ p.reject(new Error(p.kind === "steer" ? "Codex TUI detached" : "codex TUI detached"));
368
397
  }
369
398
  this.pending.clear();
370
399
  for (const ids of this.turnDeliveries.values()) for (const id of ids) this.delivery({ id, state: "needs_review", reason: "Codex TUI detached" });
@@ -470,6 +499,7 @@ export class CodexPeer extends BasePeer {
470
499
 
471
500
  private adopt(link: Link, threadId: unknown, fresh = false): void {
472
501
  if (typeof threadId !== "string" || !threadId) return;
502
+ this.abandonSteers("thread changed");
473
503
  this.tokenTotal = undefined;
474
504
  this.freshThread = fresh;
475
505
  this.link = link;
@@ -559,8 +589,8 @@ export class CodexPeer extends BasePeer {
559
589
  if (params.turn?.status === "failed") {
560
590
  this.opts.log?.(`[${this.id}] turn failed: ${params.turn.error?.message ?? "unknown error"}`);
561
591
  }
592
+ this.endSteers(nativeTurn);
562
593
  if (this.activeTurns.size) return;
563
- this.abandonSteers("turn completed");
564
594
  if (typeof nativeTurn === "string") {
565
595
  for (const id of this.turnDeliveries.get(nativeTurn) ?? []) this.delivery({ id, state: params.turn?.status === "failed" ? "needs_review" : "completed", ...(params.turn?.status === "failed" ? { reason: params.turn?.error?.message ?? "Codex turn failed" } : {}) });
566
596
  this.turnDeliveries.delete(nativeTurn);