@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,68 @@
1
+ /**
2
+ * Every bound the bridge protocol has, in one object (headless-host-bridge I-6).
3
+ *
4
+ * ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
5
+ *
6
+ * ONE AUTHORITY, NO LITERALS ELSEWHERE. "How long may a decision wait", "how many
7
+ * caller tools may exist", "how many bytes may one frame be" are all answers a
8
+ * caller can be told (`aragon info --json`) and a validator has to enforce. Two
9
+ * copies of a number are two answers the moment one of them is edited, and the
10
+ * pair that disagrees is always discovered in the field.
11
+ *
12
+ * STRUCTURAL, NOT POLICY. These are what a protocol frame can physically carry,
13
+ * which is the same distinction `config/schema.ts` draws for user-tunable keys
14
+ * (`.claude-index/index.md`, "limits.ts is structural"). Nothing here is
15
+ * persisted and nothing here is settable from `config.json`: the two timeouts
16
+ * have flags because they are per-invocation budgets, and every other number is
17
+ * a property of the wire format.
18
+ */
19
+ export const BRIDGE_LIMITS = {
20
+ /** One NDJSON frame. A larger line is skipped, never fatal. */
21
+ maxFrameBytes: 1_048_576,
22
+ /** Declared caller tools in one `tool_register` frame. */
23
+ maxCallerTools: 32,
24
+ maxCallerToolNameChars: 40,
25
+ maxCallerToolSchemaBytes: 16_384,
26
+ maxCallerToolDescriptionChars: 1_024,
27
+ /** Mirrors Core's `ToolExecutor` default `maxOutputSize` (executor.ts:42). */
28
+ maxCallerToolResultBytes: 100_000,
29
+ /** Concurrent in-flight caller tools. Over the cap, the call is refused. */
30
+ maxOutstandingCallerTools: 8,
31
+ /** Concurrent unanswered decisions, lead plus every subagent. */
32
+ maxOutstandingDecisions: 8,
33
+ /**
34
+ * Recently settled request ids remembered so a REPEATED answer can be answered
35
+ * as `bridge_duplicate_response` instead of `bridge_unknown_request`.
36
+ *
37
+ * The design dropped the 512-entry decision ledger (review P2-7) on the grounds
38
+ * that the pending table's settle-once already covered duplicate detection. That
39
+ * is true only while the entry is still IN the table: this table removes an
40
+ * entry the moment it settles, so without a small ring the very first repeat
41
+ * would be reported as an unknown request - a different code, and a misleading
42
+ * one, for a caller that answered twice. The ring has exactly one consumer (the
43
+ * duplicate branch in `pending.ts`), which is the condition P2-7 set for keeping
44
+ * any such state at all.
45
+ */
46
+ maxSettledIds: 256,
47
+ /** Default and hard ceiling for one decision's deadline. */
48
+ defaultDecisionTimeoutMs: 300_000,
49
+ maxDecisionTimeoutMs: 1_800_000,
50
+ /** Caller tool timeouts: the flag's default, and its hard ceiling. */
51
+ defaultCallerToolTimeoutMs: 60_000,
52
+ maxCallerToolTimeoutMs: 600_000,
53
+ /**
54
+ * Strings the caller supplies that end up in front of the model.
55
+ *
56
+ * BOUNDED BECAUSE THEY ARE MODEL-VISIBLE, not because they are expensive: a
57
+ * denial reason and plan feedback both reach the transcript, so an unbounded
58
+ * one is an unbounded line in every subsequent provider request.
59
+ */
60
+ maxDenyReasonChars: 500,
61
+ maxPlanFeedbackChars: 2_000,
62
+ maxCustomAnswerChars: 2_000,
63
+ /** `tool_invoke.input` is echoed back so the caller can act on it. */
64
+ maxInvokeInputChars: 16_384,
65
+ /** stdout backpressure watermarks. See `writer.ts`. */
66
+ softPendingBytes: 4_194_304,
67
+ hardPendingBytes: 16_777_216,
68
+ };
@@ -0,0 +1,17 @@
1
+ export interface NdjsonSplitterOptions {
2
+ /**
3
+ * Structural ceiling for one frame. `BRIDGE_LIMITS.maxFrameBytes` unless a
4
+ * caller is deliberately testing the boundary with a smaller one.
5
+ */
6
+ maxFrameBytes?: number;
7
+ /** One complete line, terminator removed. Never called for an empty line. */
8
+ onLine: (line: string) => void;
9
+ /** A line was dropped for exceeding the ceiling. */
10
+ onTooLarge?: (bytes: number) => void;
11
+ }
12
+ export interface NdjsonSplitter {
13
+ push(chunk: string | Buffer): void;
14
+ /** Deliver a final line that arrived without a trailing newline. */
15
+ end(): void;
16
+ }
17
+ export declare function createNdjsonSplitter(options: NdjsonSplitterOptions): NdjsonSplitter;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * NDJSON line splitting, shared by `--input-format stream-json` and `bridge`.
3
+ *
4
+ * ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
5
+ *
6
+ * PURE, AND EXTRACTED RATHER THAN COPIED. Both input formats read newline-delimited
7
+ * JSON off stdin and both must treat a bad line as non-fatal; a second copy of the
8
+ * splitting loop would be a second place for the `\r` rule below to be forgotten.
9
+ *
10
+ * THE `\r` RULE IS NOT A HYPOTHETICAL. This project ships Windows-first, and a
11
+ * caller on Windows that writes `JSON + "\r\n"` puts a carriage return at the end
12
+ * of every line. `JSON.parse` tolerates it, but the string we hand to a
13
+ * `content` field would then carry a stray `\r`, which reaches the model as part
14
+ * of a tool result. So the terminator is trimmed BEFORE the byte count and BEFORE
15
+ * parsing, in that order: counting the raw line first would reject a frame that is
16
+ * exactly at the cap only because of a line ending nobody meant to send.
17
+ */
18
+ import { BRIDGE_LIMITS } from './limits.js';
19
+ export function createNdjsonSplitter(options) {
20
+ const maxBytes = options.maxFrameBytes ?? BRIDGE_LIMITS.maxFrameBytes;
21
+ let buffer = '';
22
+ /**
23
+ * `true` while discarding the tail of an over-long line.
24
+ *
25
+ * WITHOUT THIS the reader would buffer an unbounded amount of memory waiting for
26
+ * a newline that may never come - a caller that never sends one (a corrupted
27
+ * pipe, a writer stuck mid-frame) would exhaust the process instead of costing
28
+ * one skipped frame.
29
+ */
30
+ let discarding = false;
31
+ const deliver = (raw) => {
32
+ if (discarding) {
33
+ discarding = false;
34
+ return;
35
+ }
36
+ const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
37
+ if (line.trim().length === 0)
38
+ return;
39
+ const bytes = Buffer.byteLength(line, 'utf-8');
40
+ if (bytes > maxBytes) {
41
+ options.onTooLarge?.(bytes);
42
+ return;
43
+ }
44
+ options.onLine(line);
45
+ };
46
+ return {
47
+ push(chunk) {
48
+ buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
49
+ for (;;) {
50
+ const index = buffer.indexOf('\n');
51
+ if (index < 0) {
52
+ // No terminator yet. An over-long partial line is dropped NOW rather
53
+ // than kept until one arrives; see `discarding`.
54
+ //
55
+ // ALREADY DISCARDING MEANS RETAIN NOTHING, not merely "do not report
56
+ // again": the flag only exists so the caller is told once, and keeping
57
+ // the tail would reintroduce the unbounded growth this branch was
58
+ // written to prevent - a caller that streams 100 MiB with no newline
59
+ // would still hold all of it.
60
+ if (discarding) {
61
+ buffer = '';
62
+ return;
63
+ }
64
+ const bytes = Buffer.byteLength(buffer, 'utf-8');
65
+ if (bytes > maxBytes) {
66
+ options.onTooLarge?.(bytes);
67
+ buffer = '';
68
+ discarding = true;
69
+ }
70
+ return;
71
+ }
72
+ const line = buffer.slice(0, index);
73
+ buffer = buffer.slice(index + 1);
74
+ deliver(line);
75
+ }
76
+ },
77
+ end() {
78
+ if (buffer.length > 0)
79
+ deliver(buffer);
80
+ buffer = '';
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,70 @@
1
+ import type { AgentScope, PendingKindValue } from './protocol.js';
2
+ export type PendingKind = PendingKindValue;
3
+ export interface PendingMeta {
4
+ tool?: string;
5
+ callId?: string;
6
+ agent?: AgentScope;
7
+ }
8
+ /** What an entry resolves with. Never rejects; see `DecisionOutcome`. */
9
+ export type PendingOutcome<T> = {
10
+ outcome: 'settled';
11
+ value: T;
12
+ } | {
13
+ outcome: 'timeout';
14
+ deadlineAt: number;
15
+ } | {
16
+ outcome: 'aborted';
17
+ } | {
18
+ outcome: 'closed';
19
+ reason: string;
20
+ };
21
+ export interface PendingCreateOptions {
22
+ kind: PendingKind;
23
+ timeoutMs: number;
24
+ signal?: AbortSignal;
25
+ meta?: PendingMeta;
26
+ }
27
+ export interface PendingHandle<T> {
28
+ requestId: string;
29
+ createdAt: number;
30
+ deadlineAt: number;
31
+ settled: Promise<PendingOutcome<T>>;
32
+ }
33
+ /**
34
+ * `kind_mismatch` is NOT a duplicate and NOT unknown: the two frames really do
35
+ * refer to this request id, they just answer the wrong one of the two questions
36
+ * this table multiplexes. The entry must survive, because the party on the other
37
+ * end is still waiting for its own answer (AC-55).
38
+ */
39
+ export type PendingAnswerResult = 'ok' | 'duplicate' | 'unknown' | 'kind_mismatch';
40
+ export interface PendingTable {
41
+ /** `null` at capacity: refuse immediately rather than queueing. */
42
+ create<T>(options: PendingCreateOptions): PendingHandle<T> | null;
43
+ answer(requestId: string, kind: PendingKind, value: unknown): PendingAnswerResult;
44
+ /** Turn abort / interrupt / EOF: every entry resolves `aborted`, synchronously. */
45
+ abortAll(): void;
46
+ /** Server shutdown: every entry resolves `closed`. */
47
+ closeAll(reason: string): void;
48
+ size(): number;
49
+ kindOf(requestId: string): PendingKind | undefined;
50
+ metaOf(requestId: string): PendingMeta | undefined;
51
+ /** Ids still awaiting an answer, for tests and for `close()` logging. */
52
+ pendingIds(): string[];
53
+ /**
54
+ * Was this id settled BY AN ANSWER recently enough to still be remembered?
55
+ *
56
+ * The entry itself is gone once settled, so without this probe a second answer
57
+ * is indistinguishable from an answer to an id that never existed - and those
58
+ * two are different codes on purpose (`bridge_duplicate_response` vs
59
+ * `bridge_unknown_request`). Timeouts, aborts and closures are deliberately NOT
60
+ * remembered: answering one of those is "too late", not "twice".
61
+ */
62
+ wasAnswered(requestId: string): boolean;
63
+ }
64
+ export interface PendingTableOptions {
65
+ now?: () => number;
66
+ maxDecisions?: number;
67
+ maxCallerTools?: number;
68
+ maxSettledIds?: number;
69
+ }
70
+ export declare function createPendingTable(options?: PendingTableOptions): PendingTable;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The pending-request table: one entry per unanswered request, in either direction.
3
+ *
4
+ * ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
5
+ *
6
+ * ONE TABLE FOR TWO TRAFFIC DIRECTIONS, DELIBERATELY (headless-host-bridge section 1.3).
7
+ * A `decision_request` is the CLI asking the caller, a `tool_invoke` is the caller
8
+ * serving the CLI; both need an id, a deadline, settle-once, cancellation and a
9
+ * capacity ceiling. Two implementations would be two sets of rules about what a
10
+ * duplicate answer means, and the second one would be written by someone reading
11
+ * the first one. `kind` is what keeps them distinguishable, and it is also what
12
+ * makes `bridge_kind_mismatch` detectable at all.
13
+ *
14
+ * SETTLE-ONCE IS THE WHOLE STATE MACHINE. `done` is the only authority: a second
15
+ * `answer()` for the same id returns `duplicate` and changes nothing, and an
16
+ * `answer()` for an id that was never allocated returns `unknown`. Neither case
17
+ * changes the outcome the waiting tool already received.
18
+ */
19
+ import { BRIDGE_LIMITS } from './limits.js';
20
+ export function createPendingTable(options = {}) {
21
+ const now = options.now ?? Date.now;
22
+ const maxDecisions = options.maxDecisions ?? BRIDGE_LIMITS.maxOutstandingDecisions;
23
+ const maxCallerTools = options.maxCallerTools ?? BRIDGE_LIMITS.maxOutstandingCallerTools;
24
+ const maxSettled = options.maxSettledIds ?? BRIDGE_LIMITS.maxSettledIds;
25
+ const entries = new Map();
26
+ /** Bounded FIFO of ids settled by an ANSWER recently; see `maxSettledIds`. */
27
+ const settledIds = [];
28
+ let counter = 0;
29
+ const countOf = (kind) => {
30
+ let total = 0;
31
+ for (const entry of entries.values())
32
+ if (entry.kind === kind)
33
+ total += 1;
34
+ return total;
35
+ };
36
+ const remember = (requestId) => {
37
+ settledIds.push(requestId);
38
+ if (settledIds.length > maxSettled)
39
+ settledIds.shift();
40
+ };
41
+ const settleEntry = (entry, outcome, answered = false) => {
42
+ if (entry.done)
43
+ return false;
44
+ entry.done = true;
45
+ if (entry.timer)
46
+ clearTimeout(entry.timer);
47
+ entry.detachAbort?.();
48
+ entries.delete(entry.requestId);
49
+ if (answered)
50
+ remember(entry.requestId);
51
+ // RESOLVING IS SYNCHRONOUS HERE, which is what AC-49 depends on: when the
52
+ // turn aborts, every waiting tool is settled inside the abort listener's own
53
+ // tick, so it can finish inside the executor's 5 s grace window instead of
54
+ // being abandoned with the executor's own wording.
55
+ entry.settle(outcome);
56
+ return true;
57
+ };
58
+ return {
59
+ create(create) {
60
+ const cap = create.kind === 'decision' ? maxDecisions : maxCallerTools;
61
+ if (countOf(create.kind) >= cap)
62
+ return null;
63
+ counter += 1;
64
+ const createdAt = now();
65
+ const entry = {
66
+ requestId: `req_${counter}`,
67
+ kind: create.kind,
68
+ createdAt,
69
+ deadlineAt: createdAt + create.timeoutMs,
70
+ meta: create.meta ?? {},
71
+ done: false,
72
+ settle: () => { },
73
+ };
74
+ let resolveOutcome = () => { };
75
+ const settled = new Promise((resolve) => {
76
+ resolveOutcome = resolve;
77
+ });
78
+ entry.settle = resolveOutcome;
79
+ entries.set(entry.requestId, entry);
80
+ entry.timer = setTimeout(() => {
81
+ settleEntry(entry, { outcome: 'timeout', deadlineAt: entry.deadlineAt });
82
+ }, Math.max(0, create.timeoutMs));
83
+ // `unref` so a pending deadline can never be the reason the process stays
84
+ // alive: the run has its own reason to be running, and this timer is not it.
85
+ entry.timer.unref?.();
86
+ const signal = create.signal;
87
+ if (signal?.aborted) {
88
+ // ALREADY ABORTED, SO SETTLE NOW RATHER THAN ARMING A LISTENER. The
89
+ // executor abandons a tool 5 s after abort; a wait that only learned about
90
+ // the abort later would surface as "did not stop within 5000ms of abort"
91
+ // instead of "the turn was interrupted" (AC-49).
92
+ settleEntry(entry, { outcome: 'aborted' });
93
+ }
94
+ else if (signal) {
95
+ const onAbort = () => {
96
+ settleEntry(entry, { outcome: 'aborted' });
97
+ };
98
+ signal.addEventListener('abort', onAbort, { once: true });
99
+ entry.detachAbort = () => signal.removeEventListener('abort', onAbort);
100
+ }
101
+ return {
102
+ requestId: entry.requestId,
103
+ createdAt: entry.createdAt,
104
+ deadlineAt: entry.deadlineAt,
105
+ settled: settled,
106
+ };
107
+ },
108
+ answer(requestId, kind, value) {
109
+ const entry = entries.get(requestId);
110
+ if (!entry)
111
+ return settledIds.includes(requestId) ? 'duplicate' : 'unknown';
112
+ if (entry.kind !== kind)
113
+ return 'kind_mismatch';
114
+ return settleEntry(entry, { outcome: 'settled', value }, true) ? 'ok' : 'duplicate';
115
+ },
116
+ abortAll() {
117
+ for (const entry of [...entries.values()]) {
118
+ settleEntry(entry, { outcome: 'aborted' });
119
+ }
120
+ },
121
+ closeAll(reason) {
122
+ for (const entry of [...entries.values()]) {
123
+ settleEntry(entry, { outcome: 'closed', reason });
124
+ }
125
+ },
126
+ size: () => entries.size,
127
+ kindOf: (requestId) => entries.get(requestId)?.kind,
128
+ metaOf: (requestId) => entries.get(requestId)?.meta,
129
+ pendingIds: () => [...entries.keys()],
130
+ wasAnswered: (requestId) => settledIds.includes(requestId),
131
+ };
132
+ }