super-dsh 0.1.0 → 0.1.2

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