@aragon-agent/cli 0.6.38 → 0.6.40

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 (209) hide show
  1. package/CHANGELOG.md +263 -0
  2. package/README.md +544 -11
  3. package/dist/agent/controller.d.ts +245 -10
  4. package/dist/agent/controller.js +367 -22
  5. package/dist/agent/model-profile-settings.js +12 -1
  6. package/dist/agent/plan-prompt.d.ts +1 -3
  7. package/dist/agent/plan-prompt.js +13 -7
  8. package/dist/agent/reducer.d.ts +70 -2
  9. package/dist/agent/reducer.js +59 -6
  10. package/dist/agent/skill-triage-prompt.d.ts +42 -0
  11. package/dist/agent/skill-triage-prompt.js +74 -0
  12. package/dist/agent/system-prompt.d.ts +28 -2
  13. package/dist/agent/system-prompt.js +12 -4
  14. package/dist/cli.js +106 -32
  15. package/dist/commands/builtins.js +24 -3
  16. package/dist/commands/keys-command.d.ts +23 -0
  17. package/dist/commands/keys-command.js +38 -0
  18. package/dist/commands/registry.d.ts +39 -0
  19. package/dist/commands/skills.js +86 -1
  20. package/dist/commands/subagent-command.d.ts +25 -0
  21. package/dist/commands/subagent-command.js +242 -0
  22. package/dist/commands/task-command.d.ts +39 -0
  23. package/dist/commands/task-command.js +102 -0
  24. package/dist/compaction/command.js +16 -3
  25. package/dist/compaction/compactor.d.ts +25 -0
  26. package/dist/compaction/compactor.js +111 -10
  27. package/dist/compaction/digest.d.ts +24 -1
  28. package/dist/compaction/digest.js +139 -17
  29. package/dist/compaction/limits.d.ts +21 -0
  30. package/dist/compaction/limits.js +21 -0
  31. package/dist/compaction/reason-text.d.ts +36 -0
  32. package/dist/compaction/reason-text.js +78 -0
  33. package/dist/compaction/types.d.ts +82 -14
  34. package/dist/compaction/wiring.d.ts +13 -8
  35. package/dist/compaction/wiring.js +1 -0
  36. package/dist/config/cli-commands.d.ts +23 -0
  37. package/dist/config/cli-commands.js +36 -0
  38. package/dist/config/env.js +17 -4
  39. package/dist/config/load.js +55 -3
  40. package/dist/config/schema.d.ts +125 -10
  41. package/dist/config/schema.js +138 -14
  42. package/dist/config/store.js +23 -3
  43. package/dist/diagnostics/doctor.d.ts +9 -0
  44. package/dist/diagnostics/doctor.js +34 -0
  45. package/dist/diagnostics/info.d.ts +71 -0
  46. package/dist/diagnostics/info.js +91 -5
  47. package/dist/exec/bridge/caller-tools.d.ts +96 -0
  48. package/dist/exec/bridge/caller-tools.js +213 -0
  49. package/dist/exec/bridge/decisions.d.ts +78 -0
  50. package/dist/exec/bridge/decisions.js +116 -0
  51. package/dist/exec/bridge/human-answers.d.ts +38 -0
  52. package/dist/exec/bridge/human-answers.js +164 -0
  53. package/dist/exec/bridge/limits.d.ts +68 -0
  54. package/dist/exec/bridge/limits.js +68 -0
  55. package/dist/exec/bridge/ndjson.d.ts +17 -0
  56. package/dist/exec/bridge/ndjson.js +83 -0
  57. package/dist/exec/bridge/pending.d.ts +70 -0
  58. package/dist/exec/bridge/pending.js +132 -0
  59. package/dist/exec/bridge/protocol.d.ts +346 -0
  60. package/dist/exec/bridge/protocol.js +256 -0
  61. package/dist/exec/bridge/server.d.ts +142 -0
  62. package/dist/exec/bridge/server.js +502 -0
  63. package/dist/exec/bridge/source.d.ts +58 -0
  64. package/dist/exec/bridge/source.js +145 -0
  65. package/dist/exec/bridge/writer.d.ts +26 -0
  66. package/dist/exec/bridge/writer.js +156 -0
  67. package/dist/exec/cli-commands.js +30 -2
  68. package/dist/exec/emitter.d.ts +16 -2
  69. package/dist/exec/emitter.js +26 -6
  70. package/dist/exec/events.d.ts +465 -10
  71. package/dist/exec/events.js +47 -1
  72. package/dist/exec/hooks/dispatch.d.ts +76 -0
  73. package/dist/exec/hooks/dispatch.js +169 -0
  74. package/dist/exec/hooks/index.d.ts +83 -0
  75. package/dist/exec/hooks/index.js +205 -0
  76. package/dist/exec/hooks/limits.d.ts +56 -0
  77. package/dist/exec/hooks/limits.js +49 -0
  78. package/dist/exec/hooks/registry.d.ts +49 -0
  79. package/dist/exec/hooks/registry.js +91 -0
  80. package/dist/exec/hooks/tool-wrapper.d.ts +94 -0
  81. package/dist/exec/hooks/tool-wrapper.js +154 -0
  82. package/dist/exec/hooks/turn.d.ts +61 -0
  83. package/dist/exec/hooks/turn.js +59 -0
  84. package/dist/exec/hooks/validate.d.ts +85 -0
  85. package/dist/exec/hooks/validate.js +310 -0
  86. package/dist/exec/index.d.ts +130 -4
  87. package/dist/exec/index.js +501 -10
  88. package/dist/exec/options.d.ts +124 -2
  89. package/dist/exec/options.js +413 -6
  90. package/dist/exec/permission.d.ts +9 -1
  91. package/dist/exec/permission.js +7 -1
  92. package/dist/exec/request/limits.d.ts +51 -0
  93. package/dist/exec/request/limits.js +49 -0
  94. package/dist/exec/request/scope.d.ts +106 -0
  95. package/dist/exec/request/scope.js +149 -0
  96. package/dist/exec/result/limits.d.ts +74 -0
  97. package/dist/exec/result/limits.js +72 -0
  98. package/dist/exec/result/prompt.d.ts +28 -0
  99. package/dist/exec/result/prompt.js +74 -0
  100. package/dist/exec/result/schema.d.ts +99 -0
  101. package/dist/exec/result/schema.js +464 -0
  102. package/dist/exec/result/sink.d.ts +56 -0
  103. package/dist/exec/result/sink.js +50 -0
  104. package/dist/exec/result/tool.d.ts +37 -0
  105. package/dist/exec/result/tool.js +118 -0
  106. package/dist/exec/runner.d.ts +395 -1
  107. package/dist/exec/runner.js +860 -8
  108. package/dist/exec/threads/aggregate.d.ts +34 -0
  109. package/dist/exec/threads/aggregate.js +30 -0
  110. package/dist/exec/threads/capacity.d.ts +23 -0
  111. package/dist/exec/threads/capacity.js +38 -0
  112. package/dist/exec/threads/dispatch-source.d.ts +60 -0
  113. package/dist/exec/threads/dispatch-source.js +106 -0
  114. package/dist/exec/threads/execute-threads.d.ts +53 -0
  115. package/dist/exec/threads/execute-threads.js +234 -0
  116. package/dist/exec/threads/gate.d.ts +42 -0
  117. package/dist/exec/threads/gate.js +81 -0
  118. package/dist/exec/threads/identity.d.ts +36 -0
  119. package/dist/exec/threads/identity.js +67 -0
  120. package/dist/exec/threads/interrupt.d.ts +29 -0
  121. package/dist/exec/threads/interrupt.js +79 -0
  122. package/dist/exec/threads/limits.d.ts +58 -0
  123. package/dist/exec/threads/limits.js +35 -0
  124. package/dist/exec/threads/outcome.d.ts +47 -0
  125. package/dist/exec/threads/outcome.js +26 -0
  126. package/dist/exec/threads/registry.d.ts +81 -0
  127. package/dist/exec/threads/registry.js +427 -0
  128. package/dist/exec/threads/runtime.d.ts +107 -0
  129. package/dist/exec/threads/runtime.js +523 -0
  130. package/dist/exec/threads/thread-source.d.ts +20 -0
  131. package/dist/exec/threads/thread-source.js +134 -0
  132. package/dist/fast/limits.d.ts +54 -0
  133. package/dist/fast/limits.js +54 -0
  134. package/dist/fast/summary-call.d.ts +72 -0
  135. package/dist/fast/summary-call.js +133 -0
  136. package/dist/fast/summary-prompt.d.ts +106 -0
  137. package/dist/fast/summary-prompt.js +320 -0
  138. package/dist/fast/task-summary-types.d.ts +66 -0
  139. package/dist/fast/task-summary-types.js +17 -0
  140. package/dist/logging/logger.d.ts +1 -1
  141. package/dist/proc/limits.d.ts +7 -0
  142. package/dist/proc/limits.js +7 -0
  143. package/dist/proc/log-mirror.d.ts +21 -4
  144. package/dist/proc/log-mirror.js +29 -7
  145. package/dist/proc/output-streams.d.ts +50 -0
  146. package/dist/proc/output-streams.js +54 -0
  147. package/dist/skills/service.d.ts +41 -0
  148. package/dist/skills/service.js +62 -0
  149. package/dist/skills/triage.d.ts +194 -0
  150. package/dist/skills/triage.js +393 -0
  151. package/dist/team/forced-dispatch.d.ts +95 -0
  152. package/dist/team/forced-dispatch.js +208 -0
  153. package/dist/team/limits.d.ts +21 -1
  154. package/dist/team/limits.js +21 -1
  155. package/dist/team/normalize.d.ts +15 -0
  156. package/dist/team/normalize.js +21 -4
  157. package/dist/team/prompt.d.ts +6 -3
  158. package/dist/team/prompt.js +9 -5
  159. package/dist/team/runtime.d.ts +9 -0
  160. package/dist/team/runtime.js +8 -0
  161. package/dist/team/subagent.d.ts +15 -0
  162. package/dist/team/subagent.js +45 -1
  163. package/dist/team/task-tool.js +11 -7
  164. package/dist/tools/bash-tool.d.ts +10 -0
  165. package/dist/tools/bash-tool.js +71 -6
  166. package/dist/tools/human-input.d.ts +35 -2
  167. package/dist/tools/human-input.js +38 -0
  168. package/dist/tools/index.d.ts +93 -1
  169. package/dist/tools/index.js +112 -3
  170. package/dist/tools/plan-tools.d.ts +20 -12
  171. package/dist/tools/plan-tools.js +24 -17
  172. package/dist/tools/tool-gate.d.ts +100 -0
  173. package/dist/tools/tool-gate.js +87 -0
  174. package/dist/ui/App.js +238 -12
  175. package/dist/ui/Header.d.ts +28 -16
  176. package/dist/ui/Header.js +21 -19
  177. package/dist/ui/StatusBar.d.ts +1 -0
  178. package/dist/ui/StatusBar.js +1 -0
  179. package/dist/ui/Transcript.js +1 -1
  180. package/dist/ui/entries/CompactionCard.d.ts +11 -1
  181. package/dist/ui/entries/CompactionCard.js +65 -4
  182. package/dist/ui/interaction-hints.js +6 -0
  183. package/dist/ui/layout/OverlayFrame.d.ts +18 -0
  184. package/dist/ui/layout/OverlayFrame.js +19 -4
  185. package/dist/ui/layout/header-hints.d.ts +55 -62
  186. package/dist/ui/layout/header-hints.js +59 -166
  187. package/dist/ui/layout/header-left.d.ts +115 -0
  188. package/dist/ui/layout/header-left.js +206 -0
  189. package/dist/ui/layout/shortcut-panel-layout.d.ts +191 -0
  190. package/dist/ui/layout/shortcut-panel-layout.js +321 -0
  191. package/dist/ui/layout/status-detail-layout.js +5 -1
  192. package/dist/ui/layout/status-layout.d.ts +10 -0
  193. package/dist/ui/layout/status-layout.js +18 -1
  194. package/dist/ui/layout/task-summary-cell.d.ts +61 -0
  195. package/dist/ui/layout/task-summary-cell.js +65 -0
  196. package/dist/ui/overlays/HelpOverlay.d.ts +12 -0
  197. package/dist/ui/overlays/HelpOverlay.js +29 -2
  198. package/dist/ui/overlays/SettingsScreen.js +29 -5
  199. package/dist/ui/overlays/ShortcutPanel.d.ts +39 -0
  200. package/dist/ui/overlays/ShortcutPanel.js +106 -0
  201. package/dist/ui/overlays/TeamFanoutPage.d.ts +35 -0
  202. package/dist/ui/overlays/TeamFanoutPage.js +78 -0
  203. package/dist/ui/overlays/model-profile-state.d.ts +19 -1
  204. package/dist/ui/overlays/model-profile-state.js +12 -0
  205. package/dist/ui/overlays/team-fanout-state.d.ts +73 -0
  206. package/dist/ui/overlays/team-fanout-state.js +97 -0
  207. package/dist/ui/shortcuts.d.ts +81 -0
  208. package/dist/ui/shortcuts.js +221 -0
  209. package/package.json +2 -2
@@ -0,0 +1,427 @@
1
+ /**
2
+ * The thread table and its state machine (headless-threads-and-lifecycle-hooks
3
+ * section 2.3.4).
4
+ *
5
+ * ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
6
+ *
7
+ * (absent) --thread_open--> opening --ack{accepted:true}--> open
8
+ * | |
9
+ * | ack{accepted:false} | thread_close
10
+ * v v
11
+ * (absent) closing
12
+ * | request settled,
13
+ * | controller disposed
14
+ * v
15
+ * closed --> (record kept)
16
+ *
17
+ * FOUR RULES THAT ARE EASY TO GET WRONG, AND ARE THEREFORE EXPLICIT HERE:
18
+ *
19
+ * `opening` IS ASYNCHRONOUS AND ITS FRAMES ARE NOT DROPPED. `thread_open` constructs a
20
+ * controller and takes a session lock while `handleLine` is synchronous, so a caller that
21
+ * writes `thread_open` and `prompt` back to back - the normal Web-backend shape - must
22
+ * have its prompt QUEUED, not discarded (I-T7).
23
+ *
24
+ * A CLOSED THREAD KEEPS ITS RECORD. Its account belongs in `result.threads.items`, so
25
+ * deleting it would make the process's answer disagree with the events the caller
26
+ * already received - but it stops occupying `maxOpenThreads` and never holds a slot.
27
+ *
28
+ * `interruptAll` ALSO FINISHES THE SOURCES. A process-level interrupt means "no more
29
+ * work", not "abort what is running and then keep serving": without the second half, a
30
+ * parked `next()` would keep the process alive after Ctrl-C.
31
+ *
32
+ * THE DEFAULT THREAD HAS NO `ack`. Nobody sent a frame, so a receipt would be a reply to
33
+ * a request that was never made. It is opened by `openDefault()`, which also gives
34
+ * `executeThreads` the one chance to emit `system/init` BEFORE the first thread event.
35
+ */
36
+ import { canOpen } from './capacity.js';
37
+ import { DEFAULT_THREAD_ID, resolveThreadIdentity } from './identity.js';
38
+ import { ThreadOpenError } from './runtime.js';
39
+ import { createThreadSource } from './thread-source.js';
40
+ import { withinRequestCap } from './capacity.js';
41
+ export function createThreadRegistry(deps) {
42
+ const records = new Map();
43
+ /** Open order, so `result.threads.items` can be zipped with the events. */
44
+ const order = [];
45
+ /**
46
+ * One `drained()` per runtime, pushed the moment the runtime exists.
47
+ *
48
+ * A LIST, NOT A SNAPSHOT OF `records`, because the process's exit condition is "every
49
+ * thread that was ever opened has closed" and a snapshot taken before stdin has been read
50
+ * contains only `main` (section 2.3.3: the frames arrive after `openDefault()` returns).
51
+ * With a snapshot, `end` would be followed by `result` as soon as the DEFAULT thread
52
+ * finished, cutting off a tenant that was still mid-request - AC-36's exact claim.
53
+ */
54
+ const drains = [];
55
+ let promptCounter = 0;
56
+ let firstOpen = true;
57
+ const activeCount = () => {
58
+ let count = 0;
59
+ for (const record of records.values()) {
60
+ if (record.state !== 'closed')
61
+ count += 1;
62
+ }
63
+ return count;
64
+ };
65
+ const ack = (frameId, extra) => {
66
+ deps.write({ type: 'ack', ...(frameId ? { id: frameId } : {}), ...extra });
67
+ };
68
+ const refuse = (frameId, code, message, threadId) => {
69
+ ack(frameId, { ...(threadId ? { threadId } : {}), accepted: false, code, message });
70
+ };
71
+ const deliver = (record, text, frameId) => {
72
+ promptCounter += 1;
73
+ record.source.push(text, frameId ?? `prompt_${promptCounter}`);
74
+ };
75
+ /**
76
+ * The synchronous checks. Kept apart from `start()` so a refusal never creates a record -
77
+ * "no thread was created" is one of the acceptance criteria (AC-13) and a record that
78
+ * exists only to be deleted would make that claim harder to keep.
79
+ */
80
+ const admit = (threadId, explicitSessionId) => {
81
+ const capacity = canOpen({ active: activeCount(), maxOpenThreads: deps.maxOpenThreads });
82
+ if (capacity !== true) {
83
+ return {
84
+ ok: false,
85
+ code: capacity,
86
+ message: `At most ${deps.maxOpenThreads} threads may be open at once.`,
87
+ };
88
+ }
89
+ const identity = resolveThreadIdentity({
90
+ threadId,
91
+ baseSessionId: deps.baseSessionId,
92
+ ...(explicitSessionId !== undefined ? { explicitSessionId } : {}),
93
+ saving: deps.saving,
94
+ });
95
+ if (!identity.ok) {
96
+ return {
97
+ ok: false,
98
+ code: identity.code,
99
+ message: identity.code === 'thread_id_too_long'
100
+ ? `Thread "${threadId}" derives a session id longer than 64 characters; ` +
101
+ 'shorten --session-id.'
102
+ : `Thread id "${threadId}" must match [a-z0-9][a-z0-9_-]{0,31}, and any ` +
103
+ 'explicit sessionId must be a legal session id.',
104
+ };
105
+ }
106
+ const lock = deps.saving ? (deps.acquireLock?.(identity.sessionId) ?? null) : null;
107
+ if (deps.saving && !lock) {
108
+ return {
109
+ ok: false,
110
+ code: 'session_locked',
111
+ message: `Session "${identity.sessionId}" is held by another run.`,
112
+ };
113
+ }
114
+ return {
115
+ ok: true,
116
+ record: {
117
+ threadId,
118
+ sessionId: identity.sessionId,
119
+ sessionFile: deps.sessionFileFor?.(identity.sessionId) ?? null,
120
+ state: 'opening',
121
+ source: createThreadSource({ threadId, gate: deps.gate }),
122
+ runtime: null,
123
+ pendingPrompts: [],
124
+ pendingControls: [],
125
+ lock,
126
+ },
127
+ };
128
+ };
129
+ /** Construct, open and start one admitted thread. Throws `ThreadOpenError` on failure. */
130
+ const start = async (record) => {
131
+ const runtime = deps.makeRuntime({
132
+ threadId: record.threadId,
133
+ sessionId: record.sessionId,
134
+ sessionFile: record.sessionFile,
135
+ lock: record.lock,
136
+ source: record.source,
137
+ });
138
+ record.runtime = runtime;
139
+ // RECORDED BEFORE THE FIRST `await`, so a caller that asks "is everything closed?" the
140
+ // moment this thread exists cannot miss it.
141
+ drains.push(runtime.drained());
142
+ await runtime.open();
143
+ record.state = 'open';
144
+ if (firstOpen) {
145
+ firstOpen = false;
146
+ // THE ONE CHANCE TO WRITE `system/init` BEFORE ANY THREAD EVENT.
147
+ deps.onFirstOpen?.();
148
+ }
149
+ runtime.announceOpened();
150
+ if (record.closeAfterOpen !== undefined) {
151
+ // A `thread_close` THAT ARRIVED WHILE THIS THREAD WAS STILL OPENING (section 2.3.4).
152
+ // Honouring it here - instead of after the queued prompts - is what makes the ack
153
+ // truthful: the caller was told "closing", so nothing may be served first.
154
+ record.state = 'closing';
155
+ runtime.requestClose(record.closeAfterOpen);
156
+ record.source.finish();
157
+ }
158
+ else {
159
+ for (const prompt of record.pendingPrompts.splice(0)) {
160
+ deliver(record, prompt.text, prompt.frameId);
161
+ }
162
+ for (const control of record.pendingControls.splice(0)) {
163
+ runtime.handleControl(control);
164
+ }
165
+ }
166
+ // THE RUN LOOP STARTS IN BOTH CASES, WHICH IS NOT A DETAIL: `requestClose` only ASKS a
167
+ // runtime to shut down, and `drained()` resolves from inside `run()`'s own `finally`
168
+ // (dispose, then the account, then the latch). Returning before this call left a thread
169
+ // that was closed while still opening stuck in `closing` forever, so `registry.drained()`
170
+ // never resolved and the process never wrote its `result` - a hang for one frame order,
171
+ // and the commonest one a host produces (a batch of frames written in one chunk).
172
+ void (async () => {
173
+ await runtime.run(record.source);
174
+ record.state = 'closed';
175
+ record.lock?.release();
176
+ record.lock = null;
177
+ })();
178
+ return runtime;
179
+ };
180
+ const handleOpen = (frame) => {
181
+ const existing = records.get(frame.threadId);
182
+ if (existing) {
183
+ if (existing.state === 'opening' || existing.state === 'open') {
184
+ refuse(frame.id, 'thread_exists', `Thread "${frame.threadId}" is already open.`, frame.threadId);
185
+ return;
186
+ }
187
+ if (existing.state === 'closing') {
188
+ refuse(frame.id, 'thread_closing', `Thread "${frame.threadId}" is closing.`, frame.threadId);
189
+ return;
190
+ }
191
+ // A CLOSED RECORD IS NOT REUSED: two conversations under one id would make two
192
+ // `thread_state{closed}` events share a name, and a caller could not tell its
193
+ // accounting apart.
194
+ refuse(frame.id, 'thread_exists', `Thread "${frame.threadId}" was already used in this run; choose another id.`, frame.threadId);
195
+ return;
196
+ }
197
+ const admitted = admit(frame.threadId, frame.sessionId);
198
+ if (!admitted.ok) {
199
+ refuse(frame.id, admitted.code, admitted.message, frame.threadId);
200
+ return;
201
+ }
202
+ const record = admitted.record;
203
+ records.set(record.threadId, record);
204
+ order.push(record.threadId);
205
+ void (async () => {
206
+ try {
207
+ await start(record);
208
+ ack(frame.id, {
209
+ threadId: record.threadId,
210
+ accepted: true,
211
+ sessionId: record.sessionId,
212
+ sessionFile: record.sessionFile,
213
+ resumed: false,
214
+ });
215
+ }
216
+ catch (error) {
217
+ record.state = 'closed';
218
+ record.lock?.release();
219
+ record.lock = null;
220
+ refuse(frame.id, error instanceof ThreadOpenError ? error.code : 'thread_open_failed', error instanceof Error ? error.message : 'The thread could not be opened.', record.threadId);
221
+ }
222
+ })();
223
+ };
224
+ const handleClose = (frame) => {
225
+ const record = records.get(frame.threadId);
226
+ if (!record) {
227
+ refuse(frame.id, 'unknown_thread', `Thread "${frame.threadId}" is not open.`, frame.threadId);
228
+ return;
229
+ }
230
+ if (record.state === 'closing') {
231
+ ack(frame.id, { threadId: frame.threadId, accepted: true, code: 'already_closing' });
232
+ return;
233
+ }
234
+ if (record.state === 'closed') {
235
+ refuse(frame.id, 'unknown_thread', `Thread "${frame.threadId}" is already closed.`, frame.threadId);
236
+ return;
237
+ }
238
+ if (record.state === 'opening' && record.closeAfterOpen !== undefined) {
239
+ // A SECOND CLOSE FOR A THREAD THAT IS STILL OPENING IS STILL A SECOND CLOSE (AC-33).
240
+ // The intent is already parked, so the answer is the same idempotent receipt the
241
+ // `closing` state gives - otherwise the caller cannot tell "recorded" from "ignored"
242
+ // in the one window where nothing has been announced yet.
243
+ ack(frame.id, { threadId: frame.threadId, accepted: true, code: 'already_closing' });
244
+ return;
245
+ }
246
+ ack(frame.id, { threadId: frame.threadId, accepted: true });
247
+ // THE RECORD MOVES TO `closing` HERE, not when the runtime finishes: everything that
248
+ // arrives between this frame and the last `request_end` must be answered
249
+ // `thread_closing` rather than accepted into a conversation that is ending.
250
+ if (record.state === 'opening') {
251
+ // NOT YET OPEN: park the intent and let `start()` act on it the moment the thread is
252
+ // usable, so a queued prompt is never served after the caller asked to close.
253
+ record.closeAfterOpen = frame.mode ?? 'settle';
254
+ return;
255
+ }
256
+ record.state = 'closing';
257
+ // THE SOURCE ENDS IN BOTH MODES: `settle` lets the request in flight finish and then
258
+ // ends the loop; `abort` cancels it and ends the loop now. Either way the thread reaches
259
+ // `closed` without waiting for a prompt that will never arrive.
260
+ record.runtime?.requestClose(frame.mode ?? 'settle');
261
+ record.source.finish();
262
+ };
263
+ const summaries = () => order.map((id) => {
264
+ const record = records.get(id);
265
+ if (!record) {
266
+ return {
267
+ threadId: id,
268
+ sessionId: '',
269
+ state: 'closed',
270
+ requests: 0,
271
+ turns: 0,
272
+ inFlight: false,
273
+ };
274
+ }
275
+ const summary = record.runtime?.summarize();
276
+ return summary
277
+ ? { ...summary, state: record.state }
278
+ : {
279
+ threadId: record.threadId,
280
+ sessionId: record.sessionId,
281
+ state: record.state,
282
+ requests: 0,
283
+ turns: 0,
284
+ inFlight: false,
285
+ };
286
+ });
287
+ const accounts = () => order
288
+ .map((id) => records.get(id)?.runtime?.account())
289
+ .filter((account) => account !== undefined);
290
+ /**
291
+ * Resolve once every thread that was ever opened has closed.
292
+ *
293
+ * WAITED IN WAVES, so a thread opened WHILE we were waiting is waited for too: each pass
294
+ * awaits only the runtimes that appeared since the previous one, and the loop exits when
295
+ * nothing new arrived - the only moment "every opened thread is closed" is really true.
296
+ */
297
+ const waitForDrains = async () => {
298
+ let awaited = 0;
299
+ for (;;) {
300
+ if (drains.length <= awaited)
301
+ return;
302
+ const next = drains.slice(awaited);
303
+ awaited = drains.length;
304
+ await Promise.all(next);
305
+ }
306
+ };
307
+ const closeAll = async (reason) => {
308
+ for (const record of records.values()) {
309
+ if (record.state === 'closed')
310
+ continue;
311
+ record.source.finish();
312
+ record.runtime?.requestClose(reason === 'aborted' ? 'abort' : 'settle');
313
+ }
314
+ // ONE WAIT PATH: the same waves `drained` uses, so a thread opened while closing is not cut
315
+ // off by this call either.
316
+ await waitForDrains();
317
+ return accounts();
318
+ };
319
+ return {
320
+ async openDefault() {
321
+ const admitted = admit(DEFAULT_THREAD_ID, undefined);
322
+ if (!admitted.ok) {
323
+ throw new ThreadOpenError(admitted.code === 'session_locked' ? 'session_locked' : 'thread_open_failed', admitted.message);
324
+ }
325
+ records.set(admitted.record.threadId, admitted.record);
326
+ order.push(admitted.record.threadId);
327
+ await start(admitted.record);
328
+ },
329
+ handleOpen,
330
+ handleClose,
331
+ handleList: (frame) => ack(frame.id, { accepted: true, threads: summaries() }),
332
+ deliverPrompt(threadId, text, frameId) {
333
+ const record = records.get(threadId);
334
+ if (!record || record.state === 'closed') {
335
+ refuse(frameId, 'unknown_thread', `Thread "${threadId}" is not open; send thread_open first.`, threadId);
336
+ deps.write({
337
+ type: 'error',
338
+ sessionId: deps.baseSessionId,
339
+ threadId,
340
+ fatal: false,
341
+ code: 'unknown_thread',
342
+ message: `A prompt named thread "${threadId}", which is not open.`,
343
+ });
344
+ return;
345
+ }
346
+ if (record.state === 'closing') {
347
+ refuse(frameId, 'thread_closing', `Thread "${threadId}" is closing; no new prompt is accepted.`, threadId);
348
+ return;
349
+ }
350
+ // THE LEAK GUARD IS A CEILING, NOT A NUMBER IN A TABLE (P2-4). A thread that has served
351
+ // `maxRequestsPerThread` requests is being used as a queue and should be closed and
352
+ // reopened; letting it refill forever would make one `result.threads.items` entry
353
+ // unbounded. Checked BEFORE the prompt is queued, so the caller gets a receipt.
354
+ const served = record.runtime?.summarize().requests ?? 0;
355
+ if (!withinRequestCap(served)) {
356
+ refuse(frameId, 'thread_capacity', `Thread "${threadId}" has served ${served} requests, its ceiling; ` +
357
+ 'close it and open another.', threadId);
358
+ return;
359
+ }
360
+ if (record.state === 'opening') {
361
+ record.pendingPrompts.push({ text, frameId });
362
+ return;
363
+ }
364
+ deliver(record, text, frameId);
365
+ },
366
+ deliverControl(threadId, frame) {
367
+ const record = records.get(threadId);
368
+ if (!record || record.state === 'closed') {
369
+ deps.write({
370
+ type: 'error',
371
+ sessionId: deps.baseSessionId,
372
+ threadId,
373
+ fatal: false,
374
+ code: 'unknown_thread',
375
+ message: `A "${frame.type}" frame named thread "${threadId}", which is not open.`,
376
+ });
377
+ return;
378
+ }
379
+ if (record.state === 'opening') {
380
+ record.pendingControls.push(frame);
381
+ return;
382
+ }
383
+ record.runtime?.handleControl(frame);
384
+ },
385
+ serverFor: (threadId) => records.get(threadId)?.runtime?.server() ?? undefined,
386
+ runtimeFor: (threadId) => records.get(threadId)?.runtime ?? undefined,
387
+ ids: () => [...order],
388
+ summaries,
389
+ rejectThreadedInterrupt(threadId, frameId) {
390
+ ack(frameId, {
391
+ threadId,
392
+ accepted: false,
393
+ code: 'interrupt_is_process_level',
394
+ message: 'interrupt stops the whole run. Use cancel_request{threadId} to stop one request.',
395
+ });
396
+ },
397
+ finishAll() {
398
+ for (const record of records.values())
399
+ record.source.finish();
400
+ },
401
+ drained: waitForDrains,
402
+ closeAll,
403
+ interruptAll(signo) {
404
+ for (const record of records.values()) {
405
+ if (record.state === 'closed')
406
+ continue;
407
+ record.runtime?.interrupt();
408
+ }
409
+ // NO MORE WORK IS SERVED AFTER A PROCESS INTERRUPT (see the header).
410
+ for (const record of records.values())
411
+ record.source.finish();
412
+ void signo;
413
+ },
414
+ timeoutAll() {
415
+ for (const record of records.values()) {
416
+ if (record.state === 'closed')
417
+ continue;
418
+ record.runtime?.timeout();
419
+ }
420
+ for (const record of records.values())
421
+ record.source.finish();
422
+ },
423
+ accounts,
424
+ openCount: activeCount,
425
+ };
426
+ }
427
+ export { DEFAULT_THREAD_ID };
@@ -0,0 +1,107 @@
1
+ import type { AgentController } from '../../agent/controller.js';
2
+ import type { CliFlags } from '../../config/load.js';
3
+ import type { ExecEmitter } from '../emitter.js';
4
+ import type { ExecOptions } from '../options.js';
5
+ import { type BridgeServer } from '../bridge/server.js';
6
+ import type { BridgeWriter } from '../bridge/writer.js';
7
+ import { type BridgeInputFrame } from '../bridge/protocol.js';
8
+ import { type HookRuntime } from '../hooks/index.js';
9
+ import type { ResultSchemaSpec } from '../result/schema.js';
10
+ import type { SessionLockHandle } from '../../session/store.js';
11
+ import type { ExecControllerFactory } from '../index.js';
12
+ import type { ThreadAccount } from './aggregate.js';
13
+ import type { ThreadPromptSource, ThreadSource } from './thread-source.js';
14
+ import type { ThreadSummary } from './registry.js';
15
+ export type ThreadOpenCode = 'thread_id_invalid' | 'session_locked' | 'thread_limit' | 'thread_open_failed';
16
+ export declare class ThreadOpenError extends Error {
17
+ readonly code: ThreadOpenCode;
18
+ constructor(code: ThreadOpenCode, message: string);
19
+ }
20
+ export interface ThreadPersistInput {
21
+ sessionId: string;
22
+ controller: AgentController;
23
+ turns: number;
24
+ usage: {
25
+ inputTokens: number;
26
+ outputTokens: number;
27
+ };
28
+ at: number;
29
+ }
30
+ export interface ThreadRuntimeDeps {
31
+ threadId: string;
32
+ sessionId: string;
33
+ /** `null` under `--no-save-session`. */
34
+ sessionFile: string | null;
35
+ flags: CliFlags;
36
+ options: ExecOptions;
37
+ version: string;
38
+ makeController: ExecControllerFactory;
39
+ emitter: ExecEmitter;
40
+ writer: BridgeWriter;
41
+ now: () => number;
42
+ resultSpec: ResultSchemaSpec | null;
43
+ /** The session lock, already taken by the registry, or `null` when nothing is saved. */
44
+ lock: SessionLockHandle | null;
45
+ /** Writes this thread's session file; supplied by `execute-threads`. */
46
+ persist: (input: ThreadPersistInput) => void;
47
+ /** `THREAD_LIMITS`-clamped `--hook-timeout`. */
48
+ hookTimeoutMs: number;
49
+ /**
50
+ * THIS THREAD'S OWN PROMPT QUEUE, and the object that owns its admission slot.
51
+ *
52
+ * A DEPENDENCY RATHER THAN AN ARGUMENT OF `run()` because the runner must be handed the
53
+ * bridge channel in `open()` - one step BEFORE `run()` is ever called (section 2.3.4,
54
+ * step 5). `runner.attachBridge(server, source)` is what installs the server's turn ports
55
+ * (`steer`, `interrupt{requestId}`, `cancel_request`) and what makes the runner's
56
+ * `bridge` field non-null, i.e. what makes a prompt get its `ack{accepted:true,
57
+ * requestSeq}` and a turn get its `request_end`. Omitting it is silent: the run serves
58
+ * every prompt and writes no receipt at all, which is the v1 regression (I-C4) section
59
+ * 2.3.3 spends a page preventing.
60
+ *
61
+ * THE SLOT IS RELEASED THROUGH THIS OBJECT TOO. `holding` lives inside the source, so
62
+ * freeing the gate from anywhere else leaves it set - see `onRequestSettled` below.
63
+ */
64
+ source: ThreadSource;
65
+ }
66
+ export interface ThreadRuntime {
67
+ readonly threadId: string;
68
+ readonly sessionId: string;
69
+ readonly sessionFile: string | null;
70
+ state(): 'opening' | 'open' | 'closing' | 'closed';
71
+ open(): Promise<void>;
72
+ /** Drive this thread's conversation until its source is finished. */
73
+ run(source: ThreadPromptSource): Promise<void>;
74
+ /** Begin shutting down; `drained()` resolves when it is finished. */
75
+ requestClose(mode: 'settle' | 'abort'): void;
76
+ /** Resolves once this thread is `closed` and its account is final. */
77
+ drained(): Promise<void>;
78
+ /** Process-level interrupt fan-out (section 2.3.8a). */
79
+ interrupt(): void;
80
+ /** Process-level timeout fan-out (section 2.3.8b). */
81
+ timeout(): void;
82
+ summarize(): ThreadSummary;
83
+ account(): ThreadAccount;
84
+ /** The hook table, so the router can answer `hook_answer` and `thread_list`. */
85
+ hooks(): HookRuntime | null;
86
+ /** Emit `thread_state{phase:'opened'}`. Called once, after `system/init`. */
87
+ announceOpened(): void;
88
+ /** The facts `system/init` needs from this thread's controller. */
89
+ describe(): {
90
+ model: {
91
+ provider: string;
92
+ id: string;
93
+ baseUrl: string | null;
94
+ };
95
+ cwd: string;
96
+ tools: string[];
97
+ sessionId: string;
98
+ sessionFile: string | null;
99
+ };
100
+ /** Route one control frame to this thread's own bridge server. */
101
+ handleControl(frame: BridgeInputFrame): void;
102
+ /** This thread's server, for `registry.serverFor` and for tests. */
103
+ server(): BridgeServer | null;
104
+ /** Apply a `tool_register` that arrived while this thread was still opening. */
105
+ flushCallerTools(): void;
106
+ }
107
+ export declare function createThreadRuntime(deps: ThreadRuntimeDeps): ThreadRuntime;