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,949 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { agentEvents } from "@deepseek-ai/dsh-agent";
3
+ import { Inbox } from "./inbox.js";
4
+ import { LlmAttemptId, ToolCallId, QUOTA_EXCEEDED_CODE, createAssistantMessage, createSystemMessage, createToolResultMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
5
+ import { createScope } from "@deepseek-ai/dsh-scope";
6
+ import { PI_IDLE_EXIT_MS } from "./knobs.js";
7
+ import { trace } from "./knobs.js";
8
+ import { PI_PROVIDER_ID, splitCatalogId } from "./models.js";
9
+ /**
10
+ * v2 live assistant-stream publication for the pi bridge — the same frame
11
+ * protocol the reference loop's AssistantStreamAttempt speaks (start marker,
12
+ * dense zero-based chunks, terminal settlement) emitted over this agent's own
13
+ * dispatch, so the session-controller folds pi's stream into its reconnect
14
+ * baseline. Chunks also accumulate as raw durable records embedded in the
15
+ * final assistant/message.
16
+ */
17
+ class AssistantStreamBridge {
18
+ attemptId;
19
+ records = [];
20
+ #nextRevision;
21
+ #emit;
22
+ #index = 0;
23
+ #terminal = false;
24
+ constructor(sessionId, attempt, nextRevision, emit) {
25
+ this.attemptId = LlmAttemptId(`${sessionId}:${attempt}`);
26
+ this.#nextRevision = nextRevision;
27
+ this.#emit = emit;
28
+ }
29
+ /** Whether the terminal frame already fired. */
30
+ get ended() {
31
+ return this.#terminal;
32
+ }
33
+ /** Opening marker before the first delivered chunk. */
34
+ start(turn, step) {
35
+ this.#emit({ type: "start", attemptId: this.attemptId, revision: this.#nextRevision(), turn, step });
36
+ }
37
+ /** One live chunk: durable record plus dense process-local frame. */
38
+ push(chunk) {
39
+ const time = Date.now();
40
+ this.records.push({ type: "chunk", time, chunk });
41
+ this.#emit({ type: "chunk", attemptId: this.attemptId, revision: this.#nextRevision(), index: this.#index++, time, chunk });
42
+ }
43
+ /** Terminal settlement after the durable assistant/message committed. */
44
+ settle(seq) {
45
+ this.#terminal = true;
46
+ this.#emit({ type: "end", attemptId: this.attemptId, revision: this.#nextRevision(), index: this.#index, outcome: { kind: "committed", eventType: "assistant/message", seq } });
47
+ }
48
+ /** No durable attempt event will commit. */
49
+ abandon() {
50
+ this.#terminal = true;
51
+ this.#emit({ type: "end", attemptId: this.attemptId, revision: this.#nextRevision(), index: this.#index, outcome: { kind: "abandoned" } });
52
+ }
53
+ }
54
+ export function convertContent(blocks) {
55
+ if (blocks === undefined)
56
+ return [];
57
+ const result = [];
58
+ for (const block of blocks) {
59
+ switch (block.type) {
60
+ case "text":
61
+ if (typeof block.text === "string")
62
+ result.push({ type: "text", text: block.text });
63
+ break;
64
+ case "thinking":
65
+ if (typeof block.thinking === "string")
66
+ result.push({ type: "reasoning", text: block.thinking });
67
+ break;
68
+ case "toolCall": {
69
+ const id = typeof block.id === "string" ? block.id : "";
70
+ const name = typeof block.name === "string" ? block.name : "";
71
+ const args = typeof block.arguments === "string" ? block.arguments : JSON.stringify(block.arguments ?? {});
72
+ result.push({ type: "tool-call", id: ToolCallId(id), name, arguments: args });
73
+ break;
74
+ }
75
+ default:
76
+ // images / unknown blocks are not bridged in V1.
77
+ break;
78
+ }
79
+ }
80
+ return result;
81
+ }
82
+ /**
83
+ * Classify the wire's `stopReason: "error"` assistant message into a Dash
84
+ * `failure` (`{ message, code }`). Same taxonomy as the codex bridge — quota
85
+ * wording is checked before the generic 403→AUTH because some providers
86
+ * report an exhausted billing quota as HTTP 403.
87
+ */
88
+ export function wireFailure(message) {
89
+ if (message.stopReason !== "error")
90
+ return undefined;
91
+ const status = typeof message.errorStatus === "number" ? message.errorStatus : undefined;
92
+ const text = typeof message.errorMessage === "string" && message.errorMessage.trim() !== ""
93
+ ? message.errorMessage.trim()
94
+ : undefined;
95
+ const detail = text ?? "";
96
+ let code;
97
+ 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)) {
98
+ code = QUOTA_EXCEEDED_CODE;
99
+ }
100
+ else if (status === 401 || status === 403) {
101
+ code = "AUTH";
102
+ }
103
+ else if (status === 429) {
104
+ code = "RATE_LIMIT";
105
+ }
106
+ else if (status !== undefined && status >= 500) {
107
+ code = "SERVER";
108
+ }
109
+ else {
110
+ code = "UNKNOWN";
111
+ }
112
+ return { message: text ?? (status !== undefined ? `HTTP ${status}` : "model request failed"), code };
113
+ }
114
+ /**
115
+ * Map pi's camelCase `Usage` record into Dash `TokenUsage`.
116
+ */
117
+ export function convertUsage(usage) {
118
+ if (usage === null || typeof usage !== "object")
119
+ return undefined;
120
+ const u = usage;
121
+ const inputTokens = typeof u.input === "number" ? u.input : 0;
122
+ const outputTokens = typeof u.output === "number" ? u.output : 0;
123
+ if (inputTokens === 0 && outputTokens === 0)
124
+ return undefined;
125
+ const result = { inputTokens, outputTokens };
126
+ if (typeof u.cacheRead === "number")
127
+ result.cacheReadTokens = u.cacheRead;
128
+ if (typeof u.cacheWrite === "number")
129
+ result.cacheWriteTokens = u.cacheWrite;
130
+ if (typeof u.reasoning === "number")
131
+ result.reasoningTokens = u.reasoning;
132
+ return result;
133
+ }
134
+ /** Join the visible text blocks of a Dash user message into the pi prompt string. */
135
+ function userMessageText(message) {
136
+ const parts = [];
137
+ for (const block of message.content) {
138
+ if (block.type === "text")
139
+ parts.push(block.text);
140
+ }
141
+ return parts.join("\n");
142
+ }
143
+ /**
144
+ * Dash-host plugin narration from the approval service: the policy-change
145
+ * notice `agent.inject` carries when `/permission` switches a live session's
146
+ * approval policy. pi has no runtime approval control either (the preset is a
147
+ * launch-only toolset), so forwarding the text would steer it into an
148
+ * in-flight turn and echo it into history as a fake user prompt. Detect and
149
+ * drop it.
150
+ */
151
+ function isApprovalNarration(message) {
152
+ const source = message.source;
153
+ return source.kind === "plugin" && source.plugin === "user-approval";
154
+ }
155
+ /**
156
+ * Reconstruct a Dash user message from a wire `message_start(role=user)`
157
+ * payload. Defensive fallback when a queued delivery's retained message is
158
+ * missing (the client echoes text, so the visible content is preserved).
159
+ */
160
+ function userMessageFromWire(message) {
161
+ return createUserMessage({
162
+ content: convertContent(message.content).filter((block) => block.type === "text"),
163
+ source: { kind: "user" },
164
+ });
165
+ }
166
+ export class PiAgent {
167
+ id;
168
+ options;
169
+ session;
170
+ inbox;
171
+ ctx;
172
+ #client;
173
+ #loopCtx;
174
+ #scope;
175
+ #dispatch;
176
+ #streaming = false;
177
+ #activityDone = Promise.resolve();
178
+ #resolveActivityDone = () => { };
179
+ #dashTurn = 0;
180
+ #step = 0;
181
+ #lastTurn = 0;
182
+ #turnOpen = false;
183
+ #cancelCause = null;
184
+ /** Terminal model failure of the open turn (`message_end` stopReason "error"); consumed by `agent_end`. */
185
+ #pendingFailure = null;
186
+ /** Live v2 assistant-stream publication for the in-flight wire message. */
187
+ #streamBridge;
188
+ #assistantAttemptCounter = 0;
189
+ #assistantStreamRevision = 0;
190
+ /** The current turn's user/message was appended locally and the echo is still awaited. */
191
+ #localUserPending = false;
192
+ /** A `turn_start` arrived; `step/start` is deferred until the step's owning turn is known. */
193
+ #stepStartPending = false;
194
+ /**
195
+ * Deliveries forwarded through the client, in submission order. The client
196
+ * echoes each as `message_start(role=user)`; the bridge maps it by
197
+ * transport: a `followUp` echo closes the current Dash turn and opens a
198
+ * fresh one, a `steer` echo appends a `user/message` step to the current
199
+ * turn. `sent` marks entries already dispatched to the client.
200
+ */
201
+ #remoteQueue = [];
202
+ #bridgedToolResults = new Set();
203
+ /** The last title already mirrored into the log (dedupe). */
204
+ #mirroredTitle = null;
205
+ /** The open compaction lifecycle's id (`compaction/start` … `compaction/end`). */
206
+ #compactionId = null;
207
+ /** The last selection already synced to the client via set_model, as `provider/model`. */
208
+ #lastSyncedModel = null;
209
+ /** Fires when the agent has been idle past PI_IDLE_EXIT_MS (tears down the client). */
210
+ #onIdleExit;
211
+ #idleExitTimer = undefined;
212
+ #disposed = false;
213
+ /** One-shot guard for the first-turn session-identity commit (#bootstrapSessionIdentity). */
214
+ #sessionIdentityCommitted = false;
215
+ /** Adapter-owned runtime facts (system prompt / route metadata), when supplied. */
216
+ #runtimeInfo;
217
+ /** One-shot guard for the `system/message` surface node (never re-emitted). */
218
+ #systemMessageDone = false;
219
+ /** Last `request/context` route key (`provider/model`) — dedupes per-turn emits. */
220
+ #lastRouteKey = null;
221
+ constructor(loopCtx, id, options, session, client, onIdleExit, runtimeInfo) {
222
+ this.#loopCtx = loopCtx;
223
+ this.id = id;
224
+ this.options = options;
225
+ this.session = session;
226
+ this.#client = client;
227
+ this.#onIdleExit = onIdleExit;
228
+ this.#runtimeInfo = runtimeInfo;
229
+ this.#dispatch = agentEvents(loopCtx, this);
230
+ this.inbox = new Inbox();
231
+ this.#scope = createScope(loopCtx, this);
232
+ this.ctx = this.#scope.ctx.extend({ agent: this });
233
+ this.#lastTurn = session.snapshotEvents().findLast((event) => event.type === "turn/start")?.data.turn ?? 0;
234
+ client.on((event) => this.#handleEvent(event));
235
+ client.onFailure((error) => this.#fail(error));
236
+ // Session-identity events are committed on the FIRST turn (only once the
237
+ // client actually exists), so a freshly created session announces
238
+ // completely blank — the backend takes zero part in the UI's new-session
239
+ // draft.
240
+ //
241
+ // `/permission` cannot work here: pi has no runtime approval control (the
242
+ // preset is realized as a launch-only toolset). Registering the SAME name
243
+ // on this agent's own scope SHADOWS the global permission command for
244
+ // this agent only — the switch fails cleanly instead of appending
245
+ // approval/policy events pi cannot honor. (The `inject` narration drop
246
+ // below stays as the backstop.)
247
+ this.ctx.inject(["commands"], (cmdCtx) => cmdCtx.commands.register({
248
+ name: "permission",
249
+ description: "Unavailable for pi sessions (the toolset is fixed at session start)",
250
+ input: { hint: "<preset>" },
251
+ handler: () => ({
252
+ kind: "error",
253
+ text: "The pi toolset is fixed at session start; runtime switching is not supported for pi sessions.",
254
+ }),
255
+ }));
256
+ // `/compact` parity: pi compaction is REAL and wired (compaction_start/
257
+ // compaction_end bridge into the DSH log). Shadows the (disabled) native
258
+ // pipeline so the command drives the pi session's own compaction.
259
+ this.ctx.inject(["commands"], (cmdCtx) => cmdCtx.commands.register({
260
+ name: "compact",
261
+ description: "Compact this pi session's context",
262
+ input: { hint: "[instructions]" },
263
+ handler: (invocation) => {
264
+ void this.#client.compact(invocation.rawInput.trim() === "" ? undefined : invocation.rawInput).catch(() => { });
265
+ return { kind: "success", text: "Compaction started." };
266
+ },
267
+ }));
268
+ }
269
+ get status() {
270
+ return this.#streaming ? "running" : "idle";
271
+ }
272
+ send(message, target, wakeup) {
273
+ this.#deliver(message, target, wakeup);
274
+ }
275
+ followup(message) {
276
+ // The Web UI's `session.prompt` (mode "queue") routes through followup.
277
+ // Idle → `prompt` (opens the Dash turn locally); busy → the client's own
278
+ // queue (pi delivers it as the next turn's user message). Mirrors
279
+ // dsh-agent-loop's `followup = send(input, "next-turn", true)`.
280
+ this.#deliver(message, "next-turn", true);
281
+ }
282
+ steer(message) {
283
+ // Mirrors dsh-agent-loop's `steer = send(input, "next-step", true)`: an
284
+ // idle driver starts a turn; a running driver consumes it at the next
285
+ // step (pi steers natively — delivered after the in-flight turn's tool
286
+ // calls, before the next LLM call).
287
+ this.#deliver(message, "next-step", true);
288
+ }
289
+ inject(message) {
290
+ // Dash-host approval-policy narrations have no pi runtime equivalent.
291
+ // Forwarding the text would steer it into an in-flight turn and echo it
292
+ // into history as a fake user prompt. Drop it; the accompanying
293
+ // `approval/policy` session event stays recorded so the UI's permission
294
+ // state remains truthful.
295
+ if (isApprovalNarration(message)) {
296
+ trace("inject: dropped approval-policy narration (pi lacks runtime approval control)");
297
+ return;
298
+ }
299
+ // Mirrors dsh-agent-loop's `inject = send(input, "next-step", false)`:
300
+ // queued without waking; idle drivers leave it pending until a later
301
+ // follow-up/steer wakes them (flushed at the next agent_start).
302
+ this.#deliver(message, "next-step", false);
303
+ }
304
+ cancel(cause, _options) {
305
+ // No active run → no-op: arming `#cancelCause` here would misreport a
306
+ // later, unrelated completed turn as aborted. First cause wins.
307
+ if (!this.#streaming)
308
+ return;
309
+ if (this.#cancelCause === null)
310
+ this.#cancelCause = cause;
311
+ void this.#client.abort().catch(() => { });
312
+ }
313
+ async whenIdle() {
314
+ // Event-driven: wait for the current activity to settle (agent_end), then
315
+ // confirm the client reports quiescence.
316
+ let activity;
317
+ do {
318
+ activity = this.#activityDone;
319
+ await activity;
320
+ } while (activity !== this.#activityDone);
321
+ for (;;) {
322
+ const state = this.#client.getState();
323
+ if (!state.isStreaming)
324
+ return;
325
+ await delay(50);
326
+ }
327
+ }
328
+ runMaintenance(task) {
329
+ // pi has no maintenance concept; run the task directly.
330
+ return task(new AbortController().signal);
331
+ }
332
+ /** Stop the pi session and unwind the scoped world. Idempotent. */
333
+ async dispose() {
334
+ if (this.#disposed)
335
+ return;
336
+ this.#disposed = true;
337
+ clearTimeout(this.#idleExitTimer);
338
+ this.#idleExitTimer = undefined;
339
+ this.#client.close();
340
+ await this.#scope.dispose();
341
+ }
342
+ // ── internals ────────────────────────────────────────────────────────────
343
+ /**
344
+ * Route one message by the reference loop's (target, wakeup) semantics:
345
+ * an idle waking delivery starts a fresh local turn (`prompt`); a follow-up
346
+ * sent while a turn is open is forwarded to the client's queue
347
+ * (`followUp`); steering/injection goes through `steer` (consumed at the
348
+ * next step, or parked until agent_start when not yet streaming).
349
+ */
350
+ #deliver(message, target, wakeup) {
351
+ if (this.#disposed)
352
+ return;
353
+ const busy = this.#turnOpen || this.#streaming;
354
+ trace(`#deliver target=${target} wakeup=${wakeup} busy=${busy} (turnOpen=${this.#turnOpen} streaming=${this.#streaming})`);
355
+ if (busy && target === "next-turn") {
356
+ this.#queueRemote(message, "followUp");
357
+ return;
358
+ }
359
+ if (busy || !wakeup) {
360
+ this.#queueRemote(message, "steer");
361
+ return;
362
+ }
363
+ this.#deliverPrompt(message);
364
+ }
365
+ #deliverPrompt(message) {
366
+ if (this.#disposed)
367
+ return;
368
+ trace(`#deliverPrompt turn=${this.#dashTurn + 1} text="${userMessageText(message).slice(0, 40)}"`);
369
+ // Reserve the turn synchronously, but NOTHING is appended yet — the log
370
+ // stays empty until the client exists, so a failed cold start leaves no
371
+ // orphan turn.
372
+ this.#reserveTurn();
373
+ clearTimeout(this.#idleExitTimer);
374
+ this.#idleExitTimer = undefined;
375
+ this.#beginActivity();
376
+ void this.#startTurn(message);
377
+ }
378
+ /**
379
+ * Cold-start path for one reserved turn: start the session (lazy boundary —
380
+ * pi's native session file materializes HERE), commit the session-identity
381
+ * events ahead of the turn, sync the model, then append turn/start +
382
+ * user/message and dispatch. A failed cold start synthesizes the failed
383
+ * turn so the UI's error path renders it, then unwinds the reservation.
384
+ */
385
+ async #startTurn(message) {
386
+ try {
387
+ await this.#client.ensureStarted();
388
+ await this.#bootstrapSessionIdentity();
389
+ await this.#syncModelSelection();
390
+ }
391
+ catch (error) {
392
+ trace(`#startTurn cold start failed: ${String(error)}`);
393
+ if (this.#turnOpen) {
394
+ this.session.append("turn/start", { turn: this.#dashTurn });
395
+ this.session.append("user/message", message, { surfaceOp: "append" });
396
+ this.#closeTurn({ kind: "error", error: { message: String(error), code: "UNKNOWN" } });
397
+ }
398
+ this.#endActivity();
399
+ return;
400
+ }
401
+ if (this.#disposed)
402
+ return;
403
+ this.session.append("turn/start", { turn: this.#dashTurn });
404
+ this.#emitRequestContext();
405
+ this.session.append("user/message", message, { surfaceOp: "append" });
406
+ void this.#client.prompt(userMessageText(message)).catch((error) => this.#fail(error));
407
+ }
408
+ /**
409
+ * Stamp pi's system prompt once per session as the step's surface node.
410
+ * `system/message` is a step-scoped surface event, so this runs only after
411
+ * `step/start` committed. Skips when the log already carries one (resume),
412
+ * when there is no runtime bridge, and while the session has not started —
413
+ * retried on later steps until the text is available.
414
+ */
415
+ #emitSystemMessage() {
416
+ if (this.#systemMessageDone)
417
+ return;
418
+ if (this.#runtimeInfo === undefined || !this.#turnOpen)
419
+ return;
420
+ this.#commitStepStart();
421
+ if (this.#step === 0)
422
+ return;
423
+ if (this.session.snapshotEvents().some((event) => event.type === "system/message")) {
424
+ this.#systemMessageDone = true;
425
+ return;
426
+ }
427
+ const text = this.#runtimeInfo.systemPrompt();
428
+ if (text === undefined || text.trim() === "")
429
+ return;
430
+ this.session.append("system/message", {
431
+ turn: this.#dashTurn,
432
+ step: this.#step,
433
+ message: createSystemMessage(text, "aw.agent-adapter-pi"),
434
+ }, { surfaceOp: "append" });
435
+ this.#systemMessageDone = true;
436
+ trace(`system/message stamped (${text.length} chars)`);
437
+ }
438
+ /**
439
+ * Stamp route metadata for the next request (`request/context`): the
440
+ * effective provider/model plus the model's context window when known.
441
+ * Turn-enclosed (no step required) and deduped by route key.
442
+ */
443
+ #emitRequestContext() {
444
+ if (this.#runtimeInfo === undefined || !this.#turnOpen)
445
+ return;
446
+ const route = this.#runtimeInfo.routeContext(this.options);
447
+ if (route === undefined)
448
+ return;
449
+ const key = `${route.provider}/${route.model}`;
450
+ if (key === this.#lastRouteKey)
451
+ return;
452
+ this.#lastRouteKey = key;
453
+ this.session.append("request/context", {
454
+ provider: route.provider,
455
+ model: route.model,
456
+ ...(route.contextWindow === undefined ? {} : { contextWindow: route.contextWindow }),
457
+ });
458
+ trace(`request/context ${key}${route.contextWindow === undefined ? "" : ` ctx=${route.contextWindow}`}`);
459
+ }
460
+ /**
461
+ * One-shot first-turn identity commit: the agent-preset stamp (idempotent).
462
+ */
463
+ async #bootstrapSessionIdentity() {
464
+ if (this.#sessionIdentityCommitted)
465
+ return;
466
+ this.#sessionIdentityCommitted = true;
467
+ if (!this.session.snapshotEvents().some((event) => event.type === "agent-preset/selected" && event.data?.agentPreset === "pi")) {
468
+ this.session.append("agent-preset/selected", { agentPreset: "pi" });
469
+ }
470
+ }
471
+ /**
472
+ * Queue a message for client-side delivery. `followUp` dispatches
473
+ * immediately (state-safe whether or not the client is streaming); `steer`
474
+ * dispatches now when already streaming, otherwise parks until
475
+ * agent_start.
476
+ */
477
+ #queueRemote(message, transport) {
478
+ if (this.#disposed)
479
+ return;
480
+ clearTimeout(this.#idleExitTimer);
481
+ this.#idleExitTimer = undefined;
482
+ this.#remoteQueue.push({ message, sent: false, transport });
483
+ if (this.#streaming || transport === "followUp")
484
+ this.#flushRemote();
485
+ }
486
+ #flushRemote() {
487
+ void this.#syncModelSelection().finally(() => {
488
+ for (const entry of this.#remoteQueue) {
489
+ if (entry.sent)
490
+ continue;
491
+ entry.sent = true;
492
+ const text = userMessageText(entry.message);
493
+ const sent = entry.transport === "followUp" ? this.#client.followUp(text) : this.#client.steer(text);
494
+ trace(`-> ${entry.transport} "${text.slice(0, 40)}"`);
495
+ void sent.catch((error) => this.#fail(error));
496
+ }
497
+ });
498
+ }
499
+ /**
500
+ * Push the effective model selection into the client before a turn is
501
+ * dispatched. Waterfall: the session's logged `request/header` route, then
502
+ * the global `agentDefaultModel` selection, then the agent options. The
503
+ * chosen pair threads to the client as (provider slug, bare model id) —
504
+ * exactly what the shared runtime's `getModel` takes. A LEGACY stored
505
+ * selection still arrives as the umbrella `pi` route with a composite
506
+ * `<provider>/<modelId>` model; splitCatalogId (its only remaining job)
507
+ * resolves that shape here, before the runtime lookup. pi's setModel is
508
+ * LIVE (an upgrade over the codex SDK line's next-turn switch).
509
+ */
510
+ async #syncModelSelection() {
511
+ const persisted = this.session.requestHeader()?.config;
512
+ const service = this.ctx.get("agentDefaultModel");
513
+ const selection = service?.currentSelection();
514
+ const target = persisted !== undefined && persisted.provider !== "" && persisted.model !== ""
515
+ ? { provider: persisted.provider, model: persisted.model }
516
+ : selection !== undefined && selection.provider !== "" && selection.model !== ""
517
+ ? selection
518
+ : { provider: this.options.provider ?? "", model: this.options.model ?? "" };
519
+ // Legacy composites (`<provider>/<modelId>` under the umbrella route)
520
+ // split into the real pair; real-slug pairs pass through untouched (a
521
+ // bare id may itself contain slashes — the FIRST-slash split of the
522
+ // composite `<provider>/<bare>` reproduces exactly that pair).
523
+ const legacy = target.provider === "" || target.provider === PI_PROVIDER_ID ? splitCatalogId(target.model) : undefined;
524
+ const route = legacy ?? { provider: target.provider, modelId: target.model };
525
+ if (route.provider === "" || route.modelId === "")
526
+ return;
527
+ const key = `${route.provider}/${route.modelId}`;
528
+ if (key === this.#lastSyncedModel)
529
+ return;
530
+ await this.#client.setModel(route.provider, route.modelId).catch((error) => {
531
+ trace(`set_model ${key} failed: ${String(error)}`);
532
+ });
533
+ this.#lastSyncedModel = key;
534
+ }
535
+ /**
536
+ * Reserve a turn WITHOUT appending anything: the prompt path reserves
537
+ * synchronously, then commits turn/start + user/message only after the
538
+ * cold start (#startTurn).
539
+ */
540
+ #reserveTurn() {
541
+ if (this.#turnOpen)
542
+ return;
543
+ this.#dashTurn = ++this.#lastTurn;
544
+ this.#step = 0;
545
+ this.#turnOpen = true;
546
+ this.#localUserPending = true;
547
+ }
548
+ #openTurn(message, localUser = true) {
549
+ trace(`#openTurn turnOpen=${this.#turnOpen} -> turn=${this.#dashTurn + 1} localUser=${localUser}`);
550
+ if (this.#turnOpen)
551
+ return;
552
+ this.#reserveTurn();
553
+ this.#localUserPending = localUser;
554
+ this.session.append("turn/start", { turn: this.#dashTurn });
555
+ this.#emitRequestContext();
556
+ this.session.append("user/message", message, { surfaceOp: "append" });
557
+ }
558
+ #closeTurn(reason) {
559
+ if (!this.#turnOpen)
560
+ return;
561
+ this.#turnOpen = false;
562
+ this.#localUserPending = false;
563
+ this.session.append("turn/end", { turn: this.#dashTurn, reason });
564
+ }
565
+ /**
566
+ * Append the deferred `step/start` now that the step's first message has
567
+ * revealed which Dash turn owns it (a queued follow-up opens a fresh turn).
568
+ */
569
+ #commitStepStart() {
570
+ if (!this.#turnOpen)
571
+ return;
572
+ if (!this.#stepStartPending && this.#step > 0)
573
+ return;
574
+ this.#stepStartPending = false;
575
+ this.#step += 1;
576
+ this.session.append("step/start", { turn: this.#dashTurn, step: this.#step });
577
+ trace(`step/start turn=${this.#dashTurn} step=${this.#step}`);
578
+ }
579
+ /**
580
+ * Bridge a wire `message_start(role=user)` that was NOT appended locally.
581
+ * A `followUp` echo closes the current Dash turn and opens a fresh one with
582
+ * the retained message; a steering/injection echo adds a `user/message`
583
+ * step to the current turn. Falls back to the wire's own content if no
584
+ * retained message matches.
585
+ */
586
+ #bridgeRemoteUser(wireMessage) {
587
+ const entry = this.#remoteQueue.shift();
588
+ if (entry === undefined) {
589
+ this.#closeTurn({ kind: "completed" });
590
+ this.#openTurn(userMessageFromWire(wireMessage), false);
591
+ return;
592
+ }
593
+ if (entry.transport === "followUp") {
594
+ this.#closeTurn({ kind: "completed" });
595
+ this.#openTurn(entry.message, false);
596
+ return;
597
+ }
598
+ this.session.append("user/message", entry.message, { surfaceOp: "append" });
599
+ }
600
+ #beginActivity() {
601
+ // Settle any prior in-flight activity first: `send()` can inject a prompt
602
+ // mid-stream, superseding the current activity promise. Without settling
603
+ // the old one, whenIdle()'s do-while would await an orphaned promise.
604
+ this.#resolveActivityDone();
605
+ this.#activityDone = new Promise((resolve) => {
606
+ this.#resolveActivityDone = resolve;
607
+ });
608
+ }
609
+ #endActivity() {
610
+ this.#resolveActivityDone();
611
+ }
612
+ #markRunning() {
613
+ clearTimeout(this.#idleExitTimer);
614
+ this.#idleExitTimer = undefined;
615
+ if (this.#streaming)
616
+ return;
617
+ this.#streaming = true;
618
+ this.#dispatch.emit("agent/status", { status: "running" });
619
+ }
620
+ #markIdle() {
621
+ if (!this.#streaming)
622
+ return;
623
+ this.#streaming = false;
624
+ this.#dispatch.emit("agent/status", { status: "idle" });
625
+ this.#armIdleExit();
626
+ }
627
+ /**
628
+ * Schedule the idle exit. Skipped while deliveries are still queued or
629
+ * when the exit is disabled. Any later activity restarts or cancels it.
630
+ */
631
+ #armIdleExit() {
632
+ if (this.#disposed || this.#onIdleExit === undefined || PI_IDLE_EXIT_MS === 0)
633
+ return;
634
+ if (this.#remoteQueue.some((entry) => !entry.sent))
635
+ return;
636
+ clearTimeout(this.#idleExitTimer);
637
+ this.#idleExitTimer = setTimeout(() => {
638
+ this.#idleExitTimer = undefined;
639
+ void this.#revalidateIdleExit();
640
+ }, PI_IDLE_EXIT_MS);
641
+ this.#idleExitTimer.unref?.();
642
+ }
643
+ /**
644
+ * Quiescence re-validation at fire time: local signals plus the client's
645
+ * own attestation. Any busy signal re-arms instead of killing.
646
+ */
647
+ async #revalidateIdleExit() {
648
+ if (this.#disposed || this.#streaming)
649
+ return this.#armIdleExit();
650
+ if (this.#remoteQueue.some((entry) => !entry.sent))
651
+ return this.#armIdleExit();
652
+ try {
653
+ const state = this.#client.getState();
654
+ if (this.#streaming)
655
+ return this.#armIdleExit();
656
+ if (state.isStreaming)
657
+ return this.#armIdleExit();
658
+ }
659
+ catch {
660
+ // Closed client: local signals remain authoritative.
661
+ }
662
+ trace(`idle exit after ${PI_IDLE_EXIT_MS}ms — disposing agent ${this.id}`);
663
+ this.#onIdleExit?.();
664
+ }
665
+ #fail(error) {
666
+ this.#abandonStreamBridge();
667
+ if (this.#disposed)
668
+ return;
669
+ trace(`#fail ${String(error).slice(0, 200)}`);
670
+ this.#closeTurn({ kind: "error", error: { message: String(error), code: "UNKNOWN" } });
671
+ this.#markIdle();
672
+ this.#endActivity();
673
+ }
674
+ #handleEvent(event) {
675
+ switch (event.type) {
676
+ case "agent_start":
677
+ trace("event agent_start");
678
+ this.#markRunning();
679
+ this.#flushRemote();
680
+ break;
681
+ case "turn_start":
682
+ // Defer `step/start`: the step's owning turn is only known once its
683
+ // first message arrives (a queued follow-up opens a fresh turn).
684
+ trace("event turn_start");
685
+ this.#stepStartPending = true;
686
+ this.#pendingFailure = null;
687
+ // Open the step immediately (pi may never send message_start)
688
+ // and try the system prompt now that the session exists.
689
+ this.#commitStepStart();
690
+ this.#emitSystemMessage();
691
+ break;
692
+ case "message_start": {
693
+ const message = event.message;
694
+ if (message?.role === "user") {
695
+ if (this.#localUserPending) {
696
+ // The client is echoing the user message #openTurn already appended.
697
+ this.#localUserPending = false;
698
+ }
699
+ else {
700
+ // A queued follow-up / steering message delivered by the client.
701
+ this.#bridgeRemoteUser(message);
702
+ }
703
+ }
704
+ else if (message?.role === "assistant") {
705
+ // Reset per-message streaming accumulators.
706
+ this.#abandonStreamBridge();
707
+ }
708
+ // role === "toolResult" is bridged in message_end (deduped vs tool_execution_end).
709
+ this.#commitStepStart();
710
+ // pi's system prompt is stamped inside the first OPEN step of the
711
+ // first turn (the SDK session only exposes it once started).
712
+ this.#emitSystemMessage();
713
+ break;
714
+ }
715
+ case "message_update": {
716
+ const delta = event.assistantMessageEvent;
717
+ if (delta !== undefined)
718
+ this.#handleUpdate(delta);
719
+ break;
720
+ }
721
+ case "message_end": {
722
+ const message = event.message;
723
+ if (message === undefined)
724
+ break;
725
+ const role = message.role;
726
+ if (role === "assistant") {
727
+ const failure = wireFailure(message);
728
+ if (failure !== undefined) {
729
+ this.#pendingFailure = failure;
730
+ this.#abandonStreamBridge();
731
+ }
732
+ else {
733
+ this.#appendAssistantMessage(message);
734
+ }
735
+ }
736
+ else if (role === "toolResult") {
737
+ this.#appendToolResultMessage(message);
738
+ }
739
+ else if (role !== "user") {
740
+ process.stderr.write(`pi wire: unhandled message_end role "${role}"\n`);
741
+ }
742
+ break;
743
+ }
744
+ case "tool_execution_start": {
745
+ const callId = String(event.toolCallId ?? "");
746
+ const name = String(event.toolName ?? "");
747
+ this.#commitStepStart();
748
+ this.session.append("tool/call", {
749
+ turn: this.#dashTurn,
750
+ step: this.#step,
751
+ callId: ToolCallId(callId),
752
+ name,
753
+ // The DSH payload requires the RAW JSON string the model produced;
754
+ // the projection stringified pi's parsed args object.
755
+ arguments: typeof event.args === "string" ? event.args : JSON.stringify(event.args ?? {}),
756
+ });
757
+ break;
758
+ }
759
+ case "tool_execution_end":
760
+ this.#appendToolResult(event);
761
+ break;
762
+ case "turn_end":
763
+ trace("event turn_end");
764
+ // Last chance for the system prompt. Appended inside the still-open
765
+ // step (before step/end) so the step-scope invariant holds.
766
+ this.#emitSystemMessage();
767
+ if (this.#turnOpen && this.#step > 0) {
768
+ this.session.append("step/end", { turn: this.#dashTurn, step: this.#step });
769
+ }
770
+ break;
771
+ case "agent_end": {
772
+ trace(`event agent_end cancelCause=${String(this.#cancelCause)}`);
773
+ const pendingFailure = this.#pendingFailure;
774
+ this.#pendingFailure = null;
775
+ this.#closeTurn(this.#cancelCause !== null
776
+ ? { kind: "aborted", reason: this.#cancelCause }
777
+ : pendingFailure !== null
778
+ ? { kind: "error", error: pendingFailure }
779
+ : { kind: "completed" });
780
+ this.#cancelCause = null;
781
+ this.#markIdle();
782
+ this.#endActivity();
783
+ break;
784
+ }
785
+ case "session_title":
786
+ this.#mirrorTitle(event.name);
787
+ break;
788
+ case "compaction_start":
789
+ this.#openCompaction();
790
+ break;
791
+ case "compaction_end":
792
+ this.#closeCompaction();
793
+ break;
794
+ }
795
+ }
796
+ /**
797
+ * Mirror pi's session title into a `session/title` event. Dash does not
798
+ * generate titles for pi sessions (the native title rows are disabled in
799
+ * the bundle patch); pi owns the title and reports it through
800
+ * `session_info_changed`. Empty or unchanged titles are ignored, and a
801
+ * title is never allowed to affect the turn.
802
+ */
803
+ #mirrorTitle(name) {
804
+ if (name === undefined || name.trim() === "" || name === this.#mirroredTitle)
805
+ return;
806
+ this.#mirroredTitle = name;
807
+ try {
808
+ this.session.append("session/title", {
809
+ title: name,
810
+ messageSeqs: [],
811
+ source: { kind: "provider", provider: "pi" },
812
+ });
813
+ trace(`session/title ← pi: "${name}"`);
814
+ }
815
+ catch {
816
+ // fail-soft: a title never affects the turn
817
+ }
818
+ }
819
+ /** Open the compaction lifecycle bracket in the DSH log. */
820
+ #openCompaction() {
821
+ if (this.#compactionId !== null)
822
+ return;
823
+ const compactionId = randomUUID();
824
+ this.#compactionId = compactionId;
825
+ try {
826
+ this.session.append("compaction/start", { compactionId });
827
+ trace(`compaction/start ${compactionId}`);
828
+ }
829
+ catch {
830
+ this.#compactionId = null;
831
+ }
832
+ }
833
+ /** Close the compaction lifecycle bracket (fail-soft, never throws). */
834
+ #closeCompaction() {
835
+ const compactionId = this.#compactionId;
836
+ if (compactionId === null)
837
+ return;
838
+ this.#compactionId = null;
839
+ try {
840
+ this.session.append("compaction/end", { compactionId });
841
+ trace(`compaction/end ${compactionId}`);
842
+ }
843
+ catch {
844
+ // fail-soft
845
+ }
846
+ }
847
+ /** The open stream bridge for this message, starting one on first use. */
848
+ #ensureStreamBridge() {
849
+ if (this.#streamBridge === undefined || this.#streamBridge.ended) {
850
+ this.#streamBridge = new AssistantStreamBridge(this.session.id, ++this.#assistantAttemptCounter, () => ++this.#assistantStreamRevision, (frame) => this.#dispatch.emit("agent/assistant-stream", { frame }));
851
+ this.#streamBridge.start(this.#dashTurn, this.#step);
852
+ }
853
+ return this.#streamBridge;
854
+ }
855
+ /** Abandon any open stream bridge — failure paths never settle. */
856
+ #abandonStreamBridge() {
857
+ if (this.#streamBridge !== undefined && !this.#streamBridge.ended)
858
+ this.#streamBridge.abandon();
859
+ this.#streamBridge = undefined;
860
+ }
861
+ #handleUpdate(delta) {
862
+ switch (delta.type) {
863
+ case "text_delta":
864
+ if (typeof delta.delta === "string" && delta.delta !== "") {
865
+ const chunk = { type: "text-delta", index: delta.contentIndex ?? 0, text: delta.delta };
866
+ // v2: live text no longer appends `assistant/chunk` log events — the
867
+ // stream publishes as dense agent frames and embeds durably in the
868
+ // final assistant/message.
869
+ this.#ensureStreamBridge().push(chunk);
870
+ }
871
+ break;
872
+ default:
873
+ // thinking_*/toolcall_* are folded into the final message.
874
+ break;
875
+ }
876
+ }
877
+ #appendAssistantMessage(message) {
878
+ const content = convertContent(message.content);
879
+ // pi reports usage on every assistant message — same-event attachment,
880
+ // no lag.
881
+ const usage = convertUsage(message.usage);
882
+ const assistant = createAssistantMessage({
883
+ content,
884
+ source: {
885
+ // Wire messages may carry no attribution: fall back to this agent's
886
+ // options (the omp bridge did the same).
887
+ provider: String(message.provider ?? this.options.provider ?? ""),
888
+ model: String(message.model ?? this.options.model ?? ""),
889
+ },
890
+ });
891
+ const bridge = this.#streamBridge;
892
+ this.#commitStepStart();
893
+ // v2: the attempt's timed chunks embed in the event itself;
894
+ // `sourceEventSeqs` is forbidden on assistant/message in v2.
895
+ const event = this.session.append("assistant/message", {
896
+ turn: this.#dashTurn,
897
+ step: this.#step,
898
+ message: assistant,
899
+ stream: bridge?.records ?? [],
900
+ ...(usage === undefined ? {} : { usage }),
901
+ }, {
902
+ surfaceOp: "append",
903
+ });
904
+ if (bridge !== undefined && !bridge.ended)
905
+ bridge.settle(event.seq);
906
+ this.#streamBridge = undefined;
907
+ }
908
+ #appendToolResult(event) {
909
+ const callId = String(event.toolCallId ?? "");
910
+ this.#bridgedToolResults.add(callId);
911
+ const isError = event.isError === true;
912
+ const message = createToolResultMessage({
913
+ callId: ToolCallId(callId),
914
+ content: convertContent(event.result?.content),
915
+ isError,
916
+ });
917
+ this.session.append("tool/result", {
918
+ turn: this.#dashTurn,
919
+ step: this.#step,
920
+ message,
921
+ ...(isError ? { error: { name: "ToolExecutionError", code: "TOOL_ERROR" } } : {}),
922
+ }, {
923
+ surfaceOp: "append",
924
+ });
925
+ }
926
+ #appendToolResultMessage(message) {
927
+ const callId = String(message.toolCallId ?? "");
928
+ // The normal path bridges via `tool_execution_end` first; skip the
929
+ // `message_end(toolResult)` duplicate the wire always also emits.
930
+ if (this.#bridgedToolResults.has(callId))
931
+ return;
932
+ this.#bridgedToolResults.add(callId);
933
+ const isError = Boolean(message.isError ?? false);
934
+ const result = createToolResultMessage({
935
+ callId: ToolCallId(callId),
936
+ content: convertContent(message.content),
937
+ isError,
938
+ });
939
+ this.session.append("tool/result", {
940
+ turn: this.#dashTurn,
941
+ step: this.#step,
942
+ message: result,
943
+ ...(isError ? { error: { name: "ToolExecutionError", code: "TOOL_ERROR" } } : {}),
944
+ }, {
945
+ surfaceOp: "append",
946
+ });
947
+ }
948
+ }
949
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));