@aragon-agent/cli 0.6.37 → 0.6.39

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 (162) hide show
  1. package/CHANGELOG.md +2523 -2326
  2. package/README.md +3614 -3173
  3. package/dist/agent/controller.d.ts +142 -1
  4. package/dist/agent/controller.js +171 -3
  5. package/dist/agent/reducer.d.ts +41 -1
  6. package/dist/agent/reducer.js +25 -0
  7. package/dist/agent/system-prompt.d.ts +14 -0
  8. package/dist/agent/system-prompt.js +5 -0
  9. package/dist/cli.js +85 -27
  10. package/dist/commands/builtins.js +13 -1
  11. package/dist/commands/keys-command.d.ts +23 -0
  12. package/dist/commands/keys-command.js +38 -0
  13. package/dist/commands/perf.d.ts +7 -1
  14. package/dist/commands/perf.js +2 -2
  15. package/dist/commands/registry.d.ts +39 -0
  16. package/dist/commands/task-command.d.ts +39 -0
  17. package/dist/commands/task-command.js +102 -0
  18. package/dist/config/cli-commands.d.ts +23 -0
  19. package/dist/config/cli-commands.js +36 -0
  20. package/dist/config/env.js +17 -4
  21. package/dist/config/load.js +6 -1
  22. package/dist/config/schema.d.ts +43 -4
  23. package/dist/config/schema.js +31 -5
  24. package/dist/config/store.js +17 -2
  25. package/dist/diagnostics/doctor.d.ts +9 -0
  26. package/dist/diagnostics/doctor.js +34 -0
  27. package/dist/diagnostics/info.d.ts +71 -0
  28. package/dist/diagnostics/info.js +91 -5
  29. package/dist/exec/bridge/caller-tools.d.ts +96 -0
  30. package/dist/exec/bridge/caller-tools.js +213 -0
  31. package/dist/exec/bridge/decisions.d.ts +78 -0
  32. package/dist/exec/bridge/decisions.js +116 -0
  33. package/dist/exec/bridge/human-answers.d.ts +38 -0
  34. package/dist/exec/bridge/human-answers.js +164 -0
  35. package/dist/exec/bridge/limits.d.ts +68 -0
  36. package/dist/exec/bridge/limits.js +68 -0
  37. package/dist/exec/bridge/ndjson.d.ts +17 -0
  38. package/dist/exec/bridge/ndjson.js +83 -0
  39. package/dist/exec/bridge/pending.d.ts +70 -0
  40. package/dist/exec/bridge/pending.js +132 -0
  41. package/dist/exec/bridge/protocol.d.ts +346 -0
  42. package/dist/exec/bridge/protocol.js +256 -0
  43. package/dist/exec/bridge/server.d.ts +142 -0
  44. package/dist/exec/bridge/server.js +502 -0
  45. package/dist/exec/bridge/source.d.ts +58 -0
  46. package/dist/exec/bridge/source.js +145 -0
  47. package/dist/exec/bridge/writer.d.ts +26 -0
  48. package/dist/exec/bridge/writer.js +156 -0
  49. package/dist/exec/cli-commands.js +30 -2
  50. package/dist/exec/emitter.d.ts +16 -2
  51. package/dist/exec/emitter.js +26 -6
  52. package/dist/exec/events.d.ts +465 -10
  53. package/dist/exec/events.js +47 -1
  54. package/dist/exec/hooks/dispatch.d.ts +76 -0
  55. package/dist/exec/hooks/dispatch.js +169 -0
  56. package/dist/exec/hooks/index.d.ts +83 -0
  57. package/dist/exec/hooks/index.js +205 -0
  58. package/dist/exec/hooks/limits.d.ts +56 -0
  59. package/dist/exec/hooks/limits.js +49 -0
  60. package/dist/exec/hooks/registry.d.ts +49 -0
  61. package/dist/exec/hooks/registry.js +91 -0
  62. package/dist/exec/hooks/tool-wrapper.d.ts +94 -0
  63. package/dist/exec/hooks/tool-wrapper.js +154 -0
  64. package/dist/exec/hooks/turn.d.ts +61 -0
  65. package/dist/exec/hooks/turn.js +59 -0
  66. package/dist/exec/hooks/validate.d.ts +85 -0
  67. package/dist/exec/hooks/validate.js +310 -0
  68. package/dist/exec/index.d.ts +130 -4
  69. package/dist/exec/index.js +501 -10
  70. package/dist/exec/options.d.ts +124 -2
  71. package/dist/exec/options.js +413 -6
  72. package/dist/exec/permission.d.ts +9 -1
  73. package/dist/exec/permission.js +7 -1
  74. package/dist/exec/request/limits.d.ts +51 -0
  75. package/dist/exec/request/limits.js +49 -0
  76. package/dist/exec/request/scope.d.ts +106 -0
  77. package/dist/exec/request/scope.js +149 -0
  78. package/dist/exec/result/limits.d.ts +74 -0
  79. package/dist/exec/result/limits.js +72 -0
  80. package/dist/exec/result/prompt.d.ts +28 -0
  81. package/dist/exec/result/prompt.js +74 -0
  82. package/dist/exec/result/schema.d.ts +99 -0
  83. package/dist/exec/result/schema.js +464 -0
  84. package/dist/exec/result/sink.d.ts +56 -0
  85. package/dist/exec/result/sink.js +50 -0
  86. package/dist/exec/result/tool.d.ts +37 -0
  87. package/dist/exec/result/tool.js +118 -0
  88. package/dist/exec/runner.d.ts +395 -1
  89. package/dist/exec/runner.js +860 -8
  90. package/dist/exec/threads/aggregate.d.ts +34 -0
  91. package/dist/exec/threads/aggregate.js +30 -0
  92. package/dist/exec/threads/capacity.d.ts +23 -0
  93. package/dist/exec/threads/capacity.js +38 -0
  94. package/dist/exec/threads/dispatch-source.d.ts +60 -0
  95. package/dist/exec/threads/dispatch-source.js +106 -0
  96. package/dist/exec/threads/execute-threads.d.ts +53 -0
  97. package/dist/exec/threads/execute-threads.js +234 -0
  98. package/dist/exec/threads/gate.d.ts +42 -0
  99. package/dist/exec/threads/gate.js +81 -0
  100. package/dist/exec/threads/identity.d.ts +36 -0
  101. package/dist/exec/threads/identity.js +67 -0
  102. package/dist/exec/threads/interrupt.d.ts +29 -0
  103. package/dist/exec/threads/interrupt.js +79 -0
  104. package/dist/exec/threads/limits.d.ts +58 -0
  105. package/dist/exec/threads/limits.js +35 -0
  106. package/dist/exec/threads/outcome.d.ts +47 -0
  107. package/dist/exec/threads/outcome.js +26 -0
  108. package/dist/exec/threads/registry.d.ts +81 -0
  109. package/dist/exec/threads/registry.js +427 -0
  110. package/dist/exec/threads/runtime.d.ts +107 -0
  111. package/dist/exec/threads/runtime.js +523 -0
  112. package/dist/exec/threads/thread-source.d.ts +20 -0
  113. package/dist/exec/threads/thread-source.js +134 -0
  114. package/dist/fast/limits.d.ts +54 -0
  115. package/dist/fast/limits.js +54 -0
  116. package/dist/fast/summary-call.d.ts +72 -0
  117. package/dist/fast/summary-call.js +133 -0
  118. package/dist/fast/summary-prompt.d.ts +106 -0
  119. package/dist/fast/summary-prompt.js +320 -0
  120. package/dist/fast/task-summary-types.d.ts +66 -0
  121. package/dist/fast/task-summary-types.js +17 -0
  122. package/dist/logging/logger.d.ts +1 -1
  123. package/dist/proc/limits.d.ts +7 -0
  124. package/dist/proc/limits.js +7 -0
  125. package/dist/proc/output-streams.d.ts +50 -0
  126. package/dist/proc/output-streams.js +54 -0
  127. package/dist/team/runtime.d.ts +9 -0
  128. package/dist/team/runtime.js +8 -0
  129. package/dist/team/subagent.d.ts +15 -0
  130. package/dist/team/subagent.js +45 -1
  131. package/dist/tools/bash-tool.d.ts +10 -0
  132. package/dist/tools/bash-tool.js +71 -6
  133. package/dist/tools/index.d.ts +93 -1
  134. package/dist/tools/index.js +112 -3
  135. package/dist/tools/tool-gate.d.ts +100 -0
  136. package/dist/tools/tool-gate.js +87 -0
  137. package/dist/ui/App.js +224 -11
  138. package/dist/ui/Header.d.ts +28 -16
  139. package/dist/ui/Header.js +21 -19
  140. package/dist/ui/interaction-hints.js +6 -0
  141. package/dist/ui/layout/OverlayFrame.d.ts +18 -0
  142. package/dist/ui/layout/OverlayFrame.js +19 -4
  143. package/dist/ui/layout/header-hints.d.ts +55 -62
  144. package/dist/ui/layout/header-hints.js +59 -166
  145. package/dist/ui/layout/header-left.d.ts +115 -0
  146. package/dist/ui/layout/header-left.js +206 -0
  147. package/dist/ui/layout/shortcut-panel-layout.d.ts +191 -0
  148. package/dist/ui/layout/shortcut-panel-layout.js +321 -0
  149. package/dist/ui/layout/status-detail-layout.js +5 -1
  150. package/dist/ui/layout/task-summary-cell.d.ts +61 -0
  151. package/dist/ui/layout/task-summary-cell.js +65 -0
  152. package/dist/ui/overlays/HelpOverlay.d.ts +12 -0
  153. package/dist/ui/overlays/HelpOverlay.js +29 -2
  154. package/dist/ui/overlays/ShortcutPanel.d.ts +39 -0
  155. package/dist/ui/overlays/ShortcutPanel.js +106 -0
  156. package/dist/ui/scrollback/canvas.d.ts +3 -2
  157. package/dist/ui/scrollback/canvas.js +3 -2
  158. package/dist/ui/scrollback/scrollback-writer.d.ts +13 -1
  159. package/dist/ui/scrollback/scrollback-writer.js +109 -10
  160. package/dist/ui/shortcuts.d.ts +81 -0
  161. package/dist/ui/shortcuts.js +221 -0
  162. package/package.json +2 -2
@@ -0,0 +1,523 @@
1
+ /**
2
+ * One thread: its controller, its bridge server, its runner, its lifecycle
3
+ * (headless-threads-and-lifecycle-hooks section 2.3.4).
4
+ *
5
+ * ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
6
+ *
7
+ * THE CONSTRUCTION ORDER IS THE DESIGN, and every step exists because putting it later
8
+ * leaves something half-built:
9
+ *
10
+ * 1. session identity and the lock (done by the registry, BEFORE this object exists, so a
11
+ * refusal never leaves a constructed controller behind);
12
+ * 2. the hook runtime (a plain object over the shared writer - no controller needed, and
13
+ * the controller needs it, so it must come first);
14
+ * 3. the bridge server, with a controller BOX rather than a controller - the server's
15
+ * ports reach the controller that does not exist yet, exactly as the single-run path
16
+ * solves it;
17
+ * 4. the controller, with this thread's gates, result tool and hook runner;
18
+ * 5. the runner, `attach`, then `attachBridge` with this thread's own source;
19
+ * 6. `preflight()`, whose failure ends THIS thread and nothing else;
20
+ * 7. `thread_state{opened}`.
21
+ *
22
+ * WHAT IS SHARED: the writer (one global `seq`), the emitter (one `result`), the admission
23
+ * gate and the registry. What is NOT: everything else. That list is I-T2, and it is a
24
+ * consequence of this file owning its objects rather than a rule anyone has to remember.
25
+ */
26
+ import { computeCost } from '../../agent/usage.js';
27
+ import { ExecRunner } from '../runner.js';
28
+ import { createBridgeServer } from '../bridge/server.js';
29
+ import { LEAD_SCOPE } from '../bridge/protocol.js';
30
+ import { createCallerTools } from '../bridge/caller-tools.js';
31
+ import { createCallerApprovalGate, createCallerHumanInputGate, createCallerToolGate, } from '../bridge/decisions.js';
32
+ import { createHookRuntime } from '../hooks/index.js';
33
+ import { resolvePermission } from '../permission.js';
34
+ import { RESULT_LIMITS } from '../result/limits.js';
35
+ import { createResultSink } from '../result/sink.js';
36
+ import { createSubmitResultTool, SUBMIT_RESULT_TOOL } from '../result/tool.js';
37
+ import { buildStructuredResultBlock } from '../result/prompt.js';
38
+ import { DEFAULT_THREAD_ID } from './identity.js';
39
+ export class ThreadOpenError extends Error {
40
+ code;
41
+ constructor(code, message) {
42
+ super(message);
43
+ this.code = code;
44
+ this.name = 'ThreadOpenError';
45
+ }
46
+ }
47
+ export function createThreadRuntime(deps) {
48
+ let state = 'opening';
49
+ let controller = null;
50
+ let server = null;
51
+ let runner = null;
52
+ let hooks = null;
53
+ let requests = 0;
54
+ let turns = 0;
55
+ let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
56
+ let cost = { amount: 0, currency: 'USD', known: false };
57
+ let isError = false;
58
+ let endReason;
59
+ let openedAt = deps.now();
60
+ let closedAt;
61
+ let logDisposers = [];
62
+ let drain = null;
63
+ let drainedPromise = null;
64
+ /** The controller box the server's ports read (step 3 of the header's order). */
65
+ const box = { controller: null };
66
+ let parkedSpecs = null;
67
+ const finish = (reason) => {
68
+ if (state === 'closed')
69
+ return;
70
+ state = 'closed';
71
+ endReason = reason;
72
+ closedAt = deps.now();
73
+ drain?.();
74
+ drain = null;
75
+ };
76
+ const account = () => ({
77
+ threadId: deps.threadId,
78
+ sessionId: deps.sessionId,
79
+ requests,
80
+ turns,
81
+ usage,
82
+ cost,
83
+ isError,
84
+ reason: endReason ?? 'closed',
85
+ });
86
+ const summarize = () => ({
87
+ threadId: deps.threadId,
88
+ sessionId: deps.sessionId,
89
+ state,
90
+ requests,
91
+ turns,
92
+ inFlight: false,
93
+ });
94
+ const emitThreadState = (phase) => {
95
+ const current = account();
96
+ deps.emitter.emit({
97
+ type: 'thread',
98
+ sessionId: deps.sessionId,
99
+ threadId: deps.threadId,
100
+ phase,
101
+ ...(phase === 'closed'
102
+ ? {
103
+ reason: current.reason,
104
+ requests: current.requests,
105
+ turns: current.turns,
106
+ durationMs: Math.max(0, (closedAt ?? deps.now()) - openedAt),
107
+ usage: current.usage,
108
+ cost: current.cost,
109
+ isError: current.isError,
110
+ }
111
+ : {}),
112
+ });
113
+ };
114
+ const applySpecs = (specs) => {
115
+ if (!box.controller || !server) {
116
+ parkedSpecs = specs;
117
+ return;
118
+ }
119
+ box.controller.applyCallerTools(createCallerTools(specs, server, deps.options.callerToolTimeoutMs));
120
+ };
121
+ const openInternal = async () => {
122
+ // The one source this runtime serves; `run()` re-states it, and this is the copy the
123
+ // bridge channel is wired to.
124
+ const source = deps.source;
125
+ // --- 2. the hook runtime, BEFORE the controller that consumes it. ---
126
+ hooks = createHookRuntime({
127
+ sessionId: deps.sessionId,
128
+ // THE SHARED WRITER, NOT THE SERVER: the controller is constructed with the runner's
129
+ // hook port, and `writer` is the one object that stamps `seq` on every line anyway.
130
+ write: (event) => deps.writer.write({ ...event, threadId: deps.threadId }),
131
+ knownTools: () => {
132
+ const names = new Set((box.controller?.listTools() ?? []).map((t) => t.name));
133
+ for (const name of box.controller?.callerToolNames() ?? [])
134
+ names.add(name);
135
+ return names;
136
+ },
137
+ timeoutMs: deps.hookTimeoutMs,
138
+ policy: () => deps.options.onHookTimeout,
139
+ now: deps.now,
140
+ });
141
+ // --- 3. the server, whose ports reach a controller that does not exist yet. ---
142
+ server = createBridgeServer({
143
+ writer: deps.writer,
144
+ sessionId: deps.sessionId,
145
+ now: deps.now,
146
+ decisionTimeoutMs: deps.options.decisionTimeoutMs,
147
+ callerToolTimeoutMs: deps.options.callerToolTimeoutMs,
148
+ ports: {
149
+ applyCallerTools: applySpecs,
150
+ isTurnActive: () => box.controller?.isRunning() === true,
151
+ callerToolNames: () => box.controller?.callerToolNames() ?? [],
152
+ getAgentMode: () => box.controller?.getAgentMode() ?? 'build',
153
+ },
154
+ hookPorts: {
155
+ register: (frame) => {
156
+ const ack = hooks?.register(frame) ?? {
157
+ accepted: false,
158
+ code: 'hook_requires_bridge',
159
+ message: 'Lifecycle hooks are not available on this thread.',
160
+ };
161
+ deps.writer.write({
162
+ type: 'ack',
163
+ ...(frame.id ? { id: frame.id } : {}),
164
+ threadId: deps.threadId,
165
+ accepted: ack.accepted,
166
+ ...(ack.code ? { code: ack.code } : {}),
167
+ ...(ack.message ? { message: ack.message } : {}),
168
+ ...(ack.hooks ? { hooks: ack.hooks } : {}),
169
+ ...(ack.warnings ? { warnings: ack.warnings } : {}),
170
+ });
171
+ },
172
+ answer: (frame) => hooks?.answer(frame) ?? false,
173
+ },
174
+ onAudit: (record) => {
175
+ // A HOOK DECISION IS AUDITED ON THE HOOK CHANNEL, not double-counted here: the
176
+ // `hook_resolved` frame already carries outcome and code, and a second line would
177
+ // make one decision look like two.
178
+ if (record.kind === 'hook')
179
+ return;
180
+ },
181
+ });
182
+ const permission = resolvePermission({
183
+ mode: deps.options.permissionMode,
184
+ allow: deps.options.allowTools,
185
+ deny: deps.options.denyTools,
186
+ });
187
+ const resultSink = deps.resultSpec ? createResultSink({ now: deps.now }) : null;
188
+ const resultTool = deps.resultSpec && resultSink
189
+ ? createSubmitResultTool({ spec: deps.resultSpec, sink: resultSink })
190
+ : undefined;
191
+ const structuredResultBlock = deps.resultSpec
192
+ ? buildStructuredResultBlock({
193
+ toolName: SUBMIT_RESULT_TOOL,
194
+ hasDefinitions: deps.resultSpec.hasDefinitions,
195
+ repairTurns: deps.options.resultRepairTurns ?? RESULT_LIMITS.defaultRepairTurns,
196
+ })
197
+ : '';
198
+ const gateScope = LEAD_SCOPE;
199
+ const allow = new Set(deps.options.allowTools);
200
+ const gateFor = (scope) => createCallerToolGate({
201
+ server: server,
202
+ mode: deps.options.permissionMode,
203
+ allow,
204
+ scope,
205
+ decisionTimeoutMs: deps.options.decisionTimeoutMs,
206
+ });
207
+ // --- 4. the controller. ---
208
+ const created = deps.makeController(deps.options.permissionMode === 'plan' ? { ...deps.flags, plan: true } : deps.flags, {
209
+ ...(permission ? { permission } : {}),
210
+ ...(resultTool ? { resultTool } : {}),
211
+ ...(structuredResultBlock ? { structuredResultBlock } : {}),
212
+ ...(hooks ? { hookRunner: hooks.toolRunner } : {}),
213
+ toolGate: gateFor(gateScope),
214
+ // ONE TABLE, ONE SET OF IDS, ONE CAPACITY, exactly as the single-run path wires it.
215
+ toolGateForChild: (label) => gateFor({ id: label, label, depth: 1 }),
216
+ approval: createCallerApprovalGate(server),
217
+ humanInput: createCallerHumanInputGate(server),
218
+ decisionBudgetMs: deps.options.decisionTimeoutMs,
219
+ // THE TWO FLAGS THAT MAKE PROCESS-LEVEL LOG MOUNTS HAPPEN ONCE (P1-8). `threadId`
220
+ // stamps this thread; `processLevelMounts` is true for exactly one construction in
221
+ // the process - the default thread's - because a mount guarded on `threadId` would
222
+ // never run at all (the default thread has an id too).
223
+ threadId: deps.threadId,
224
+ processLevelMounts: deps.threadId === DEFAULT_THREAD_ID,
225
+ onAttach: (disposers) => {
226
+ logDisposers = disposers;
227
+ },
228
+ });
229
+ controller = created;
230
+ box.controller = created;
231
+ if (parkedSpecs) {
232
+ const specs = parkedSpecs;
233
+ parkedSpecs = null;
234
+ applySpecs(specs);
235
+ }
236
+ // --- 5. the runner. ---
237
+ runner = new ExecRunner({
238
+ emitter: deps.emitter,
239
+ sessionId: deps.sessionId,
240
+ threadId: deps.threadId,
241
+ // PROCESS-LEVEL CEILINGS STAY PROCESS-LEVEL (section 2.3.8b): `--max-turns` is refused
242
+ // with `--threads`, and `--max-duration` is one timer owned by `executeThreads`, so
243
+ // neither may be re-installed here.
244
+ ...(deps.options.requestMaxTurns !== undefined
245
+ ? { requestMaxTurns: deps.options.requestMaxTurns }
246
+ : {}),
247
+ ...(deps.options.requestMaxDurationMs !== undefined
248
+ ? { requestMaxDurationMs: deps.options.requestMaxDurationMs }
249
+ : {}),
250
+ ...(resultSink
251
+ ? {
252
+ resultSink,
253
+ ...(deps.resultSpec ? { resultSchemaName: deps.resultSpec.name } : {}),
254
+ resultRepairTurns: deps.options.resultRepairTurns ?? RESULT_LIMITS.defaultRepairTurns,
255
+ onResultUnsatisfied: deps.options.onResultUnsatisfied ?? 'error',
256
+ }
257
+ : {}),
258
+ cost: (reading, compaction) => threadCost(created, reading, compaction),
259
+ partialMessages: deps.options.partialMessages,
260
+ includeThinking: deps.options.includeThinking,
261
+ followThrough: created.getTodoConfig().followThrough,
262
+ quiet: deps.options.quiet,
263
+ stderr: process.stderr,
264
+ now: deps.now,
265
+ // THE TERMINATOR BELONGS TO THE PROCESS, NOT TO THIS RUNNER (section 2.3.8a).
266
+ installSignalTerminator: false,
267
+ // THE ADMISSION SLOT IS FREED THROUGH THE SOURCE THAT TOOK IT.
268
+ //
269
+ // `gate.release()` called from anywhere else leaves the source's `holding` flag set,
270
+ // and a source that believes it still holds a slot never acquires again: from that
271
+ // thread's SECOND request onward `--thread-concurrency` would silently stop applying,
272
+ // while every subsequent direct release threw "without a matching acquire". The
273
+ // release is idempotent (`holding` guards it), so a request that never acquired -
274
+ // impossible today, but cheap to be sure about - is a no-op.
275
+ onRequestSettled: () => {
276
+ requests += 1;
277
+ deps.source.release();
278
+ },
279
+ ...(hooks
280
+ ? {
281
+ hookPort: {
282
+ promptPre: (input) => hooks
283
+ ? hooks.promptPre({
284
+ text: input.text,
285
+ queued: input.queued,
286
+ requestId: input.requestId,
287
+ })
288
+ : Promise.resolve({ action: 'allow' }),
289
+ },
290
+ beforeTurn: (info) => hooks
291
+ ? hooks.beforeTurn({
292
+ turn: info.turn,
293
+ source: info.source,
294
+ requestId: info.requestId,
295
+ })
296
+ : Promise.resolve(undefined),
297
+ }
298
+ : {}),
299
+ });
300
+ runner.attach(created);
301
+ // THE BRIDGE CHANNEL, WITH THIS THREAD'S OWN SOURCE (section 2.3.4 step 5).
302
+ //
303
+ // THE WHOLE FRAME CONTRACT DEPENDS ON THIS CALL: the server's turn ports are installed
304
+ // here and the runner's `bridge` / `bridgeSource` fields are assigned here - which is what
305
+ // makes a served prompt write `ack{accepted:true, requestSeq}`, a turn write
306
+ // `request_end`, a `steer` reach `queueUserMessage`, and `interrupt{requestId}` reach
307
+ // `cancelQueued`. Without it a thread-mode run is silent on every one of those, and
308
+ // `cancel_request` is dropped without a receipt.
309
+ runner.attachBridge(server, source);
310
+ // --- 6. preflight. A failure here ends this thread and none of the others. ---
311
+ const pre = created.preflight();
312
+ if (!pre.ok) {
313
+ isError = true;
314
+ emitThreadState('opened');
315
+ finish('error');
316
+ throw new ThreadOpenError('thread_open_failed', pre.message ?? 'Configuration error.');
317
+ }
318
+ state = 'open';
319
+ openedAt = deps.now();
320
+ // `opened` IS ANNOUNCED BY THE REGISTRY, NOT HERE (implementation note): the process
321
+ // `system/init` line must precede the first thread event, and init needs the controller
322
+ // this method has just built. Announcing from here would queue `thread{opened}` ahead of
323
+ // init in the writer, which flushes strictly in arrival order.
324
+ };
325
+ const dispose = () => {
326
+ // A SLOT CANNOT OUTLIVE THE THREAD THAT TOOK IT: if the run ends while a request still
327
+ // held one (an aborted close, a provider error between two turns), the gate would keep
328
+ // that slot for the rest of the process and every other thread would queue behind a
329
+ // tenant that no longer exists. `release()` is idempotent.
330
+ deps.source.release();
331
+ hooks?.close();
332
+ for (const off of logDisposers.splice(0)) {
333
+ try {
334
+ off();
335
+ }
336
+ catch {
337
+ // A disposer that was already torn down is not a reason to skip the rest, and
338
+ // reporting it here would replace the thread's own account with a stack trace.
339
+ }
340
+ }
341
+ if (runner) {
342
+ const stats = runner.stats();
343
+ turns = stats.turns;
344
+ usage = {
345
+ inputTokens: stats.usage.inputTokens,
346
+ outputTokens: stats.usage.outputTokens,
347
+ totalTokens: stats.usage.inputTokens + stats.usage.outputTokens,
348
+ };
349
+ if (controller)
350
+ cost = threadCost(controller, stats.usage, stats.compactionBilling);
351
+ // A TIMEOUT THAT FIRED ON THIS THREAD IS A THREAD-LEVEL FACT even though the timer is
352
+ // process-level: the account has to say why this conversation stopped.
353
+ if (stats.stopReason === 'timeout' && endReason !== 'error')
354
+ endReason ??= 'timeout';
355
+ if (stats.errored)
356
+ isError = true;
357
+ runner.detach();
358
+ runner = null;
359
+ }
360
+ const owned = controller;
361
+ controller = null;
362
+ box.controller = null;
363
+ if (owned) {
364
+ if (deps.sessionFile !== null) {
365
+ try {
366
+ deps.persist({
367
+ sessionId: deps.sessionId,
368
+ controller: owned,
369
+ turns,
370
+ usage: { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens },
371
+ at: deps.now(),
372
+ });
373
+ }
374
+ catch {
375
+ // PERSIST FIRST, ANNOUNCE SECOND, AND A FAILED PERSIST IS NOT A FAILED RUN: the
376
+ // caller still receives the account, and the next invocation starts fresh.
377
+ }
378
+ }
379
+ try {
380
+ owned.dispose();
381
+ }
382
+ catch {
383
+ deps.writer.write({
384
+ type: 'error',
385
+ sessionId: deps.sessionId,
386
+ threadId: deps.threadId,
387
+ fatal: false,
388
+ code: 'thread_dispose_failed',
389
+ message: `Thread "${deps.threadId}" could not dispose its controller cleanly.`,
390
+ });
391
+ }
392
+ }
393
+ deps.lock?.release();
394
+ void server?.close('run_finished');
395
+ };
396
+ return {
397
+ threadId: deps.threadId,
398
+ sessionId: deps.sessionId,
399
+ sessionFile: deps.sessionFile,
400
+ state: () => state,
401
+ async open() {
402
+ await openInternal();
403
+ },
404
+ async run(source) {
405
+ if (!runner)
406
+ throw new Error('ThreadRuntime.run() before open()');
407
+ // ONE SOURCE PER RUNTIME, ENFORCED RATHER THAN ASSUMED: `open()` wired the bridge
408
+ // channel to `deps.source`, so a different queue here would leave the two disagreeing
409
+ // about which prompt is in flight, and `cancelQueued` would answer about the wrong one.
410
+ if (source !== deps.source) {
411
+ throw new Error("ThreadRuntime.run() received a source that is not this thread's queue");
412
+ }
413
+ try {
414
+ await runner.run(source);
415
+ }
416
+ catch (error) {
417
+ // ONE TENANT'S PROVIDER ERROR IS NOT THE PROCESS'S (I-T4). The runner has recorded
418
+ // the error and re-thrown; absorbing it here is what keeps the other conversations
419
+ // - and the process's `result` - alive.
420
+ isError = true;
421
+ endReason ??= 'error';
422
+ deps.writer.write({
423
+ type: 'error',
424
+ sessionId: deps.sessionId,
425
+ threadId: deps.threadId,
426
+ fatal: false,
427
+ code: 'agent_error',
428
+ message: error instanceof Error ? error.message : String(error),
429
+ });
430
+ }
431
+ finally {
432
+ const reason = endReason ?? (isError ? 'error' : 'closed');
433
+ dispose();
434
+ finish(reason);
435
+ emitThreadState('closed');
436
+ }
437
+ },
438
+ requestClose(mode) {
439
+ if (state === 'closed')
440
+ return;
441
+ state = 'closing';
442
+ emitThreadState('closing');
443
+ // EVERY IN-FLIGHT HOOK SETTLES NOW: a hook waiting for an answer on a thread that is
444
+ // closing would otherwise hold the request open until its timeout (I-H2's spirit).
445
+ hooks?.abortAll();
446
+ if (mode === 'abort') {
447
+ runner?.cancelRequest(undefined, undefined);
448
+ endReason ??= 'aborted';
449
+ return;
450
+ }
451
+ endReason ??= 'closed';
452
+ },
453
+ drained() {
454
+ if (state === 'closed')
455
+ return Promise.resolve();
456
+ drainedPromise ??= new Promise((resolve) => {
457
+ drain = resolve;
458
+ });
459
+ return drainedPromise;
460
+ },
461
+ interrupt() {
462
+ runner?.requestInterrupt();
463
+ },
464
+ timeout() {
465
+ // THE SAME ABORT PATH AS A SIGNAL, with the reason that makes `result` honest. The
466
+ // process timer is `executeThreads`' single owner; a thread only reports it.
467
+ endReason ??= 'timeout';
468
+ runner?.requestInterrupt();
469
+ },
470
+ summarize,
471
+ account,
472
+ hooks: () => hooks,
473
+ announceOpened: () => emitThreadState('opened'),
474
+ describe() {
475
+ const config = box.controller?.getConfig();
476
+ return {
477
+ model: {
478
+ provider: config?.provider ?? 'unknown',
479
+ id: config?.model ?? 'unknown',
480
+ baseUrl: config?.baseUrl ?? null,
481
+ },
482
+ cwd: box.controller?.getCwd() ?? process.cwd(),
483
+ tools: (box.controller?.listTools() ?? []).map((tool) => tool.name),
484
+ sessionId: deps.sessionId,
485
+ sessionFile: deps.sessionFile,
486
+ };
487
+ },
488
+ handleControl(frame) {
489
+ server?.handleFrame(frame);
490
+ },
491
+ server: () => server,
492
+ flushCallerTools() {
493
+ if (parkedSpecs)
494
+ applySpecs(parkedSpecs);
495
+ },
496
+ };
497
+ }
498
+ /**
499
+ * The cost of one thread's usage.
500
+ *
501
+ * A LOCAL WRAPPER OVER `computeCost`, deliberately: `exec/index.ts`'s `buildCost` takes the
502
+ * composition root's controller, and importing it here would make
503
+ * `exec/index.ts -> threads -> exec/index.ts` a cycle. The shared authority is
504
+ * `computeCost`; this is the four-line projection between them.
505
+ */
506
+ function threadCost(controller, reading, compaction) {
507
+ const config = controller.getConfig();
508
+ const known = compaction?.pricingUnknown !== true &&
509
+ controller.isPricedModel({ providerId: config.provider, modelId: config.model });
510
+ const main = {
511
+ inputTokens: reading.inputTokens - (compaction?.usage.inputTokens ?? 0),
512
+ outputTokens: reading.outputTokens - (compaction?.usage.outputTokens ?? 0),
513
+ cacheReadTokens: (reading.cacheReadTokens ?? 0) - (compaction?.usage.cacheReadTokens ?? 0),
514
+ cacheWriteTokens: (reading.cacheWriteTokens ?? 0) - (compaction?.usage.cacheWriteTokens ?? 0),
515
+ };
516
+ return {
517
+ amount: known
518
+ ? computeCost(main, controller.getModelInfo().cost) + (compaction?.costUsd ?? 0)
519
+ : 0,
520
+ currency: 'USD',
521
+ known,
522
+ };
523
+ }
@@ -0,0 +1,20 @@
1
+ import type { BridgePromptSource } from '../bridge/source.js';
2
+ import { type AdmissionGate } from './gate.js';
3
+ export interface ThreadPromptSource extends BridgePromptSource {
4
+ /** Prompts accepted but not yet served, for `thread_list`. */
5
+ waiting(): number;
6
+ }
7
+ /** The write side: what the dispatch source uses to feed this thread. */
8
+ export interface ThreadSource extends ThreadPromptSource {
9
+ /** Accept a prompt. Never rejects: a finished source drops it (the router acks first). */
10
+ push(text: string, requestId: string, queued?: boolean): void;
11
+ /** No more prompts will arrive (`end` frame, EOF, or a close). */
12
+ finish(): void;
13
+ /** Release a held slot; called by the runtime when a request settles, and on close. */
14
+ release(): void;
15
+ }
16
+ export interface ThreadSourceOptions {
17
+ threadId: string;
18
+ gate: AdmissionGate;
19
+ }
20
+ export declare function createThreadSource(options: ThreadSourceOptions): ThreadSource;
@@ -0,0 +1,134 @@
1
+ import { GateClosedError } from './gate.js';
2
+ export function createThreadSource(options) {
3
+ const queue = [];
4
+ let parked = [];
5
+ let finished = false;
6
+ let inFlight = false;
7
+ let last;
8
+ let deliveredId;
9
+ /** Every id ever served, so a finished turn is `unknown` rather than `in_flight`. */
10
+ const taken = [];
11
+ let holding = false;
12
+ const wake = () => {
13
+ const waiting = parked;
14
+ parked = [];
15
+ for (const resolve of waiting)
16
+ resolve(null);
17
+ };
18
+ const finish = () => {
19
+ if (finished)
20
+ return;
21
+ finished = true;
22
+ // EVERY PARKED `next()` RESOLVES NOW, so a run that was waiting for input ends
23
+ // instead of hanging the process it is supposed to be releasing.
24
+ wake();
25
+ };
26
+ /**
27
+ * Wait until there is something to serve.
28
+ *
29
+ * Returns the index of a servable prompt, or `null` when the source is finished and
30
+ * drained. PARKED RESOLVERS ARE RE-CHECKED IN A LOOP because `cancelQueued` can
31
+ * remove the very prompt a waiter was woken for.
32
+ */
33
+ const takeCandidate = async () => {
34
+ for (;;) {
35
+ const candidate = queue[0];
36
+ if (candidate) {
37
+ if (!holding) {
38
+ try {
39
+ await options.gate.acquire(options.threadId);
40
+ }
41
+ catch (err) {
42
+ // THE GATE CLOSED: the process is shutting down, so this source ends rather
43
+ // than reporting a prompt it could never be allowed to run.
44
+ if (err instanceof GateClosedError)
45
+ return null;
46
+ throw err;
47
+ }
48
+ holding = true;
49
+ }
50
+ // The prompt may have been cancelled while this thread queued for a slot.
51
+ const now = queue[0];
52
+ if (now)
53
+ return now;
54
+ // THE SLOT IS HANDED BACK, NOT CARRIED AWAY: `now` is undefined only when the queue
55
+ // drained under us, and continuing to the `parked` branch below while `holding` is
56
+ // true would leave this thread holding a slot with no request in flight - forever,
57
+ // because nothing else can release it. With `--thread-concurrency 1` that starves
58
+ // every other tenant.
59
+ release();
60
+ continue;
61
+ }
62
+ if (finished)
63
+ return null;
64
+ await new Promise((resolve) => parked.push(resolve));
65
+ }
66
+ };
67
+ const release = () => {
68
+ if (!holding)
69
+ return;
70
+ holding = false;
71
+ try {
72
+ options.gate.release(options.threadId);
73
+ }
74
+ catch {
75
+ // A double release after `gate.close()` is not worth failing a completed request
76
+ // over; the gate is being torn down and every slot is about to be discarded.
77
+ }
78
+ };
79
+ return {
80
+ async next() {
81
+ const candidate = await takeCandidate();
82
+ if (!candidate) {
83
+ inFlight = false;
84
+ last = undefined;
85
+ return null;
86
+ }
87
+ const index = queue.indexOf(candidate);
88
+ if (index >= 0)
89
+ queue.splice(index, 1);
90
+ inFlight = true;
91
+ taken.push(candidate.requestId);
92
+ deliveredId = candidate.requestId;
93
+ last = {
94
+ text: candidate.text,
95
+ requestId: candidate.requestId,
96
+ queued: candidate.queued === true,
97
+ };
98
+ return candidate.text;
99
+ },
100
+ lastPrompt: () => last,
101
+ cancelQueued(requestId) {
102
+ const index = queue.findIndex((item) => item.requestId === requestId);
103
+ if (index >= 0) {
104
+ queue.splice(index, 1);
105
+ return 'cancelled';
106
+ }
107
+ if (requestId === deliveredId && inFlight)
108
+ return 'in_flight';
109
+ // A FINISHED TURN IS `unknown`, NEVER `in_flight` - the rule the bridge source
110
+ // records at length; answering otherwise makes a late cancel abort a healthy run.
111
+ return 'unknown';
112
+ },
113
+ isFinished: () => finished,
114
+ queueDepth: () => queue.length,
115
+ waiting: () => queue.length,
116
+ push(text, requestId, queued) {
117
+ if (finished)
118
+ return;
119
+ // `queued` IS CAPTURED AT ARRIVAL for the same reason the bridge source captures
120
+ // it there: by the time the runner asks, "was something ahead of me" is always
121
+ // false and the field would be useless.
122
+ const wasQueued = queued ?? (inFlight || queue.length > 0);
123
+ queue.push({ text, requestId, queued: wasQueued });
124
+ // A parked waiter is released with `null` (its loop re-checks the queue), which
125
+ // keeps one wake-up path instead of two that can disagree.
126
+ wake();
127
+ },
128
+ finish,
129
+ close() {
130
+ finish();
131
+ },
132
+ release,
133
+ };
134
+ }