@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,58 @@
1
+ /**
2
+ * `--input-format bridge`: one stdin stream, demultiplexed into turns and control.
3
+ *
4
+ * ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
5
+ *
6
+ * TWO KINDS OF FRAME ARRIVE ON THE SAME PIPE and they must not be confused:
7
+ *
8
+ * TURN frames `prompt` (and the v1 `user`) queue a message for the runner.
9
+ * CONTROL frames `decision`, `tool_result`, `tool_register`, `ping`, `steer`,
10
+ * `interrupt` are answers to something already in flight and are
11
+ * routed straight to the server.
12
+ *
13
+ * The distinction is not cosmetic. A control frame that waited behind a turn would
14
+ * deadlock the run it is trying to unblock - a `decision` answer arriving while the
15
+ * tool that asked is still executing is the NORMAL case, not an edge case.
16
+ *
17
+ * UNLIKE `stdin-stream.ts` THIS SOURCE REMEMBERS METADATA PER PROMPT. A bridge
18
+ * prompt carries the caller's own id, and the turn that id starts has to be
19
+ * reportable in `user` / `turn_state` / `assistant` / `tool_call` / `tool_result`
20
+ * events. `lastPrompt()` is that channel; it is read once, synchronously, right
21
+ * after `next()` resolves, and it is deliberately NOT part of `next()`'s return
22
+ * value so `stdin-stream.ts` can keep serving plain strings (and its existing test
23
+ * can keep asserting exactly that).
24
+ */
25
+ import type { ExecPrompt, ExecPromptSource } from '../runner.js';
26
+ import type { BridgeServer } from './server.js';
27
+ export interface BridgeSourceOptions {
28
+ input: NodeJS.ReadableStream;
29
+ server: BridgeServer;
30
+ /** A non-fatal frame problem, already carrying its `error.code`. */
31
+ onBadLine: (code: string, message: string) => void;
32
+ }
33
+ export interface BridgePromptSource extends ExecPromptSource {
34
+ /**
35
+ * Remove a QUEUED turn by the caller's id.
36
+ *
37
+ * Used by `interrupt{requestId}`: a caller that deletes its own queued message
38
+ * before it runs is the one case of cancellation that needs no abort at all.
39
+ */
40
+ cancelQueued(requestId: string): 'cancelled' | 'in_flight' | 'unknown';
41
+ isFinished(): boolean;
42
+ /** How many turns are waiting. Reported for tests and for `/status`-style views. */
43
+ queueDepth(): number;
44
+ /**
45
+ * Stop reading stdin and let the process exit.
46
+ *
47
+ * A CALLER THAT SENDS `end` HAS ALREADY SAID IT HAS NOTHING MORE TO SAY, and it
48
+ * is entitled to keep its own pipe open while it reads `result` - the reference
49
+ * host does exactly that. A `data` listener left attached would hold this
50
+ * process's event loop open forever, so the run would print `result`, exit
51
+ * nothing, and look hung while reporting success. Removing the listeners and
52
+ * pausing the stream is what turns "the run is over" into "the process ends".
53
+ */
54
+ close(): void;
55
+ }
56
+ export declare function createBridgePromptSource(options: BridgeSourceOptions): BridgePromptSource;
57
+ /** The bridge source's own view of the id space, for tests and for `ack` wiring. */
58
+ export declare function bridgePromptId(prompt: ExecPrompt | undefined): string | undefined;
@@ -0,0 +1,145 @@
1
+ import { createNdjsonSplitter } from './ndjson.js';
2
+ import { parseBridgeInput } from './protocol.js';
3
+ export function createBridgePromptSource(options) {
4
+ const queue = [];
5
+ const waiting = [];
6
+ let finished = false;
7
+ /** A prompt has been handed over and the runner has not asked for the next. */
8
+ let inFlight = false;
9
+ let last;
10
+ let deliveredId;
11
+ /** Ids already handed to the runner, so `interrupt` can tell queued from running. */
12
+ const taken = [];
13
+ const finish = () => {
14
+ if (finished)
15
+ return;
16
+ finished = true;
17
+ // NO MORE ANSWERS CAN ARRIVE, SO NOTHING MAY KEEP WAITING FOR ONE (section
18
+ // 2.3.8 rows 1-2). Without this, `end` or EOF with an unanswered decision
19
+ // leaves the run parked for the whole `--decision-timeout` (300 s by default)
20
+ // and then reports `timeout` instead of the truth, which is that the caller
21
+ // went away. It runs BEFORE the parked `next()` is released: the reverse order
22
+ // would let the run finish and report `result` while its tool still waited.
23
+ options.server.abortInFlight();
24
+ // Everything still parked resolves with `null` so a `next()` in flight when
25
+ // the pipe closes ends the run instead of hanging it forever.
26
+ while (waiting.length > 0)
27
+ waiting.shift()?.(null);
28
+ };
29
+ const acceptPrompt = (frame) => {
30
+ // `queued` IS CAPTURED HERE, AT ARRIVAL, because that is the only moment the
31
+ // question means anything: "was something already ahead of me?". By the time
32
+ // the runner picks this up the answer is always "no", which would make the
33
+ // field useless to a caller deciding whether to send a fourth message.
34
+ const queued = inFlight || queue.length > 0;
35
+ const requestId = frame.id ?? `prompt_${taken.length + queue.length + 1}`;
36
+ const resolve = waiting.shift();
37
+ if (resolve) {
38
+ inFlight = true;
39
+ taken.push(requestId);
40
+ deliveredId = requestId;
41
+ last = { text: frame.text, requestId, queued };
42
+ resolve(frame.text);
43
+ return;
44
+ }
45
+ // NOTHING IS RESET HERE. `last` describes the prompt just HANDED OVER, and the
46
+ // runner reads it one microtask later; clearing it for a queued arrival would
47
+ // wipe the metadata of a delivery that happened in the same chunk - the turn
48
+ // would lose its `requestId` and its `ack` entirely. `next()` clears it when
49
+ // there is nothing left to describe.
50
+ queue.push({ text: frame.text, requestId, queued });
51
+ };
52
+ const handleLine = (line) => {
53
+ const parsed = parseBridgeInput(line);
54
+ if (!parsed.ok) {
55
+ options.onBadLine(parsed.code, parsed.message);
56
+ return;
57
+ }
58
+ // `end` MEANS "NO MORE TURNS", NOT "NO MORE FRAMES".
59
+ //
60
+ // The v1 rule - after `end`, nothing else is a turn - is preserved exactly. But
61
+ // a bridge caller legitimately says "that is my last prompt" while a turn is
62
+ // still running, and then answers the decisions that turn produces; dropping
63
+ // those frames would deadlock the run until the decision deadline fired. So
64
+ // turn frames stop at `end` and control frames do not. After `result`, the
65
+ // server is closed and everything is ignored, which is the rule the design
66
+ // actually states.
67
+ if (parsed.frame.type === 'prompt') {
68
+ if (finished)
69
+ return;
70
+ acceptPrompt(parsed.frame);
71
+ return;
72
+ }
73
+ if (parsed.frame.type === 'end') {
74
+ finish();
75
+ return;
76
+ }
77
+ if (parsed.frame.type === 'ignored')
78
+ return;
79
+ options.server.handleFrame(parsed.frame);
80
+ };
81
+ const splitter = createNdjsonSplitter({
82
+ onLine: handleLine,
83
+ onTooLarge: (bytes) => {
84
+ options.onBadLine('bridge_frame_too_large', `Skipped a bridge frame of ${bytes} bytes.`);
85
+ },
86
+ });
87
+ const onData = (chunk) => splitter.push(chunk);
88
+ const onEnd = () => {
89
+ splitter.end();
90
+ finish();
91
+ };
92
+ const onError = () => finish();
93
+ options.input.setEncoding?.('utf-8');
94
+ options.input.on('data', onData);
95
+ options.input.on('end', onEnd);
96
+ options.input.on('error', onError);
97
+ return {
98
+ next: () => {
99
+ const queued = queue.shift();
100
+ if (queued) {
101
+ inFlight = true;
102
+ taken.push(queued.requestId);
103
+ deliveredId = queued.requestId;
104
+ last = { text: queued.text, requestId: queued.requestId, queued: queued.queued };
105
+ return Promise.resolve(queued.text);
106
+ }
107
+ // NOBODY IS QUEUED, SO NO TURN IS RUNNING. This is also what makes the next
108
+ // arriving prompt report `queued:false`, which is how a caller knows its
109
+ // message started driving the run.
110
+ inFlight = false;
111
+ last = undefined;
112
+ if (finished)
113
+ return Promise.resolve(null);
114
+ return new Promise((resolve) => waiting.push(resolve));
115
+ },
116
+ lastPrompt: () => last,
117
+ cancelQueued(requestId) {
118
+ const index = queue.findIndex((item) => item.requestId === requestId);
119
+ if (index >= 0) {
120
+ queue.splice(index, 1);
121
+ return 'cancelled';
122
+ }
123
+ if (requestId === deliveredId && inFlight)
124
+ return 'in_flight';
125
+ // A FINISHED TURN IS `unknown`, NOT `in_flight`. `taken` remembers every id
126
+ // ever delivered, so answering `in_flight` for one of those would make the
127
+ // runner abort a healthy run because the caller cancelled a message that had
128
+ // already been answered.
129
+ return 'unknown';
130
+ },
131
+ isFinished: () => finished,
132
+ queueDepth: () => queue.length,
133
+ close: () => {
134
+ finished = true;
135
+ options.input.removeListener('data', onData);
136
+ options.input.removeListener('end', onEnd);
137
+ options.input.removeListener('error', onError);
138
+ options.input.pause?.();
139
+ },
140
+ };
141
+ }
142
+ /** The bridge source's own view of the id space, for tests and for `ack` wiring. */
143
+ export function bridgePromptId(prompt) {
144
+ return prompt?.requestId;
145
+ }
@@ -0,0 +1,26 @@
1
+ export interface BridgeWriterOptions {
2
+ out: NodeJS.WritableStream;
3
+ /** Stamped onto the `caller_not_reading` error this writer emits itself. */
4
+ sessionId: string;
5
+ softPendingBytes?: number;
6
+ hardPendingBytes?: number;
7
+ /** Invoked ONCE, when the hard watermark is crossed. The run aborts from here. */
8
+ onHardLimit?: () => void;
9
+ }
10
+ export interface BridgeWriter {
11
+ /** Stamp, serialize and write (or queue, before `open()`). */
12
+ write(event: Record<string, unknown>): void;
13
+ /** True once `system/init` has been written. */
14
+ isOpened(): boolean;
15
+ /** The highest `seq` written so far; `0` before anything. */
16
+ currentSeq(): number;
17
+ /** Bytes written and not yet accepted by the transport. */
18
+ pendingBytes(): number;
19
+ /** Await `drain` at a TURN BOUNDARY only - never per frame. */
20
+ drainIfNeeded(): Promise<void>;
21
+ /** Everything queued plus everything in flight. Called before `result` is written. */
22
+ flush(): Promise<void>;
23
+ /** Idempotent. No writes are accepted afterwards. */
24
+ close(): Promise<void>;
25
+ }
26
+ export declare function createBridgeWriter(options: BridgeWriterOptions): BridgeWriter;
@@ -0,0 +1,156 @@
1
+ /**
2
+ * The ONLY object that writes stdout in bridge mode (headless-host-bridge section 2.4.5).
3
+ *
4
+ * ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
5
+ *
6
+ * THREE JOBS, AND EACH OF THEM IS A CONTRACT SOMETHING ELSE RELIES ON:
7
+ *
8
+ * 1. `seq`. Every line that leaves this process gets a gap-free, monotonically
9
+ * increasing number, assigned HERE and nowhere else, which is what lets a
10
+ * caller detect a dropped line and sort what it received.
11
+ * 2. `opened`. Nothing at all is written before `system/init`. The stdin source
12
+ * starts reading about eighty lines before the emitter exists, so an early
13
+ * `tool_register` or `prompt` can legitimately be answered before `init` has
14
+ * been built; without this queue the `ack` would win the race and break the
15
+ * one contract every wrapper builds its read loop on - `init` first,
16
+ * `result` last (`exec/index.ts:70-79` records why that matters: a wrapper
17
+ * that gets a `result` with no `init` cannot tell "never started" from
18
+ * "failed").
19
+ * 3. Backpressure. A caller that never drains stdout must not be able to wedge
20
+ * the agent inside a `write()` call, and must not be able to make this
21
+ * process buffer without limit either. Soft and hard watermarks, both from
22
+ * `BRIDGE_LIMITS`; the hard one fails the run loudly instead of growing.
23
+ */
24
+ import { BRIDGE_LIMITS } from './limits.js';
25
+ /** Identify the init event without importing the event module (keeps this pure). */
26
+ function isInitEvent(event) {
27
+ return event.type === 'system' && event.subtype === 'init';
28
+ }
29
+ export function createBridgeWriter(options) {
30
+ const out = options.out;
31
+ const soft = options.softPendingBytes ?? BRIDGE_LIMITS.softPendingBytes;
32
+ const hard = options.hardPendingBytes ?? BRIDGE_LIMITS.hardPendingBytes;
33
+ let seq = 0;
34
+ let opened = false;
35
+ let closed = false;
36
+ let hardLimited = false;
37
+ let pendingBytes = 0;
38
+ const queue = [];
39
+ let drainWaiters = [];
40
+ const releaseDrainWaiters = () => {
41
+ const waiters = drainWaiters;
42
+ drainWaiters = [];
43
+ for (const resolve of waiters)
44
+ resolve();
45
+ };
46
+ // ONE listener for the life of the writer, attached lazily: a test double with
47
+ // no `on()` simply never reports backpressure, which is the honest reading of a
48
+ // stream that always accepts.
49
+ if (typeof out.on === 'function') {
50
+ out.on('drain', () => {
51
+ pendingBytes = 0;
52
+ releaseDrainWaiters();
53
+ });
54
+ }
55
+ const rawWrite = (event) => {
56
+ seq += 1;
57
+ const line = `${JSON.stringify({ ...event, seq })}\n`;
58
+ try {
59
+ // ONLY A REFUSED WRITE IS BACKLOG. `write()` returning `true` means the
60
+ // stream took the chunk - counting it anyway would report a caller as "not
61
+ // reading" merely because it is reading normally, and every flush would wait
62
+ // for a `drain` event that is never coming.
63
+ const accepted = out.write(line);
64
+ if (accepted === false)
65
+ pendingBytes += Buffer.byteLength(line, 'utf-8');
66
+ }
67
+ catch {
68
+ // A write that throws means the pipe is gone. The run is over either way;
69
+ // throwing from here would replace the caller-facing `result` with a stack
70
+ // trace, which is strictly less information.
71
+ pendingBytes = 0;
72
+ }
73
+ };
74
+ const enforceHardLimit = () => {
75
+ if (hardLimited)
76
+ return;
77
+ hardLimited = true;
78
+ // THE ONE FRAME THAT BYPASSES THE QUEUE. Telling the caller it is not reading
79
+ // is worthless if the telling itself waits behind the backlog it is about.
80
+ rawWrite({
81
+ type: 'error',
82
+ sessionId: options.sessionId,
83
+ fatal: true,
84
+ code: 'caller_not_reading',
85
+ message: `The caller has not read stdout for ${hard} bytes. ` +
86
+ 'The run is aborting rather than buffering without limit.',
87
+ });
88
+ options.onHardLimit?.();
89
+ };
90
+ const writeNow = (event) => {
91
+ if (pendingBytes >= hard)
92
+ enforceHardLimit();
93
+ rawWrite(event);
94
+ };
95
+ const write = (event) => {
96
+ if (closed)
97
+ return;
98
+ if (isInitEvent(event)) {
99
+ writeNow(event);
100
+ opened = true;
101
+ // ARRIVAL ORDER, not priority: an `ack` that came before `init` was
102
+ // scheduled keeps its position relative to every other queued frame.
103
+ for (const queued of queue.splice(0))
104
+ writeNow(queued);
105
+ return;
106
+ }
107
+ if (!opened) {
108
+ queue.push(event);
109
+ return;
110
+ }
111
+ writeNow(event);
112
+ };
113
+ const drainIfNeeded = async () => {
114
+ if (pendingBytes < soft)
115
+ return;
116
+ // A caller that is reading will emit `drain`; one that is not will cross the
117
+ // hard watermark instead, and `enforceHardLimit` wakes this waiter.
118
+ await new Promise((resolve) => {
119
+ if (pendingBytes < soft) {
120
+ resolve();
121
+ return;
122
+ }
123
+ drainWaiters.push(resolve);
124
+ const timer = setTimeout(resolve, 1_000);
125
+ timer.unref?.();
126
+ });
127
+ };
128
+ return {
129
+ write,
130
+ isOpened: () => opened,
131
+ currentSeq: () => seq,
132
+ pendingBytes: () => pendingBytes,
133
+ drainIfNeeded,
134
+ async flush() {
135
+ if (pendingBytes <= 0)
136
+ return;
137
+ await new Promise((resolve) => {
138
+ drainWaiters.push(resolve);
139
+ const timer = setTimeout(() => {
140
+ pendingBytes = 0;
141
+ releaseDrainWaiters();
142
+ resolve();
143
+ }, 200);
144
+ timer.unref?.();
145
+ });
146
+ },
147
+ async close() {
148
+ if (closed)
149
+ return;
150
+ for (const queued of queue.splice(0))
151
+ writeNow(queued);
152
+ closed = true;
153
+ releaseDrainWaiters();
154
+ },
155
+ };
156
+ }
@@ -24,7 +24,7 @@ export function registerExecCommand(program, toFlags, deps) {
24
24
  .description('Headless, machine-facing run: JSON events, sessions, tool permissions, budgets')
25
25
  .argument('[prompt]', 'The prompt (or use --prompt-file, or pipe it on stdin)')
26
26
  .option('--output-format <fmt>', 'text | json | stream-json (default: text)')
27
- .option('--input-format <fmt>', 'text | stream-json (needs --output-format stream-json)')
27
+ .option('--input-format <fmt>', 'text | stream-json | bridge (the last two need --output-format stream-json)')
28
28
  .option('--partial-messages', 'Emit text_delta events (stream-json only)')
29
29
  .option('--include-thinking', 'Emit thinking events (stream-json only)')
30
30
  .option('--prompt-file <path>', 'Read the prompt from a file')
@@ -35,7 +35,35 @@ export function registerExecCommand(program, toFlags, deps) {
35
35
  .option('--max-turns <n>', 'Stop after n assistant turns (exit 3)')
36
36
  .option('--max-duration <ms>', 'Stop after ms of wall clock (exit 3). --idle-timeout still applies to a silent ' +
37
37
  'stretch inside the run, and --tool-timeout to a single tool call')
38
- .option('--permission-mode <m>', 'auto | plan | strict (default: auto)')
38
+ .option('--permission-mode <m>', 'auto | plan | strict | ask (default: auto). `ask` needs --input-format bridge: ' +
39
+ 'it makes each mutating tool wait for the caller to allow it')
40
+ // THE FOUR BRIDGE BUDGETS. Declared here rather than hidden in `bridge/` so
41
+ // `aragon exec --help` is the complete list of what a caller can tune; the
42
+ // cross-checks that refuse them outside bridge mode live in `options.ts`.
43
+ .option('--decision-timeout <ms>', 'How long the caller has to answer one decision (bridge only; default 300000)')
44
+ .option('--on-decision-timeout <mode>', 'deny | error: what an unanswered decision means for the turn (bridge only; ' +
45
+ 'default deny)')
46
+ .option('--caller-tool-timeout <ms>', 'Ceiling for one caller tool call (bridge only; default 60000)')
47
+ // THE STRUCTURED RESULT AND THE TWO PER-REQUEST BUDGETS. Declared here for the
48
+ // reason the four bridge budgets are: `aragon exec --help` has to be the complete
49
+ // list of what a caller can tune, and the cross-checks that refuse a combination
50
+ // live in `options.ts`, where they are unit-testable.
51
+ .option('--result-schema <path>', 'Validate the final answer against this JSON Schema and return it as ' +
52
+ '`result.structured` (json / stream-json / bridge)')
53
+ .option('--result-repair-turns <n>', 'How many times the model is asked to fix a rejected result (0-2; default 1)')
54
+ .option('--on-result-unsatisfied <mode>', 'error | continue: what an unsubmitted result means for the run (default error)')
55
+ .option('--request-max-turns <n>', 'Stop ONE request after n assistant turns, keeping the process alive (bridge only)')
56
+ .option('--request-max-duration <ms>', 'Stop ONE request after ms of wall clock, keeping the process alive (bridge only)')
57
+ // THE THREAD AND HOOK FLAGS (headless-threads-and-lifecycle-hooks section 4.1).
58
+ // Declared here for the reason the four bridge budgets are: `aragon exec --help` has to be
59
+ // the complete list of what a caller can tune, and the cross-checks that refuse a
60
+ // combination live in `options.ts`, where they are unit-testable.
61
+ .option('--threads', 'Serve N isolated conversations from one process (needs --input-format bridge and ' +
62
+ '--output-format stream-json). Open them with thread_open on stdin')
63
+ .option('--max-open-threads <n>', 'How many threads may exist at once, 1-8 (thread mode; default 4)')
64
+ .option('--thread-concurrency <n>', 'How many threads may hold a request in flight (thread mode; default 2)')
65
+ .option('--hook-timeout <ms>', 'Ceiling for one caller lifecycle hook round trip (bridge only; default 5000)')
66
+ .option('--on-hook-timeout <mode>', 'deny | allow | error: what an unanswered hook means (bridge only; default deny)')
39
67
  .option('--allow-tool <names>', 'Add tools to the baseline (repeatable; comma-separated accepted)', collect)
40
68
  .option('--deny-tool <names>', 'Remove tools; wins over --allow-tool (repeatable; comma-separated accepted)', collect)
41
69
  .option('--append-system-prompt <text>', 'Append text to the builtin system prompt')
@@ -16,6 +16,7 @@
16
16
  * error, budget and signal - rather than true for the paths someone remembered.
17
17
  */
18
18
  import type { ExecEvent, ExecInitParams, ExecResultEvent, ExecResultParams } from './events.js';
19
+ import type { BridgeWriter } from './bridge/writer.js';
19
20
  export interface ExecEmitter {
20
21
  init(params: ExecInitParams): void;
21
22
  /** Every non-`result` event. Ignored wholesale by `text` and `json`. */
@@ -70,11 +71,24 @@ export declare class JsonEmitter implements ExecEmitter {
70
71
  */
71
72
  export declare class StreamJsonEmitter implements ExecEmitter {
72
73
  private readonly out;
74
+ private readonly writer?;
73
75
  private done;
74
- constructor(out: NodeJS.WritableStream);
76
+ /**
77
+ * `writer` is ABSENT IN EVERY NON-BRIDGE RUN, and that absence is the whole
78
+ * compatibility story (I-1): with no writer the three methods below fall through
79
+ * to the exact `this.out.write(toNdjsonLine(...))` they have always done, so
80
+ * `stream-json` stays byte-identical while bridge mode gains `seq`, the `init`
81
+ * queue and backpressure from one object.
82
+ *
83
+ * It is not the emitter that stamps `seq` - a stamp belongs where EVERY line
84
+ * passes, and bridge events (`ack`, `decision_request`) are produced by the
85
+ * server, which never sees this class.
86
+ */
87
+ constructor(out: NodeJS.WritableStream, writer?: BridgeWriter | undefined);
75
88
  init(params: ExecInitParams): void;
76
89
  emit(event: ExecEvent): void;
77
90
  result(params: ExecResultParams): void;
91
+ private write;
78
92
  emitted(): ExecResultEvent | null;
79
93
  }
80
- export declare function createEmitter(format: 'text' | 'json' | 'stream-json', out: NodeJS.WritableStream): ExecEmitter;
94
+ export declare function createEmitter(format: 'text' | 'json' | 'stream-json', out: NodeJS.WritableStream, writer?: BridgeWriter): ExecEmitter;
@@ -94,34 +94,54 @@ export class JsonEmitter {
94
94
  */
95
95
  export class StreamJsonEmitter {
96
96
  out;
97
+ writer;
97
98
  done = null;
98
- constructor(out) {
99
+ /**
100
+ * `writer` is ABSENT IN EVERY NON-BRIDGE RUN, and that absence is the whole
101
+ * compatibility story (I-1): with no writer the three methods below fall through
102
+ * to the exact `this.out.write(toNdjsonLine(...))` they have always done, so
103
+ * `stream-json` stays byte-identical while bridge mode gains `seq`, the `init`
104
+ * queue and backpressure from one object.
105
+ *
106
+ * It is not the emitter that stamps `seq` - a stamp belongs where EVERY line
107
+ * passes, and bridge events (`ack`, `decision_request`) are produced by the
108
+ * server, which never sees this class.
109
+ */
110
+ constructor(out, writer) {
99
111
  this.out = out;
112
+ this.writer = writer;
100
113
  }
101
114
  init(params) {
102
115
  if (this.done)
103
116
  return;
104
- this.out.write(toNdjsonLine(buildInitEvent(params)));
117
+ this.write(buildInitEvent(params));
105
118
  }
106
119
  emit(event) {
107
120
  if (this.done)
108
121
  return;
109
- this.out.write(toNdjsonLine(event));
122
+ this.write(event);
110
123
  }
111
124
  result(params) {
112
125
  if (this.done)
113
126
  return;
114
127
  this.done = buildResultEvent(params);
115
- this.out.write(toNdjsonLine(this.done));
128
+ this.write(this.done);
129
+ }
130
+ write(event) {
131
+ if (this.writer) {
132
+ this.writer.write(event);
133
+ return;
134
+ }
135
+ this.out.write(toNdjsonLine(event));
116
136
  }
117
137
  emitted() {
118
138
  return this.done;
119
139
  }
120
140
  }
121
- export function createEmitter(format, out) {
141
+ export function createEmitter(format, out, writer) {
122
142
  if (format === 'json')
123
143
  return new JsonEmitter(out);
124
144
  if (format === 'stream-json')
125
- return new StreamJsonEmitter(out);
145
+ return new StreamJsonEmitter(out, writer);
126
146
  return new TextEmitter();
127
147
  }