super-dsh 0.1.9 → 0.1.10

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 (166) hide show
  1. package/README.md +25 -3
  2. package/agent-agy/cordis.patch.yml +7 -4
  3. package/agent-agy/dist/agent.d.ts +12 -11
  4. package/agent-agy/dist/agy-auth-gateway.d.ts +124 -0
  5. package/agent-agy/dist/agy-auth-gateway.js +233 -0
  6. package/agent-agy/dist/agy-auth.d.ts +153 -0
  7. package/agent-agy/dist/agy-auth.js +179 -0
  8. package/agent-agy/dist/agy-cli-client.d.ts +39 -9
  9. package/agent-agy/dist/agy-cli-client.js +82 -18
  10. package/agent-agy/dist/agy-store.d.ts +8 -0
  11. package/agent-agy/dist/index.d.ts +46 -16
  12. package/agent-agy/dist/index.js +82 -56
  13. package/agent-agy/dist/models.d.ts +70 -0
  14. package/agent-agy/dist/models.js +102 -3
  15. package/agent-agy/package.json +7 -5
  16. package/agent-agy-sdk/cordis.patch.yml +58 -0
  17. package/agent-agy-sdk/dist/adapter.d.ts +15 -0
  18. package/agent-agy-sdk/dist/adapter.js +42 -0
  19. package/agent-agy-sdk/dist/agent-preset-agy.d.ts +27 -0
  20. package/agent-agy-sdk/dist/agent-preset-agy.js +32 -0
  21. package/agent-agy-sdk/dist/agent.d.ts +138 -0
  22. package/agent-agy-sdk/dist/agent.js +494 -0
  23. package/{agent-agy → agent-agy-sdk}/dist/agy-client.d.ts +26 -6
  24. package/{agent-agy → agent-agy-sdk}/dist/agy-client.js +43 -25
  25. package/agent-agy-sdk/dist/agy-sessions.d.ts +25 -0
  26. package/agent-agy-sdk/dist/agy-sessions.js +107 -0
  27. package/agent-agy-sdk/dist/agy-store.d.ts +39 -0
  28. package/agent-agy-sdk/dist/agy-store.js +83 -0
  29. package/agent-agy-sdk/dist/inbox.d.ts +26 -0
  30. package/agent-agy-sdk/dist/inbox.js +53 -0
  31. package/agent-agy-sdk/dist/index.d.ts +93 -0
  32. package/agent-agy-sdk/dist/index.js +476 -0
  33. package/agent-agy-sdk/dist/models.d.ts +41 -0
  34. package/agent-agy-sdk/dist/models.js +74 -0
  35. package/agent-agy-sdk/dist/sdk-venv.d.ts +76 -0
  36. package/agent-agy-sdk/dist/sdk-venv.js +173 -0
  37. package/agent-agy-sdk/dist/world-plugin.d.ts +10 -0
  38. package/agent-agy-sdk/dist/world-plugin.js +82 -0
  39. package/agent-agy-sdk/package.json +64 -0
  40. package/agent-codex/README.md +141 -36
  41. package/agent-codex/cordis.patch.yml +14 -10
  42. package/agent-codex/dist/adapter.js +64 -28
  43. package/agent-codex/dist/agent.js +255 -81
  44. package/agent-codex/dist/app-server-approvals.js +165 -0
  45. package/agent-codex/dist/app-server-client.js +723 -0
  46. package/agent-codex/dist/app-server-events.js +432 -0
  47. package/agent-codex/dist/app-server-protocol.js +95 -0
  48. package/agent-codex/dist/app-server-wire.js +299 -0
  49. package/agent-codex/dist/codex-launch.js +78 -0
  50. package/agent-codex/dist/codex-native-config.js +392 -0
  51. package/agent-codex/dist/codex-provider-gateway.js +402 -0
  52. package/agent-codex/dist/codex-providers.js +376 -0
  53. package/agent-codex/dist/codex-route.js +86 -0
  54. package/agent-codex/dist/index.js +256 -115
  55. package/agent-codex/dist/permission.js +75 -1
  56. package/agent-codex/package.json +1 -2
  57. package/agent-codex-sdk/README.md +79 -0
  58. package/agent-codex-sdk/cordis.patch.yml +81 -0
  59. package/agent-codex-sdk/dist/adapter.js +91 -0
  60. package/agent-codex-sdk/dist/agent-preset-codex.js +32 -0
  61. package/agent-codex-sdk/dist/agent.js +1085 -0
  62. package/agent-codex-sdk/dist/codex-native-config.js +392 -0
  63. package/agent-codex-sdk/dist/codex-provider-gateway.js +402 -0
  64. package/agent-codex-sdk/dist/codex-providers.js +376 -0
  65. package/agent-codex-sdk/dist/codex-route.js +86 -0
  66. package/{agent-codex/dist/codex-client.js → agent-codex-sdk/dist/codex-sdk-client.js} +253 -32
  67. package/agent-codex-sdk/dist/codex-sdk-launch.js +92 -0
  68. package/agent-codex-sdk/dist/codex-store.js +180 -0
  69. package/agent-codex-sdk/dist/inbox.js +53 -0
  70. package/agent-codex-sdk/dist/index.js +739 -0
  71. package/agent-codex-sdk/dist/permission.js +71 -0
  72. package/agent-codex-sdk/dist/sdk-runtime.js +307 -0
  73. package/agent-codex-sdk/dist/session-map.js +167 -0
  74. package/agent-codex-sdk/dist/world-plugin.js +93 -0
  75. package/agent-codex-sdk/package.json +79 -0
  76. package/agent-codex-sdk/scripts/fetch-sdk-runtime.mjs +57 -0
  77. package/agent-omp/README.md +72 -55
  78. package/agent-omp/dist/agent.js +36 -11
  79. package/agent-omp/dist/index.js +92 -52
  80. package/agent-omp/dist/lazy-rpc.js +11 -4
  81. package/agent-omp/dist/omp-cli.js +114 -97
  82. package/agent-omp/dist/omp-store.js +12 -4
  83. package/agent-omp/dist/permission.js +70 -5
  84. package/agent-omp/dist/rpc-client.js +409 -0
  85. package/agent-omp/dist/world-plugin.js +36 -3
  86. package/agent-omp/package.json +2 -6
  87. package/agent-omp-sdk/README.md +103 -0
  88. package/agent-omp-sdk/cordis.patch.yml +108 -0
  89. package/agent-omp-sdk/dist/adapter.d.ts +21 -0
  90. package/agent-omp-sdk/dist/adapter.js +36 -0
  91. package/agent-omp-sdk/dist/agent-preset-omp.d.ts +31 -0
  92. package/agent-omp-sdk/dist/agent-preset-omp.js +32 -0
  93. package/agent-omp-sdk/dist/agent.d.ts +64 -0
  94. package/agent-omp-sdk/dist/agent.js +1178 -0
  95. package/agent-omp-sdk/dist/discovery.d.ts +57 -0
  96. package/agent-omp-sdk/dist/discovery.js +116 -0
  97. package/agent-omp-sdk/dist/inbox.d.ts +28 -0
  98. package/agent-omp-sdk/dist/inbox.js +53 -0
  99. package/agent-omp-sdk/dist/index.d.ts +60 -0
  100. package/agent-omp-sdk/dist/index.js +632 -0
  101. package/agent-omp-sdk/dist/knobs.d.ts +34 -0
  102. package/agent-omp-sdk/dist/knobs.js +50 -0
  103. package/agent-omp-sdk/dist/lazy-rpc.d.ts +98 -0
  104. package/agent-omp-sdk/dist/lazy-rpc.js +178 -0
  105. package/agent-omp-sdk/dist/mobile/zoom-guard.d.ts +187 -0
  106. package/agent-omp-sdk/dist/mobile/zoom-guard.js +294 -0
  107. package/agent-omp-sdk/dist/mobile-boot.d.ts +49 -0
  108. package/agent-omp-sdk/dist/mobile-boot.js +38 -0
  109. package/agent-omp-sdk/dist/models.d.ts +39 -0
  110. package/agent-omp-sdk/dist/models.js +299 -0
  111. package/agent-omp-sdk/dist/omp-cli.d.ts +67 -0
  112. package/agent-omp-sdk/dist/omp-cli.js +164 -0
  113. package/agent-omp-sdk/dist/omp-disk-discovery.d.ts +27 -0
  114. package/agent-omp-sdk/dist/omp-disk-discovery.js +178 -0
  115. package/agent-omp-sdk/dist/omp-executable.d.ts +2 -0
  116. package/agent-omp-sdk/dist/omp-executable.js +30 -0
  117. package/agent-omp-sdk/dist/omp-store.d.ts +134 -0
  118. package/agent-omp-sdk/dist/omp-store.js +526 -0
  119. package/agent-omp-sdk/dist/pairing.d.ts +7 -0
  120. package/agent-omp-sdk/dist/pairing.js +45 -0
  121. package/agent-omp-sdk/dist/permission.d.ts +138 -0
  122. package/agent-omp-sdk/dist/permission.js +161 -0
  123. package/agent-omp-sdk/dist/protocol.d.ts +189 -0
  124. package/agent-omp-sdk/dist/rpc-types.d.ts +77 -0
  125. package/agent-omp-sdk/dist/rpc-types.js +1 -0
  126. package/agent-omp-sdk/dist/sdk-client.d.ts +160 -0
  127. package/{agent-omp → agent-omp-sdk}/dist/sdk-client.js +108 -56
  128. package/agent-omp-sdk/dist/sdk-runtime.d.ts +211 -0
  129. package/agent-omp-sdk/dist/sdk-runtime.js +418 -0
  130. package/agent-omp-sdk/dist/sidecar-client.d.ts +87 -0
  131. package/agent-omp-sdk/dist/sidecar-client.js +197 -0
  132. package/agent-omp-sdk/dist/store/adapter.d.ts +17 -0
  133. package/agent-omp-sdk/dist/store/adapter.js +47 -0
  134. package/agent-omp-sdk/dist/store/db.d.ts +70 -0
  135. package/agent-omp-sdk/dist/store/db.js +210 -0
  136. package/agent-omp-sdk/dist/store/index.d.ts +23 -0
  137. package/agent-omp-sdk/dist/store/index.js +59 -0
  138. package/agent-omp-sdk/dist/store/reconcile.d.ts +15 -0
  139. package/agent-omp-sdk/dist/store/reconcile.js +51 -0
  140. package/agent-omp-sdk/dist/store/schema.d.ts +8 -0
  141. package/agent-omp-sdk/dist/store/schema.js +40 -0
  142. package/agent-omp-sdk/dist/transport.d.ts +49 -0
  143. package/agent-omp-sdk/dist/transport.js +35 -0
  144. package/agent-omp-sdk/dist/world-home.d.ts +4 -0
  145. package/agent-omp-sdk/dist/world-home.js +23 -0
  146. package/agent-omp-sdk/dist/world-plugin.d.ts +10 -0
  147. package/agent-omp-sdk/dist/world-plugin.js +137 -0
  148. package/agent-omp-sdk/package.json +87 -0
  149. package/client/index.js +1115 -93
  150. package/cordis.patch.yml +64 -0
  151. package/package.json +17 -7
  152. package/superd-hub/dist/client/agent-settings.d.ts +156 -0
  153. package/superd-hub/dist/client/agent-settings.js +229 -0
  154. package/superd-hub/dist/client/roster.js +6 -0
  155. package/superd-hub/dist/client/runtime-settings-api.d.ts +76 -0
  156. package/superd-hub/dist/client/runtime-settings-api.js +137 -0
  157. package/superd-hub/dist/client/switch.d.ts +165 -0
  158. package/superd-hub/dist/client/switch.js +295 -0
  159. package/superd-hub/lib/client/index.js +1115 -93
  160. package/agent-codex/dist/models.js +0 -152
  161. package/agent-omp/dist/sidecar-client.js +0 -195
  162. /package/{agent-agy → agent-agy-sdk}/bridge/agy_bridge.py +0 -0
  163. /package/{agent-agy → agent-agy-sdk}/bridge/gemini_openai_server.mjs +0 -0
  164. /package/{agent-codex/dist/codex-events.js → agent-codex-sdk/dist/codex-sdk-events.js} +0 -0
  165. /package/{agent-omp → agent-omp-sdk}/dist/protocol.js +0 -0
  166. /package/{agent-omp → agent-omp-sdk}/sidecar/main.ts +0 -0
@@ -0,0 +1,1178 @@
1
+ import { agentEvents } from "@deepseek-ai/dsh-agent";
2
+ import { Inbox } from "./inbox.js";
3
+ import { LlmAttemptId, ToolCallId, QUOTA_EXCEEDED_CODE, createAssistantMessage, createSystemMessage, createToolResultMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
4
+ import { createScope } from "@deepseek-ai/dsh-scope";
5
+ import { ompPermissionCommand } from "./permission.js";
6
+ import { getBridgeStore } from "./store/index.js";
7
+ import { withSystemHead } from "../../superd-hub/dist/index.js";
8
+ /**
9
+ * v2 live assistant-stream publication for the OMP bridge — the same frame
10
+ * protocol the reference loop's AssistantStreamAttempt speaks (start marker,
11
+ * dense zero-based chunks, terminal settlement) emitted over this agent's own
12
+ * dispatch, so the session-controller folds OMP's stream into its reconnect
13
+ * baseline. Chunks also accumulate as raw durable records embedded in the
14
+ * final assistant/message.
15
+ */
16
+ class AssistantStreamBridge {
17
+ attemptId;
18
+ records = [];
19
+ #nextRevision;
20
+ #emit;
21
+ #index = 0;
22
+ #terminal = false;
23
+ constructor(sessionId, attempt, nextRevision, emit) {
24
+ this.attemptId = LlmAttemptId(`${sessionId}:${attempt}`);
25
+ this.#nextRevision = nextRevision;
26
+ this.#emit = emit;
27
+ }
28
+ /** Whether the terminal frame already fired. */
29
+ get ended() {
30
+ return this.#terminal;
31
+ }
32
+ /** Opening marker before the first delivered chunk. */
33
+ start(turn, step) {
34
+ this.#emit({ type: "start", attemptId: this.attemptId, revision: this.#nextRevision(), turn, step });
35
+ }
36
+ /** One live chunk: durable record plus dense process-local frame. */
37
+ push(chunk) {
38
+ const time = Date.now();
39
+ this.records.push({ type: "chunk", time, chunk });
40
+ this.#emit({ type: "chunk", attemptId: this.attemptId, revision: this.#nextRevision(), index: this.#index++, time, chunk });
41
+ }
42
+ /** Terminal settlement after the durable assistant/message committed. */
43
+ settle(seq) {
44
+ this.#terminal = true;
45
+ this.#emit({ type: "end", attemptId: this.attemptId, revision: this.#nextRevision(), index: this.#index, outcome: { kind: "committed", eventType: "assistant/message", seq } });
46
+ }
47
+ /** No durable attempt event will commit. */
48
+ abandon() {
49
+ this.#terminal = true;
50
+ this.#emit({ type: "end", attemptId: this.attemptId, revision: this.#nextRevision(), index: this.#index, outcome: { kind: "abandoned" } });
51
+ }
52
+ }
53
+ /** Diagnostic trace (set OMP_TRACE=1 on the dsh process to enable). */
54
+ const TRACE = process.env.OMP_TRACE === "1";
55
+ const trace = (...parts) => {
56
+ if (TRACE)
57
+ process.stderr.write(`[omp-agent ${Date.now() % 1_000_000}] ${parts.join(" ")}\n`);
58
+ };
59
+ export function convertContent(blocks) {
60
+ if (blocks === undefined)
61
+ return [];
62
+ const result = [];
63
+ for (const block of blocks) {
64
+ switch (block.type) {
65
+ case "text":
66
+ if (typeof block.text === "string")
67
+ result.push({ type: "text", text: block.text });
68
+ break;
69
+ case "thinking":
70
+ if (typeof block.thinking === "string")
71
+ result.push({ type: "reasoning", text: block.thinking });
72
+ break;
73
+ case "toolCall": {
74
+ const id = typeof block.id === "string" ? block.id : "";
75
+ const name = typeof block.name === "string" ? block.name : "";
76
+ const args = typeof block.arguments === "string" ? block.arguments : JSON.stringify(block.arguments ?? {});
77
+ result.push({ type: "tool-call", id: ToolCallId(id), name, arguments: args });
78
+ break;
79
+ }
80
+ default:
81
+ // images / unknown blocks are not bridged in Phase 2.
82
+ break;
83
+ }
84
+ }
85
+ return result;
86
+ }
87
+ /**
88
+ * Classify OMP's `stopReason: "error"` assistant message into a Dash `failure`
89
+ * (`{ message, code }`). This is the same taxonomy the harness adapters use for
90
+ * `turn/end` reasons — `AUTH`, `RATE_LIMIT`, `QUOTA`, `SERVER` — so the Web UI
91
+ * renders it through its own error path (the red "API key is invalid" alert,
92
+ * turn-positioned failure feedback) instead of an empty assistant bubble.
93
+ * Quota/usage wording is checked before the generic 403→AUTH because OMP's
94
+ * providers (e.g. kimi) report an exhausted billing quota as HTTP 403.
95
+ */
96
+ export function ompFailure(message) {
97
+ if (message.stopReason !== "error")
98
+ return undefined;
99
+ const status = typeof message.errorStatus === "number" ? message.errorStatus : undefined;
100
+ const text = typeof message.errorMessage === "string" && message.errorMessage.trim() !== ""
101
+ ? message.errorMessage.trim()
102
+ : undefined;
103
+ const detail = text ?? "";
104
+ let code;
105
+ if (/\b(?:usage|rate)\s*limit\b/i.test(detail) || /\bquota\b/i.test(detail) || /\bbilling\b/i.test(detail) || /\binsufficient\b/i.test(detail)) {
106
+ code = QUOTA_EXCEEDED_CODE;
107
+ }
108
+ else if (status === 401 || status === 403) {
109
+ code = "AUTH";
110
+ }
111
+ else if (status === 429) {
112
+ code = "RATE_LIMIT";
113
+ }
114
+ else if (status !== undefined && status >= 500) {
115
+ code = "SERVER";
116
+ }
117
+ else {
118
+ code = "UNKNOWN";
119
+ }
120
+ return { message: text ?? (status !== undefined ? `HTTP ${status}` : "model request failed"), code };
121
+ }
122
+ /** Map OMP's usage accounting into Dash `TokenUsage` (disjoint field names). */
123
+ export function convertUsage(usage) {
124
+ if (usage === null || typeof usage !== "object")
125
+ return undefined;
126
+ const u = usage;
127
+ const inputTokens = typeof u.input === "number" ? u.input : 0;
128
+ const outputTokens = typeof u.output === "number" ? u.output : 0;
129
+ if (inputTokens === 0 && outputTokens === 0)
130
+ return undefined;
131
+ const result = { inputTokens, outputTokens };
132
+ if (typeof u.cacheRead === "number")
133
+ result.cacheReadTokens = u.cacheRead;
134
+ if (typeof u.cacheWrite === "number")
135
+ result.cacheWriteTokens = u.cacheWrite;
136
+ if (typeof u.reasoningTokens === "number")
137
+ result.reasoningTokens = u.reasoningTokens;
138
+ return result;
139
+ }
140
+ /** Join the visible text blocks of a Dash user message into the OMP prompt string. */
141
+ function userMessageText(message) {
142
+ const parts = [];
143
+ for (const block of message.content) {
144
+ if (block.type === "text")
145
+ parts.push(block.text);
146
+ }
147
+ return parts.join("\n");
148
+ }
149
+ /**
150
+ * Dash-host plugin narration from the approval service: the policy-change
151
+ * notice `agent.inject` carries when `/permission` switches a live session's
152
+ * approval policy. OMP has no runtime approval control (its `--approval-mode`
153
+ * is pinned at launch, with no RPC to change it mid-session), so forwarding
154
+ * the text would steer it into an in-flight turn (interrupting tool work) and
155
+ * echo it into history as a fake user prompt. Detect and drop it.
156
+ */
157
+ function isApprovalNarration(message) {
158
+ // 0.1.7: the shared `plugin` source kind is gone (MessageSourceMap is
159
+ // merge-extensible; each producer declares its own kind) — user-approval
160
+ // now carries the dedicated `user-approval` kind.
161
+ return message.source.kind === "user-approval";
162
+ }
163
+ /**
164
+ * Reconstruct a Dash user message from an OMP `message_start(role=user)`
165
+ * payload. Used only as a defensive fallback when a queued delivery's retained
166
+ * message is missing (OMP echoes text, so the visible content is preserved).
167
+ */
168
+ function userMessageFromOmp(message) {
169
+ return createUserMessage({
170
+ content: convertContent(message.content).filter((block) => block.type === "text"),
171
+ source: { kind: "user" },
172
+ });
173
+ }
174
+ /** Extract every tool call an OMP assistant message names, in order, for approval correlation. */
175
+ function toolCalls(blocks) {
176
+ if (blocks === undefined)
177
+ return [];
178
+ const result = [];
179
+ for (const block of blocks) {
180
+ if (block.type === "toolCall" && typeof block.id === "string") {
181
+ result.push({ callId: block.id, name: typeof block.name === "string" ? block.name : "" });
182
+ }
183
+ }
184
+ return result;
185
+ }
186
+ /** Parse the tool name out of an OMP approval-card title (`Allow tool: <name>…`). */
187
+ function approvalToolName(title) {
188
+ const match = /Allow tool:\s*(\S+)/.exec(title);
189
+ return match?.[1] ?? "tool";
190
+ }
191
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
192
+ /**
193
+ * Approval answer timeout: fail closed (Deny) after this long so a headless or
194
+ * server run can't hang forever waiting on a card nobody answers. `0` disables
195
+ * the timeout. Default: 5 minutes.
196
+ */
197
+ const OMP_IDLE_EXIT_MS = parseIdleExit(process.env.OMP_IDLE_EXIT_MS);
198
+ /**
199
+ * How long an agent may sit idle before its SDK sidecar session is torn down.
200
+ * The Dash host keeps resumed agents registered forever (nothing else disposes
201
+ * them), and every idle agent pins a live sidecar session. Tearing the agent
202
+ * down at idle costs nothing observable — the next prompt re-attaches it from
203
+ * the dsh session log (of record) plus the OMP app-home session file.
204
+ * `0` disables the exit.
205
+ */
206
+ function parseIdleExit(raw) {
207
+ if (raw === undefined || raw.trim() === "")
208
+ return 600_000;
209
+ const value = Number(raw);
210
+ return Number.isFinite(value) && value >= 0 ? value : 600_000;
211
+ }
212
+ const OMP_APPROVAL_TIMEOUT_MS = parseApprovalTimeout(process.env.OMP_APPROVAL_TIMEOUT_MS);
213
+ function parseApprovalTimeout(raw) {
214
+ if (raw === undefined || raw.trim() === "")
215
+ return 300_000;
216
+ const value = Number(raw);
217
+ return Number.isFinite(value) && value >= 0 ? value : 300_000;
218
+ }
219
+ /**
220
+ * Race an approval request against the configured timeout. On timeout the
221
+ * signal aborts (withdrawing the question) and we fail closed as `"cancelled"`.
222
+ */
223
+ async function raceApproval(request, controller, timeoutMs) {
224
+ if (timeoutMs <= 0)
225
+ return request;
226
+ let timer;
227
+ try {
228
+ return await Promise.race([
229
+ request,
230
+ new Promise((resolve) => {
231
+ timer = setTimeout(() => {
232
+ controller.abort();
233
+ resolve("cancelled");
234
+ }, timeoutMs);
235
+ }),
236
+ ]);
237
+ }
238
+ finally {
239
+ clearTimeout(timer);
240
+ }
241
+ }
242
+ export class OmpAgent {
243
+ id;
244
+ options;
245
+ session;
246
+ inbox;
247
+ ctx;
248
+ #rpc;
249
+ #loopCtx;
250
+ #scope;
251
+ #dispatch;
252
+ #streaming = false;
253
+ #activityDone = Promise.resolve();
254
+ #resolveActivityDone = () => { };
255
+ #dashTurn = 0;
256
+ #step = 0;
257
+ #lastTurn = 0;
258
+ #turnOpen = false;
259
+ #cancelCause = null;
260
+ /** Terminal model failure of the open turn (`message_end` stopReason "error"); consumed by `agent_end`. */
261
+ #pendingFailure = null;
262
+ /** Live v2 assistant-stream publication for the in-flight OMP message. */
263
+ #streamBridge;
264
+ #assistantAttemptCounter = 0;
265
+ #assistantStreamRevision = 0;
266
+ /** The current turn's user/message was appended locally and OMP's echo is still awaited. */
267
+ #localUserPending = false;
268
+ /** A `turn_start` arrived; `step/start` is deferred until the step's owning turn is known. */
269
+ #stepStartPending = false;
270
+ /**
271
+ * Deliveries forwarded through OMP's own queue, in submission order. OMP
272
+ * echoes each as `message_start(role=user)`; the bridge maps it by transport:
273
+ * a `followUp` echo closes the current Dash turn and opens a fresh one
274
+ * (OMP queues it and auto-generates the next turn — connection layer
275
+ * hardcodes `resumeIfIdle: true`), a `steer` echo appends a `user/message`
276
+ * step to the current turn. `sent` marks entries already dispatched to OMP
277
+ * (a parked injection flushes at agent_start).
278
+ */
279
+ #remoteQueue = [];
280
+ #bridgedToolResults = new Set();
281
+ #pendingToolCalls = [];
282
+ #approvalAborts = new Set();
283
+ /** The last selection already synced to OMP via `set_model`, as `provider/model`. */
284
+ #lastSyncedModel = null;
285
+ /** Fires when the agent has been idle past OMP_IDLE_EXIT_MS (tears down the rpc child). */
286
+ #onIdleExit;
287
+ #idleExitTimer = undefined;
288
+ #disposed = false;
289
+ /** Last OMP-reported session title mirrored into this log (dedupe). */
290
+ #mirroredTitle;
291
+ /** One-shot guard for the first-turn session-identity commit (#bootstrapSessionIdentity). */
292
+ #sessionIdentityCommitted = false;
293
+ constructor(loopCtx, id, options, session, rpc, onIdleExit) {
294
+ this.#loopCtx = loopCtx;
295
+ this.id = id;
296
+ this.options = options;
297
+ this.session = withSystemHead(session, () => createSystemMessage(""));
298
+ this.#rpc = rpc;
299
+ this.#onIdleExit = onIdleExit;
300
+ this.#dispatch = agentEvents(loopCtx, this);
301
+ this.inbox = new Inbox();
302
+ this.#scope = createScope(loopCtx, this);
303
+ this.ctx = this.#scope.ctx.extend({ agent: this });
304
+ this.#lastTurn = session.snapshotEvents().findLast((event) => event.type === "turn/start")?.data.turn ?? 0;
305
+ rpc.on((event) => this.#handleEvent(event));
306
+ // OMP's own title arrives on its own frame (the session event stream carries
307
+ // no title event), so it is applied whenever OMP reports it.
308
+ rpc.onTitle((title) => this.#applyOmpTitle(title));
309
+ rpc.onFailure((error) => this.#fail(error));
310
+ // Session-identity events (the agent-preset stamp and the surface-node-0
311
+ // system prompt) are committed on the FIRST turn by
312
+ // #bootstrapSessionIdentity — only once the lazy child actually exists —
313
+ // so a freshly created session announces completely blank, exactly like
314
+ // the native agent-loop factory's: the backend takes zero part in the
315
+ // UI's browser-local new-session draft.
316
+ // `/permission` — a launch fact, so the shadow routes it by child state:
317
+ // OMP pins `--approval-mode` at spawn and no RPC moves it mid-session, so
318
+ // an accepted switch only exists while this session's child does NOT. This
319
+ // agent-scoped registration (a child of `agent.ctx`) shadows the global
320
+ // command; before the first prompt it writes the same durable preset the
321
+ // global handler would (`permissionPresets.set`, no live-agent narration),
322
+ // and afterwards it refuses instead of appending a preset the running
323
+ // `omp` cannot honor. The spawn reads exactly those events
324
+ // (`resolveSpawnPermission`), so the pick reaches the child's argv.
325
+ this.ctx.inject(["commands"], (cmdCtx) => cmdCtx.commands.register({
326
+ name: "permission",
327
+ description: "Switch the permission preset (takes effect at the OMP child's launch)",
328
+ input: { hint: "<preset>" },
329
+ handler: (invocation) => this.#handlePermissionCommand(invocation.rawInput),
330
+ }));
331
+ // `/compact` parity (route 1): OMP owns context compaction — the Dash log
332
+ // is only the UI's scrollback projection, so no compaction/* events are
333
+ // fabricated and the surface is never replaced. Shadowing the loop-owned
334
+ // global name keeps the (disabled) native pipeline from receiving it; the
335
+ // command node renders the lifecycle and outcome natively.
336
+ this.ctx.inject(["commands"], (cmdCtx) => cmdCtx.commands.register({
337
+ name: "compact",
338
+ description: "Compact the OMP session context (OMP-native compaction)",
339
+ input: { hint: "[instructions]" },
340
+ handler: (invocation) => this.#handleCompactCommand(invocation),
341
+ }));
342
+ }
343
+ get status() {
344
+ return this.#streaming ? "running" : "idle";
345
+ }
346
+ send(message, target, wakeup) {
347
+ this.#deliver(message, target, wakeup);
348
+ }
349
+ followup(message) {
350
+ // The Web UI's `session.prompt` (mode "queue") routes through followup.
351
+ // Idle → `prompt` (opens the Dash turn locally, before OMP's ~2s cold
352
+ // start); busy → `follow_up` (OMP's own queue: delivered as the next
353
+ // turn's user message and auto-generated). Mirrors dsh-agent-loop's
354
+ // `followup = send(input, "next-turn", true)`.
355
+ this.#deliver(message, "next-turn", true);
356
+ }
357
+ steer(message) {
358
+ // Mirrors dsh-agent-loop's `steer = send(input, "next-step", true)`: an
359
+ // idle driver starts a turn; a running driver consumes it at the next step.
360
+ this.#deliver(message, "next-step", true);
361
+ }
362
+ inject(message) {
363
+ // Dash-host approval-policy narrations have no OMP runtime equivalent
364
+ // (OMP pins `--approval-mode` at launch; no RPC changes it mid-session).
365
+ // Forwarding the text would steer it into an in-flight turn — interrupting
366
+ // tool work — and echo it into history as a fake user prompt. Drop it; the
367
+ // accompanying `approval/policy` session event stays recorded so the UI's
368
+ // permission state remains truthful.
369
+ if (isApprovalNarration(message)) {
370
+ trace("inject: dropped approval-policy narration (OMP lacks runtime approval control)");
371
+ return;
372
+ }
373
+ // Mirrors dsh-agent-loop's `inject = send(input, "next-step", false)`:
374
+ // queued without waking; idle drivers leave it pending until a later
375
+ // follow-up/steer wakes them (flushed at the next agent_start).
376
+ this.#deliver(message, "next-step", false);
377
+ }
378
+ cancel(cause, _options) {
379
+ // No active run → no-op: arming `#cancelCause` here would misreport a later,
380
+ // unrelated completed turn as aborted. First cause wins: later cancels must
381
+ // not overwrite an earlier cause (dash contract).
382
+ if (!this.#streaming)
383
+ return;
384
+ if (this.#cancelCause === null)
385
+ this.#cancelCause = cause;
386
+ void this.#rpc.abort().catch(() => { });
387
+ }
388
+ async whenIdle() {
389
+ // Event-driven: wait for the current activity to settle (agent_end), then
390
+ // confirm OMP reports quiescence via get_state (the mapping's poll).
391
+ let activity;
392
+ do {
393
+ activity = this.#activityDone;
394
+ await activity;
395
+ } while (activity !== this.#activityDone);
396
+ for (;;) {
397
+ const state = await this.#rpc.getState().catch(() => null);
398
+ if (state === null || !state.isStreaming)
399
+ return;
400
+ await delay(50);
401
+ }
402
+ }
403
+ runMaintenance(task) {
404
+ // OMP has no maintenance concept; run the task directly.
405
+ return task(new AbortController().signal);
406
+ }
407
+ /** Stop OMP and unwind the scoped world. Idempotent. */
408
+ async dispose() {
409
+ if (this.#disposed)
410
+ return;
411
+ this.#disposed = true;
412
+ clearTimeout(this.#idleExitTimer);
413
+ this.#idleExitTimer = undefined;
414
+ for (const controller of this.#approvalAborts)
415
+ controller.abort();
416
+ this.#approvalAborts.clear();
417
+ this.#rpc.close();
418
+ await this.#scope.dispose();
419
+ }
420
+ // ── internals ────────────────────────────────────────────────────────────
421
+ /**
422
+ * Route one message by the reference loop's (target, wakeup) semantics:
423
+ * an idle waking delivery starts a fresh local turn (`prompt`); a follow-up
424
+ * sent while a turn is open is forwarded to OMP's own queue (`follow_up`:
425
+ * OMP delivers it as the next turn's user message and auto-generates that
426
+ * turn); steering/injection goes through `steer` (consumed at the next
427
+ * step, or parked until agent_start when not yet streaming).
428
+ *
429
+ * Busy is the LOCAL turn state (`#turnOpen`), not OMP's `#streaming` flag:
430
+ * OMP needs a moment (cold child start) to emit `agent_start` after a
431
+ * prompt, and in that gap a bare `prompt` would be rejected by OMP while
432
+ * `follow_up` is state-safe (it queues and resumes from either state).
433
+ */
434
+ #deliver(message, target, wakeup) {
435
+ if (this.#disposed)
436
+ return;
437
+ const busy = this.#turnOpen || this.#streaming;
438
+ trace(`#deliver target=${target} wakeup=${wakeup} busy=${busy} (turnOpen=${this.#turnOpen} streaming=${this.#streaming})`);
439
+ if (busy && target === "next-turn") {
440
+ this.#queueRemote(message, "followUp");
441
+ return;
442
+ }
443
+ if (busy || !wakeup) {
444
+ this.#queueRemote(message, "steer");
445
+ return;
446
+ }
447
+ this.#deliverPrompt(message);
448
+ }
449
+ #deliverPrompt(message) {
450
+ if (this.#disposed)
451
+ return;
452
+ trace(`#deliverPrompt turn=${this.#dashTurn + 1} text="${userMessageText(message).slice(0, 40)}"`);
453
+ // Reserve the turn synchronously: busy-flag semantics stay identical to
454
+ // the eager line (a second delivery during the cold start queues
455
+ // remotely), but NOTHING is appended yet — the log stays empty until the
456
+ // lazy child exists, so a failed cold start leaves no orphan turn.
457
+ this.#reserveTurn();
458
+ clearTimeout(this.#idleExitTimer);
459
+ this.#idleExitTimer = undefined;
460
+ this.#beginActivity();
461
+ void this.#startTurn(message);
462
+ }
463
+ /**
464
+ * Cold-start path for one reserved turn: spawn the lazy child (first prompt
465
+ * only), commit the session-identity events ahead of the turn (surface node
466
+ * 0 = system prompt — the native loop's step() ordering), sync the model,
467
+ * then append turn/start + user/message and dispatch. A failed cold start
468
+ * synthesizes the failed turn (turn/end with the error reason) so the UI's
469
+ * error path renders it, then unwinds the reservation.
470
+ */
471
+ async #startTurn(message) {
472
+ try {
473
+ await this.#rpc.ensureStarted();
474
+ await this.#bootstrapSessionIdentity();
475
+ await this.#syncModelSelection();
476
+ }
477
+ catch (error) {
478
+ trace(`#startTurn cold start failed: ${String(error)}`);
479
+ if (this.#turnOpen) {
480
+ this.session.append("turn/start", { turn: this.#dashTurn });
481
+ this.session.append("user/message", message, { surfaceOp: "append" });
482
+ this.#closeTurn({ kind: "error", error: { message: String(error), code: "UNKNOWN" } });
483
+ }
484
+ this.#endActivity();
485
+ return;
486
+ }
487
+ if (this.#disposed)
488
+ return;
489
+ this.session.append("turn/start", { turn: this.#dashTurn });
490
+ this.session.append("user/message", message, { surfaceOp: "append" });
491
+ void this.#rpc.prompt(userMessageText(message)).catch((error) => this.#fail(error));
492
+ }
493
+ /**
494
+ * One-shot first-turn identity commit, right after the lazy child spawns:
495
+ * the agent-preset stamp (idempotent) and the live system prompt as surface
496
+ * node 0. This is now the ONLY system-prompt emitter — cold replay emits
497
+ * none (the OMP JSONL never records a system prompt), so a resumed session
498
+ * gets the RPC prompt appended immediately before its first live
499
+ * `turn/start` (timeline-tail semantics), while a fresh session gets it as
500
+ * node 0 (that render IS turn-1). The guard — skip when the seed already
501
+ * carries a `system/message` — stays.
502
+ */
503
+ async #bootstrapSessionIdentity() {
504
+ if (this.#sessionIdentityCommitted)
505
+ return;
506
+ this.#sessionIdentityCommitted = true;
507
+ if (!this.session.snapshotEvents().some((event) => event.type === "agent-preset/selected" && event.data?.agentPreset === "omp")) {
508
+ this.session.append("agent-preset/selected", { agentPreset: "omp" });
509
+ }
510
+ {
511
+ const systemPrompt = await this.#rpc.getSystemPrompt();
512
+ if (systemPrompt !== undefined && systemPrompt !== "") {
513
+ // dsh session v3 requires positive turn/step coordinates on surface
514
+ // events (`turn must be positive`); native fresh sessions use 1/1 for the
515
+ // node-0 system prompt. 0/0 made the native jsonl writer reject the whole
516
+ // live batch, stalling every append before turn/start.
517
+ // 0.1.7: createSystemMessage dropped its origin parameter (the source
518
+ // kind is fixed at `system-prompt`).
519
+ // v4 protected-head posture: node-0 on a fresh surface (append), or a
520
+ // replacement of the placeholder when a user message raced the async
521
+ // prompt fetch — both shapes the 0.1.7 reader accepts.
522
+ this.session.stampSystemPrompt(1, 1, createSystemMessage(systemPrompt));
523
+ }
524
+ }
525
+ }
526
+ /**
527
+ * `/permission` handler: mirror the global command's write for a session whose
528
+ * `omp` child does not exist yet (the pick then lands in the launch flag via
529
+ * {@link resolveSpawnPermission}), and refuse it once the child does — the
530
+ * policy is a launch flag, so accepting later would desync UI from runtime.
531
+ * Uses `permissionPresets.set` (log-only preset + knob events) rather than the
532
+ * global handler's `approval.setPolicy`, which would inject a narration user
533
+ * message into the OMP session for a change OMP never sees.
534
+ */
535
+ #handlePermissionCommand(rawInput) {
536
+ const presets = this.#loopCtx.get("permissionPresets");
537
+ if (presets === undefined) {
538
+ return { kind: "error", text: "permission presets are not available on this host" };
539
+ }
540
+ const decision = ompPermissionCommand({
541
+ rawInput,
542
+ spawned: this.#rpc.spawned,
543
+ names: presets.names,
544
+ current: presets.current(this.session),
545
+ });
546
+ if (decision.action === "apply")
547
+ presets.set(this.session, decision.preset);
548
+ return decision.result;
549
+ }
550
+ /**
551
+ * /compact handler (route 1): run OMP's own compaction on the live child
552
+ * and report its real metering as the command outcome. No compaction/*
553
+ * events are appended and the Dash surface is never replaced — the Dash log
554
+ * is the UI's scrollback projection; only OMP's own context shrinks.
555
+ */
556
+ async #handleCompactCommand(invocation) {
557
+ if (this.#disposed)
558
+ return { kind: "error", text: "Session is disposed." };
559
+ if (!this.#rpc.spawned) {
560
+ return { kind: "success", text: "Session context is empty — nothing to compact yet." };
561
+ }
562
+ const instructions = invocation.rawInput.trim();
563
+ const before = await this.#rpc.contextUsage();
564
+ try {
565
+ await this.#rpc.compact(instructions === "" ? undefined : instructions);
566
+ }
567
+ catch (error) {
568
+ return { kind: "error", text: `Compaction failed: ${String(error)}` };
569
+ }
570
+ const after = await this.#rpc.contextUsage();
571
+ if (before?.tokens === undefined || after?.tokens === undefined) {
572
+ return { kind: "success", text: "Compaction complete." };
573
+ }
574
+ const saved = before.tokens - after.tokens;
575
+ return {
576
+ kind: "success",
577
+ text: `Compaction complete. Context tokens: ${before.tokens.toLocaleString("en-US")} → ${after.tokens.toLocaleString("en-US")} (saved ${saved.toLocaleString("en-US")}).`,
578
+ };
579
+ }
580
+ /**
581
+ * Queue a message for OMP-side delivery. `followUp` dispatches immediately
582
+ * (state-safe whether or not OMP is streaming); `steer` dispatches now when
583
+ * already streaming, otherwise parks until agent_start.
584
+ */
585
+ #queueRemote(message, transport) {
586
+ if (this.#disposed)
587
+ return;
588
+ clearTimeout(this.#idleExitTimer);
589
+ this.#idleExitTimer = undefined;
590
+ this.#remoteQueue.push({ message, sent: false, transport });
591
+ if (this.#streaming || transport === "followUp")
592
+ this.#flushRemote();
593
+ }
594
+ #flushRemote() {
595
+ void this.#syncModelSelection().finally(() => {
596
+ for (const entry of this.#remoteQueue) {
597
+ if (entry.sent)
598
+ continue;
599
+ entry.sent = true;
600
+ const text = userMessageText(entry.message);
601
+ const sent = entry.transport === "followUp" ? this.#rpc.followUp(text) : this.#rpc.steer(text);
602
+ trace(`-> ${entry.transport} "${text.slice(0, 40)}"`);
603
+ void sent.catch((error) => this.#fail(error));
604
+ }
605
+ });
606
+ }
607
+ /**
608
+ * The selection a turn should run under, resolved exactly as the native
609
+ * loop resolves it (`buildRequest`): the base is the session's logged
610
+ * `request/header` (the replay synthesizes it from OMP's transcript, so a
611
+ * resumed session keeps ITS last model) falling back to the global default;
612
+ * the `system-prompt/assemble` → `agent/request` waterfall pair then applies
613
+ * the apiproxy's installed selection, so a switch made in the Web UI this
614
+ * process still wins. OMP itself already restores the transcript's model on
615
+ `--resume`, which is why {@link #syncModelSelection} only pushes when the
616
+ * resolved target differs from OMP's live state.
617
+ */
618
+ async #effectiveModelSelection() {
619
+ const base = this.#baseModelSelection();
620
+ const systemPrompt = this.ctx.get("systemPrompt");
621
+ if (systemPrompt !== undefined) {
622
+ try {
623
+ await systemPrompt.assemble({ agent: this, scope: this });
624
+ }
625
+ catch (error) {
626
+ trace(`model selection assemble failed: ${String(error)}`);
627
+ }
628
+ }
629
+ try {
630
+ const resolved = await this.#dispatch.waterfall("agent/request", { turn: this.#dashTurn, step: this.#step, signal: new AbortController().signal }, () => Promise.resolve(base));
631
+ if (resolved.provider !== "" && resolved.model !== "") {
632
+ return { provider: resolved.provider, model: resolved.model };
633
+ }
634
+ }
635
+ catch (error) {
636
+ trace(`model selection waterfall failed: ${String(error)}`);
637
+ }
638
+ return base;
639
+ }
640
+ /** Tier-2/3 base: the session's logged header config, else the global default, else agent options. */
641
+ #baseModelSelection() {
642
+ const persisted = this.session.requestHeader()?.config;
643
+ if (persisted !== undefined && persisted.provider !== "" && persisted.model !== "") {
644
+ return { provider: persisted.provider, model: persisted.model };
645
+ }
646
+ const service = this.ctx.get("agentDefaultModel");
647
+ const selection = service?.currentSelection();
648
+ if (selection !== undefined && selection.provider !== "" && selection.model !== "")
649
+ return selection;
650
+ return { provider: this.options.provider ?? "", model: this.options.model ?? "" };
651
+ }
652
+ /**
653
+ * Sync the effective model selection into OMP before a turn is dispatched.
654
+ * `set_model` fires only when the resolved target differs from OMP's live
655
+ * model, so a plain resume (OMP already restored the transcript's model, and
656
+ * the replayed header names the same one) performs no round-trip at all.
657
+ */
658
+ async #syncModelSelection() {
659
+ const target = await this.#effectiveModelSelection();
660
+ const key = `${target.provider}/${target.model}`;
661
+ if (key === this.#lastSyncedModel)
662
+ return;
663
+ const state = await this.#rpc.getState().catch(() => null);
664
+ const ompModel = state?.model;
665
+ if (ompModel?.provider === target.provider && ompModel?.id === target.model) {
666
+ this.#lastSyncedModel = key;
667
+ this.#recordModel(target);
668
+ return;
669
+ }
670
+ await this.#rpc.setModel(target.provider, target.model).catch((error) => {
671
+ trace(`set_model ${key} failed: ${String(error)}`);
672
+ });
673
+ this.#lastSyncedModel = key;
674
+ this.#recordModel(target);
675
+ }
676
+ /** Event-driven index update: mirror the live model switch immediately (D5.3). */
677
+ #recordModel(target) {
678
+ const store = getBridgeStore();
679
+ if (store === undefined)
680
+ return;
681
+ store.updateModel(String(this.session.id), target.provider, target.model);
682
+ }
683
+ /**
684
+ * Reserve a turn WITHOUT appending anything: the prompt path reserves
685
+ * synchronously, then commits turn/start + user/message only after the lazy
686
+ * cold start (#startTurn). #openTurn keeps the eager append for paths that
687
+ * run against an already-live child (#bridgeRemoteUser echoes).
688
+ */
689
+ #reserveTurn() {
690
+ if (this.#turnOpen)
691
+ return;
692
+ this.#dashTurn = ++this.#lastTurn;
693
+ this.#step = 0;
694
+ this.#turnOpen = true;
695
+ this.#localUserPending = true;
696
+ }
697
+ #openTurn(message, localUser = true) {
698
+ trace(`#openTurn turnOpen=${this.#turnOpen} -> turn=${this.#dashTurn + 1} localUser=${localUser}`);
699
+ if (this.#turnOpen)
700
+ return;
701
+ this.#reserveTurn();
702
+ this.#localUserPending = localUser;
703
+ this.session.append("turn/start", { turn: this.#dashTurn });
704
+ this.session.append("user/message", message, { surfaceOp: "append" });
705
+ }
706
+ #closeTurn(reason) {
707
+ if (!this.#turnOpen)
708
+ return;
709
+ this.#turnOpen = false;
710
+ this.#localUserPending = false;
711
+ this.session.append("turn/end", { turn: this.#dashTurn, reason });
712
+ void this.#mirrorOmpTitle();
713
+ }
714
+ /**
715
+ * Mirror OMP's own session title into a `session/title` event. Dash does not
716
+ * generate titles for OMP sessions (the native title rows are disabled in the
717
+ * bundle patch); OMP owns the title and only reports it through the SDK. Empty
718
+ * or unchanged titles are ignored, so a session with no OMP title keeps its
719
+ * creation value.
720
+ */
721
+ async #mirrorOmpTitle() {
722
+ if (!this.#rpc.spawned)
723
+ return;
724
+ try {
725
+ const state = await this.#rpc.getState();
726
+ this.#applyOmpTitle(state.title);
727
+ }
728
+ catch {
729
+ // fail-soft: a missing title never affects the turn
730
+ }
731
+ }
732
+ /**
733
+ * Record a title OMP reported. OMP is a title *provider* (source.kind:
734
+ * "provider"), not Dash's built-in fallback. The event belongs to the
735
+ * `session-title` package's vocabulary, which the bridge does not import, so
736
+ * the append is cast; it is synchronous and never throws into the turn.
737
+ */
738
+ #applyOmpTitle(title) {
739
+ if (typeof title !== "string" || title.trim() === "")
740
+ return;
741
+ if (title === this.#mirroredTitle)
742
+ return;
743
+ this.#mirroredTitle = title;
744
+ try {
745
+ this.session.append("session/title", {
746
+ title,
747
+ messageSeqs: [],
748
+ source: { kind: "provider", provider: "omp" },
749
+ });
750
+ }
751
+ catch {
752
+ // fail-soft: a title is never allowed to affect the turn
753
+ }
754
+ }
755
+ /**
756
+ * Append the deferred `step/start` now that the step's first message has
757
+ * revealed which Dash turn owns it (a queued follow-up opens a fresh turn).
758
+ */
759
+ #commitStepStart() {
760
+ if (!this.#stepStartPending)
761
+ return;
762
+ this.#stepStartPending = false;
763
+ if (!this.#turnOpen)
764
+ return;
765
+ this.#step += 1;
766
+ this.session.append("step/start", { turn: this.#dashTurn, step: this.#step });
767
+ }
768
+ /**
769
+ * Bridge an OMP `message_start(role=user)` that was NOT appended locally.
770
+ * A `followUp` echo closes the current Dash turn and opens a fresh one with
771
+ * the retained message (OMP queued it and is now generating its turn); a
772
+ * steering/injection echo adds a `user/message` step to the current turn.
773
+ * Falls back to OMP's own content if no retained message matches.
774
+ */
775
+ #bridgeRemoteUser(ompMessage) {
776
+ const entry = this.#remoteQueue.shift();
777
+ if (entry === undefined) {
778
+ this.#closeTurn({ kind: "completed" });
779
+ this.#openTurn(userMessageFromOmp(ompMessage), false);
780
+ return;
781
+ }
782
+ if (entry.transport === "followUp") {
783
+ this.#closeTurn({ kind: "completed" });
784
+ this.#openTurn(entry.message, false);
785
+ return;
786
+ }
787
+ this.session.append("user/message", entry.message, { surfaceOp: "append" });
788
+ }
789
+ #beginActivity() {
790
+ // Settle any prior in-flight activity first: `send()` can inject a prompt
791
+ // mid-stream (streamingBehavior followUp/steer), superseding the current
792
+ // activity promise. Without settling the old one, whenIdle()'s do-while
793
+ // would await an orphaned promise that never resolves.
794
+ this.#resolveActivityDone();
795
+ this.#activityDone = new Promise((resolve) => {
796
+ this.#resolveActivityDone = resolve;
797
+ });
798
+ }
799
+ #endActivity() {
800
+ this.#resolveActivityDone();
801
+ }
802
+ #markRunning() {
803
+ clearTimeout(this.#idleExitTimer);
804
+ this.#idleExitTimer = undefined;
805
+ if (this.#streaming)
806
+ return;
807
+ this.#streaming = true;
808
+ this.#dispatch.emit("agent/status", { status: "running" });
809
+ }
810
+ #markIdle() {
811
+ if (!this.#streaming)
812
+ return;
813
+ this.#streaming = false;
814
+ this.#dispatch.emit("agent/status", { status: "idle" });
815
+ this.#armIdleExit();
816
+ }
817
+ /**
818
+ * Schedule the idle exit. Skipped while deliveries are still queued (an
819
+ * unanswered parked injection means work is pending, not idle) or when the
820
+ * exit is disabled. Any later activity restarts or cancels the timer.
821
+ */
822
+ #armIdleExit() {
823
+ if (this.#disposed || this.#onIdleExit === undefined || OMP_IDLE_EXIT_MS === 0)
824
+ return;
825
+ if (this.#remoteQueue.some((entry) => !entry.sent))
826
+ return;
827
+ clearTimeout(this.#idleExitTimer);
828
+ this.#idleExitTimer = setTimeout(() => {
829
+ this.#idleExitTimer = undefined;
830
+ void this.#revalidateIdleExit();
831
+ }, OMP_IDLE_EXIT_MS);
832
+ this.#idleExitTimer.unref?.();
833
+ }
834
+ /**
835
+ * Five-fold quiescence re-validation at fire time. The idle timer is only a
836
+ * hint: before teardown, re-confirm no live work via local signals and OMP's
837
+ * own self-attestation (get_state streaming/compacting/queued, get_subagents
838
+ * liveness). Any busy signal re-arms instead of killing; a query failure
839
+ * fail-softs to the local signals rather than forcing a teardown.
840
+ */
841
+ async #revalidateIdleExit() {
842
+ if (this.#disposed || this.#streaming)
843
+ return this.#armIdleExit();
844
+ // Never-spawned draft: no child exists and no work is possible — drop the
845
+ // exit instead of re-arming timers or querying the rpc (passive queries
846
+ // must never conjure a child).
847
+ if (!this.#rpc.spawned)
848
+ return;
849
+ if (this.#remoteQueue.some((entry) => !entry.sent))
850
+ return this.#armIdleExit();
851
+ if (this.#approvalAborts.size > 0)
852
+ return this.#armIdleExit();
853
+ try {
854
+ const state = await this.#rpc.getState();
855
+ if (this.#streaming)
856
+ return this.#armIdleExit();
857
+ if (state.isStreaming)
858
+ return this.#armIdleExit();
859
+ if (state.isCompacting === true)
860
+ return this.#armIdleExit();
861
+ if (typeof state.queuedMessageCount === "number" && state.queuedMessageCount > 0)
862
+ return this.#armIdleExit();
863
+ }
864
+ catch {
865
+ // Self-attestation unavailable: local signals remain authoritative.
866
+ }
867
+ try {
868
+ const subagents = await this.#rpc.getSubagents();
869
+ if (this.#streaming)
870
+ return this.#armIdleExit();
871
+ if (subagents.length > 0)
872
+ return this.#armIdleExit();
873
+ }
874
+ catch {
875
+ // getSubagents fail-softs already; belt-and-suspenders.
876
+ }
877
+ if (this.#streaming)
878
+ return this.#armIdleExit();
879
+ trace(`idle exit after ${OMP_IDLE_EXIT_MS}ms — disposing agent ${this.id}`);
880
+ this.#onIdleExit?.();
881
+ }
882
+ #fail(error) {
883
+ this.#abandonStreamBridge();
884
+ if (this.#disposed)
885
+ return;
886
+ trace(`#fail ${String(error).slice(0, 200)}`);
887
+ this.#closeTurn({ kind: "error", error: { message: String(error), code: "UNKNOWN" } });
888
+ this.#markIdle();
889
+ this.#endActivity();
890
+ }
891
+ #handleEvent(event) {
892
+ switch (event.type) {
893
+ case "agent_start":
894
+ trace("event agent_start");
895
+ this.#markRunning();
896
+ this.#flushRemote();
897
+ break;
898
+ case "turn_start":
899
+ // Defer `step/start`: the step's owning turn is only known once its
900
+ // first message arrives (a queued follow-up opens a fresh turn).
901
+ trace("event turn_start");
902
+ this.#stepStartPending = true;
903
+ this.#pendingFailure = null;
904
+ break;
905
+ case "message_start": {
906
+ const message = event.message;
907
+ if (message?.role === "user") {
908
+ if (this.#localUserPending) {
909
+ // OMP is echoing the user message #openTurn already appended.
910
+ this.#localUserPending = false;
911
+ }
912
+ else {
913
+ // A queued follow-up / steering message delivered by OMP.
914
+ this.#bridgeRemoteUser(message);
915
+ }
916
+ }
917
+ else if (message?.role === "assistant") {
918
+ // Reset per-message streaming accumulators.
919
+ this.#abandonStreamBridge();
920
+ }
921
+ // role === "toolResult" is bridged in message_end (deduped vs tool_execution_end).
922
+ this.#commitStepStart();
923
+ break;
924
+ }
925
+ case "message_update": {
926
+ const delta = event.assistantMessageEvent;
927
+ if (delta !== undefined)
928
+ this.#handleUpdate(delta);
929
+ break;
930
+ }
931
+ case "message_end": {
932
+ const message = event.message;
933
+ if (message === undefined)
934
+ break;
935
+ const role = message.role;
936
+ if (role === "assistant") {
937
+ const failure = ompFailure(message);
938
+ if (failure !== undefined) {
939
+ this.#pendingFailure = failure;
940
+ this.#abandonStreamBridge();
941
+ }
942
+ else {
943
+ this.#appendAssistantMessage(message);
944
+ }
945
+ }
946
+ else if (role === "toolResult") {
947
+ this.#appendToolResultMessage(message);
948
+ }
949
+ else if (role !== "user") {
950
+ process.stderr.write(`omp rpc: unhandled message_end role "${role}"\n`);
951
+ }
952
+ break;
953
+ }
954
+ case "tool_execution_start": {
955
+ const callId = String(event.toolCallId ?? "");
956
+ const name = String(event.toolName ?? "");
957
+ this.session.append("tool/call", {
958
+ turn: this.#dashTurn,
959
+ step: this.#step,
960
+ callId: ToolCallId(callId),
961
+ name,
962
+ arguments: JSON.stringify(event.args ?? {}),
963
+ });
964
+ // The assistant's pending tool call is now materialized as a running call.
965
+ this.#pendingToolCalls = this.#pendingToolCalls.filter((pending) => pending.callId !== callId);
966
+ break;
967
+ }
968
+ case "extension_ui_request":
969
+ void this.#handleExtensionUiRequest(event);
970
+ break;
971
+ case "tool_execution_end":
972
+ this.#appendToolResult(event);
973
+ break;
974
+ case "turn_end":
975
+ trace("event turn_end");
976
+ if (this.#turnOpen) {
977
+ this.session.append("step/end", { turn: this.#dashTurn, step: this.#step });
978
+ }
979
+ break;
980
+ case "agent_end": {
981
+ trace(`event agent_end cancelCause=${String(this.#cancelCause)}`);
982
+ const pendingFailure = this.#pendingFailure;
983
+ this.#pendingFailure = null;
984
+ this.#closeTurn(this.#cancelCause !== null
985
+ ? { kind: "aborted", reason: this.#cancelCause }
986
+ : pendingFailure !== null
987
+ ? { kind: "error", error: pendingFailure }
988
+ : { kind: "completed" });
989
+ this.#cancelCause = null;
990
+ this.#markIdle();
991
+ this.#endActivity();
992
+ // Live metadata the Dash surface has no projection seam for yet:
993
+ // surface OMP's own accounting (tokens, contextUsage, cost) in the
994
+ // diagnostic trace so it is observable per completed turn.
995
+ void this.#rpc
996
+ .getSessionStats()
997
+ .then((stats) => {
998
+ trace(`sessionStats tokens=${JSON.stringify(stats.tokens ?? {})} contextUsage=${String(stats.contextUsage)} cost=${JSON.stringify(stats.cost ?? {})}`);
999
+ })
1000
+ .catch(() => { });
1001
+ break;
1002
+ }
1003
+ }
1004
+ }
1005
+ /** The open stream bridge for this message, starting one on first use. */
1006
+ #ensureStreamBridge() {
1007
+ if (this.#streamBridge === undefined || this.#streamBridge.ended) {
1008
+ this.#streamBridge = new AssistantStreamBridge(this.session.id, ++this.#assistantAttemptCounter, () => ++this.#assistantStreamRevision, (frame) => this.#dispatch.emit("agent/assistant-stream", { frame }));
1009
+ this.#streamBridge.start(this.#dashTurn, this.#step);
1010
+ }
1011
+ return this.#streamBridge;
1012
+ }
1013
+ /** Abandon any open stream bridge — failure paths never settle. */
1014
+ #abandonStreamBridge() {
1015
+ if (this.#streamBridge !== undefined && !this.#streamBridge.ended)
1016
+ this.#streamBridge.abandon();
1017
+ this.#streamBridge = undefined;
1018
+ }
1019
+ #handleUpdate(delta) {
1020
+ switch (delta.type) {
1021
+ case "text_delta":
1022
+ if (typeof delta.delta === "string" && delta.delta !== "") {
1023
+ const chunk = { type: "text-delta", index: delta.contentIndex ?? 0, text: delta.delta };
1024
+ // v2: live text no longer appends `assistant/chunk` log events — the
1025
+ // stream publishes as dense agent frames and embeds durably in the
1026
+ // final assistant/message.
1027
+ this.#ensureStreamBridge().push(chunk);
1028
+ }
1029
+ break;
1030
+ default:
1031
+ // text_start/text_end/thinking_*/toolcall_* are folded into the final message.
1032
+ break;
1033
+ }
1034
+ }
1035
+ /**
1036
+ * Bridge an OMP `extension_ui_request`. Approval cards (`select` with an
1037
+ * "Approve" option) round-trip through the Dash approval seam
1038
+ * (`ctx.get("approval").request()`), which the Web UI renders as an
1039
+ * ApprovalPanel. Every OTHER blocking dialog (confirm/input/editor, or a
1040
+ * select lacking "Approve") gets the universal cancellation so OMP never
1041
+ * blocks forever.
1042
+ */
1043
+ async #handleExtensionUiRequest(event) {
1044
+ const id = String(event.id ?? "");
1045
+ if (id === "")
1046
+ return;
1047
+ const method = String(event.method ?? "");
1048
+ const options = Array.isArray(event.options) ? event.options : [];
1049
+ if (method !== "select" || !options.includes("Approve")) {
1050
+ // Not an approval card: cancel the dialog so OMP can keep going.
1051
+ this.#rpc.sendRaw({ type: "extension_ui_response", id, cancelled: true });
1052
+ return;
1053
+ }
1054
+ const title = typeof event.title === "string" ? event.title : "";
1055
+ const pending = this.#matchPendingToolCall(title);
1056
+ const toolName = pending !== null && pending.name !== "" ? pending.name : approvalToolName(title);
1057
+ const callId = pending !== null ? ToolCallId(pending.callId) : undefined;
1058
+ const reason = title !== "" ? title : undefined;
1059
+ // `ctx.approval` is provided by a sibling fiber in the real profile, so a
1060
+ // property read throws "cannot get property ... without inject"; `ctx.get`
1061
+ // resolves the store from any ctx instead.
1062
+ const approval = this.ctx.get("approval");
1063
+ if (approval === undefined) {
1064
+ // Fail closed: without an answerer the tool must not run.
1065
+ this.#rpc.sendRaw({ type: "extension_ui_response", id, value: "Deny" });
1066
+ return;
1067
+ }
1068
+ const controller = new AbortController();
1069
+ this.#approvalAborts.add(controller);
1070
+ try {
1071
+ const outcome = await raceApproval(approval.request({
1072
+ agent: this,
1073
+ toolName,
1074
+ ...(callId === undefined ? {} : { callId }),
1075
+ ...(reason === undefined ? {} : { reason }),
1076
+ signal: controller.signal,
1077
+ }), controller, OMP_APPROVAL_TIMEOUT_MS);
1078
+ // Fail closed: only an explicit one-shot grant approves.
1079
+ this.#rpc.sendRaw({
1080
+ type: "extension_ui_response",
1081
+ id,
1082
+ value: outcome === "allowed-once" ? "Approve" : "Deny",
1083
+ });
1084
+ }
1085
+ catch {
1086
+ // A synchronous throw (no open turn / audit append failure) still denies
1087
+ // rather than hanging OMP.
1088
+ this.#rpc.sendRaw({ type: "extension_ui_response", id, value: "Deny" });
1089
+ }
1090
+ finally {
1091
+ this.#approvalAborts.delete(controller);
1092
+ }
1093
+ }
1094
+ /**
1095
+ * Match an approval card to its pending tool call, preferring the pending
1096
+ * call whose name equals the card title's tool name and falling back to the
1097
+ * first pending call. Consumes the match so a denied card (which never emits
1098
+ * `tool_execution_start`) can't be re-matched by a later card.
1099
+ */
1100
+ #matchPendingToolCall(title) {
1101
+ if (this.#pendingToolCalls.length === 0)
1102
+ return null;
1103
+ const titleName = approvalToolName(title);
1104
+ let index = this.#pendingToolCalls.findIndex((pending) => pending.name !== "" && pending.name === titleName);
1105
+ if (index === -1)
1106
+ index = 0;
1107
+ const [matched] = this.#pendingToolCalls.splice(index, 1);
1108
+ return matched ?? null;
1109
+ }
1110
+ #appendAssistantMessage(message) {
1111
+ this.#pendingToolCalls = toolCalls(message.content);
1112
+ const content = convertContent(message.content);
1113
+ const usage = convertUsage(message.usage);
1114
+ const assistant = createAssistantMessage({
1115
+ content,
1116
+ source: {
1117
+ provider: String(message.provider ?? this.options.provider ?? ""),
1118
+ model: String(message.model ?? this.options.model ?? ""),
1119
+ },
1120
+ });
1121
+ const bridge = this.#streamBridge;
1122
+ // v2: the attempt's timed chunks embed in the event itself;
1123
+ // `sourceEventSeqs` is forbidden on assistant/message in v2.
1124
+ const event = this.session.append("assistant/message", {
1125
+ turn: this.#dashTurn,
1126
+ step: this.#step,
1127
+ message: assistant,
1128
+ stream: bridge?.records ?? [],
1129
+ ...(usage === undefined ? {} : { usage }),
1130
+ }, {
1131
+ surfaceOp: "append",
1132
+ });
1133
+ if (bridge !== undefined && !bridge.ended)
1134
+ bridge.settle(event.seq);
1135
+ this.#streamBridge = undefined;
1136
+ }
1137
+ #appendToolResult(event) {
1138
+ const callId = String(event.toolCallId ?? "");
1139
+ this.#bridgedToolResults.add(callId);
1140
+ const result = event.result;
1141
+ const isError = Boolean(event.isError ?? result?.isError ?? false);
1142
+ const message = createToolResultMessage({
1143
+ callId: ToolCallId(callId),
1144
+ content: convertContent(result?.content),
1145
+ isError,
1146
+ });
1147
+ this.session.append("tool/result", {
1148
+ turn: this.#dashTurn,
1149
+ step: this.#step,
1150
+ message,
1151
+ ...(isError ? { error: { name: "ToolExecutionError", code: "TOOL_ERROR" } } : {}),
1152
+ }, {
1153
+ surfaceOp: "append",
1154
+ });
1155
+ }
1156
+ #appendToolResultMessage(message) {
1157
+ const callId = String(message.toolCallId ?? "");
1158
+ // The normal path bridges via `tool_execution_end` first; skip the
1159
+ // `message_end(toolResult)` duplicate OMP always also emits.
1160
+ if (this.#bridgedToolResults.has(callId))
1161
+ return;
1162
+ this.#bridgedToolResults.add(callId);
1163
+ const isError = Boolean(message.isError ?? false);
1164
+ const result = createToolResultMessage({
1165
+ callId: ToolCallId(callId),
1166
+ content: convertContent(message.content),
1167
+ isError,
1168
+ });
1169
+ this.session.append("tool/result", {
1170
+ turn: this.#dashTurn,
1171
+ step: this.#step,
1172
+ message: result,
1173
+ ...(isError ? { error: { name: "ToolExecutionError", code: "TOOL_ERROR" } } : {}),
1174
+ }, {
1175
+ surfaceOp: "append",
1176
+ });
1177
+ }
1178
+ }