@staix/agent-hub 0.12.21 → 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 (87) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +27 -12
  3. package/docs/agent-notes/adapters.md +15 -7
  4. package/docs/agent-notes/benchmarks.md +4 -1
  5. package/docs/agent-notes/budget.md +4 -0
  6. package/docs/agent-notes/bus.md +2 -1
  7. package/docs/agent-notes/daemon.md +12 -1
  8. package/docs/agent-notes/local-worker.md +4 -1
  9. package/docs/agent-notes/tasks.md +2 -1
  10. package/docs/agent-notes/tests.md +4 -0
  11. package/docs/events.md +17 -4
  12. package/docs/operations.md +478 -41
  13. package/docs/quickstart.md +14 -6
  14. package/docs/security.md +23 -11
  15. package/docs/smoke.md +133 -7
  16. package/docs/specs/2026-09-19-agent-hub-design.md +115 -15
  17. package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -8
  18. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  19. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  20. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  21. package/docs/verified.json +32 -17
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +14 -3
  25. package/src/adapters/acp-quota.ts +13 -0
  26. package/src/adapters/acp.ts +105 -17
  27. package/src/adapters/codex-appserver.ts +70 -35
  28. package/src/adapters/local-worker.ts +44 -10
  29. package/src/adapters/pi.ts +158 -19
  30. package/src/cli/bench.ts +2 -1
  31. package/src/cli/console-state.ts +28 -22
  32. package/src/cli/console.ts +20 -10
  33. package/src/cli/help.ts +25 -19
  34. package/src/cli/identity.ts +3 -3
  35. package/src/cli/init.ts +1 -1
  36. package/src/cli/launch.ts +182 -7
  37. package/src/cli/main.ts +409 -177
  38. package/src/cli/output.ts +363 -0
  39. package/src/cli/preview.ts +2 -1
  40. package/src/cli/recovery-package.ts +17 -0
  41. package/src/cli/reset.ts +3 -0
  42. package/src/cli/status-lines.ts +0 -28
  43. package/src/cli/terminal-recovery.ts +27 -1
  44. package/src/cli/upgrade-interactive.ts +297 -0
  45. package/src/cli/upgrade-runtime.ts +80 -9
  46. package/src/cli/upgrade.ts +50 -15
  47. package/src/hub/board.ts +2 -0
  48. package/src/hub/budget.ts +39 -7
  49. package/src/hub/bus.ts +15 -14
  50. package/src/hub/child-process.ts +11 -0
  51. package/src/hub/conductor.ts +23 -11
  52. package/src/hub/config-trust.ts +16 -5
  53. package/src/hub/conflicts.ts +2 -0
  54. package/src/hub/context-window.ts +8 -1
  55. package/src/hub/crash.ts +2 -1
  56. package/src/hub/daemon.ts +483 -83
  57. package/src/hub/db-timeout.ts +3 -0
  58. package/src/hub/delivery-journal.ts +2 -1
  59. package/src/hub/envelope.ts +1 -1
  60. package/src/hub/events.ts +4 -1
  61. package/src/hub/execution-budget.ts +2 -0
  62. package/src/hub/facts.ts +5 -4
  63. package/src/hub/git.ts +32 -0
  64. package/src/hub/hub-tools.ts +1 -1
  65. package/src/hub/lifecycle.ts +10 -4
  66. package/src/hub/manager.ts +18 -5
  67. package/src/hub/peers.ts +7 -5
  68. package/src/hub/permission-mode.ts +14 -0
  69. package/src/hub/project.ts +2 -1
  70. package/src/hub/recovery-error.ts +31 -0
  71. package/src/hub/recovery-store.ts +43 -4
  72. package/src/hub/registry.ts +2 -1
  73. package/src/hub/routing.ts +114 -13
  74. package/src/hub/settings.ts +392 -0
  75. package/src/hub/snapshots.ts +4 -2
  76. package/src/hub/start-mode.ts +48 -0
  77. package/src/hub/tasks.ts +158 -43
  78. package/src/hub/terminal-open.ts +64 -0
  79. package/src/hub/ui.ts +25 -13
  80. package/src/local/deny.ts +71 -2
  81. package/src/local/sandbox.ts +47 -6
  82. package/src/local/tools.ts +25 -6
  83. package/src/memory/recall.ts +2 -2
  84. package/src/models/mlx.ts +2 -1
  85. package/src/pi/extension.ts +43 -20
  86. package/src/pi/tool-receipts.ts +19 -2
  87. package/src/ui/index.html +172 -21
@@ -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": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
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": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
14
+ "verifiedAgainst": "e3313ddb1673edc91fb567793e42723d441e53f2",
15
15
  "paths": [
16
16
  "src/",
17
17
  "templates/"
18
18
  ]
19
19
  },
20
20
  "docs/operations.md": {
21
- "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
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": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
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": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
46
+ "verifiedAgainst": "e3313ddb1673edc91fb567793e42723d441e53f2",
47
47
  "paths": [
48
48
  "src/adapters/",
49
49
  "src/pi/",
@@ -55,11 +55,14 @@
55
55
  "src/hub/daemon.ts",
56
56
  "src/hub/permission-mode.ts",
57
57
  "src/hub/tool-refusal.ts",
58
- "src/cli/identity.ts"
58
+ "src/cli/identity.ts",
59
+ "src/local/tools.ts",
60
+ "src/hub/start-mode.ts",
61
+ "src/hub/terminal-open.ts"
59
62
  ]
60
63
  },
61
64
  "docs/agent-notes/benchmarks.md": {
62
- "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
65
+ "verifiedAgainst": "88d49207d57a43e8beed8dbf95ad4a0283495b43",
63
66
  "paths": [
64
67
  "scripts/benchmarks/",
65
68
  "test/benchmarks/",
@@ -71,7 +74,7 @@
71
74
  ]
72
75
  },
73
76
  "docs/agent-notes/budget.md": {
74
- "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
77
+ "verifiedAgainst": "e3313ddb1673edc91fb567793e42723d441e53f2",
75
78
  "paths": [
76
79
  "src/hub/budget.ts",
77
80
  "src/cli/statusline-tee.ts",
@@ -83,7 +86,7 @@
83
86
  ]
84
87
  },
85
88
  "docs/agent-notes/bus.md": {
86
- "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
89
+ "verifiedAgainst": "e3313ddb1673edc91fb567793e42723d441e53f2",
87
90
  "paths": [
88
91
  "src/hub/bus.ts",
89
92
  "src/hub/envelope.ts",
@@ -95,7 +98,7 @@
95
98
  ]
96
99
  },
97
100
  "docs/agent-notes/daemon.md": {
98
- "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
101
+ "verifiedAgainst": "e3313ddb1673edc91fb567793e42723d441e53f2",
99
102
  "paths": [
100
103
  "src/hub/daemon.ts",
101
104
  "src/hub/control-client.ts",
@@ -118,21 +121,32 @@
118
121
  "src/cli/console-state.ts",
119
122
  "src/cli/identity.ts",
120
123
  "src/cli/identity-audit.ts",
121
- "src/hub/registry.ts"
124
+ "src/hub/registry.ts",
125
+ "src/cli/main.ts",
126
+ "src/cli/launch.ts",
127
+ "src/hub/child-process.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"
122
135
  ]
123
136
  },
124
137
  "docs/agent-notes/local-worker.md": {
125
- "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
138
+ "verifiedAgainst": "88d49207d57a43e8beed8dbf95ad4a0283495b43",
126
139
  "paths": [
127
140
  "src/adapters/local-worker.ts",
128
141
  "src/local/",
129
142
  "src/memory/capture.ts",
130
143
  "src/hub/facts.ts",
131
- "src/hub/permission-mode.ts"
144
+ "src/hub/permission-mode.ts",
145
+ "src/hub/git.ts"
132
146
  ]
133
147
  },
134
148
  "docs/agent-notes/models.md": {
135
- "verifiedAgainst": "ab2925f7848a2eb848e9f0ab664df297c2c0f5fb",
149
+ "verifiedAgainst": "88d49207d57a43e8beed8dbf95ad4a0283495b43",
136
150
  "paths": [
137
151
  "src/models/",
138
152
  "src/hub/inference.ts",
@@ -141,7 +155,7 @@
141
155
  ]
142
156
  },
143
157
  "docs/agent-notes/tasks.md": {
144
- "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
158
+ "verifiedAgainst": "e3313ddb1673edc91fb567793e42723d441e53f2",
145
159
  "paths": [
146
160
  "src/hub/tasks.ts",
147
161
  "src/hub/board.ts",
@@ -158,7 +172,7 @@
158
172
  ]
159
173
  },
160
174
  "docs/agent-notes/tests.md": {
161
- "verifiedAgainst": "c084278691cd0c978e8208df6ba9eaa62031d7f2",
175
+ "verifiedAgainst": "e3313ddb1673edc91fb567793e42723d441e53f2",
162
176
  "paths": [
163
177
  "test/",
164
178
  "scripts/check.sh",
@@ -172,7 +186,8 @@
172
186
  "src/hub/child-process.ts",
173
187
  "src/switchyard/sidecar.ts",
174
188
  "src/local/sandbox.ts",
175
- "src/models/mlx.ts"
189
+ "src/models/mlx.ts",
190
+ "src/cli/upgrade-interactive.ts"
176
191
  ]
177
192
  }
178
193
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-hub",
3
- "version": "0.12.21",
3
+ "version": "0.12.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.21",
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.21",
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>;
@@ -128,8 +134,12 @@ export class AcpPeer extends BasePeer {
128
134
  private readonly toolTitles = new Map<string, string>(); // the title a call was announced with, per call id (#138)
129
135
  private primed = false;
130
136
  private turn = 0; // generation: a prompt cancelled by the watchdog must not touch the turn that followed it
137
+ private stopping = false;
131
138
  private activeDeliveryId: string | undefined;
139
+ private activeLegacy: { turn: number; envs: Envelope[] } | undefined;
132
140
  private deliveryAccepted = false;
141
+ private cancelWait: { turn: number; successor: number; timer: ReturnType<typeof setTimeout> } | undefined;
142
+ private promptActivity = false;
133
143
 
134
144
  constructor(
135
145
  id: PeerId,
@@ -144,6 +154,7 @@ export class AcpPeer extends BasePeer {
144
154
  }
145
155
 
146
156
  async start(): Promise<void> {
157
+ this.stopping = false;
147
158
  const [bin, ...args] = this.opts.cmd;
148
159
  // Its own process group, stopped as a whole (#115, as Codex's in #113): an agent CLI may be a launcher with a native child.
149
160
  const proc = spawn(bin!, args, { cwd: this.opts.cwd, env: peerChildEnv(this.id, { ...process.env, ...(this.opts.env ?? {}) }), stdio: ["pipe", "pipe", "pipe"], detached: true });
@@ -222,24 +233,35 @@ export class AcpPeer extends BasePeer {
222
233
  this.permissionMode = mode;
223
234
  }
224
235
 
225
- async stop(): Promise<void> {
226
- if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "ACP session stopped before settlement" });
236
+ async stop(reason?: string): Promise<void> {
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);
244
+ if (reason !== undefined) this.turn++; // a requested stop cannot escalate or complete the cancelled turn
245
+ if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: reason ?? "ACP session stopped before settlement" });
227
246
  this.activeDeliveryId = undefined;
228
247
  const proc = this.proc;
229
- if (!proc) return;
248
+ if (!proc) { this.setState("offline"); return; }
230
249
  await stopOwnedProcess(proc, { group: true }); // also when it exited: what it left in its group fails the stop
231
250
  if (this.proc === proc) this.proc = undefined;
251
+ this.setState("offline");
232
252
  }
233
253
 
234
254
  /** Resolves once the prompt is in flight; the turn result arrives on its own. */
235
255
  async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
236
- if (this.state !== "idle") {
256
+ if (this.state !== "idle" || this.stopping) {
237
257
  if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} is ${this.state}` });
238
258
  throw new Error(`${this.id} is ${this.state}`);
239
259
  }
240
260
  const turn = ++this.turn;
241
261
  this.activeDeliveryId = deliveryId;
262
+ this.activeLegacy = deliveryId ? undefined : { turn, envs };
242
263
  this.deliveryAccepted = false;
264
+ this.promptActivity = false;
243
265
  this.chunks = [];
244
266
  this.setState("busy");
245
267
  // session/prompt answers only when the turn ends, so deliver resolves now and failures come back through onFailed.
@@ -247,6 +269,9 @@ export class AcpPeer extends BasePeer {
247
269
  prompt
248
270
  .then((result) => {
249
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;
250
275
  this.observeUsage(result, "prompt_result");
251
276
  this.primed = true;
252
277
  const body = this.chunks.join("").trim();
@@ -259,13 +284,35 @@ export class AcpPeer extends BasePeer {
259
284
  }
260
285
  })
261
286
  .catch((e: Error) => {
262
- this.opts.log?.(`[${this.id}] prompt failed: ${e.message}`);
263
- if (turn === this.turn) this.reportTurnFailure(envs, e.message); // a watchdog-cancelled turn reports stale, never as this turn's failure
264
- if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason: e.message });
265
- 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); }
266
306
  })
267
307
  .finally(() => {
268
- 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;
269
316
  if (turn === this.turn && this.activeDeliveryId === deliveryId) this.activeDeliveryId = undefined;
270
317
  });
271
318
  }
@@ -280,6 +327,10 @@ export class AcpPeer extends BasePeer {
280
327
  }
281
328
 
282
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
283
334
  const durable = !!this.activeDeliveryId;
284
335
  if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "ACP turn watchdog timeout" });
285
336
  this.activeDeliveryId = undefined;
@@ -288,12 +339,31 @@ export class AcpPeer extends BasePeer {
288
339
  // ACP updates carry only a session ID, not a turn ID. After a durable turn times out,
289
340
  // keep the adapter offline until it is restarted so late chunks cannot contaminate a new turn.
290
341
  if (durable) this.setState("offline");
291
- 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;
292
362
  }
293
363
 
294
364
  private observeUsage(value: unknown, source: ACPUsageDiagnostic["source"]): void {
295
365
  const observation = normalizeACPUsage(value, source);
296
- this.opts.onUsageDiagnostic?.(observation);
366
+ this.opts.onUsageDiagnostic?.(observation, this.sessionId);
297
367
  if (observation.availability === "known") this.opts.onTokens?.(observation.total!, this.sessionId);
298
368
  }
299
369
 
@@ -304,6 +374,7 @@ export class AcpPeer extends BasePeer {
304
374
  }
305
375
 
306
376
  private down(reason: string): void {
377
+ this.clearCancelWait();
307
378
  this.opts.log?.(`[${this.id}] ${reason}`);
308
379
  for (const p of this.pending.values()) p.reject(new Error(reason));
309
380
  this.pending.clear();
@@ -313,19 +384,34 @@ export class AcpPeer extends BasePeer {
313
384
  }
314
385
 
315
386
  private onLine(line: string): void {
387
+ if (this.stopping) return; // buffered native callbacks cannot recreate approvals after peer_stop
316
388
  let msg: any;
317
389
  try {
318
390
  msg = JSON.parse(line);
319
391
  } catch {
320
392
  return; // not protocol output
321
393
  }
322
- if (this.state === "busy") this.touch();
394
+ if (this.state === "busy" && !this.cancelWait) this.touch();
323
395
 
324
396
  if (msg.method === "session/update") {
325
397
  if (msg.params?.sessionId && msg.params.sessionId !== this.sessionId) return;
326
- if (this.state !== "busy") return;
327
- this.acceptDelivery();
328
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();
329
415
  if (u?.sessionUpdate === "agent_message_chunk" && u.content?.type === "text") this.chunks.push(u.content.text);
330
416
  // The permission request that follows may carry no `rawInput` (Kimi 2.0.1 does not), and then the
331
417
  // console would be asked to approve a bare tool name. Keep what the call said it would run (issue #31).
@@ -344,6 +430,8 @@ export class AcpPeer extends BasePeer {
344
430
  }
345
431
  else if (u?.sessionUpdate === "usage_update") this.observeUsage(u, "usage_update");
346
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;
347
435
  void this.answerPermission(msg);
348
436
  } else if (msg.method && msg.id !== undefined) {
349
437
  this.send({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "method not supported by agent-hub" } });
@@ -395,7 +483,7 @@ export class AcpPeer extends BasePeer {
395
483
  } finally {
396
484
  clearInterval(alive); // also when there is no handler at all
397
485
  }
398
- const valid = options.some((o) => o.optionId === picked);
486
+ const valid = turn === this.turn && !this.stopping && options.some((o) => o.optionId === picked);
399
487
  this.send({
400
488
  jsonrpc: "2.0",
401
489
  id: msg.id,