@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
@@ -17,6 +17,7 @@
17
17
  * happen.
18
18
  */
19
19
  import { readFileSync } from 'node:fs';
20
+ import { basename } from 'node:path';
20
21
  import { randomUUID } from 'node:crypto';
21
22
  import process from 'node:process';
22
23
  import { runHeadless } from '../agent/headless.js';
@@ -28,6 +29,20 @@ import { resolveExecOptions } from './options.js';
28
29
  import { resolvePermission } from './permission.js';
29
30
  import { ExecRunner, singlePrompt } from './runner.js';
30
31
  import { createStdinPromptSource, prefixSource } from './stdin-stream.js';
32
+ import { getLogger } from '../logging/logger.js';
33
+ import { LEAD_SCOPE } from './bridge/protocol.js';
34
+ import { createBridgeWriter } from './bridge/writer.js';
35
+ import { createBridgeServer } from './bridge/server.js';
36
+ import { createBridgePromptSource } from './bridge/source.js';
37
+ import { createCallerTools } from './bridge/caller-tools.js';
38
+ import { createCallerApprovalGate, createCallerHumanInputGate, createCallerToolGate, } from './bridge/decisions.js';
39
+ import { RESULT_LIMITS } from './result/limits.js';
40
+ import { parseResultSchema } from './result/schema.js';
41
+ import { createResultSink } from './result/sink.js';
42
+ import { createSubmitResultTool, SUBMIT_RESULT_TOOL } from './result/tool.js';
43
+ import { buildStructuredResultBlock } from './result/prompt.js';
44
+ import { createHookRuntime } from './hooks/index.js';
45
+ import { executeThreads } from './threads/execute-threads.js';
31
46
  /**
32
47
  * Run one `aragon exec` invocation and resolve with the process exit code.
33
48
  *
@@ -43,7 +58,14 @@ export async function runExec(flags, raw, promptArg, ctx) {
43
58
  const out = ctx.stdout ?? process.stdout;
44
59
  const err = ctx.stderr ?? process.stderr;
45
60
  const now = ctx.now ?? Date.now;
46
- const resolved = resolveExecOptions(raw);
61
+ // THE ONE FACT FROM CONFIG THAT CHANGES A FLAG'S LEGALITY, READ ONCE. `--confirm`
62
+ // is `config.confirmTools` (D-6), and without it `ask` plus that setting would
63
+ // arm two approval layers over the same three tools - an invocation whose
64
+ // visible failure is "it never asks". `loadConfig` is read here rather than
65
+ // below because this is the first consumer, and `options.ts` stays a pure
66
+ // flag parser: it is told the fact, it does not go looking for it.
67
+ const config = loadConfig(flags);
68
+ const resolved = resolveExecOptions(raw, { confirmTools: config.confirmTools });
47
69
  if (!resolved.ok) {
48
70
  err.write(`${resolved.message}\n`);
49
71
  return 2;
@@ -54,10 +76,63 @@ export async function runExec(flags, raw, promptArg, ctx) {
54
76
  const appended = readOptionalText(options.appendSystemPrompt, options.appendSystemPromptFile, err, '--append-system-prompt-file');
55
77
  if (appended === null)
56
78
  return 2;
57
- const cwd = loadConfig(flags).cwd;
79
+ // THE CALLER'S SCHEMA, READ AND VALIDATED BEFORE ANYTHING ELSE HAPPENS.
80
+ //
81
+ // EXIT 2 WITH NOTHING ON STDOUT on every failure path, which is the same contract
82
+ // options, prompt and session errors already follow: a run that never started must
83
+ // not emit a `result` line, because `system/init` being first is what lets a wrapper
84
+ // tell "failed to start" from "started and failed".
85
+ //
86
+ // THE SINK IS CREATED HERE, before the controller, for the same reason the interrupt
87
+ // box is: the tool factory needs it, the tool is passed to the controller factory,
88
+ // and the runner only ever reads it.
89
+ const resultSpec = readResultSchema(options, err);
90
+ if (resultSpec === 'failed')
91
+ return 2;
92
+ const resultSink = resultSpec === null ? null : createResultSink({ now });
93
+ const cwd = config.cwd;
58
94
  const plan = planSession(options, cwd, err);
59
95
  if (!plan)
60
96
  return 2;
97
+ // THREAD MODE TAKES ITS OWN PATH, AND IT TAKES IT HERE (section 2.6 row 6).
98
+ //
99
+ // BEFORE ANY PROMPT SOURCE EXISTS, because a source would have started reading stdin. This
100
+ // is the ONE branch the feature adds to this file: everything below - the interrupt box,
101
+ // the bridge runtime, the prompt source, the single-runner assembly - belongs to the
102
+ // single-conversation path and is untouched when `--threads` is on.
103
+ if (options.threads) {
104
+ // A THREAD RUN OWNS STDIN (I-T12). A pre-built source would be a second splitter on the
105
+ // same bytes, and the frames would be split between them - so the combination is refused
106
+ // outright rather than diagnosed later from mysterious lost prompts.
107
+ // A POSITIONAL PROMPT CANNOT BE HONOURED HERE, WHATEVER STDIN IS DOING: thread mode has
108
+ // no single conversation to send it to, and dropping it silently would look like a model
109
+ // that ignored the request. Refused for every caller, not just the ones without a stdin.
110
+ if (promptArg !== undefined) {
111
+ err.write('threads_flag_conflict: --threads takes its prompts from stdin frames; drop the [prompt] argument.\n');
112
+ return 2;
113
+ }
114
+ return executeThreads({
115
+ flags,
116
+ options,
117
+ plan: { id: plan.id, saving: plan.saving, cwd },
118
+ ctx,
119
+ out,
120
+ err,
121
+ now,
122
+ version: ctx.version,
123
+ resultSpec: resultSpec,
124
+ stdin: ctx.stdin ?? process.stdin,
125
+ persist: (input) => persistThreadSession({
126
+ sessionId: input.sessionId,
127
+ controller: input.controller,
128
+ turns: input.turns,
129
+ usage: input.usage,
130
+ at: input.at,
131
+ cli: ctx.version,
132
+ cwd,
133
+ }),
134
+ });
135
+ }
61
136
  /**
62
137
  * Late-bound interrupt hook (see `resolvePromptSource` and `execute`).
63
138
  *
@@ -69,7 +144,43 @@ export async function runExec(flags, raw, promptArg, ctx) {
69
144
  * `execute` for why "does nothing" beats "does half of it".
70
145
  */
71
146
  const interrupt = { fire: () => { } };
72
- const prompts = resolvePromptSource(options, promptArg, ctx, err, interrupt);
147
+ // THE BRIDGE EXISTS BEFORE THE PROMPT SOURCE, because the source routes control
148
+ // frames to it and the source is what starts reading stdin. It also exists before
149
+ // the controller, which is why the server reaches the controller through
150
+ // `bindController` rather than a constructor argument.
151
+ //
152
+ // THE HOOK RUNTIME EXISTS BEFORE BOTH (headless-threads-and-lifecycle-hooks section
153
+ // 2.4.9): the tool array is built once, inside `makeController`, so the wrapper has to be
154
+ // available then - even though `hook_register` cannot arrive until later. It is created for
155
+ // EVERY bridge run, and with no hook registered it emits nothing and changes nothing.
156
+ const controllerBox = { controller: null };
157
+ /**
158
+ * The writer, in a box because the hook runtime needs it before the bridge exists.
159
+ *
160
+ * ONE WRITER, ONE `seq` (I-C3). The hook runtime is constructed first only because the
161
+ * controller is; the object it writes through is the same one the server uses, reached
162
+ * through this box instead of being duplicated.
163
+ */
164
+ const bridgeWriterBox = {};
165
+ const hooks = options.inputFormat === 'bridge'
166
+ ? createHookRuntime({
167
+ sessionId: plan.id,
168
+ write: (event) => bridgeWriterBox.writer?.write(event),
169
+ knownTools: () => {
170
+ const names = new Set((controllerBox.controller?.listTools() ?? []).map((tool) => tool.name));
171
+ for (const name of controllerBox.controller?.callerToolNames() ?? [])
172
+ names.add(name);
173
+ return names;
174
+ },
175
+ timeoutMs: options.hookTimeoutMs,
176
+ policy: () => options.onHookTimeout,
177
+ now,
178
+ })
179
+ : undefined;
180
+ const bridge = options.inputFormat === 'bridge'
181
+ ? createBridgeRuntime({ out, sessionId: plan.id, options, hooks, writerBox: bridgeWriterBox })
182
+ : null;
183
+ const prompts = resolvePromptSource(options, promptArg, ctx, err, interrupt, bridge);
73
184
  if (!prompts)
74
185
  return 2;
75
186
  const lock = plan.saving ? tryAcquireSessionLock(plan.id) : null;
@@ -79,12 +190,207 @@ export async function runExec(flags, raw, promptArg, ctx) {
79
190
  return 2;
80
191
  }
81
192
  try {
82
- return await execute({ flags, options, plan, prompts, ctx, out, err, now, appended, interrupt });
193
+ return await execute({
194
+ flags,
195
+ options,
196
+ plan,
197
+ prompts,
198
+ ctx,
199
+ out,
200
+ err,
201
+ now,
202
+ appended,
203
+ interrupt,
204
+ bridge,
205
+ ...(hooks ? { hooks } : {}),
206
+ controllerBox,
207
+ bridgeWriterBox,
208
+ resultSpec: resultSpec,
209
+ resultSink,
210
+ });
83
211
  }
84
212
  finally {
213
+ // A SECOND CLOSE, AND THE ONLY ONE ON THE EARLY-EXIT PATHS. The real one runs
214
+ // inside `execute()` before `persist()` and before `result` (see there);
215
+ // `close()` is idempotent, so this call covers a run that never reached that
216
+ // point - a throw, or a usage error after the channel existed. It cannot settle
217
+ // anything into a stream whose `result` has already gone out, because
218
+ // `execute()` sealed the writer.
219
+ await bridge?.server.close('run_finished');
220
+ // AFTER the protocol is closed, never before: releasing stdin first would drop
221
+ // the frames a caller is still entitled to send while the run finishes
222
+ // (`decision`, `tool_result`). Once the server is closed, nothing more can be
223
+ // answered - and the listener that would keep this process alive is exactly
224
+ // what `close()` removes.
225
+ toBridgeSource(prompts)?.close();
85
226
  lock?.release();
86
227
  }
87
228
  }
229
+ /**
230
+ * Read and preflight `--result-schema`.
231
+ *
232
+ * THREE OUTCOMES, and the third one is not a schema: `null` means "no schema was
233
+ * asked for", `'failed'` means "one was asked for and it is unusable". The caller
234
+ * turns the second into exit 2 with nothing on stdout, which is the contract every
235
+ * other preflight failure in this file already follows.
236
+ */
237
+ function readResultSchema(options, err) {
238
+ if (options.resultSchemaPath === undefined)
239
+ return null;
240
+ let text;
241
+ try {
242
+ text = readFileSync(options.resultSchemaPath, 'utf-8');
243
+ }
244
+ catch (e) {
245
+ reportUsageError(err, 'result_schema_unreadable', `--result-schema: could not read ${options.resultSchemaPath} (${e.message}).`);
246
+ return 'failed';
247
+ }
248
+ const parsed = parseResultSchema(text, basename(options.resultSchemaPath));
249
+ if (!parsed.ok) {
250
+ reportUsageError(err, parsed.code, parsed.message);
251
+ return 'failed';
252
+ }
253
+ return parsed.spec;
254
+ }
255
+ /** One line on stderr, prefixed with the stable code so a caller can grep for it. */
256
+ function reportUsageError(err, code, message) {
257
+ err.write(`${code}: ${message}\n`);
258
+ }
259
+ /**
260
+ * Build the writer and the server.
261
+ *
262
+ * ORDER INSIDE HERE IS NOT COSMETIC: the writer is what the `caller_not_reading`
263
+ * escalation needs, the server is what the three gates and the prompt source
264
+ * need, and the controller - which the server's ports call - is bound later by
265
+ * `bindController`. Every port below is therefore written as `bound?.`-style
266
+ * access rather than closing over a controller that does not exist yet.
267
+ */
268
+ function createBridgeRuntime(args) {
269
+ let bound = null;
270
+ /**
271
+ * A `tool_register` CAN ARRIVE BEFORE THE CONTROLLER EXISTS.
272
+ *
273
+ * The prompt source starts reading stdin in `runExec`, and the controller is
274
+ * built a few statements later in `execute`. A caller whose first write is its
275
+ * tool declaration - which is the natural order, and the one the README shows -
276
+ * would otherwise be acknowledged and then silently dropped, and the model would
277
+ * never see the tool with nothing anywhere reporting why. So the set is parked
278
+ * here and applied by `bindController`.
279
+ */
280
+ let parkedSpecs = null;
281
+ const hardLimit = { fired: false };
282
+ const writer = createBridgeWriter({
283
+ out: args.out,
284
+ sessionId: args.sessionId,
285
+ onHardLimit: () => {
286
+ // ONE WAY OUT, and it is the existing abort: the run settles through the
287
+ // normal path (persist, then `result`), and `execute` turns the flag into
288
+ // exit 1. Exiting from inside a write would skip both.
289
+ hardLimit.fired = true;
290
+ bound?.abort();
291
+ },
292
+ });
293
+ const box = { server: null };
294
+ const server = createBridgeServer({
295
+ writer,
296
+ sessionId: args.sessionId,
297
+ decisionTimeoutMs: args.options.decisionTimeoutMs,
298
+ callerToolTimeoutMs: args.options.callerToolTimeoutMs,
299
+ ports: {
300
+ applyCallerTools: (specs) => {
301
+ const target = box.server;
302
+ if (!target || !bound) {
303
+ parkedSpecs = specs;
304
+ return;
305
+ }
306
+ bound.applyCallerTools(createCallerTools(specs, target, args.options.callerToolTimeoutMs));
307
+ },
308
+ isTurnActive: () => bound?.isRunning() === true,
309
+ callerToolNames: () => bound?.callerToolNames() ?? [],
310
+ getAgentMode: () => bound?.getAgentMode() ?? 'build',
311
+ },
312
+ // THE AUDIT TRAIL IS THE LOG FILE (section 2.5). Not a second in-memory ledger: the
313
+ // pending table already detects duplicates, and a ring buffer with no reader
314
+ // is state that can only drift.
315
+ onAudit: (record) => getLogger().info('bridge', 'decision', { ...record }),
316
+ // THE HOOK CHANNEL (headless-threads-and-lifecycle-hooks section 2.4.2). Present on
317
+ // every bridge run, because a hook may be registered at any moment; with none
318
+ // registered these two callbacks are never entered.
319
+ ...(args.hooks
320
+ ? {
321
+ hookPorts: {
322
+ register: (frame) => {
323
+ const ack = args.hooks?.register(frame);
324
+ if (!ack)
325
+ return;
326
+ writer.write({
327
+ type: 'ack',
328
+ ...(frame.id ? { id: frame.id } : {}),
329
+ accepted: ack.accepted,
330
+ ...(ack.code ? { code: ack.code } : {}),
331
+ ...(ack.message ? { message: ack.message } : {}),
332
+ ...(ack.hooks ? { hooks: ack.hooks } : {}),
333
+ ...(ack.warnings ? { warnings: ack.warnings } : {}),
334
+ });
335
+ },
336
+ answer: (frame) => args.hooks?.answer(frame) ?? false,
337
+ },
338
+ }
339
+ : {}),
340
+ });
341
+ box.server = server;
342
+ // THE BOX, filled the moment the writer exists (section 2.4.9): the hook runtime is built
343
+ // before this function runs, and it must write through THIS writer so `seq` stays global.
344
+ if (args.writerBox)
345
+ args.writerBox.writer = writer;
346
+ return {
347
+ writer,
348
+ server,
349
+ sessionId: args.sessionId,
350
+ hardLimit,
351
+ bindController: (controller) => {
352
+ bound = controller;
353
+ if (!parkedSpecs)
354
+ return;
355
+ const specs = parkedSpecs;
356
+ parkedSpecs = null;
357
+ const target = box.server;
358
+ if (!target)
359
+ return;
360
+ controller.applyCallerTools(createCallerTools(specs, target, args.options.callerToolTimeoutMs));
361
+ },
362
+ };
363
+ }
364
+ /**
365
+ * The three gates, or nothing at all.
366
+ *
367
+ * `{}` IS THE WHOLE COMPATIBILITY GUARANTEE: with no bridge, `makeController`
368
+ * receives exactly the deps object it receives today, so `createBuiltinTools`
369
+ * builds today's array and every `AC-G17`-style identity assertion still holds.
370
+ */
371
+ function bridgeGates(bridge, options) {
372
+ const allow = new Set(options.allowTools);
373
+ const gateFor = (scope) => createCallerToolGate({
374
+ server: bridge.server,
375
+ mode: options.permissionMode,
376
+ allow,
377
+ scope,
378
+ decisionTimeoutMs: options.decisionTimeoutMs,
379
+ });
380
+ return {
381
+ toolGate: gateFor(LEAD_SCOPE),
382
+ // ONE TABLE, ONE SET OF IDS, ONE CAPACITY - a fan-out of three is still three
383
+ // outstanding decisions, not three independent budgets.
384
+ toolGateForChild: (label) => gateFor({ id: label, label, depth: 1 }),
385
+ approval: createCallerApprovalGate(bridge.server),
386
+ humanInput: createCallerHumanInputGate(bridge.server),
387
+ decisionBudgetMs: options.decisionTimeoutMs,
388
+ };
389
+ }
390
+ /** `ExecPromptSource` -> the bridge source's narrower type, or `undefined`. */
391
+ function toBridgeSource(source) {
392
+ return 'cancelQueued' in source ? source : undefined;
393
+ }
88
394
  async function execute(input) {
89
395
  const { options, plan, ctx, out, err, now, interrupt } = input;
90
396
  const startedAt = now();
@@ -93,22 +399,101 @@ async function execute(input) {
93
399
  allow: options.allowTools,
94
400
  deny: options.denyTools,
95
401
  });
402
+ const bridge = input.bridge;
403
+ const gates = bridge ? bridgeGates(bridge, options) : {};
404
+ // THE TWO INJECTION POINTS OF R3 (section 2.6 rows 11 and 17), both SPREAD
405
+ // CONDITIONALLY: with no schema neither key exists, so every other path builds the
406
+ // controller it builds today and I-S2 stays provable by construction.
407
+ //
408
+ // BOTH OR NEITHER IS THE RULE, and the tool is what the prompt block is about: a
409
+ // block without the tool asks the model for something it cannot do, and a tool
410
+ // without the block leaves it undiscovered (the R1 mitigation in section 7 is
411
+ // exactly this pair).
412
+ const resultTool = input.resultSpec && input.resultSink
413
+ ? createSubmitResultTool({ spec: input.resultSpec, sink: input.resultSink })
414
+ : undefined;
415
+ const structuredResultBlock = input.resultSpec
416
+ ? buildStructuredResultBlock({
417
+ toolName: SUBMIT_RESULT_TOOL,
418
+ hasDefinitions: input.resultSpec.hasDefinitions,
419
+ repairTurns: options.resultRepairTurns ?? RESULT_LIMITS.defaultRepairTurns,
420
+ })
421
+ : '';
96
422
  const controller = ctx.makeController(withPermissionMode(input.flags, options.permissionMode), {
97
423
  ...(permission ? { permission } : {}),
98
424
  ...(input.appended ? { appendSystemPrompt: input.appended } : {}),
425
+ ...(resultTool ? { resultTool } : {}),
426
+ ...(structuredResultBlock ? { structuredResultBlock } : {}),
427
+ // THE HOOK RUNNER, SPREAD CONDITIONALLY AND NEXT TO ITS SIBLINGS (D-12): the tool array
428
+ // is built inside this factory, so a dep that is not forwarded here is dropped in
429
+ // silence and the caller's hooks never fire.
430
+ ...(input.hooks ? { hookRunner: input.hooks.toolRunner } : {}),
431
+ ...gates,
99
432
  });
100
- const emitter = createEmitter(options.outputFormat, out);
433
+ // THE BOX, so the hook runtime's tool whitelist can see the union this controller owns -
434
+ // including any `tool_register` that lands later.
435
+ if (input.controllerBox)
436
+ input.controllerBox.controller = controller;
437
+ // THE ONE ASSIGNMENT THAT CLOSES THE ORDERING HOLE: the server was built before
438
+ // the controller (the stdin source needs it) and needs the controller for the
439
+ // three host-side ports. Nothing else in the run may bind it.
440
+ bridge?.bindController(controller);
441
+ const emitter = createEmitter(options.outputFormat, out, bridge?.writer);
101
442
  const runner = new ExecRunner({
102
443
  emitter,
103
444
  sessionId: plan.id,
104
445
  ...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}),
105
446
  ...(options.maxDurationMs !== undefined ? { maxDurationMs: options.maxDurationMs } : {}),
447
+ // THE REQUEST-LEVEL BUDGETS AND THE RESULT BOX. Every one of them is conditional, so
448
+ // a run without the flags constructs exactly the runner it constructs today and the
449
+ // request counters inside it are never consulted (I-C1).
450
+ ...(input.resultSink
451
+ ? {
452
+ resultSink: input.resultSink,
453
+ ...(input.resultSpec ? { resultSchemaName: input.resultSpec.name } : {}),
454
+ resultRepairTurns: options.resultRepairTurns ?? RESULT_LIMITS.defaultRepairTurns,
455
+ onResultUnsatisfied: options.onResultUnsatisfied ?? 'error',
456
+ }
457
+ : {}),
458
+ ...(options.requestMaxTurns !== undefined
459
+ ? { requestMaxTurns: options.requestMaxTurns }
460
+ : {}),
461
+ ...(options.requestMaxDurationMs !== undefined
462
+ ? { requestMaxDurationMs: options.requestMaxDurationMs }
463
+ : {}),
464
+ // THE ONE COST AUTHORITY, CLOSED OVER HERE. `buildCost` needs the controller's price
465
+ // table, and the runner needs it to answer "what did THIS request cost" - the same
466
+ // function read twice and differenced by the scope, which is what makes a request's
467
+ // `cost` equal to running it alone (AC-38).
468
+ cost: (usage, compaction) => buildCost(controller, usage, compaction),
106
469
  partialMessages: options.partialMessages,
107
470
  includeThinking: options.includeThinking,
108
471
  followThrough: controller.getTodoConfig().followThrough,
109
472
  quiet: options.quiet,
110
473
  stderr: err,
111
474
  now,
475
+ // THE TWO HOOK POINTS THAT LIVE IN THE RUNNER (section 2.4.6 / 2.4.7). Spread
476
+ // conditionally, so a run without a hook channel builds exactly today's runner.
477
+ ...(input.hooks
478
+ ? {
479
+ hookPort: {
480
+ promptPre: (probe) => input.hooks
481
+ ? input.hooks.promptPre({
482
+ text: probe.text,
483
+ queued: probe.queued,
484
+ requestId: probe.requestId,
485
+ })
486
+ : Promise.resolve({ action: 'allow' }),
487
+ },
488
+ beforeTurn: (info) => input.hooks
489
+ ? input.hooks.beforeTurn({
490
+ turn: info.turn,
491
+ source: info.source,
492
+ requestId: info.requestId,
493
+ })
494
+ : Promise.resolve(undefined),
495
+ }
496
+ : {}),
112
497
  });
113
498
  if (plan.saved) {
114
499
  controller.replaceMessages(plan.saved.messages, plan.saved.compactionIdentity);
@@ -126,8 +511,19 @@ async function execute(input) {
126
511
  baseUrl: config.baseUrl ?? null,
127
512
  },
128
513
  permissionMode: options.permissionMode,
514
+ // `listTools()` IS THE UNION, so a caller tool registered before `init` (which
515
+ // the writer queues behind this very line) is already listed here. Reporting
516
+ // only the host's own set would make `init.tools` disagree with the model.
129
517
  tools: controller.listTools().map((t) => t.name),
130
518
  resumed: plan.saved !== null,
519
+ ...(bridge ? { bridge: true } : {}),
520
+ // THE HOOK CHANNEL IS A BRIDGE CAPABILITY, NOT A THREAD ONE (section 9.9): a single
521
+ // conversation with one redactor is the commonest embedding shape, and tying hooks to
522
+ // threads would force it to sign a multi-tenant contract it does not use.
523
+ ...(bridge ? { hooks: true } : {}),
524
+ // THE CAPABILITY IS A FACT ABOUT THIS RUN, NOT ABOUT THE BUILD (P0-4). Stamped from
525
+ // the preflighted schema, which is the only thing that can answer it.
526
+ ...(input.resultSpec ? { structuredResult: true } : {}),
131
527
  // W2 - where this run's conversation lives, so a wrapper can check on disk
132
528
  // whether it is still resumable rather than re-deriving the sessions dir.
133
529
  //
@@ -143,6 +539,12 @@ async function execute(input) {
143
539
  sessionFile: plan.saving ? sessionPathFor(plan.id) : null,
144
540
  });
145
541
  runner.attach(controller);
542
+ // AFTER `attach`, and before the first prompt: the turn ports reach the
543
+ // controller, and `steer` / `interrupt` must be answerable from the very first
544
+ // frame the caller can send.
545
+ if (bridge) {
546
+ runner.attachBridge(bridge.server, toBridgeSource(input.prompts));
547
+ }
146
548
  // AFTER `attach`, never before. The terminator closure ends on
147
549
  // `this.controller?.abort()`, so before `attach` the controller is still
148
550
  // `undefined` and that last statement is a silent no-op - while the SAME
@@ -165,8 +567,22 @@ async function execute(input) {
165
567
  }
166
568
  finally {
167
569
  runner.detach();
570
+ // NOTHING IS SETTLED HERE: the protocol is closed a few lines below, while
571
+ // `result` is still ahead. Disposing the controller first would tear down the
572
+ // tool that is still waiting for its answer.
168
573
  controller.dispose();
169
574
  }
575
+ // A CALLER THAT STOPPED READING FAILED THE RUN. `caller_not_reading` already
576
+ // went out as a fatal `error` event; exit 1 is the part a shell script reads.
577
+ if (bridge?.hardLimit.fired)
578
+ exitCode = exitCode === 0 ? 1 : exitCode;
579
+ // CLOSE THE PROTOCOL HERE, WHILE `result` IS STILL AHEAD OF US. Closing it any
580
+ // later (in `runExec`'s `finally`) would be after `result` had already gone out,
581
+ // so an unanswered decision settled by `closeAll` would emit its
582
+ // `decision_resolved` on a stream whose last line is supposed to be `result`
583
+ // (AC-5). Doing it before `persist()` costs nothing: every answer that could
584
+ // still have arrived has been awaited already.
585
+ await bridge?.server.close('run_finished');
170
586
  // PERSIST FIRST, ANNOUNCE SECOND (D-2). A caller that reads `result` and
171
587
  // immediately respawns with the same `--session-id` must not race the writer
172
588
  // of the file it is about to read.
@@ -174,12 +590,41 @@ async function execute(input) {
174
590
  if (plan.saving)
175
591
  persist(plan, controller, stats.turns, stats.usage, ctx.version, now());
176
592
  const todo = controller.getTodoSnapshot?.() ?? null;
593
+ // EVERYTHING QUEUED BEHIND `result` GOES OUT FIRST. `result` is the last line by
594
+ // contract, so a `decision_resolved` still sitting in the writer would either be
595
+ // dropped (the emitter refuses to write after `result`) or arrive out of order.
596
+ await bridge?.writer.flush();
597
+ // THE STRUCTURED ANSWER, IF ANY. `result.result` is REPLACED by the canonical JSON
598
+ // text on success, because under a schema the answer IS that object - the README's
599
+ // `| jq -r .result` idiom has to keep meaning "the answer" - and the prose moves to
600
+ // `result.text` instead of being lost (section 2.3.7, rule 2).
601
+ const structured = stats.lastStructured;
602
+ const structuredValue = structured
603
+ ? JSON.stringify(structured.value)
604
+ : stats.lastAssistantText;
177
605
  emitter.result({
178
606
  sessionId: plan.id,
179
- isError: exitCode === 1,
180
- stopReason: stats.stopReason,
607
+ isError: exitCode === 1 || bridge?.hardLimit.fired === true || stats.resultUnsatisfied,
608
+ stopReason: bridge?.hardLimit.fired ? 'error' : stats.stopReason,
181
609
  exitCode,
182
- result: stats.lastAssistantText,
610
+ result: structuredValue,
611
+ ...(input.resultSpec
612
+ ? {
613
+ // EXPLICIT `null` ON FAILURE, NOT A MISSING KEY (section 2.3.7, rule 1): a
614
+ // caller that passed `--result-schema` must be able to read one field and
615
+ // know, and "absent" would be indistinguishable from "an old build".
616
+ structured: structured
617
+ ? {
618
+ value: structured.value,
619
+ schemaName: input.resultSpec.name,
620
+ schemaBytes: input.resultSpec.bytes,
621
+ attempts: structured.attempts,
622
+ acceptedAt: now(),
623
+ }
624
+ : null,
625
+ text: stats.lastAssistantText,
626
+ }
627
+ : {}),
183
628
  turns: stats.turns,
184
629
  durationMs: now() - startedAt,
185
630
  usage: {
@@ -202,8 +647,24 @@ async function execute(input) {
202
647
  ? { code: stats.errorCode, message: stats.errorMessage }
203
648
  : null,
204
649
  });
650
+ // THE SEAL, AFTER `result` AND NEVER BEFORE. It is `result` that must be last, so
651
+ // the writer stays open until that line is out; closing it afterwards makes the
652
+ // contract an enforced one - a frame a late continuation still tries to write is
653
+ // dropped instead of landing behind the final line.
654
+ await bridge?.writer.close();
205
655
  return exitCode;
206
656
  }
657
+ /**
658
+ * Persist one thread's session, for thread mode.
659
+ *
660
+ * A THIN WRAPPER OVER `persist()`, and deliberately not a second implementation: the file
661
+ * shape, the cumulative counters and the "a failed write is not a failed run" rule are one
662
+ * set of decisions. Threads never resume (section 1.4), so there is no previous metadata to
663
+ * accumulate onto; `--resume` stays a process-level, default-thread feature.
664
+ */
665
+ export function persistThreadSession(input) {
666
+ persist({ id: input.sessionId, saved: null, saving: true, cwd: input.cwd }, input.controller, input.turns, input.usage, input.cli, input.at);
667
+ }
207
668
  /**
208
669
  * `text` DELEGATES TO `runHeadless` VERBATIM (D-1 / AC-1).
209
670
  *
@@ -309,10 +770,22 @@ function failedResult(sessionId, config, message, code, durationMs) {
309
770
  * Returns `null` after writing the reason to stderr, so the caller can exit 2
310
771
  * without a second error vocabulary.
311
772
  */
312
- function resolvePromptSource(options, promptArg, ctx, err, interrupt) {
773
+ function resolvePromptSource(options, promptArg, ctx, err, interrupt, bridge) {
313
774
  const stdin = ctx.stdin ?? process.stdin;
314
- const opening = [];
315
775
  const positional = promptArg?.trim();
776
+ // THE GUARD RUNS BEFORE ANY FILE IS READ, and before `opening` exists.
777
+ //
778
+ // WHY IT HAS TO EXIST AT ALL: `prefixSource` turns a positional argument into a
779
+ // turn that runs BEFORE the first `prompt` frame, so a caller that passed both
780
+ // would see one prompt answered twice - and only the second would carry a
781
+ // `requestId`. A warning would leave that trap armed; a usage error costs one
782
+ // rerun. The message names both offenders so the fix is obvious.
783
+ if (bridge && (positional || options.promptFile)) {
784
+ err.write('--input-format bridge takes its prompts on stdin. ' +
785
+ 'Drop the [prompt] argument and --prompt-file.\n');
786
+ return null;
787
+ }
788
+ const opening = [];
316
789
  if (positional)
317
790
  opening.push(positional);
318
791
  else if (options.promptFile) {
@@ -322,6 +795,24 @@ function resolvePromptSource(options, promptArg, ctx, err, interrupt) {
322
795
  if (text.trim().length > 0)
323
796
  opening.push(text);
324
797
  }
798
+ if (bridge) {
799
+ return createBridgePromptSource({
800
+ input: stdin,
801
+ server: bridge.server,
802
+ // A BAD FRAME IS AN `error` EVENT, NOT A STDERR LINE (this is the mode that
803
+ // has an event for it). Written through the writer rather than the emitter
804
+ // because the emitter does not exist yet - and the writer queues anything
805
+ // that arrives before `system/init`, which is exactly the guarantee I-11
806
+ // needs for a caller whose first write is already malformed.
807
+ onBadLine: (code, message) => bridge.writer.write({
808
+ type: 'error',
809
+ sessionId: bridge.sessionId,
810
+ fatal: false,
811
+ code,
812
+ message,
813
+ }),
814
+ });
815
+ }
325
816
  if (options.inputFormat === 'stream-json') {
326
817
  return prefixSource(opening, createStdinPromptSource({
327
818
  input: stdin,