@aragon-agent/cli 0.6.37 → 0.6.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/CHANGELOG.md +2523 -2326
  2. package/README.md +3614 -3173
  3. package/dist/agent/controller.d.ts +142 -1
  4. package/dist/agent/controller.js +171 -3
  5. package/dist/agent/reducer.d.ts +41 -1
  6. package/dist/agent/reducer.js +25 -0
  7. package/dist/agent/system-prompt.d.ts +14 -0
  8. package/dist/agent/system-prompt.js +5 -0
  9. package/dist/cli.js +85 -27
  10. package/dist/commands/builtins.js +13 -1
  11. package/dist/commands/keys-command.d.ts +23 -0
  12. package/dist/commands/keys-command.js +38 -0
  13. package/dist/commands/perf.d.ts +7 -1
  14. package/dist/commands/perf.js +2 -2
  15. package/dist/commands/registry.d.ts +39 -0
  16. package/dist/commands/task-command.d.ts +39 -0
  17. package/dist/commands/task-command.js +102 -0
  18. package/dist/config/cli-commands.d.ts +23 -0
  19. package/dist/config/cli-commands.js +36 -0
  20. package/dist/config/env.js +17 -4
  21. package/dist/config/load.js +6 -1
  22. package/dist/config/schema.d.ts +43 -4
  23. package/dist/config/schema.js +31 -5
  24. package/dist/config/store.js +17 -2
  25. package/dist/diagnostics/doctor.d.ts +9 -0
  26. package/dist/diagnostics/doctor.js +34 -0
  27. package/dist/diagnostics/info.d.ts +71 -0
  28. package/dist/diagnostics/info.js +91 -5
  29. package/dist/exec/bridge/caller-tools.d.ts +96 -0
  30. package/dist/exec/bridge/caller-tools.js +213 -0
  31. package/dist/exec/bridge/decisions.d.ts +78 -0
  32. package/dist/exec/bridge/decisions.js +116 -0
  33. package/dist/exec/bridge/human-answers.d.ts +38 -0
  34. package/dist/exec/bridge/human-answers.js +164 -0
  35. package/dist/exec/bridge/limits.d.ts +68 -0
  36. package/dist/exec/bridge/limits.js +68 -0
  37. package/dist/exec/bridge/ndjson.d.ts +17 -0
  38. package/dist/exec/bridge/ndjson.js +83 -0
  39. package/dist/exec/bridge/pending.d.ts +70 -0
  40. package/dist/exec/bridge/pending.js +132 -0
  41. package/dist/exec/bridge/protocol.d.ts +346 -0
  42. package/dist/exec/bridge/protocol.js +256 -0
  43. package/dist/exec/bridge/server.d.ts +142 -0
  44. package/dist/exec/bridge/server.js +502 -0
  45. package/dist/exec/bridge/source.d.ts +58 -0
  46. package/dist/exec/bridge/source.js +145 -0
  47. package/dist/exec/bridge/writer.d.ts +26 -0
  48. package/dist/exec/bridge/writer.js +156 -0
  49. package/dist/exec/cli-commands.js +30 -2
  50. package/dist/exec/emitter.d.ts +16 -2
  51. package/dist/exec/emitter.js +26 -6
  52. package/dist/exec/events.d.ts +465 -10
  53. package/dist/exec/events.js +47 -1
  54. package/dist/exec/hooks/dispatch.d.ts +76 -0
  55. package/dist/exec/hooks/dispatch.js +169 -0
  56. package/dist/exec/hooks/index.d.ts +83 -0
  57. package/dist/exec/hooks/index.js +205 -0
  58. package/dist/exec/hooks/limits.d.ts +56 -0
  59. package/dist/exec/hooks/limits.js +49 -0
  60. package/dist/exec/hooks/registry.d.ts +49 -0
  61. package/dist/exec/hooks/registry.js +91 -0
  62. package/dist/exec/hooks/tool-wrapper.d.ts +94 -0
  63. package/dist/exec/hooks/tool-wrapper.js +154 -0
  64. package/dist/exec/hooks/turn.d.ts +61 -0
  65. package/dist/exec/hooks/turn.js +59 -0
  66. package/dist/exec/hooks/validate.d.ts +85 -0
  67. package/dist/exec/hooks/validate.js +310 -0
  68. package/dist/exec/index.d.ts +130 -4
  69. package/dist/exec/index.js +501 -10
  70. package/dist/exec/options.d.ts +124 -2
  71. package/dist/exec/options.js +413 -6
  72. package/dist/exec/permission.d.ts +9 -1
  73. package/dist/exec/permission.js +7 -1
  74. package/dist/exec/request/limits.d.ts +51 -0
  75. package/dist/exec/request/limits.js +49 -0
  76. package/dist/exec/request/scope.d.ts +106 -0
  77. package/dist/exec/request/scope.js +149 -0
  78. package/dist/exec/result/limits.d.ts +74 -0
  79. package/dist/exec/result/limits.js +72 -0
  80. package/dist/exec/result/prompt.d.ts +28 -0
  81. package/dist/exec/result/prompt.js +74 -0
  82. package/dist/exec/result/schema.d.ts +99 -0
  83. package/dist/exec/result/schema.js +464 -0
  84. package/dist/exec/result/sink.d.ts +56 -0
  85. package/dist/exec/result/sink.js +50 -0
  86. package/dist/exec/result/tool.d.ts +37 -0
  87. package/dist/exec/result/tool.js +118 -0
  88. package/dist/exec/runner.d.ts +395 -1
  89. package/dist/exec/runner.js +860 -8
  90. package/dist/exec/threads/aggregate.d.ts +34 -0
  91. package/dist/exec/threads/aggregate.js +30 -0
  92. package/dist/exec/threads/capacity.d.ts +23 -0
  93. package/dist/exec/threads/capacity.js +38 -0
  94. package/dist/exec/threads/dispatch-source.d.ts +60 -0
  95. package/dist/exec/threads/dispatch-source.js +106 -0
  96. package/dist/exec/threads/execute-threads.d.ts +53 -0
  97. package/dist/exec/threads/execute-threads.js +234 -0
  98. package/dist/exec/threads/gate.d.ts +42 -0
  99. package/dist/exec/threads/gate.js +81 -0
  100. package/dist/exec/threads/identity.d.ts +36 -0
  101. package/dist/exec/threads/identity.js +67 -0
  102. package/dist/exec/threads/interrupt.d.ts +29 -0
  103. package/dist/exec/threads/interrupt.js +79 -0
  104. package/dist/exec/threads/limits.d.ts +58 -0
  105. package/dist/exec/threads/limits.js +35 -0
  106. package/dist/exec/threads/outcome.d.ts +47 -0
  107. package/dist/exec/threads/outcome.js +26 -0
  108. package/dist/exec/threads/registry.d.ts +81 -0
  109. package/dist/exec/threads/registry.js +427 -0
  110. package/dist/exec/threads/runtime.d.ts +107 -0
  111. package/dist/exec/threads/runtime.js +523 -0
  112. package/dist/exec/threads/thread-source.d.ts +20 -0
  113. package/dist/exec/threads/thread-source.js +134 -0
  114. package/dist/fast/limits.d.ts +54 -0
  115. package/dist/fast/limits.js +54 -0
  116. package/dist/fast/summary-call.d.ts +72 -0
  117. package/dist/fast/summary-call.js +133 -0
  118. package/dist/fast/summary-prompt.d.ts +106 -0
  119. package/dist/fast/summary-prompt.js +320 -0
  120. package/dist/fast/task-summary-types.d.ts +66 -0
  121. package/dist/fast/task-summary-types.js +17 -0
  122. package/dist/logging/logger.d.ts +1 -1
  123. package/dist/proc/limits.d.ts +7 -0
  124. package/dist/proc/limits.js +7 -0
  125. package/dist/proc/output-streams.d.ts +50 -0
  126. package/dist/proc/output-streams.js +54 -0
  127. package/dist/team/runtime.d.ts +9 -0
  128. package/dist/team/runtime.js +8 -0
  129. package/dist/team/subagent.d.ts +15 -0
  130. package/dist/team/subagent.js +45 -1
  131. package/dist/tools/bash-tool.d.ts +10 -0
  132. package/dist/tools/bash-tool.js +71 -6
  133. package/dist/tools/index.d.ts +93 -1
  134. package/dist/tools/index.js +112 -3
  135. package/dist/tools/tool-gate.d.ts +100 -0
  136. package/dist/tools/tool-gate.js +87 -0
  137. package/dist/ui/App.js +224 -11
  138. package/dist/ui/Header.d.ts +28 -16
  139. package/dist/ui/Header.js +21 -19
  140. package/dist/ui/interaction-hints.js +6 -0
  141. package/dist/ui/layout/OverlayFrame.d.ts +18 -0
  142. package/dist/ui/layout/OverlayFrame.js +19 -4
  143. package/dist/ui/layout/header-hints.d.ts +55 -62
  144. package/dist/ui/layout/header-hints.js +59 -166
  145. package/dist/ui/layout/header-left.d.ts +115 -0
  146. package/dist/ui/layout/header-left.js +206 -0
  147. package/dist/ui/layout/shortcut-panel-layout.d.ts +191 -0
  148. package/dist/ui/layout/shortcut-panel-layout.js +321 -0
  149. package/dist/ui/layout/status-detail-layout.js +5 -1
  150. package/dist/ui/layout/task-summary-cell.d.ts +61 -0
  151. package/dist/ui/layout/task-summary-cell.js +65 -0
  152. package/dist/ui/overlays/HelpOverlay.d.ts +12 -0
  153. package/dist/ui/overlays/HelpOverlay.js +29 -2
  154. package/dist/ui/overlays/ShortcutPanel.d.ts +39 -0
  155. package/dist/ui/overlays/ShortcutPanel.js +106 -0
  156. package/dist/ui/scrollback/canvas.d.ts +3 -2
  157. package/dist/ui/scrollback/canvas.js +3 -2
  158. package/dist/ui/scrollback/scrollback-writer.d.ts +13 -1
  159. package/dist/ui/scrollback/scrollback-writer.js +109 -10
  160. package/dist/ui/shortcuts.d.ts +81 -0
  161. package/dist/ui/shortcuts.js +221 -0
  162. package/package.json +2 -2
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The bridge server: one multiplexer for both directions of the channel.
3
+ *
4
+ * ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
5
+ *
6
+ * WHAT IT OWNS
7
+ *
8
+ * - the pending table (ids, deadlines, settle-once, cancellation),
9
+ * - the outbound frames that are NOT exec events (`decision_request`,
10
+ * `tool_invoke`, `tool_cancel`, `ack`, `pong`),
11
+ * - the caller-tool registry (validation and the turn-boundary write),
12
+ * - the open/closed state every gate reads as `isOpen()`.
13
+ *
14
+ * WHAT IT DOES NOT OWN. It never prompts the model, never advances a turn and
15
+ * never writes `result`: the runner does the first two, `exec/index.ts` the third.
16
+ * The consequence worth stating is that `close()` can be called from any exit path
17
+ * without any of those owners being consulted.
18
+ *
19
+ * THE ONE ASYMMETRY THAT MATTERS. `requestDecision` resolves; it never rejects and
20
+ * never throws, even for a frame that arrived mangled. A gate is therefore allowed
21
+ * to `await` it without a `try`, and the fail-closed answer for anything untrusted
22
+ * is the same one for a timeout: "not allowed", with the reason written where the
23
+ * model cannot see it and the caller can.
24
+ */
25
+ import { type ToolResult } from '@aragon-agent/core';
26
+ import { type AgentScope, type BridgeInputFrame, type CallerToolCall, type CallerToolSpec, type DecisionAnswer, type DecisionAsk, type DecisionKind, type DecisionOutcome } from './protocol.js';
27
+ import type { BridgeWriter } from './writer.js';
28
+ /** What the host must provide for the frames that change host state. */
29
+ export interface BridgeServerPorts {
30
+ /** Replace the caller-served tool set. ONLY called at a turn boundary. */
31
+ applyCallerTools: (tools: CallerToolSpec[]) => void;
32
+ /** True while `agent.state.isRunning` - registration must wait. */
33
+ isTurnActive: () => boolean;
34
+ /** The names the registry holds RIGHT NOW (the host's view of the union). */
35
+ callerToolNames: () => string[];
36
+ /** `'plan'` refuses `tool_register` outright. */
37
+ getAgentMode: () => 'build' | 'plan';
38
+ }
39
+ export interface BridgeTurnPorts {
40
+ /** `steer` frame: enqueue guidance, or refuse when the agent is idle. */
41
+ steer: (text: string, id: string | undefined) => void;
42
+ /** `interrupt` frame: cancel a queued turn, or abort the one in flight. */
43
+ interrupt: (requestId: string | undefined, id: string | undefined) => void;
44
+ /**
45
+ * `cancel_request` frame: stop ONE request and keep the process serving.
46
+ *
47
+ * The same two arguments as `interrupt` on purpose - the frame shapes are identical
48
+ * and the meaning is what differs - so an implementer cannot accidentally wire one
49
+ * into the other's slot without the types noticing.
50
+ */
51
+ cancelRequest: (requestId: string | undefined, id: string | undefined) => void;
52
+ }
53
+ export interface BridgeAuditRecord {
54
+ requestId: string;
55
+ kind: DecisionKind | 'caller_tool' | 'hook';
56
+ outcome: string;
57
+ tool?: string;
58
+ reason?: string;
59
+ }
60
+ /**
61
+ * The lifecycle-hook channel, as the server sees it
62
+ * (headless-threads-and-lifecycle-hooks section 2.4.2).
63
+ *
64
+ * WHY THE SERVER AND NOT THE SOURCE: `bridge/source.ts` routes every non-turn frame to
65
+ * `server.handleFrame` and is on this round's UNTOUCHED list, so the two hook frames have
66
+ * to be understood here. The server owns no hook state - it validates nothing, times
67
+ * nothing out and writes nothing on its own - which is what keeps this a two-case
68
+ * addition rather than a second dispatcher.
69
+ */
70
+ export interface BridgeHookPorts {
71
+ /** `hook_register`: answer with the ids that are now live, or a refusal. */
72
+ register: (frame: Extract<BridgeInputFrame, {
73
+ type: 'hook_register';
74
+ }>) => void;
75
+ /**
76
+ * `hook_answer`: `true` when it named a live request. The port writes the ack itself
77
+ * when it returns `false`, because only it knows which code applies
78
+ * (`hook_not_registered`) and whether the frame even belongs to this channel.
79
+ */
80
+ answer: (frame: Extract<BridgeInputFrame, {
81
+ type: 'hook_answer';
82
+ }>) => boolean;
83
+ }
84
+ export interface BridgeServerOptions {
85
+ writer: BridgeWriter;
86
+ sessionId: string;
87
+ now?: () => number;
88
+ decisionTimeoutMs: number;
89
+ callerToolTimeoutMs: number;
90
+ ports: BridgeServerPorts;
91
+ /** Names a caller tool may not shadow. Defaults to host + subagent tool names. */
92
+ reservedToolNames?: ReadonlySet<string>;
93
+ /**
94
+ * The one structured audit line per decision (headless-host-bridge section 2.5).
95
+ *
96
+ * A CALLBACK RATHER THAN A LOGGER IMPORT, so this module stays testable without
97
+ * process state and so `exec/index.ts` remains the only place that decides what
98
+ * "logged" means.
99
+ */
100
+ onAudit?: (record: BridgeAuditRecord) => void;
101
+ /** A non-fatal protocol problem, for the same reason. */
102
+ onProtocolError?: (code: string, message: string) => void;
103
+ /**
104
+ * The hook channel, present only when this server is the `hook_register` destination
105
+ * (bridge mode; every thread's own server in thread mode).
106
+ *
107
+ * ABSENT MEANS THE FRAMES ARE REFUSED, not ignored: a caller that sent `hook_register` to
108
+ * a run without the channel must be told (`hook_requires_bridge`), because silence reads
109
+ * as "registered" and the next symptom is a redactor that never fires.
110
+ */
111
+ hookPorts?: BridgeHookPorts;
112
+ }
113
+ export interface BridgeServer {
114
+ isOpen(): boolean;
115
+ /** Resolves with a discriminated outcome; NEVER rejects. */
116
+ requestDecision(ask: DecisionAsk, signal?: AbortSignal, validate?: (answer: DecisionAnswer) => boolean): Promise<DecisionOutcome>;
117
+ invokeCallerTool(call: CallerToolCall, signal?: AbortSignal): Promise<ToolResult>;
118
+ /** Route one control frame. Turn frames are the source's business. */
119
+ handleFrame(frame: BridgeInputFrame): void;
120
+ /** Soft-limit backpressure, awaited at turn boundaries only. */
121
+ drainIfNeeded(): Promise<void>;
122
+ /** Settle everything, flush, and refuse all later frames. */
123
+ close(reason: 'run_finished' | 'caller_eof' | 'hard_limit'): Promise<void>;
124
+ /** Settle every unanswered decision as `aborted`; used on interrupt and EOF. */
125
+ abortInFlight(): void;
126
+ /** Names currently registered, for `ack.tools` and `info`. */
127
+ callerToolNames(): string[];
128
+ /** Write one outbound frame through the sealed writer. */
129
+ write(event: Record<string, unknown>): void;
130
+ /**
131
+ * Called by the runner as a turn begins: records the turn number and flushes a
132
+ * deferred `tool_register`. THE ONLY PLACE THE REGISTRY IS WRITTEN, which is what
133
+ * makes I-8 a structural fact rather than a promise.
134
+ */
135
+ beginTurn(turn: number): void;
136
+ currentTurn(): number;
137
+ /** Installed by `runner.attachBridge`; before that the frames are refused. */
138
+ attachTurnPorts(ports: BridgeTurnPorts): void;
139
+ }
140
+ export declare function createBridgeServer(options: BridgeServerOptions): BridgeServer;
141
+ /** Re-exported so `decisions.ts` and the tests read one name. */
142
+ export type { AgentScope };
@@ -0,0 +1,502 @@
1
+ /**
2
+ * The bridge server: one multiplexer for both directions of the channel.
3
+ *
4
+ * ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
5
+ *
6
+ * WHAT IT OWNS
7
+ *
8
+ * - the pending table (ids, deadlines, settle-once, cancellation),
9
+ * - the outbound frames that are NOT exec events (`decision_request`,
10
+ * `tool_invoke`, `tool_cancel`, `ack`, `pong`),
11
+ * - the caller-tool registry (validation and the turn-boundary write),
12
+ * - the open/closed state every gate reads as `isOpen()`.
13
+ *
14
+ * WHAT IT DOES NOT OWN. It never prompts the model, never advances a turn and
15
+ * never writes `result`: the runner does the first two, `exec/index.ts` the third.
16
+ * The consequence worth stating is that `close()` can be called from any exit path
17
+ * without any of those owners being consulted.
18
+ *
19
+ * THE ONE ASYMMETRY THAT MATTERS. `requestDecision` resolves; it never rejects and
20
+ * never throws, even for a frame that arrived mangled. A gate is therefore allowed
21
+ * to `await` it without a `try`, and the fail-closed answer for anything untrusted
22
+ * is the same one for a timeout: "not allowed", with the reason written where the
23
+ * model cannot see it and the caller can.
24
+ */
25
+ import { errorResult } from '@aragon-agent/core';
26
+ import { HOST_TOOL_NAMES } from '../../tools/index.js';
27
+ import { TEAM_SUBAGENT_TOOL_NAMES } from '../../team/limits.js';
28
+ import { BRIDGE_LIMITS } from './limits.js';
29
+ import { createPendingTable } from './pending.js';
30
+ import { LEAD_SCOPE, } from './protocol.js';
31
+ import { UnsupportedContentError, callerToolResult, validateCallerTools, } from './caller-tools.js';
32
+ export function createBridgeServer(options) {
33
+ const now = options.now ?? Date.now;
34
+ const sessionId = options.sessionId;
35
+ const writer = options.writer;
36
+ const pending = createPendingTable({ now });
37
+ const reserved = options.reservedToolNames ??
38
+ new Set([...HOST_TOOL_NAMES, ...TEAM_SUBAGENT_TOOL_NAMES]);
39
+ let open = true;
40
+ let turn = 0;
41
+ let turnPorts = null;
42
+ /** A validated set waiting for the next turn boundary (I-8). */
43
+ let deferredSpecs = null;
44
+ const write = (event) => writer.write(event);
45
+ const protocolError = (code, message) => {
46
+ write({ type: 'error', sessionId, fatal: false, code, message });
47
+ options.onProtocolError?.(code, message);
48
+ };
49
+ const audit = (record) => {
50
+ options.onAudit?.(record);
51
+ };
52
+ const isTurnActive = () => options.ports.isTurnActive();
53
+ const register = (specs, id) => {
54
+ const names = specs.map((spec) => spec.name);
55
+ if (isTurnActive()) {
56
+ // DEFERRED, AND THE ACK SAYS SO. Writing the live registry mid-turn is the
57
+ // hazard `submit_plan` already taught this codebase once: the engine is
58
+ // iterating it. `appliedAtTurn` is the promise that it will be there for the
59
+ // next request.
60
+ deferredSpecs = specs;
61
+ write({
62
+ type: 'ack',
63
+ ...(id ? { id } : {}),
64
+ accepted: true,
65
+ scope: 'lead',
66
+ tools: names,
67
+ appliedAtTurn: turn + 1,
68
+ });
69
+ return;
70
+ }
71
+ options.ports.applyCallerTools(specs);
72
+ write({
73
+ type: 'ack',
74
+ ...(id ? { id } : {}),
75
+ // `turn + 1` IN BOTH BRANCHES, so the field means one thing: "the earliest
76
+ // turn whose LLM request can see these tools". Before the first turn that is
77
+ // turn 1 (not 0, which reads as "a turn that never happened"), between turns
78
+ // it is the next one, and mid-turn it is the one after the current.
79
+ accepted: true,
80
+ scope: 'lead',
81
+ tools: names,
82
+ appliedAtTurn: turn + 1,
83
+ });
84
+ };
85
+ const handleToolRegister = (frame) => {
86
+ if (options.ports.getAgentMode() === 'plan') {
87
+ // WHOLE-FRAME REFUSAL, not "register and refuse at call time": plan mode's
88
+ // promise is read-only, and a caller tool's side effects are unknown to this
89
+ // process. Registering one would open an arbitrary hole in that promise.
90
+ write({
91
+ type: 'ack',
92
+ ...(frame.id ? { id: frame.id } : {}),
93
+ accepted: false,
94
+ code: 'plan_mode_read_only',
95
+ message: 'Plan mode refuses caller tools; send this frame again in build mode.',
96
+ });
97
+ return;
98
+ }
99
+ const validated = validateCallerTools(frame.tools, reserved);
100
+ if (!validated.ok) {
101
+ write({
102
+ type: 'ack',
103
+ ...(frame.id ? { id: frame.id } : {}),
104
+ accepted: false,
105
+ code: validated.code,
106
+ message: validated.message,
107
+ });
108
+ return;
109
+ }
110
+ register(validated.specs, frame.id);
111
+ if (validated.warnings.length > 0) {
112
+ write({
113
+ type: 'ack',
114
+ ...(frame.id ? { id: frame.id } : {}),
115
+ accepted: true,
116
+ scope: 'lead',
117
+ tools: validated.specs.map((spec) => spec.name),
118
+ warnings: validated.warnings,
119
+ });
120
+ }
121
+ };
122
+ const handleDecisionFrame = (frame) => {
123
+ const kind = pending.kindOf(frame.respondsTo);
124
+ if (kind === undefined) {
125
+ reportMissing(frame.respondsTo);
126
+ return;
127
+ }
128
+ if (kind !== 'decision') {
129
+ // THE OTHER TABLE'S QUESTION. Answering it here would settle a `tool_invoke`
130
+ // with a decision payload, so the entry must survive (AC-55).
131
+ protocolError('bridge_kind_mismatch', `"${frame.respondsTo}" awaits a caller tool result, not a decision.`);
132
+ return;
133
+ }
134
+ reportAnswer(frame.respondsTo, 'decision', frame);
135
+ };
136
+ const handleToolResultFrame = (frame) => {
137
+ const kind = pending.kindOf(frame.respondsTo);
138
+ if (kind === undefined) {
139
+ reportMissing(frame.respondsTo);
140
+ return;
141
+ }
142
+ if (kind !== 'caller_tool') {
143
+ protocolError('bridge_kind_mismatch', `"${frame.respondsTo}" awaits a decision, not a caller tool result.`);
144
+ return;
145
+ }
146
+ reportAnswer(frame.respondsTo, 'caller_tool', {
147
+ content: frame.content,
148
+ isError: frame.isError === true,
149
+ });
150
+ };
151
+ /**
152
+ * An answer that matched no live entry is ONE OF TWO THINGS, and the codes
153
+ * differ (section 2.3.8 rows 4 and 5): answered twice, or answered too late /
154
+ * never asked. The pending table remembers only the first kind, which is why it
155
+ * has to be asked rather than assumed.
156
+ */
157
+ const reportMissing = (requestId) => {
158
+ if (pending.wasAnswered(requestId)) {
159
+ protocolError('bridge_duplicate_response', `"${requestId}" was already answered.`);
160
+ return;
161
+ }
162
+ protocolError('bridge_unknown_request', `"${requestId}" is not an outstanding request.`);
163
+ };
164
+ const reportAnswer = (requestId, kind, value) => {
165
+ // A live entry of the WRONG kind never reaches here: the two frame handlers
166
+ // reject it above and leave the entry pending (AC-55).
167
+ if (pending.answer(requestId, kind, value) === 'ok')
168
+ return;
169
+ reportMissing(requestId);
170
+ };
171
+ const decisionFrame = (requestId, ask, createdAt, deadlineAt) => {
172
+ return {
173
+ type: 'decision_request',
174
+ requestId,
175
+ sessionId,
176
+ kind: ask.kind,
177
+ createdAt,
178
+ deadlineAt,
179
+ timeoutMs: options.decisionTimeoutMs,
180
+ ...(ask.kind === 'tool' ? { tool: { ...ask.tool, turn: ask.tool.turn || turn } } : {}),
181
+ ...(ask.kind === 'questions' ? { questions: ask.questions } : {}),
182
+ ...(ask.kind === 'plan' ? { plan: ask.plan } : {}),
183
+ ...(ask.kind === 'skill_install' ? { install: ask.install } : {}),
184
+ agent: ask.agent ?? LEAD_SCOPE,
185
+ };
186
+ };
187
+ return {
188
+ isOpen: () => open,
189
+ write,
190
+ beginTurn(nextTurn) {
191
+ turn = nextTurn;
192
+ if (!deferredSpecs)
193
+ return;
194
+ const specs = deferredSpecs;
195
+ deferredSpecs = null;
196
+ options.ports.applyCallerTools(specs);
197
+ },
198
+ currentTurn: () => turn,
199
+ attachTurnPorts(ports) {
200
+ turnPorts = ports;
201
+ },
202
+ callerToolNames() {
203
+ // A DEFERRED SET IS THE HONEST ANSWER: it is what the model will be able to
204
+ // call from the next turn, and `ack.appliedAtTurn` already told the caller
205
+ // when. Reporting the older names would make the ack contradict itself.
206
+ const deferred = deferredSpecs;
207
+ return deferred ? deferred.map((spec) => spec.name) : options.ports.callerToolNames();
208
+ },
209
+ async requestDecision(ask, signal, validate) {
210
+ if (!open)
211
+ return { outcome: 'closed', reason: 'server_closed' };
212
+ const handle = pending.create({
213
+ kind: 'decision',
214
+ timeoutMs: options.decisionTimeoutMs,
215
+ ...(signal ? { signal } : {}),
216
+ meta: {
217
+ ...(ask.kind === 'tool' ? { tool: ask.tool.name, callId: ask.tool.callId } : {}),
218
+ ...(ask.agent ? { agent: ask.agent } : {}),
219
+ },
220
+ });
221
+ if (!handle) {
222
+ // IMMEDIATE REFUSAL, NOT A QUEUE. Queueing would make a three-way fan-out
223
+ // look frozen; refusing is explainable and the model can route around it.
224
+ protocolError('bridge_decision_capacity', `More than ${BRIDGE_LIMITS.maxOutstandingDecisions} decisions are awaiting an answer.`);
225
+ audit({ requestId: '-', kind: ask.kind, outcome: 'deny', reason: 'decision_capacity' });
226
+ return { outcome: 'answered', answer: { decision: 'deny', reason: 'decision_capacity' } };
227
+ }
228
+ write(decisionFrame(handle.requestId, ask, handle.createdAt, handle.deadlineAt));
229
+ const settled = await handle.settled;
230
+ const answer = settled.outcome === 'settled'
231
+ ? clipAnswerReason(settled.value)
232
+ : undefined;
233
+ const rejected = answer !== undefined && validate !== undefined && !validate(answer);
234
+ const outcome = resolvedOutcome(settled.outcome, answer, rejected);
235
+ write({
236
+ type: 'decision_resolved',
237
+ requestId: handle.requestId,
238
+ sessionId,
239
+ kind: ask.kind,
240
+ outcome,
241
+ ...(answer?.scope ? { scope: answer.scope } : {}),
242
+ ...(answer?.reason && outcome === 'deny' ? { reason: clipReason(answer.reason) } : {}),
243
+ });
244
+ if (rejected) {
245
+ protocolError('bridge_frame_invalid', `The answer to "${handle.requestId}" was not a valid ${ask.kind} decision.`);
246
+ }
247
+ audit({
248
+ requestId: handle.requestId,
249
+ kind: ask.kind,
250
+ outcome,
251
+ ...(ask.kind === 'tool' ? { tool: ask.tool.name } : {}),
252
+ ...(answer?.reason ? { reason: clipReason(answer.reason) } : {}),
253
+ });
254
+ if (settled.outcome === 'settled') {
255
+ return { outcome: 'answered', answer: answer ?? {} };
256
+ }
257
+ if (settled.outcome === 'timeout') {
258
+ return { outcome: 'timeout', deadlineAt: settled.deadlineAt };
259
+ }
260
+ if (settled.outcome === 'aborted')
261
+ return { outcome: 'aborted' };
262
+ return { outcome: 'closed', reason: settled.reason };
263
+ },
264
+ async invokeCallerTool(call, signal) {
265
+ if (!open)
266
+ return errorResult('The caller channel is closed; this tool was not run.');
267
+ const handle = pending.create({
268
+ kind: 'caller_tool',
269
+ timeoutMs: call.timeoutMs,
270
+ ...(signal ? { signal } : {}),
271
+ meta: { tool: call.tool, callId: call.callId, agent: call.agent },
272
+ });
273
+ if (!handle) {
274
+ protocolError('bridge_caller_capacity', `More than ${BRIDGE_LIMITS.maxOutstandingCallerTools} caller tool calls are in flight.`);
275
+ return errorResult(`Caller tool "${call.tool}" was not run: too many caller tool calls are in flight.`);
276
+ }
277
+ write({
278
+ type: 'tool_invoke',
279
+ requestId: handle.requestId,
280
+ sessionId,
281
+ callId: call.callId,
282
+ turn: call.turn,
283
+ tool: call.tool,
284
+ input: truncateInput(call.input),
285
+ timeoutMs: call.timeoutMs,
286
+ deadlineAt: handle.deadlineAt,
287
+ agent: call.agent,
288
+ });
289
+ const settled = await handle.settled;
290
+ if (settled.outcome === 'settled') {
291
+ const built = buildCallerToolResult(call, settled.value, protocolError);
292
+ return built;
293
+ }
294
+ if (settled.outcome === 'timeout') {
295
+ write({
296
+ type: 'tool_cancel',
297
+ requestId: handle.requestId,
298
+ sessionId,
299
+ reason: 'timeout',
300
+ });
301
+ protocolError('caller_tool_timeout', `Caller tool "${call.tool}" did not answer within ${call.timeoutMs} ms.`);
302
+ return errorResult(`Caller tool "${call.tool}" did not answer within ${call.timeoutMs} ms.`);
303
+ }
304
+ if (settled.outcome === 'aborted') {
305
+ // MANDATORY, NOT DEFENSIVE: an unsettled caller tool is a `tool_use` with
306
+ // no `tool_result`, and the next provider request is an HTTP 400.
307
+ write({
308
+ type: 'tool_cancel',
309
+ requestId: handle.requestId,
310
+ sessionId,
311
+ reason: 'turn_aborted',
312
+ });
313
+ return errorResult(`Caller tool "${call.tool}" was cancelled: the turn was interrupted.`);
314
+ }
315
+ return errorResult(`Caller tool "${call.tool}" was cancelled: the caller channel closed.`);
316
+ },
317
+ handleFrame(frame) {
318
+ if (!open)
319
+ return;
320
+ switch (frame.type) {
321
+ case 'decision':
322
+ handleDecisionFrame(frame);
323
+ return;
324
+ case 'tool_result':
325
+ handleToolResultFrame(frame);
326
+ return;
327
+ case 'tool_register':
328
+ handleToolRegister(frame);
329
+ return;
330
+ case 'ping':
331
+ write({ type: 'pong', ...(frame.id ? { id: frame.id } : {}), now: now() });
332
+ return;
333
+ case 'steer':
334
+ if (turnPorts)
335
+ turnPorts.steer(frame.text, frame.id);
336
+ else {
337
+ write({
338
+ type: 'ack',
339
+ ...(frame.id ? { id: frame.id } : {}),
340
+ accepted: false,
341
+ code: 'not_running',
342
+ });
343
+ }
344
+ return;
345
+ case 'interrupt':
346
+ if (turnPorts)
347
+ turnPorts.interrupt(frame.requestId, frame.id);
348
+ return;
349
+ case 'cancel_request':
350
+ // NO FALLBACK `ack` WHEN THE PORTS ARE MISSING, unlike `steer`: a frame that
351
+ // arrives before `runner.attachBridge` names a request that cannot be in
352
+ // flight yet, and inventing a refusal for it would be a lie about a state
353
+ // that does not exist. The requester re-sends or times out - the same
354
+ // contract `interrupt` already has.
355
+ if (turnPorts)
356
+ turnPorts.cancelRequest(frame.requestId, frame.id);
357
+ return;
358
+ // THE THREE THREAD FRAMES, REFUSED WHERE THEY CANNOT BE ROUTED (AC-2).
359
+ //
360
+ // In thread mode they never reach this switch: `dispatch-source.ts` routes
361
+ // `thread_open`/`thread_close`/`thread_list` to the registry, which owns the state
362
+ // machine. So a thread frame arriving HERE means this run did not open `--threads`,
363
+ // and the answer must be a receipt rather than the `default` branch's silence - a
364
+ // wrapper that believed it had opened a thread would send prompts into `main` and
365
+ // receive answers to a conversation it never asked for.
366
+ case 'thread_open':
367
+ case 'thread_close':
368
+ case 'thread_list':
369
+ write({
370
+ type: 'ack',
371
+ ...(frame.id ? { id: frame.id } : {}),
372
+ accepted: false,
373
+ code: 'threads_disabled',
374
+ message: 'Threads are not enabled; start the run with --threads.',
375
+ });
376
+ return;
377
+ case 'hook_register':
378
+ if (!options.hookPorts) {
379
+ write({
380
+ type: 'ack',
381
+ ...(frame.id ? { id: frame.id } : {}),
382
+ accepted: false,
383
+ code: 'hook_requires_bridge',
384
+ message: 'Lifecycle hooks need --input-format bridge; this run has no hook channel.',
385
+ });
386
+ return;
387
+ }
388
+ options.hookPorts.register(frame);
389
+ return;
390
+ case 'hook_answer':
391
+ if (!options.hookPorts) {
392
+ write({
393
+ type: 'ack',
394
+ ...(frame.id ? { id: frame.id } : {}),
395
+ accepted: false,
396
+ code: 'hook_requires_bridge',
397
+ message: 'Lifecycle hooks need --input-format bridge; this run has no hook channel.',
398
+ });
399
+ return;
400
+ }
401
+ // THE ANSWER IS NOT ACKED ON SUCCESS. `hook_answer` is an answer to an answer:
402
+ // the fact the caller needs is `hook_resolved`, which the dispatcher writes for
403
+ // the exact id. A second ack would be a third frame saying the same thing.
404
+ if (!options.hookPorts.answer(frame)) {
405
+ write({
406
+ type: 'ack',
407
+ ...(frame.id ? { id: frame.id } : {}),
408
+ accepted: false,
409
+ code: 'hook_not_registered',
410
+ message: `"${frame.respondsTo}" is not an outstanding hook request.`,
411
+ });
412
+ }
413
+ return;
414
+ default:
415
+ // `prompt` / `user` / `end` belong to the source; an unknown type is
416
+ // ignored by the parser long before it reaches this switch.
417
+ return;
418
+ }
419
+ },
420
+ async drainIfNeeded() {
421
+ await writer.drainIfNeeded();
422
+ },
423
+ abortInFlight() {
424
+ // NO `tool_cancel` IS WRITTEN HERE, DELIBERATELY. Every in-flight caller
425
+ // tool is awaiting its own `handle.settled`, and that continuation already
426
+ // writes exactly one `tool_cancel{turn_aborted}` when it observes `aborted`
427
+ // (see `invokeCallerTool`). Emitting one here as well would put the same
428
+ // frame on the wire twice for one requestId, and a caller that tracks
429
+ // outstanding calls by id would count it twice.
430
+ pending.abortAll();
431
+ },
432
+ async close(reason) {
433
+ if (!open)
434
+ return;
435
+ open = false;
436
+ pending.closeAll(reason);
437
+ await writer.flush();
438
+ },
439
+ };
440
+ }
441
+ /** `allow` / `deny` / `error` for a settled decision; the frame's own outcome otherwise. */
442
+ function resolvedOutcome(settled, answer, rejected) {
443
+ if (settled === 'timeout')
444
+ return 'timeout';
445
+ if (settled === 'aborted')
446
+ return 'aborted';
447
+ if (settled === 'closed')
448
+ return 'aborted';
449
+ if (rejected)
450
+ return 'error';
451
+ return answer?.decision === 'deny' ? 'deny' : 'allow';
452
+ }
453
+ /**
454
+ * Bound the one caller-supplied string that becomes MODEL-VISIBLE.
455
+ *
456
+ * `denialMessage` puts the reason into a `tool_result`, so `maxDenyReasonChars`
457
+ * has to be applied HERE - where the answer enters the CLI - rather than only on
458
+ * the echoed frame and the audit line. Otherwise a megabyte of "reason" would
459
+ * ride into every later provider request, which is exactly the unbounded
460
+ * prompt-injection lane the limit exists to close.
461
+ */
462
+ function clipAnswerReason(answer) {
463
+ const reason = answer.reason;
464
+ if (typeof reason !== 'string')
465
+ return answer;
466
+ const clipped = clipReason(reason);
467
+ return clipped === reason ? answer : { ...answer, reason: clipped };
468
+ }
469
+ function clipReason(reason) {
470
+ return reason.length <= BRIDGE_LIMITS.maxDenyReasonChars
471
+ ? reason
472
+ : reason.slice(0, BRIDGE_LIMITS.maxDenyReasonChars);
473
+ }
474
+ /**
475
+ * Bound the echoed `input`. The caller needs it to act, so it travels - but a
476
+ * megabyte of SQL does not need to travel four times (invoke, event, result, log).
477
+ */
478
+ function truncateInput(input) {
479
+ const serialized = JSON.stringify(input ?? {});
480
+ if (serialized.length <= BRIDGE_LIMITS.maxInvokeInputChars)
481
+ return input ?? {};
482
+ return { truncated: true, preview: serialized.slice(0, BRIDGE_LIMITS.maxInvokeInputChars) };
483
+ }
484
+ /**
485
+ * The `tool_result` payload as an `AgentTool` result.
486
+ *
487
+ * TWO FAILURE SHAPES, BOTH NAMED: content this build cannot accept (an `image`
488
+ * part, refused rather than silently dropped) and the caller's own `isError`. A
489
+ * third possibility - an exception escaping - would be converted by
490
+ * `ToolExecutor` into wording that says nothing about which of the two happened.
491
+ */
492
+ function buildCallerToolResult(call, value, protocolError) {
493
+ try {
494
+ return callerToolResult(value.content, value.isError === true);
495
+ }
496
+ catch (e) {
497
+ if (!(e instanceof UnsupportedContentError))
498
+ throw e;
499
+ protocolError('bridge_unsupported_content', `Caller tool "${call.tool}": ${e.message}`);
500
+ return errorResult(`Caller tool "${call.tool}" returned content this build cannot accept: ${e.message}`);
501
+ }
502
+ }