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,1271 @@
1
+ /**
2
+ * ClaudeAgent — the Dash `Agent` shim over one `ClaudeSdkClient` (a
3
+ * per-session Claude Agent SDK bridge).
4
+ *
5
+ * Bridges Claude's wire event stream (the neutral vocabulary projected by
6
+ * claude-events.ts) into the Dash `SessionEventMap`:
7
+ *
8
+ * Claude wire → Dash session event
9
+ * ────────────────────────────────────────────────────────────────
10
+ * (prompt delivers) → turn/start + request/context + user/message
11
+ * (first step-scoped event) → step/start + system/message (once per session)
12
+ * assistant_reasoning → live stream chunk + assistant/message {reasoning}
13
+ * assistant_text → live stream chunk + assistant/message {text}
14
+ * tool_start → tool/call (+ todo/write for TodoWrite)
15
+ * tool_end → tool/result
16
+ * compact_boundary → compaction/start + compaction/end bracket
17
+ * result (turn_end) → turn/end (+ usage)
18
+ * permission_denied → log-only trace
19
+ * model_refusal_fallback → retracted uuids traced + content re-projected
20
+ *
21
+ * One Claude turn (user prompt → result) maps to one Dash turn. A Dash step
22
+ * is one model attempt: the SDK delivers COMPLETE assistant messages (no
23
+ * deltas), each of which streams one live bridge chunk and buffers its text;
24
+ * the buffer flushes to an `assistant/message` immediately BEFORE the first
25
+ * `tool/call` it requested and again at `turn_end`. When a new assistant
26
+ * message arrives after the current step's attempt was already flushed, the
27
+ * step closes and the next one opens — the turn-usage fold requires exactly
28
+ * one assistant/message (one usage sample) per step.
29
+ *
30
+ * Usage: Claude reports accounting on every assistant message AND at
31
+ * `result` (`turn_end`). Every flush attaches the sample of the message that
32
+ * opened the attempt (RC-6) — the mid-turn `tool_start` flush included — so
33
+ * every tool turn proves its usage; `totalTokens` is synthesized from
34
+ * Claude's own counters. Nothing stashes across turns.
35
+ *
36
+ * The module is deliberately plain-TS-`private` (never `#`): the agent is
37
+ * handed to `ctx.agents.enter`, whose scope carrier and Cordis tracing proxy
38
+ * break on hard-private receivers.
39
+ */
40
+ import { randomUUID } from "node:crypto";
41
+ import { agentEvents } from "@deepseek-ai/dsh-agent";
42
+ import { LlmAttemptId, ToolCallId, createAssistantMessage, createSystemMessage, createToolResultMessage, createUserMessage, } from "@deepseek-ai/dsh-llm";
43
+ import { createScope } from "@deepseek-ai/dsh-scope";
44
+ import { toClaudeContent } from "./content.js";
45
+ import { Inbox } from "./inbox.js";
46
+ import { resetProjectionState } from "./claude-events.js";
47
+ import { CLAUDE_PROVIDER_ID } from "./adapter.js";
48
+ import { claudePermissionMode, presetFromEvents } from "./permission.js";
49
+ import { catalogFromInit, modelEntryFromSdk, readModelCatalog, setModelCatalog } from "./models.js";
50
+ import { registerClaudeCommands, slashCommandsFromReported, } from "./commands.js";
51
+ /** Diagnostic trace (set CLAUDE_TRACE=1 on the dsh process to enable). */
52
+ const TRACE = process.env.CLAUDE_TRACE === "1";
53
+ const trace = (...parts) => {
54
+ if (TRACE)
55
+ process.stderr.write(`[claude-agent ${Date.now() % 1_000_000}] ${parts.join(" ")}\n`);
56
+ };
57
+ /**
58
+ * Classify a Claude-side failure into a stable turn-error code (2026-09-18).
59
+ * `CONVERSATION_NOT_FOUND` is terminal for the pairing: the CLI never
60
+ * materialized a conversation under the recorded derived id (e.g. a failed
61
+ * first turn), so every later prompt would fail identically — the session is
62
+ * marked not resumable and ignored (user ruling: never resurrect, never
63
+ * re-wrap prior content).
64
+ */
65
+ export function classifyClaudeError(error) {
66
+ const text = error instanceof Error ? error.message : String(error);
67
+ return /No conversation found with session ID/i.test(text) ? "CONVERSATION_NOT_FOUND" : "UNKNOWN";
68
+ }
69
+ /** Map Claude's `result.usage` accounting into Dash `TokenUsage`. */
70
+ /** Map Claude's `result.usage` / assistant-message accounting into Dash `TokenUsage`. */
71
+ export function convertUsage(usage) {
72
+ if (usage === null || typeof usage !== "object")
73
+ return undefined;
74
+ const u = usage;
75
+ const inputTokens = typeof u.input_tokens === "number" ? u.input_tokens : 0;
76
+ const outputTokens = typeof u.output_tokens === "number" ? u.output_tokens : 0;
77
+ if (inputTokens === 0 && outputTokens === 0)
78
+ return undefined;
79
+ const cacheReadTokens = typeof u.cache_read_input_tokens === "number" ? u.cache_read_input_tokens : 0;
80
+ const cacheWriteTokens = typeof u.cache_creation_input_tokens === "number" ? u.cache_creation_input_tokens : 0;
81
+ // Synthesize the exact total from Claude's own counters (RC-6): the
82
+ // turn-usage fold's `normalizeUsage` needs `totalTokens` (or BOTH cache
83
+ // buckets) to prove an attempt, and prompt = input + cacheRead + cacheWrite
84
+ // is exactly the identity Claude's accounting guarantees.
85
+ const result = {
86
+ inputTokens,
87
+ outputTokens,
88
+ totalTokens: inputTokens + cacheReadTokens + cacheWriteTokens + outputTokens,
89
+ };
90
+ if (cacheReadTokens > 0)
91
+ result.cacheReadTokens = cacheReadTokens;
92
+ if (cacheWriteTokens > 0)
93
+ result.cacheWriteTokens = cacheWriteTokens;
94
+ return result;
95
+ }
96
+ /** Text-only preview of a Dash user message for the delivery trace. */
97
+ function userMessagePreview(message) {
98
+ const parts = [];
99
+ for (const block of message.content) {
100
+ if (block.type === "text")
101
+ parts.push(block.text);
102
+ }
103
+ return parts.join("\n");
104
+ }
105
+ /** The verified image reference returned by `readImage`, defensively read. */
106
+ function storedMediaType(stored) {
107
+ const mediaType = stored?.ref?.mediaType;
108
+ return typeof mediaType === "string" && mediaType !== "" ? mediaType : undefined;
109
+ }
110
+ /** A non-empty string field, or undefined when absent/blank/unusable. */
111
+ function stringOf(value) {
112
+ return typeof value === "string" && value !== "" ? value : undefined;
113
+ }
114
+ /** Human-readable detail for a contained failure. */
115
+ function describeError(error) {
116
+ const message = error instanceof Error ? error.message : String(error);
117
+ return message === "" ? "unknown error" : message;
118
+ }
119
+ /** One durable attachment reference projected into the fields the transcoder reads. */
120
+ function attachmentFields(ref) {
121
+ if (ref === null || typeof ref !== "object")
122
+ return {};
123
+ const record = ref;
124
+ return {
125
+ ...(typeof record.mediaType === "string" ? { mediaType: record.mediaType } : {}),
126
+ ...(typeof record.attachmentId === "string" ? { attachmentId: record.attachmentId } : {}),
127
+ };
128
+ }
129
+ /**
130
+ * Project one Dash user message's content blocks into the transcoder's input.
131
+ * Text, image and file blocks are ALL forwarded: an unattachable one must reach
132
+ * the transcoder so it is reported, not passed over. Reasoning and tool blocks
133
+ * are not user-attachable content and carry no wire meaning here.
134
+ */
135
+ function dshPromptBlocks(content) {
136
+ const blocks = [];
137
+ for (const block of content) {
138
+ switch (block.type) {
139
+ case "text":
140
+ blocks.push({ type: "text", text: block.text });
141
+ break;
142
+ case "image":
143
+ case "file":
144
+ blocks.push({ type: block.type, ...attachmentFields(block.attachment) });
145
+ break;
146
+ default:
147
+ break;
148
+ }
149
+ }
150
+ return blocks;
151
+ }
152
+ /**
153
+ * How long an agent may sit idle before its Claude client is torn down. The
154
+ * Dash host keeps resumed agents registered forever, and every idle agent pins
155
+ * a live CLI subprocess. `0` disables the exit.
156
+ */
157
+ const CLAUDE_IDLE_EXIT_MS = parseIdleExit(process.env.CLAUDE_IDLE_EXIT_MS);
158
+ function parseIdleExit(raw) {
159
+ if (raw === undefined || raw.trim() === "")
160
+ return 600_000;
161
+ const value = Number(raw);
162
+ return Number.isFinite(value) && value >= 0 ? value : 600_000;
163
+ }
164
+ /** Text/reasoning blocks for a refusal-fallback `content` payload. */
165
+ function refusalBlocks(content) {
166
+ if (!Array.isArray(content))
167
+ return [];
168
+ const blocks = [];
169
+ for (const raw of content) {
170
+ if (raw === null || typeof raw !== "object")
171
+ continue;
172
+ const block = raw;
173
+ if (block.type === "text" && typeof block.text === "string" && block.text !== "") {
174
+ blocks.push({ type: "text", text: block.text });
175
+ }
176
+ else if (block.type === "reasoning" && typeof block.text === "string" && block.text !== "") {
177
+ blocks.push({ type: "reasoning", text: block.text });
178
+ }
179
+ }
180
+ return blocks;
181
+ }
182
+ /**
183
+ * v2 live assistant-stream publication for the Claude bridge — the same
184
+ * frame protocol the reference loop's AssistantStreamAttempt speaks (start
185
+ * marker, dense zero-based chunks, terminal settlement) emitted over this
186
+ * agent's own dispatch, so the session-controller folds the stream into its
187
+ * reconnect baseline. The SDK delivers COMPLETE assistant messages (no
188
+ * deltas), so each arriving message's text/thinking pushes ONE chunk — that
189
+ * alone makes the text visible before the first `tool_start` flush. Chunks
190
+ * also accumulate as raw durable records embedded in the final
191
+ * assistant/message.
192
+ */
193
+ class AssistantStreamBridge {
194
+ attemptId;
195
+ records = [];
196
+ nextRevision;
197
+ emitFrame;
198
+ index = 0;
199
+ terminal = false;
200
+ constructor(sessionId, attempt, nextRevision, emitFrame) {
201
+ this.attemptId = LlmAttemptId(`${sessionId}:${attempt}`);
202
+ this.nextRevision = nextRevision;
203
+ this.emitFrame = emitFrame;
204
+ }
205
+ /** Whether the terminal frame already fired. */
206
+ get ended() {
207
+ return this.terminal;
208
+ }
209
+ /** Opening marker before the first delivered chunk. */
210
+ start(turn, step) {
211
+ this.emitFrame({ type: "start", attemptId: this.attemptId, revision: this.nextRevision(), turn, step });
212
+ }
213
+ /** One live chunk: durable record plus dense process-local frame. */
214
+ push(chunk) {
215
+ const time = Date.now();
216
+ this.records.push({ type: "chunk", time, chunk });
217
+ this.emitFrame({ type: "chunk", attemptId: this.attemptId, revision: this.nextRevision(), index: this.index++, time, chunk });
218
+ }
219
+ /** Terminal settlement after the durable assistant/message committed. */
220
+ settle(seq) {
221
+ this.terminal = true;
222
+ this.emitFrame({ type: "end", attemptId: this.attemptId, revision: this.nextRevision(), index: this.index, outcome: { kind: "committed", eventType: "assistant/message", seq } });
223
+ }
224
+ /** No durable attempt event will commit. */
225
+ abandon() {
226
+ this.terminal = true;
227
+ this.emitFrame({ type: "end", attemptId: this.attemptId, revision: this.nextRevision(), index: this.index, outcome: { kind: "abandoned" } });
228
+ }
229
+ }
230
+ export class ClaudeAgent {
231
+ id;
232
+ options;
233
+ session;
234
+ inbox;
235
+ ctx;
236
+ client;
237
+ loopCtx;
238
+ scope;
239
+ dispatch;
240
+ streaming = false;
241
+ activityDone = Promise.resolve();
242
+ resolveActivityDone = () => { };
243
+ dashTurn = 0;
244
+ step = 0;
245
+ lastTurn = 0;
246
+ turnOpen = false;
247
+ cancelCause = null;
248
+ /** Accumulated reasoning text of the in-flight assistant message. */
249
+ reasoningBuffer = "";
250
+ /** Accumulated visible text of the in-flight assistant message. */
251
+ textBuffer = "";
252
+ /** Usage captured from the latest projected assistant message (RC-6). */
253
+ capturedUsage = undefined;
254
+ /** The model the CLI put on the latest assistant message (observed, not requested). */
255
+ observedMessageModel = "";
256
+ /** Whether the current step already committed its `assistant/message` (one attempt per step). */
257
+ stepFlushed = false;
258
+ /** Whether a step is currently OPEN (the counter itself never resets mid-turn). */
259
+ stepOpen = false;
260
+ /** One-shot guard for the `system/message` surface node (never re-emitted). */
261
+ systemMessageDone = false;
262
+ /** Last `request/context` route key (`provider/model`) — dedupes per-turn emits. */
263
+ lastRouteKey = null;
264
+ /** Live v2 assistant-stream publication for the in-flight wire message. */
265
+ streamBridge;
266
+ assistantAttemptCounter = 0;
267
+ assistantStreamRevision = 0;
268
+ /** In-flight nested (subagent) tool calls, tracked from `parent_tool_use_id`. */
269
+ subagentCount = 0;
270
+ /** In-flight approval requests (the interaction bridge is wired in a later task). */
271
+ /** In-flight approval requests, tracked around the interaction bridge. */
272
+ pendingApproval = 0;
273
+ /** The runtime's own default model (from `session_init`), for the catalog. */
274
+ observedDefaultModel = "";
275
+ /** Deliveries parked while idle without a wakeup; flushed at the next prompt. */
276
+ remoteQueue = [];
277
+ /** Serialization chain for async prompt deliveries (attachment prefetch). */
278
+ deliveryChain = Promise.resolve();
279
+ sessionIdentityCommitted = false;
280
+ disposed = false;
281
+ idleExitTimer = undefined;
282
+ onIdleExit;
283
+ runtimeInfo;
284
+ notResumableMarked = false;
285
+ /**
286
+ * Claude's advertised command surface for THIS session, as last observed
287
+ * (`session_init.slash_commands`, then enriched by `supportedCommands()`).
288
+ * Read live by the registration mirror's `listSlashCommands`.
289
+ */
290
+ observedSlashCommands = [];
291
+ /** The agent-scoped `commands` service, once the registry is available. */
292
+ commandsAccessor = undefined;
293
+ /** Signature of the mirrored list, so an unchanged re-observation is a no-op. */
294
+ commandsSignature = "";
295
+ /** Releases the current mirror before it is rebuilt or the agent is disposed. */
296
+ commandsDisposer = undefined;
297
+ constructor(loopCtx, id, options, session, client, onIdleExit, runtimeInfo) {
298
+ this.loopCtx = loopCtx;
299
+ this.id = id;
300
+ this.options = options;
301
+ this.session = session;
302
+ this.client = client;
303
+ this.onIdleExit = onIdleExit;
304
+ this.runtimeInfo = runtimeInfo;
305
+ this.dispatch = agentEvents(loopCtx, this);
306
+ this.inbox = new Inbox();
307
+ this.scope = createScope(loopCtx, this);
308
+ this.ctx = this.scope.ctx.extend({ agent: this });
309
+ // Claude's slash commands are session state, so the mirror is registered on
310
+ // THIS agent's scope (`this.ctx` carries the agent's scope key): the
311
+ // registry then resolves it as an agent-scoped shadow, which is what keeps
312
+ // sibling sessions from colliding on the same `claude-*` names and what
313
+ // disposes the mirror with the session. `inject` also re-runs if the
314
+ // commands service is reloaded, so the mirror is rebuilt against the new
315
+ // registry instance.
316
+ this.ctx.inject(["commands"], (commandCtx) => {
317
+ this.attachCommandRuntime(commandCtx.commands);
318
+ });
319
+ this.lastTurn = session.snapshotEvents().findLast((event) => event.type === "turn/start")?.data.turn ?? 0;
320
+ client.on((event) => this.handleEvent(event));
321
+ // Session-start projection reset: the module-level tool-name map must never
322
+ // leak another session's tool into this one (Task 4 review finding).
323
+ resetProjectionState();
324
+ // Mid-session permission switching: reconcile the effective Claude mode on
325
+ // every DSH permission event (RC-5). `permission/preset` carries the mode
326
+ // itself; `sandbox/mode` names the skeleton preset directly; an
327
+ // `approval/policy` alone cannot pick a mode, so the log is re-folded.
328
+ this.ctx.on("session/event", (session, event) => {
329
+ if (session !== this.session)
330
+ return;
331
+ if (event.type !== "permission/preset" && event.type !== "sandbox/mode" && event.type !== "approval/policy")
332
+ return;
333
+ let mode;
334
+ if (event.type === "permission/preset") {
335
+ mode = event.data.claudeMode ?? claudePermissionMode(event.data.preset);
336
+ }
337
+ else if (event.type === "sandbox/mode") {
338
+ // The skeleton's sandbox mode names ARE the preset names.
339
+ mode = claudePermissionMode(event.data.mode);
340
+ }
341
+ if (mode === undefined) {
342
+ const folded = presetFromEvents(this.session.snapshotEvents());
343
+ if (folded === undefined)
344
+ return;
345
+ mode = claudePermissionMode(folded);
346
+ }
347
+ trace(`${event.type} observed — re-applying claudeMode ${mode}`);
348
+ void this.client.setPermissionMode(mode).catch((error) => {
349
+ trace(`setPermissionMode ${mode} failed: ${String(error)}`);
350
+ });
351
+ this.runtimeInfo?.onModeChange?.(mode);
352
+ });
353
+ }
354
+ get status() {
355
+ return this.streaming ? "running" : "idle";
356
+ }
357
+ /** Drive one prompt (the primary driving surface). */
358
+ prompt(message) {
359
+ this.deliver(message, "next-turn", true);
360
+ }
361
+ /** Queue a follow-up turn (the Web UI's `session.prompt` "queue" mode). */
362
+ followUp(message) {
363
+ this.deliver(message, "next-turn", true);
364
+ }
365
+ send(message, target, wakeup) {
366
+ this.deliver(message, target, wakeup);
367
+ }
368
+ followup(message) {
369
+ this.deliver(message, "next-turn", true);
370
+ }
371
+ steer(message) {
372
+ this.deliver(message, "next-step", true);
373
+ }
374
+ inject(message) {
375
+ this.deliver(message, "next-step", false);
376
+ }
377
+ interrupt() {
378
+ // Turn-level interrupt: the session survives, only the in-flight turn stops.
379
+ this.cancel({ kind: "user" }, { keepInbox: true });
380
+ }
381
+ cancel(cause, _options) {
382
+ if (this.disposed)
383
+ return;
384
+ if (!this.streaming)
385
+ return;
386
+ if (this.cancelCause === null)
387
+ this.cancelCause = cause;
388
+ void this.client.interrupt().catch(() => { });
389
+ }
390
+ async whenIdle() {
391
+ let activity;
392
+ do {
393
+ activity = this.activityDone;
394
+ await activity;
395
+ } while (activity !== this.activityDone);
396
+ }
397
+ /** Track one in-flight approval so the idle-TTL gate protects a session waiting on a human. */
398
+ async runApproval(task) {
399
+ this.pendingApproval += 1;
400
+ try {
401
+ return await task();
402
+ }
403
+ finally {
404
+ this.pendingApproval -= 1;
405
+ }
406
+ }
407
+ runMaintenance(task) {
408
+ return task(new AbortController().signal);
409
+ }
410
+ async dispose() {
411
+ if (this.disposed)
412
+ return;
413
+ this.disposed = true;
414
+ clearTimeout(this.idleExitTimer);
415
+ this.idleExitTimer = undefined;
416
+ if (this.turnOpen) {
417
+ this.abandonStreamBridge();
418
+ this.closeTurn({ kind: "aborted", reason: { kind: "disposed" } });
419
+ }
420
+ this.markIdle();
421
+ this.endActivity();
422
+ this.client.close();
423
+ resetProjectionState();
424
+ // Release the command mirror explicitly (the agent scope would also dispose
425
+ // it): the registry's own duplicate check makes the release observable.
426
+ const releaseCommands = this.commandsDisposer;
427
+ this.commandsDisposer = undefined;
428
+ this.commandsSignature = "";
429
+ // Session-observed data must not outlive the session (a resumed/next
430
+ // session re-observes it from its own `session_init`).
431
+ this.observedSlashCommands = [];
432
+ try {
433
+ releaseCommands?.();
434
+ }
435
+ finally {
436
+ await this.scope.dispose();
437
+ }
438
+ }
439
+ // ── internals ────────────────────────────────────────────────────────────
440
+ deliver(message, target, wakeup) {
441
+ if (this.disposed)
442
+ return;
443
+ const busy = this.turnOpen || this.streaming;
444
+ trace(`deliver target=${target} wakeup=${wakeup} busy=${busy}`);
445
+ if (busy) {
446
+ this.appendUserMessage(message);
447
+ this.scheduleDelivery(async () => {
448
+ const payload = await this.promptContent(message);
449
+ if (payload.kind === "empty") {
450
+ // A steer/followUp is not the turn's own content (unlike the opening
451
+ // prompt), so its notice has fired and the delivery is DROPPED here —
452
+ // never pushed empty, and never allowed to abort a turn the user is
453
+ // actively watching because one image failed to resolve.
454
+ trace(`busy ${target} delivery for turn ${this.dashTurn} has no attachable content — not pushed`);
455
+ return;
456
+ }
457
+ const sent = target === "next-turn" ? this.client.followUp(payload.content) : this.client.steer(payload.content);
458
+ await sent;
459
+ });
460
+ return;
461
+ }
462
+ if (!wakeup) {
463
+ // Idle injection: record it durably and park; a later prompt delivers it.
464
+ this.appendUserMessage(message);
465
+ this.remoteQueue.push({ message, sent: false, transport: "steer" });
466
+ return;
467
+ }
468
+ this.deliverPrompt(message);
469
+ }
470
+ /**
471
+ * Transcode one queued user message into the SDK client's prompt content and
472
+ * REPORT every block that could not be attached.
473
+ *
474
+ * Attachment bytes are read asynchronously HERE — DSH's store is async and
475
+ * returns a `Uint8Array` — into a plain id -> {mediaType, base64} map, and the
476
+ * unchanged pure `toClaudeContent` consumes it as a synchronous lookup. The
477
+ * resulting block array is handed to the client AS-IS: there is no "\n"
478
+ * re-join, so the transcoder's one-block-per-input-block shape reaches the
479
+ * wire intact.
480
+ */
481
+ async promptContent(message) {
482
+ const blocks = dshPromptBlocks(message.content);
483
+ let resolved = new Map();
484
+ let failures = new Map();
485
+ try {
486
+ ({ resolved, failures } = await this.resolveAttachments(message.content));
487
+ }
488
+ catch (error) {
489
+ // A catastrophic prefetch failure must not abort the turn: report it and
490
+ // continue text-only (the per-block skips below still name every loss).
491
+ this.reportSkippedBlocks([`attachment prefetch failed (${describeError(error)})`]);
492
+ }
493
+ const { content, skipped } = toClaudeContent(blocks, (id) => {
494
+ const hit = resolved.get(id);
495
+ if (hit !== undefined)
496
+ return hit;
497
+ const failure = failures.get(id);
498
+ // The transcoder contains a throwing reader into a reported skip, so the
499
+ // store's own failure detail survives into the notice.
500
+ if (failure !== undefined)
501
+ throw new Error(failure);
502
+ return undefined;
503
+ });
504
+ if (content.length === 0 && skipped.length === 0) {
505
+ // `dshPromptBlocks` filtered everything out (no text/image at all, or no
506
+ // content), so the transcoder had nothing to report. Name the loss HERE:
507
+ // this is the one path where content could otherwise vanish silently.
508
+ skipped.push(`the message carried no attachable content (${message.content.length} source block(s), none of them text or image)`);
509
+ }
510
+ this.reportSkippedBlocks(skipped);
511
+ if (content.length === 0) {
512
+ // Never synthesize the empty-text block the transcoder refuses to emit.
513
+ // The notice above has fired; the caller fails the turn loudly.
514
+ return { kind: "empty", reason: `no attachable content: ${skipped.join("; ")}` };
515
+ }
516
+ return { kind: "content", content };
517
+ }
518
+ /**
519
+ * Prefetch every durable image attachment one message references, because
520
+ * `toClaudeContent`'s reader is synchronous by contract while the DSH store
521
+ * is async. Every failure mode — a missing service, a malformed ref, a
522
+ * rejecting `readImage`, a non-byte payload, an unusable media type — is
523
+ * recorded with its detail and re-raised through the reader, so each one
524
+ * lands in `skipped` with an actionable reason.
525
+ */
526
+ async resolveAttachments(content) {
527
+ const resolved = new Map();
528
+ const failures = new Map();
529
+ const attachments = this.loopCtx.get("attachments");
530
+ for (const block of content) {
531
+ if (block.type !== "image")
532
+ continue;
533
+ const ref = block.attachment;
534
+ const id = ref !== null && typeof ref === "object"
535
+ ? stringOf(ref.attachmentId)
536
+ : undefined;
537
+ if (id === undefined)
538
+ continue; // malformed ref: the transcoder reports the missing attachmentId
539
+ if (resolved.has(id) || failures.has(id))
540
+ continue;
541
+ if (attachments === undefined || typeof attachments.readImage !== "function") {
542
+ failures.set(id, "no attachment service is mounted on the context");
543
+ continue;
544
+ }
545
+ try {
546
+ const stored = await attachments.readImage(ref);
547
+ const data = stored?.data;
548
+ if (!(data instanceof Uint8Array)) {
549
+ failures.set(id, "readImage returned no byte payload");
550
+ continue;
551
+ }
552
+ const mediaType = storedMediaType(stored) ?? stringOf(ref.mediaType);
553
+ if (mediaType === undefined) {
554
+ failures.set(id, "the stored reference carries no mediaType");
555
+ continue;
556
+ }
557
+ resolved.set(id, { mediaType, data: Buffer.from(data).toString("base64") });
558
+ }
559
+ catch (error) {
560
+ failures.set(id, describeError(error));
561
+ }
562
+ }
563
+ return { resolved, failures };
564
+ }
565
+ /**
566
+ * Serialize async prompt deliveries so a slow attachment read cannot reorder
567
+ * the pushes the client queue preserves FIFO.
568
+ */
569
+ scheduleDelivery(task) {
570
+ this.deliveryChain = this.deliveryChain.then(task).catch((error) => { this.fail(error); });
571
+ }
572
+ /**
573
+ * Surface every block the transcoder could not attach. Channel: an always-on
574
+ * stderr warning plus the CLAUDE_TRACE diagnostic. A log-only session event
575
+ * was rejected on purpose — `Session.append` cannot set the envelope's
576
+ * `ignorable` marker, and the persistence read path refuses any event type
577
+ * unknown to this harness that is not ignorable
578
+ * (`@deepseek-ai/dsh-session-persistence` `validateStoredEvents`), so
579
+ * appending a custom notice would make the whole session log unreadable.
580
+ */
581
+ reportSkippedBlocks(skipped) {
582
+ if (skipped.length === 0)
583
+ return;
584
+ const detail = skipped.join("; ");
585
+ process.stderr.write(`agent-claude: ${skipped.length} content block(s) not attached to the Claude request: ${detail}\n`);
586
+ trace(`content blocks not attached: ${detail}`);
587
+ }
588
+ deliverPrompt(message) {
589
+ if (this.disposed)
590
+ return;
591
+ trace(`deliverPrompt turn=${this.dashTurn + 1} text="${userMessagePreview(message).slice(0, 40)}"`);
592
+ this.reserveTurn();
593
+ clearTimeout(this.idleExitTimer);
594
+ this.idleExitTimer = undefined;
595
+ this.beginActivity();
596
+ this.markRunning();
597
+ // Enter the SAME serialization chain the busy path uses, so a steer that
598
+ // arrives during cold start (after `reserveTurn` flips `turnOpen`) cannot
599
+ // overtake this opening prompt and invert FIFO: the chain slot is taken
600
+ // here, before `startTurn` yields on `ensureStarted`.
601
+ this.scheduleDelivery(() => this.startTurn(message));
602
+ }
603
+ reserveTurn() {
604
+ if (this.turnOpen)
605
+ return;
606
+ this.dashTurn = ++this.lastTurn;
607
+ this.step = 0;
608
+ this.stepOpen = false;
609
+ this.stepFlushed = false;
610
+ this.turnOpen = true;
611
+ }
612
+ /**
613
+ * Cold-start path for one reserved turn: confirm the client is started,
614
+ * commit the session-identity events (agent-preset + permission stamps),
615
+ * then append turn/start + user/message and dispatch. A failed cold start
616
+ * synthesizes the failed turn (`turn/end{reason:{kind:'error'}}`) rather
617
+ * than hanging, then unwinds the reservation.
618
+ */
619
+ async startTurn(message) {
620
+ try {
621
+ await this.client.ensureStarted();
622
+ await this.bootstrapSessionIdentity();
623
+ }
624
+ catch (error) {
625
+ trace(`startTurn cold start failed: ${String(error)}`);
626
+ if (this.turnOpen) {
627
+ this.session.append("turn/start", { turn: this.dashTurn });
628
+ this.appendUserMessage(message);
629
+ this.closeTurn({ kind: "error", error: { message: String(error), code: classifyClaudeError(error) } });
630
+ }
631
+ this.markNotResumableIfDead(error);
632
+ this.markIdle();
633
+ this.endActivity();
634
+ return;
635
+ }
636
+ if (this.disposed)
637
+ return;
638
+ this.session.append("turn/start", { turn: this.dashTurn });
639
+ // Route metadata for the next request (`request/context`) lands before
640
+ // the turn's messages, exactly as the reference loop stamps it.
641
+ this.emitRequestContext();
642
+ this.appendUserMessage(message);
643
+ // Flush any idle-injection that was parked before this turn.
644
+ for (const entry of this.remoteQueue.splice(0)) {
645
+ this.appendUserMessage(entry.message);
646
+ const parked = await this.promptContent(entry.message);
647
+ if (parked.kind === "empty") {
648
+ // A parked injection is context, not the turn's content: report it
649
+ // (already done) and drop it rather than pushing an empty message or
650
+ // aborting the turn the host actually asked for.
651
+ trace(`parked injection for turn ${this.dashTurn} has no attachable content — not pushed`);
652
+ continue;
653
+ }
654
+ void this.client.steer(parked.content).catch((error) => this.fail(error));
655
+ }
656
+ const payload = await this.promptContent(message);
657
+ if (payload.kind === "empty") {
658
+ // Nothing attachable: never push a blank turn — synthesize the failed
659
+ // turn exactly as a cold-start failure does, after the notice fired.
660
+ this.fail(new Error(payload.reason));
661
+ return;
662
+ }
663
+ void this.client.prompt(payload.content).catch((error) => this.fail(error));
664
+ }
665
+ /**
666
+ * One-shot first-turn identity commit: the agent-preset stamp plus, when
667
+ * the log fold carries no revivable preset, the `permission/preset` event
668
+ * carrying the single-source `claudeMode` and the live `setPermissionMode`
669
+ * re-application.
670
+ *
671
+ * RC-5: the LOG FOLD is the permission authority at first turn. A preset
672
+ * chosen in the blank window (between create and the first prompt) is
673
+ * already folded into `permission/preset` — re-stamping here would bounce
674
+ * the user's choice back to the create-time preset, so BOTH the stamp and
675
+ * the live call are skipped when the fold already carries one (the resume
676
+ * path re-applied the cached mode at client construction).
677
+ */
678
+ async bootstrapSessionIdentity() {
679
+ if (this.sessionIdentityCommitted)
680
+ return;
681
+ this.sessionIdentityCommitted = true;
682
+ if (!this.session.snapshotEvents().some((event) => event.type === "agent-preset/selected" && event.data?.agentPreset === "claude")) {
683
+ this.session.append("agent-preset/selected", { agentPreset: "claude" });
684
+ }
685
+ if (presetFromEvents(this.session.snapshotEvents()) !== undefined) {
686
+ trace("permission/preset already folded into the log — skipping the first-turn re-stamp and mode re-application");
687
+ return;
688
+ }
689
+ const preset = this.runtimeInfo?.preset;
690
+ const claudeMode = this.runtimeInfo?.claudeMode ?? claudePermissionMode(preset);
691
+ if (preset !== undefined) {
692
+ this.session.append("permission/preset", { preset, claudeMode });
693
+ trace(`permission/preset ${preset} -> claudeMode ${claudeMode}`);
694
+ }
695
+ void this.client.setPermissionMode(claudeMode).catch((error) => {
696
+ trace(`setPermissionMode ${claudeMode} failed: ${String(error)}`);
697
+ });
698
+ }
699
+ /**
700
+ * Stamp the session's system prompt once per session as the step's surface
701
+ * node. `system/message` is a step-scoped surface event, so this runs only
702
+ * after `step/start` committed. Skips when the log already carries one
703
+ * (resume) and while no step is open — retried on later steps until the
704
+ * facts are available.
705
+ *
706
+ * Prompt source: the Claude Agent SDK exposes NO system-prompt text to
707
+ * hosts (verified against `SDKSystemMessage` and the initialize response —
708
+ * `systemPrompt` exists only as an outbound Options/request field, and
709
+ * `systemPromptSections` is a /context token report). So the node records
710
+ * clearly-labeled adapter-synthesized FACTS, never a fabricated CLI prompt.
711
+ */
712
+ emitSystemMessage() {
713
+ if (this.systemMessageDone)
714
+ return;
715
+ if (!this.turnOpen)
716
+ return;
717
+ this.commitStepStart();
718
+ if (!this.stepOpen)
719
+ return;
720
+ if (this.session.snapshotEvents().some((event) => event.type === "system/message")) {
721
+ this.systemMessageDone = true;
722
+ return;
723
+ }
724
+ const model = this.observedMessageModel || this.observedDefaultModel || this.options.model || "unknown";
725
+ const mode = this.runtimeInfo?.claudeMode ?? claudePermissionMode(presetFromEvents(this.session.snapshotEvents()));
726
+ const text = [
727
+ "(agent-claude adapter) — session facts synthesized by the adapter.",
728
+ "The Claude Agent SDK does not expose the Claude Code system prompt to hosts, so this node records the resolved runtime facts instead:",
729
+ `- model: ${model}`,
730
+ `- cwd: ${this.client.cwd}`,
731
+ `- permission mode: ${mode}`,
732
+ ].join("\n");
733
+ this.session.append("system/message", {
734
+ turn: this.dashTurn,
735
+ step: this.step,
736
+ message: createSystemMessage(text, "aw.agent-adapter-claude"),
737
+ }, { surfaceOp: "append" });
738
+ this.systemMessageDone = true;
739
+ trace(`system/message stamped (${text.length} chars)`);
740
+ }
741
+ /**
742
+ * Stamp route metadata for the next request (`request/context`): the
743
+ * effective provider/model plus the model's context window when genuinely
744
+ * known. Turn-enclosed (no step required) and deduped by route key.
745
+ */
746
+ emitRequestContext() {
747
+ if (!this.turnOpen)
748
+ return;
749
+ const model = this.observedMessageModel || this.observedDefaultModel || this.options.model || "";
750
+ if (model === "")
751
+ return;
752
+ const key = `${CLAUDE_PROVIDER_ID}/${model}`;
753
+ if (key === this.lastRouteKey)
754
+ return;
755
+ this.lastRouteKey = key;
756
+ const entry = readModelCatalog().models.find((candidate) => candidate.id === model);
757
+ const contextWindow = entry?.contextWindow;
758
+ this.session.append("request/context", {
759
+ provider: CLAUDE_PROVIDER_ID,
760
+ model,
761
+ ...(contextWindow !== undefined && contextWindow > 0 ? { contextWindow } : {}),
762
+ });
763
+ trace(`request/context ${key}${contextWindow === undefined ? "" : ` ctx=${contextWindow}`}`);
764
+ }
765
+ /** The open stream bridge for this message, starting one on first use. */
766
+ ensureStreamBridge() {
767
+ if (this.streamBridge === undefined || this.streamBridge.ended) {
768
+ this.streamBridge = new AssistantStreamBridge(this.session.id, ++this.assistantAttemptCounter, () => ++this.assistantStreamRevision, (frame) => this.dispatch.emit("agent/assistant-stream", { frame }));
769
+ this.streamBridge.start(this.dashTurn, this.step);
770
+ }
771
+ return this.streamBridge;
772
+ }
773
+ /** Abandon any open stream bridge — failure paths never settle. */
774
+ abandonStreamBridge() {
775
+ if (this.streamBridge !== undefined && !this.streamBridge.ended)
776
+ this.streamBridge.abandon();
777
+ this.streamBridge = undefined;
778
+ }
779
+ /** Capture the usage/model an assistant wire event reported (RC-6). */
780
+ captureProvenance(event) {
781
+ if (typeof event.model === "string" && event.model !== "")
782
+ this.observedMessageModel = event.model;
783
+ const usage = convertUsage(event.usage);
784
+ if (usage !== undefined)
785
+ this.capturedUsage = usage;
786
+ }
787
+ /**
788
+ * Close the current step after its attempt was already flushed, so the next
789
+ * assistant message opens a fresh attempt step (the turn-usage fold needs
790
+ * exactly one usage sample per step). No-op unless a flushed message is
791
+ * actually open.
792
+ */
793
+ closeAttemptStep() {
794
+ if (!this.turnOpen || !this.stepOpen || !this.stepFlushed)
795
+ return;
796
+ this.session.append("step/end", { turn: this.dashTurn, step: this.step });
797
+ // Keep the COUNTER: steps within one turn number 1, 2, 3, … (the r1 code
798
+ // reset it to 0 here, so every step of a multi-step turn was numbered 1 —
799
+ // duplicate (turn, step) coordinates corrupted the Web transcript replay,
800
+ // live-found 2026-09-18).
801
+ this.stepOpen = false;
802
+ this.stepFlushed = false;
803
+ }
804
+ appendUserMessage(message) {
805
+ this.session.append("user/message", message, { surfaceOp: "append" });
806
+ }
807
+ /** Open the step lazily before the first step-scoped event of the turn. */
808
+ commitStepStart() {
809
+ if (!this.turnOpen || this.stepOpen)
810
+ return;
811
+ this.step += 1;
812
+ this.stepOpen = true;
813
+ this.stepFlushed = false;
814
+ this.session.append("step/start", { turn: this.dashTurn, step: this.step });
815
+ trace(`step/start turn=${this.dashTurn} step=${this.step}`);
816
+ // The system prompt is stamped inside the first OPEN step of the session
817
+ // (the node precedes the step's messages, mirroring the reference loop).
818
+ this.emitSystemMessage();
819
+ }
820
+ commitStepEnd() {
821
+ if (!this.turnOpen || !this.stepOpen)
822
+ return;
823
+ this.session.append("step/end", { turn: this.dashTurn, step: this.step });
824
+ this.stepOpen = false;
825
+ }
826
+ closeTurn(reason) {
827
+ if (!this.turnOpen)
828
+ return;
829
+ this.turnOpen = false;
830
+ this.session.append("turn/end", { turn: this.dashTurn, reason });
831
+ }
832
+ /**
833
+ * Flush buffered reasoning/text as one `assistant/message` (the attempt's
834
+ * durable settlement), then reset. The sample rides the SAME event (RC-6):
835
+ * the explicit `usage` wins, else the latest assistant-message usage the
836
+ * projection carried — so the `tool_start` flush lands a same-attempt
837
+ * sample and nothing ever stashes across turns.
838
+ */
839
+ flushAssistant(usage) {
840
+ if (this.reasoningBuffer === "" && this.textBuffer === "") {
841
+ if (usage !== undefined)
842
+ trace("flush had usage but no buffered content — sample dropped (no message carries it)");
843
+ return;
844
+ }
845
+ const blocks = [];
846
+ if (this.reasoningBuffer !== "")
847
+ blocks.push({ type: "reasoning", text: this.reasoningBuffer });
848
+ if (this.textBuffer !== "")
849
+ blocks.push({ type: "text", text: this.textBuffer });
850
+ this.reasoningBuffer = "";
851
+ this.textBuffer = "";
852
+ const effectiveUsage = usage ?? this.capturedUsage;
853
+ this.capturedUsage = undefined;
854
+ this.commitStepStart();
855
+ const bridge = this.streamBridge;
856
+ const assistant = createAssistantMessage({
857
+ content: blocks,
858
+ // The OBSERVED model (what the CLI actually ran), not the requested one.
859
+ source: { provider: CLAUDE_PROVIDER_ID, model: this.observedMessageModel || this.observedDefaultModel || this.options.model || "" },
860
+ });
861
+ const event = this.session.append("assistant/message", {
862
+ turn: this.dashTurn,
863
+ step: this.step,
864
+ message: assistant,
865
+ stream: bridge?.records ?? [],
866
+ ...(effectiveUsage === undefined ? {} : { usage: effectiveUsage }),
867
+ }, {
868
+ surfaceOp: "append",
869
+ });
870
+ this.stepFlushed = true;
871
+ if (bridge !== undefined && !bridge.ended)
872
+ bridge.settle(event.seq);
873
+ this.streamBridge = undefined;
874
+ }
875
+ appendToolCall(callId, name, args) {
876
+ this.commitStepStart();
877
+ this.session.append("tool/call", {
878
+ turn: this.dashTurn,
879
+ step: this.step,
880
+ callId: ToolCallId(callId),
881
+ name,
882
+ arguments: JSON.stringify(args ?? {}),
883
+ });
884
+ }
885
+ appendToolResult(callId, content, isError) {
886
+ this.commitStepStart();
887
+ const message = createToolResultMessage({
888
+ callId: ToolCallId(callId),
889
+ content: content === "" ? [] : [{ type: "text", text: content }],
890
+ isError,
891
+ });
892
+ this.session.append("tool/result", {
893
+ turn: this.dashTurn,
894
+ step: this.step,
895
+ message,
896
+ ...(isError ? { error: { name: "ToolExecutionError", code: "TOOL_ERROR" } } : {}),
897
+ }, {
898
+ surfaceOp: "append",
899
+ });
900
+ }
901
+ /**
902
+ * Feed the observed model into the catalog: the degraded `session_init.model`
903
+ * read immediately, then the full `supportedModels()` list replaces it.
904
+ */
905
+ observeModelCatalog(init) {
906
+ if (typeof init.model === "string" && init.model !== "")
907
+ this.observedDefaultModel = init.model;
908
+ const entries = catalogFromInit(init);
909
+ if (entries.length > 0)
910
+ setModelCatalog(entries, this.observedDefaultModel);
911
+ void this.client.supportedModels().then((models) => {
912
+ const full = Array.isArray(models) ? models.flatMap((m) => modelEntryFromSdk(m)) : [];
913
+ if (full.length > 0)
914
+ setModelCatalog(full, this.observedDefaultModel);
915
+ }).catch((error) => {
916
+ trace(`supportedModels() failed: ${String(error)}`);
917
+ });
918
+ }
919
+ /**
920
+ * Learn Claude's command surface for this session: the degraded
921
+ * `session_init.slash_commands` name list immediately (same two-stage shape as
922
+ * the model catalog above), then the authoritative `supportedCommands()` list
923
+ * — which carries each command's description and argument hint — replaces it.
924
+ * Both stages end in {@link syncClaudeCommands}, so a session that reports
925
+ * nothing lands zero registrations rather than throwing.
926
+ */
927
+ observeSlashCommands(init) {
928
+ this.observedSlashCommands = slashCommandsFromReported(init.slashCommands);
929
+ trace(`observed ${this.observedSlashCommands.length} Claude slash command(s) from session_init`);
930
+ this.syncClaudeCommands();
931
+ const fetchSupported = this.client.supportedCommands;
932
+ if (typeof fetchSupported !== "function")
933
+ return;
934
+ void fetchSupported.call(this.client).then((commands) => {
935
+ const full = slashCommandsFromReported(commands);
936
+ if (full.length === 0)
937
+ return;
938
+ this.observedSlashCommands = full;
939
+ trace(`observed ${full.length} Claude slash command(s) from supportedCommands()`);
940
+ this.syncClaudeCommands();
941
+ }).catch((error) => {
942
+ trace(`supportedCommands() failed: ${String(error)}`);
943
+ });
944
+ }
945
+ /** Adopt the agent-scoped commands registry and build the mirror against it. */
946
+ attachCommandRuntime(runtime) {
947
+ this.commandsAccessor = { commands: runtime };
948
+ // `inject` re-runs when the service is replaced: the previous mirror lived
949
+ // on the previous registry instance (and was disposed with it), so forgetting
950
+ // the signature forces a rebuild instead of an early return.
951
+ this.commandsSignature = "";
952
+ this.commandsDisposer = undefined;
953
+ this.syncClaudeCommands();
954
+ }
955
+ /**
956
+ * Keep the agent-scoped command mirror equal to the observed list.
957
+ *
958
+ * Rebuilds only when the observed surface actually changed, and always
959
+ * releases the previous mirror BEFORE registering the new one: the registry
960
+ * rejects a duplicate name inside one scope layer, so a rebuild that
961
+ * registered first would throw on its own names.
962
+ */
963
+ syncClaudeCommands() {
964
+ const accessor = this.commandsAccessor;
965
+ if (accessor === undefined || this.disposed) {
966
+ // No commands service in this world: the adapter simply has no mirror.
967
+ return;
968
+ }
969
+ const signature = this.observedSlashCommands
970
+ .map((command) => `${command.name}\u0000${command.description ?? ""}\u0000${command.argumentHint ?? ""}`)
971
+ .join("\u0001");
972
+ if (signature === this.commandsSignature)
973
+ return;
974
+ this.commandsSignature = signature;
975
+ const previous = this.commandsDisposer;
976
+ this.commandsDisposer = undefined;
977
+ previous?.();
978
+ if (this.observedSlashCommands.length === 0)
979
+ return;
980
+ try {
981
+ this.commandsDisposer = registerClaudeCommands(accessor, {
982
+ listSlashCommands: () => this.observedSlashCommands,
983
+ submit: (agent, line) => { this.submitClaudeLine(agent, line); },
984
+ });
985
+ }
986
+ catch (error) {
987
+ // A rejected mirror must not pin the signature (a later observation would
988
+ // then skip the retry); the client's listener containment reports it loud.
989
+ this.commandsSignature = "";
990
+ throw error;
991
+ }
992
+ trace(`mirrored ${this.observedSlashCommands.length} Claude command(s) onto ctx.commands`);
993
+ }
994
+ /**
995
+ * Deliver one forwarded Claude slash line to THIS session.
996
+ *
997
+ * The mirror is registered on this agent's scope, so the invocation's agent is
998
+ * always this instance; the identity guard makes a misrouted invocation a
999
+ * loud no-op instead of driving another session's CLI. The line becomes an
1000
+ * ordinary user turn (`prompt`): it queues behind an open turn and opens one
1001
+ * when idle, so Claude's own slash-command handling sees the exact line and
1002
+ * the output lands in the transcript like any other turn.
1003
+ */
1004
+ submitClaudeLine(agent, line) {
1005
+ if (agent !== this) {
1006
+ trace(`claude command "${line}" arrived for a foreign agent — dropped`);
1007
+ return;
1008
+ }
1009
+ trace(`forwarding "${line}" to Claude`);
1010
+ this.prompt(createUserMessage({ content: [{ type: "text", text: line }], source: { kind: "user" } }));
1011
+ }
1012
+ handleEvent(event) {
1013
+ switch (event.type) {
1014
+ case "session_init":
1015
+ trace(`session_init model=${String(event.model)}`);
1016
+ this.observeModelCatalog(event);
1017
+ this.observeSlashCommands(event);
1018
+ break;
1019
+ case "assistant_reasoning":
1020
+ case "assistant_text": {
1021
+ // One COMPLETE SDK assistant message (no deltas): capture its
1022
+ // usage/model, make the text visible NOW via one bridge chunk (it
1023
+ // must precede the first tool_start flush), and buffer it for the
1024
+ // durable flush.
1025
+ const text = typeof event.text === "string" ? event.text : "";
1026
+ this.captureProvenance(event);
1027
+ if ((event.parentToolUseId ?? null) === null) {
1028
+ // A new main-thread assistant message after this step's attempt was
1029
+ // already flushed (tool round completed) opens the NEXT attempt's
1030
+ // step — the turn-usage fold needs one assistant/message per step.
1031
+ if (this.stepFlushed)
1032
+ this.closeAttemptStep();
1033
+ const bridge = this.ensureStreamBridge();
1034
+ if (text !== "") {
1035
+ bridge.push(event.type === "assistant_text"
1036
+ ? { type: "text-delta", index: 0, text }
1037
+ : { type: "reasoning-delta", index: 0, text });
1038
+ }
1039
+ }
1040
+ if (event.type === "assistant_reasoning")
1041
+ this.reasoningBuffer += text;
1042
+ else
1043
+ this.textBuffer += text;
1044
+ break;
1045
+ }
1046
+ case "assistant_message":
1047
+ // A raw assistant body with no recognized text/reasoning/tool block.
1048
+ trace("assistant_message (unrecognized body) ignored");
1049
+ break;
1050
+ case "tool_start": {
1051
+ const callId = String(event.callId ?? "");
1052
+ const name = String(event.name ?? "tool");
1053
+ const parent = event.parentToolUseId ?? null;
1054
+ if (parent !== null)
1055
+ this.subagentCount += 1;
1056
+ // RC-6: the mid-turn flush closes the attempt WITH the sample the
1057
+ // message itself reported (captured above / on the assistant events).
1058
+ this.captureProvenance(event);
1059
+ this.flushAssistant();
1060
+ this.appendToolCall(callId, name, event.arguments);
1061
+ break;
1062
+ }
1063
+ case "tool_end": {
1064
+ const callId = String(event.callId ?? "");
1065
+ const parent = event.parentToolUseId ?? null;
1066
+ if (parent !== null)
1067
+ this.subagentCount = Math.max(0, this.subagentCount - 1);
1068
+ const isError = event.isError === true;
1069
+ const content = typeof event.content === "string" ? event.content : "";
1070
+ this.appendToolResult(callId, content, isError);
1071
+ break;
1072
+ }
1073
+ case "turn_end": {
1074
+ trace(`turn_end isError=${String(event.isError)} stopReason=${String(event.stopReason)}`);
1075
+ // Last chance for the system prompt (appended inside the still-open
1076
+ // step, before step/end, so the step-scope invariant holds).
1077
+ this.emitSystemMessage();
1078
+ const usage = convertUsage(event.usage);
1079
+ this.flushAssistant(usage);
1080
+ this.commitStepEnd();
1081
+ const cancelCause = this.cancelCause;
1082
+ this.cancelCause = null;
1083
+ this.closeTurn(cancelCause !== null
1084
+ ? { kind: "aborted", reason: cancelCause }
1085
+ : event.isError === true
1086
+ ? { kind: "error", error: { message: "Claude turn failed", code: classifyClaudeError(event.error) } }
1087
+ : { kind: "completed" });
1088
+ this.markIdle();
1089
+ this.endActivity();
1090
+ break;
1091
+ }
1092
+ case "todo_write": {
1093
+ // Log-only whole-list snapshot (latest-wins); the tool pair already
1094
+ // rode the same assistant message.
1095
+ const todos = Array.isArray(event.todos) ? event.todos : [];
1096
+ try {
1097
+ this.session.append("todo/write", { todos });
1098
+ trace(`todo/write ${todos.length} item(s)`);
1099
+ }
1100
+ catch (error) {
1101
+ trace(`todo/write failed: ${String(error)}`);
1102
+ }
1103
+ break;
1104
+ }
1105
+ case "compaction": {
1106
+ // The CLI's compact_boundary reports an ALREADY-COMPLETE compaction:
1107
+ // emit the DSH bracket (start+end, standalone owner when no turn is
1108
+ // open) so the vocabulary folds instead of staying trace-only.
1109
+ const compactionId = randomUUID();
1110
+ const ownerTurn = this.turnOpen ? this.dashTurn : null;
1111
+ try {
1112
+ this.session.append("compaction/start", { compactionId, turn: ownerTurn });
1113
+ this.session.append("compaction/end", { compactionId, turn: ownerTurn });
1114
+ trace(`compaction bracket ${compactionId} (turn=${String(ownerTurn)}) metadata=${JSON.stringify(event.metadata)}`);
1115
+ }
1116
+ catch (error) {
1117
+ trace(`compaction bracket failed: ${String(error)}`);
1118
+ }
1119
+ break;
1120
+ }
1121
+ case "compaction":
1122
+ // V1 gap: the dsh-compaction event vocabulary is not referenced here.
1123
+ trace(`compaction boundary (log-only): ${JSON.stringify(event.metadata)}`);
1124
+ break;
1125
+ case "permission_denied":
1126
+ trace(`permission_denied (log-only): ${String(event.toolName)} ${String(event.reasonType)}`);
1127
+ break;
1128
+ case "refusal_fallback": {
1129
+ const uuids = Array.isArray(event.retractedMessageUuids) ? event.retractedMessageUuids : [];
1130
+ trace(`refusal_fallback: retracted=${JSON.stringify(uuids)} original=${String(event.originalModel)} fallback=${String(event.fallbackModel)}`);
1131
+ const blocks = refusalBlocks(event.content);
1132
+ if (blocks.length > 0) {
1133
+ this.commitStepStart();
1134
+ const assistant = createAssistantMessage({
1135
+ content: blocks,
1136
+ source: { provider: CLAUDE_PROVIDER_ID, model: String(event.fallbackModel ?? this.options.model ?? "") },
1137
+ });
1138
+ this.session.append("assistant/message", {
1139
+ turn: this.dashTurn,
1140
+ step: this.step,
1141
+ message: assistant,
1142
+ stream: [],
1143
+ }, { surfaceOp: "append" });
1144
+ }
1145
+ break;
1146
+ }
1147
+ case "refusal_no_fallback":
1148
+ trace(`refusal_no_fallback (log-only): ${String(event.originalModel)} ${String(event.category)}`);
1149
+ break;
1150
+ case "local_command_output":
1151
+ trace(`local_command_output (log-only): ${String(event.content).slice(0, 120)}`);
1152
+ break;
1153
+ case "agent_end": {
1154
+ const isError = event.isError === true;
1155
+ const error = typeof event.error === "string" ? event.error : undefined;
1156
+ if (this.turnOpen) {
1157
+ this.flushAssistant();
1158
+ this.commitStepEnd();
1159
+ const cancelCause = this.cancelCause;
1160
+ this.cancelCause = null;
1161
+ this.closeTurn(cancelCause !== null
1162
+ ? { kind: "aborted", reason: cancelCause }
1163
+ : isError
1164
+ ? { kind: "error", error: { message: error ?? "Claude session ended with an error", code: classifyClaudeError(error) } }
1165
+ : { kind: "completed" });
1166
+ }
1167
+ this.markIdle();
1168
+ this.endActivity();
1169
+ break;
1170
+ }
1171
+ default:
1172
+ // Unknown wire shapes are ignored, never an error (Task 9 hazard 2).
1173
+ break;
1174
+ }
1175
+ }
1176
+ fail(error) {
1177
+ if (this.disposed)
1178
+ return;
1179
+ trace(`fail ${String(error).slice(0, 200)}`);
1180
+ // Failure paths never settle a stream attempt.
1181
+ this.abandonStreamBridge();
1182
+ if (this.turnOpen) {
1183
+ this.flushAssistant();
1184
+ this.commitStepEnd();
1185
+ this.closeTurn({ kind: "error", error: { message: String(error), code: classifyClaudeError(error) } });
1186
+ }
1187
+ this.markNotResumableIfDead(error);
1188
+ this.markIdle();
1189
+ this.endActivity();
1190
+ }
1191
+ /** A conversation-not-found failure is terminal for the pairing (2026-09-18):
1192
+ * mark the map record dead once so later prompts fail fast at resume. */
1193
+ markNotResumableIfDead(error) {
1194
+ if (this.notResumableMarked || classifyClaudeError(error) !== "CONVERSATION_NOT_FOUND")
1195
+ return;
1196
+ this.notResumableMarked = true;
1197
+ try {
1198
+ this.runtimeInfo?.onNotResumable?.();
1199
+ }
1200
+ catch (error) {
1201
+ trace(`mark not resumable failed: ${String(error)}`);
1202
+ }
1203
+ }
1204
+ beginActivity() {
1205
+ this.resolveActivityDone();
1206
+ this.activityDone = new Promise((resolve) => {
1207
+ this.resolveActivityDone = resolve;
1208
+ });
1209
+ }
1210
+ endActivity() {
1211
+ this.resolveActivityDone();
1212
+ }
1213
+ markRunning() {
1214
+ clearTimeout(this.idleExitTimer);
1215
+ this.idleExitTimer = undefined;
1216
+ if (this.streaming)
1217
+ return;
1218
+ this.streaming = true;
1219
+ this.dispatch.emit("agent/status", { status: "running" });
1220
+ }
1221
+ markIdle() {
1222
+ if (!this.streaming)
1223
+ return;
1224
+ this.streaming = false;
1225
+ this.dispatch.emit("agent/status", { status: "idle" });
1226
+ this.armIdleExit();
1227
+ }
1228
+ armIdleExit() {
1229
+ if (this.disposed || this.onIdleExit === undefined || CLAUDE_IDLE_EXIT_MS === 0)
1230
+ return;
1231
+ if (this.remoteQueue.some((entry) => !entry.sent))
1232
+ return;
1233
+ clearTimeout(this.idleExitTimer);
1234
+ this.idleExitTimer = setTimeout(() => {
1235
+ this.idleExitTimer = undefined;
1236
+ void this.revalidateIdleExit();
1237
+ }, CLAUDE_IDLE_EXIT_MS);
1238
+ this.idleExitTimer.unref?.();
1239
+ }
1240
+ /**
1241
+ * Quiescence re-validation at fire time. The idle timer is only a hint:
1242
+ * before teardown, re-confirm no live work via local signals. Any busy
1243
+ * signal re-arms; a never-spawned draft is abandoned outright (no re-arm).
1244
+ */
1245
+ async revalidateIdleExit() {
1246
+ if (this.disposed || this.streaming)
1247
+ return this.armIdleExit();
1248
+ if (!this.client.spawned) {
1249
+ trace(`idle exit: never-spawned draft ${this.id} — abandoning without re-arm`);
1250
+ this.onIdleExit?.();
1251
+ return;
1252
+ }
1253
+ if (this.remoteQueue.some((entry) => !entry.sent))
1254
+ return this.armIdleExit();
1255
+ if (this.pendingApproval > 0)
1256
+ return this.armIdleExit();
1257
+ // The client self-reports it is still running (a turn in flight) — re-arm.
1258
+ try {
1259
+ const state = await this.client.getState();
1260
+ if (state.isStreaming)
1261
+ return this.armIdleExit();
1262
+ }
1263
+ catch {
1264
+ // Self-attestation unavailable: local signals remain authoritative.
1265
+ }
1266
+ if (this.subagentCount > 0)
1267
+ return this.armIdleExit();
1268
+ trace(`idle exit after ${CLAUDE_IDLE_EXIT_MS}ms — disposing agent ${this.id}`);
1269
+ this.onIdleExit?.();
1270
+ }
1271
+ }