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,1068 @@
1
+ import { agentEvents } from "@deepseek-ai/dsh-agent";
2
+ import { Inbox } from "./inbox.js";
3
+ import { LlmAttemptId, ToolCallId, QUOTA_EXCEEDED_CODE, createAssistantMessage, createSystemMessage, createToolResultMessage } from "@deepseek-ai/dsh-llm";
4
+ import { createScope } from "@deepseek-ai/dsh-scope";
5
+ import { GatewayRpcError, HERMES_SPAWN_ATTEMPTS } from "./hermes-client.js";
6
+ import { approvalChoiceFromOutcome } from "./permission.js";
7
+ /**
8
+ * v2 live assistant-stream publication for the Hermes bridge — the same frame
9
+ * protocol the reference loop's AssistantStreamAttempt speaks (start marker,
10
+ * dense zero-based chunks, terminal settlement) emitted over this agent's own
11
+ * dispatch, so the session-controller folds Hermes's stream into its reconnect
12
+ * baseline. Chunks also accumulate as raw durable records embedded in the
13
+ * final assistant/message.
14
+ */
15
+ class AssistantStreamBridge {
16
+ attemptId;
17
+ records = [];
18
+ #nextRevision;
19
+ #emit;
20
+ #index = 0;
21
+ #terminal = false;
22
+ constructor(sessionId, attempt, nextRevision, emit) {
23
+ this.attemptId = LlmAttemptId(`${sessionId}:${attempt}`);
24
+ this.#nextRevision = nextRevision;
25
+ this.#emit = emit;
26
+ }
27
+ /** Whether the terminal frame already fired. */
28
+ get ended() {
29
+ return this.#terminal;
30
+ }
31
+ /** Opening marker before the first delivered chunk. */
32
+ start(turn, step) {
33
+ this.#emit({ type: "start", attemptId: this.attemptId, revision: this.#nextRevision(), turn, step });
34
+ }
35
+ /** One live chunk: durable record plus dense process-local frame. */
36
+ push(chunk) {
37
+ const time = Date.now();
38
+ this.records.push({ type: "chunk", time, chunk });
39
+ this.#emit({ type: "chunk", attemptId: this.attemptId, revision: this.#nextRevision(), index: this.#index++, time, chunk });
40
+ }
41
+ /** Terminal settlement after the durable assistant/message committed. */
42
+ settle(seq) {
43
+ this.#terminal = true;
44
+ this.#emit({ type: "end", attemptId: this.attemptId, revision: this.#nextRevision(), index: this.#index, outcome: { kind: "committed", eventType: "assistant/message", seq } });
45
+ }
46
+ /** No durable attempt event will commit. */
47
+ abandon() {
48
+ this.#terminal = true;
49
+ this.#emit({ type: "end", attemptId: this.attemptId, revision: this.#nextRevision(), index: this.#index, outcome: { kind: "abandoned" } });
50
+ }
51
+ }
52
+ /** Diagnostic trace (set HERMES_TRACE=1 on the dsh process to enable). */
53
+ const TRACE = process.env.HERMES_TRACE === "1";
54
+ const trace = (...parts) => {
55
+ if (TRACE)
56
+ process.stderr.write(`[hermes-agent ${Date.now() % 1_000_000}] ${parts.join(" ")}\n`);
57
+ };
58
+ export function convertContent(blocks) {
59
+ if (blocks === undefined)
60
+ return [];
61
+ const result = [];
62
+ for (const block of blocks) {
63
+ switch (block.type) {
64
+ case "text":
65
+ if (typeof block.text === "string")
66
+ result.push({ type: "text", text: block.text });
67
+ break;
68
+ case "thinking":
69
+ if (typeof block.thinking === "string")
70
+ result.push({ type: "reasoning", text: block.thinking });
71
+ break;
72
+ case "toolCall": {
73
+ const id = typeof block.id === "string" ? block.id : "";
74
+ const name = typeof block.name === "string" ? block.name : "";
75
+ const args = typeof block.arguments === "string" ? block.arguments : JSON.stringify(block.arguments ?? {});
76
+ result.push({ type: "tool-call", id: ToolCallId(id), name, arguments: args });
77
+ break;
78
+ }
79
+ default:
80
+ // images / unknown blocks are not bridged in V1.
81
+ break;
82
+ }
83
+ }
84
+ return result;
85
+ }
86
+ /**
87
+ * Classify the wire's `stopReason: "error"` assistant message into a Dash
88
+ * `failure` (`{ message, code }`). Same taxonomy as the harness adapters use
89
+ * for `turn/end` reasons — `AUTH`, `RATE_LIMIT`, `QUOTA`, `SERVER` — so the
90
+ * Web UI renders it through its own error path instead of an empty assistant
91
+ * bubble.
92
+ */
93
+ export function wireFailure(message) {
94
+ if (message.stopReason !== "error")
95
+ return undefined;
96
+ const status = typeof message.errorStatus === "number" ? message.errorStatus : undefined;
97
+ const text = typeof message.errorMessage === "string" && message.errorMessage.trim() !== ""
98
+ ? message.errorMessage.trim()
99
+ : undefined;
100
+ const detail = text ?? "";
101
+ let code;
102
+ 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)) {
103
+ code = QUOTA_EXCEEDED_CODE;
104
+ }
105
+ else if (status === 401 || status === 403) {
106
+ code = "AUTH";
107
+ }
108
+ else if (status === 429) {
109
+ code = "RATE_LIMIT";
110
+ }
111
+ else if (status !== undefined && status >= 500) {
112
+ code = "SERVER";
113
+ }
114
+ else {
115
+ code = "UNKNOWN";
116
+ }
117
+ return { message: text ?? (status !== undefined ? `HTTP ${status}` : "model request failed"), code };
118
+ }
119
+ /**
120
+ * Map Hermes's `message.complete.usage` accounting into Dash `TokenUsage`
121
+ /**
122
+ * Map Hermes's `message.complete.usage` accounting into Dash `TokenUsage`
123
+ * (fixture-grounded: the captured shape is `input` / `output` / `reasoning` /
124
+ * `calls` / `total` / `context_used` / …). There is NO `cache_read` field in
125
+ * the captured shape, so no cache token is invented (`cache_hit_pct` is a
126
+ * percentage, not a token count).
127
+ *
128
+ * `totalTokens` is SYNTHESIZED as `input + output`, never the gateway's own
129
+ * `total`: fixture math proves `total` is context-wide (26805 = prompt 26803
130
+ * + completion 2, with `prompt === context_used` — the whole re-sent window),
131
+ * while the DSH fold needs the per-attempt-provable sum. `reasoningTokens`
132
+ * rides along only when `reasoning <= output` (Dash treats reasoning as a
133
+ * subset of output; the fold rejects a larger value outright).
134
+ */
135
+ export function convertUsage(usage) {
136
+ if (usage === null || typeof usage !== "object")
137
+ return undefined;
138
+ const u = usage;
139
+ const inputTokens = typeof u.input === "number" ? u.input : 0;
140
+ const outputTokens = typeof u.output === "number" ? u.output : 0;
141
+ if (inputTokens === 0 && outputTokens === 0)
142
+ return undefined;
143
+ const result = { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens };
144
+ if (typeof u.reasoning === "number" && u.reasoning <= outputTokens)
145
+ result.reasoningTokens = u.reasoning;
146
+ return result;
147
+ }
148
+ /** Gateway RPC error codes that mean the stored gateway session no longer
149
+ * exists (session.resume against a pruned / unknown durable key). */
150
+ const NATIVE_SESSION_GONE_RPC_CODES = new Set([4001, 4006]);
151
+ /**
152
+ * Classify a turn-failure into the stable `{message, code}` the Web UI error
153
+ * chip renders (2026-09-17 ruling — never a bare UNKNOWN for a diagnosable
154
+ * client/gateway state):
155
+ *
156
+ * - `GatewayRpcError` 4001/4006 → `NATIVE_SESSION_GONE` (the stored gateway
157
+ * session is gone; the message names it so the user can start fresh);
158
+ * - gateway child exit/error → `GATEWAY_CRASH`;
159
+ * - closed / terminal-failed client → `CLIENT_CLOSED`;
160
+ * - anything else → `UNKNOWN` (genuinely unclassified).
161
+ */
162
+ export function classifyTurnError(error, context) {
163
+ if (error instanceof GatewayRpcError && error.code !== undefined && NATIVE_SESSION_GONE_RPC_CODES.has(error.code)) {
164
+ return {
165
+ message: `${error.message} (stored gateway session ${context.storedSessionId ?? "unknown"} is gone — start a new session)`,
166
+ code: "NATIVE_SESSION_GONE",
167
+ };
168
+ }
169
+ const text = error instanceof Error ? error.message : String(error);
170
+ if (/^gateway child (?:exited|error)\b/.test(text))
171
+ return { message: text, code: "GATEWAY_CRASH" };
172
+ if (!context.clientUsable)
173
+ return { message: text, code: "CLIENT_CLOSED" };
174
+ return { message: text, code: "UNKNOWN" };
175
+ }
176
+ /** Join the visible text blocks of a Dash user message into the Hermes prompt string. */
177
+ function userMessageText(message) {
178
+ const parts = [];
179
+ for (const block of message.content) {
180
+ if (block.type === "text")
181
+ parts.push(block.text);
182
+ }
183
+ return parts.join("\n");
184
+ }
185
+ /**
186
+ * Dash-host plugin narration from the approval service: the policy-change
187
+ * notice `agent.inject` carries when `/permission` switches a live session's
188
+ * approval policy. Hermes has no runtime approval-policy switch either (the
189
+ * preset is fixed; approval rides the gateway's runtime request/response), so
190
+ * forwarding the text would steer it into an in-flight turn (interrupting tool
191
+ * work) and echo it into history as a fake user prompt. Detect and drop it.
192
+ */
193
+ function isApprovalNarration(message) {
194
+ const source = message.source;
195
+ return source.kind === "plugin" && source.plugin === "user-approval";
196
+ }
197
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
198
+ /**
199
+ * How long an agent may sit idle before its gateway client is torn down. The
200
+ * Dash host keeps resumed agents registered forever (nothing else disposes
201
+ * them), and every idle agent pins a live gateway child. `0` disables the exit.
202
+ */
203
+ const HERMES_IDLE_EXIT_MS = parseIdleExit(process.env.HERMES_IDLE_EXIT_MS);
204
+ function parseIdleExit(raw) {
205
+ if (raw === undefined || raw.trim() === "")
206
+ return 600_000;
207
+ const value = Number(raw);
208
+ return Number.isFinite(value) && value >= 0 ? value : 600_000;
209
+ }
210
+ /**
211
+ * Agent-level turn deadline: the client's `prompt()` resolves on the settled
212
+ * pair (`message.complete` → `session.info running=false`), but a never-arriving
213
+ * pair would hang the open Dash turn. This deadline fails the open turn
214
+ * (`turn/end {kind:"error"}`) if no wire terminal arrived in time. `0` disables.
215
+ */
216
+ const HERMES_TURN_DEADLINE_MS = parseTurnDeadline(process.env.HERMES_TURN_DEADLINE_MS);
217
+ function parseTurnDeadline(raw) {
218
+ if (raw === undefined || raw.trim() === "")
219
+ return 300_000;
220
+ const value = Number(raw);
221
+ return Number.isFinite(value) && value >= 0 ? value : 300_000;
222
+ }
223
+ /** Approval deadline for one `approval.request` round-trip (fail closed). */
224
+ const HERMES_APPROVAL_TIMEOUT_MS = 30_000;
225
+ /**
226
+ * Race an approval request against its deadline, aborting the controller on
227
+ * timeout. Mirrors omp's `raceApproval` (AbortController + race + timeout),
228
+ * not its select-card string matching.
229
+ */
230
+ function raceApproval(promise, controller, timeoutMs) {
231
+ return new Promise((resolve, reject) => {
232
+ const timer = setTimeout(() => {
233
+ controller.abort();
234
+ reject(new Error(`approval timed out after ${timeoutMs}ms`));
235
+ }, timeoutMs);
236
+ promise.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
237
+ });
238
+ }
239
+ /** The tool identity of a gateway approval: `pattern_key` when present, else a generic name. */
240
+ function approvalToolName(req) {
241
+ const raw = req.raw;
242
+ const key = typeof raw?.["pattern_key"] === "string" && raw["pattern_key"] !== "" ? raw["pattern_key"] : undefined;
243
+ return key ?? "command";
244
+ }
245
+ export class HermesAgent {
246
+ id;
247
+ options;
248
+ session;
249
+ inbox;
250
+ ctx;
251
+ #client;
252
+ #loopCtx;
253
+ #scope;
254
+ #dispatch;
255
+ #streaming = false;
256
+ #activityDone = Promise.resolve();
257
+ #resolveActivityDone = () => { };
258
+ #dashTurn = 0;
259
+ #step = 0;
260
+ #lastTurn = 0;
261
+ #turnOpen = false;
262
+ #cancelCause = null;
263
+ /** Terminal model failure of the open turn (`message_end` stopReason "error"); consumed by `agent_end`. */
264
+ #pendingFailure = null;
265
+ /** Last title mirrored into `session/title` (dedupes repeated gateway events). */
266
+ #mirroredTitle = null;
267
+ /** Live v2 assistant-stream publication for the in-flight wire message. */
268
+ #streamBridge;
269
+ #assistantAttemptCounter = 0;
270
+ #assistantStreamRevision = 0;
271
+ /** A `turn_start` arrived; `step/start` is deferred until the step's owning turn is known. */
272
+ #stepStartPending = false;
273
+ /**
274
+ * Deliveries forwarded through the client's own queue, in submission order.
275
+ * `followUp` entries submit as a fresh prompt once the gateway is idle (never
276
+ * while busy); `steer` entries drive the in-flight turn (or degrade to a
277
+ * prompt on idle-steer refusal). `sent` marks entries already dispatched.
278
+ */
279
+ #remoteQueue = [];
280
+ #bridgedToolResults = new Set();
281
+ /** Fires when the agent has been idle past HERMES_IDLE_EXIT_MS (tears down the client). */
282
+ #onIdleExit;
283
+ #idleExitTimer = undefined;
284
+ /** Fails the open turn if no wire terminal arrived within HERMES_TURN_DEADLINE_MS. */
285
+ #turnDeadlineTimer = undefined;
286
+ #disposed = false;
287
+ /** One-shot guard for the first-turn session-identity commit (#bootstrapSessionIdentity). */
288
+ #sessionIdentityCommitted = false;
289
+ /** Adapter-owned runtime facts (system prompt / route metadata / session context), when supplied. */
290
+ #runtimeInfo;
291
+ /** One-shot guard for the `system/message` surface node (never re-emitted). */
292
+ #systemMessageDone = false;
293
+ /** Last `request/context` route key (`provider/model`) — dedupes per-turn emits. */
294
+ #lastRouteKey = null;
295
+ /** Memoized route resolution (the async catalog is resolved once per agent). */
296
+ #resolvedRoute;
297
+ #routeResolved = false;
298
+ constructor(loopCtx, id, options, session, client, onIdleExit, runtimeInfo) {
299
+ this.#loopCtx = loopCtx;
300
+ this.id = id;
301
+ this.options = options;
302
+ this.session = session;
303
+ this.#client = client;
304
+ this.#onIdleExit = onIdleExit;
305
+ this.#runtimeInfo = runtimeInfo;
306
+ this.#dispatch = agentEvents(loopCtx, this);
307
+ this.inbox = new Inbox();
308
+ this.#scope = createScope(loopCtx, this);
309
+ this.ctx = this.#scope.ctx.extend({ agent: this });
310
+ this.#lastTurn = session.snapshotEvents().findLast((event) => event.type === "turn/start")?.data.turn ?? 0;
311
+ client.on((event) => this.#handleEvent(event));
312
+ client.onFailure((error) => this.#fail(error));
313
+ client.onApproval((req) => this.#handleApproval(req));
314
+ // Session-identity events (the agent-preset stamp) are committed on the
315
+ // FIRST turn by #bootstrapSessionIdentity — only once the client actually
316
+ // exists — so a freshly created session announces completely blank,
317
+ // exactly like the native agent-loop factory's.
318
+ // `/permission` cannot switch here: Hermes approval is runtime
319
+ // (gateway approval.request → Dash approval.request → respond); the preset
320
+ // cannot be re-voiced. Shadowing the SAME name on this agent's own scope
321
+ // fails cleanly instead of appending approval/policy events Hermes cannot
322
+ // honor mid-session.
323
+ this.ctx.inject(["commands"], (cmdCtx) => cmdCtx.commands.register({
324
+ name: "permission",
325
+ description: "Unavailable for Hermes sessions (approval is handled at runtime through the gateway)",
326
+ input: { hint: "<preset>" },
327
+ handler: () => ({
328
+ kind: "error",
329
+ text: "Approval is handled at runtime by the Hermes gateway (approval.request events); the permission preset cannot be switched for Hermes sessions.",
330
+ }),
331
+ }));
332
+ // `/compact` forwards to the gateway's native compaction surface
333
+ // (session.compress). Unlike the codex V1 refusal, this runs real native
334
+ // compaction inside the gateway session; DSH-side the compression is
335
+ // reflected in subsequent session.info / usage.
336
+ this.ctx.inject(["commands"], (cmdCtx) => cmdCtx.commands.register({
337
+ name: "compact",
338
+ description: "Compact the Hermes gateway session (native session.compress)",
339
+ input: { hint: "[instructions]" },
340
+ handler: async () => {
341
+ try {
342
+ await this.#client.compress();
343
+ return {
344
+ kind: "success",
345
+ text: "Compaction requested: the Hermes gateway session is compacting (subsequent session.info/usage reflect the reduced context).",
346
+ };
347
+ }
348
+ catch (error) {
349
+ return { kind: "error", text: `Compaction failed: ${String(error)}` };
350
+ }
351
+ },
352
+ }));
353
+ }
354
+ get status() {
355
+ return this.#streaming ? "running" : "idle";
356
+ }
357
+ send(message, target, wakeup) {
358
+ this.#deliver(message, target, wakeup);
359
+ }
360
+ followup(message) {
361
+ // The Web UI's `session.prompt` (mode "queue") routes through followup.
362
+ // Idle → `prompt` (opens the Dash turn locally); busy → parked in the
363
+ // remote queue and submitted as a fresh prompt once the gateway is idle.
364
+ this.#deliver(message, "next-turn", true);
365
+ }
366
+ steer(message) {
367
+ // Mirrors dsh-agent-loop's `steer = send(input, "next-step", true)`: an
368
+ // idle driver starts a turn; a running driver steers the in-flight gateway
369
+ // turn (native `session.steer`, degrading to a prompt on idle refusal).
370
+ this.#deliver(message, "next-step", true);
371
+ }
372
+ inject(message) {
373
+ // Dash-host approval-policy narrations have no Hermes runtime equivalent
374
+ // (the preset is fixed; approval rides the gateway's runtime request
375
+ // surface). Forwarding the text would steer it into an in-flight turn.
376
+ if (isApprovalNarration(message)) {
377
+ trace("inject: dropped approval-policy narration (Hermes has no runtime approval-policy switch)");
378
+ return;
379
+ }
380
+ // Mirrors dsh-agent-loop's `inject = send(input, "next-step", false)`:
381
+ // queued without waking; flushed at the next agent_start.
382
+ this.#deliver(message, "next-step", false);
383
+ }
384
+ cancel(cause, _options) {
385
+ // No active run → no-op. First cause wins: later cancels must not overwrite
386
+ // an earlier cause (dash contract). The gateway interrupt cancels the
387
+ // in-flight turn.
388
+ if (!this.#streaming)
389
+ return;
390
+ if (this.#cancelCause === null)
391
+ this.#cancelCause = cause;
392
+ void this.#client.interrupt().catch(() => { });
393
+ }
394
+ async whenIdle() {
395
+ // Event-driven: wait for the current activity to settle (agent_end).
396
+ // Hermes has no get_state polling surface — the wire terminal (agent_end),
397
+ // which resolves #activityDone, is the authoritative idle signal.
398
+ let activity;
399
+ do {
400
+ activity = this.#activityDone;
401
+ await activity;
402
+ } while (activity !== this.#activityDone);
403
+ }
404
+ runMaintenance(task) {
405
+ // Hermes has no maintenance concept; run the task directly.
406
+ return task(new AbortController().signal);
407
+ }
408
+ /** Stop the gateway client and unwind the scoped world. Idempotent. */
409
+ async dispose() {
410
+ if (this.#disposed)
411
+ return;
412
+ this.#disposed = true;
413
+ clearTimeout(this.#idleExitTimer);
414
+ this.#idleExitTimer = undefined;
415
+ this.#clearTurnDeadline();
416
+ this.#client.close();
417
+ await this.#scope.dispose();
418
+ }
419
+ // ── internals ────────────────────────────────────────────────────────────
420
+ /**
421
+ * Route one message by the reference loop's (target, wakeup) semantics:
422
+ * an idle waking delivery starts a fresh local turn (`prompt`); a follow-up
423
+ * sent while a turn is open is parked for a later idle submit; steering goes
424
+ * through `steer` (consumed at the next step, or parked until agent_start).
425
+ */
426
+ #deliver(message, target, wakeup) {
427
+ if (this.#disposed)
428
+ return;
429
+ const busy = this.#turnOpen || this.#streaming;
430
+ trace(`#deliver target=${target} wakeup=${wakeup} busy=${busy} (turnOpen=${this.#turnOpen} streaming=${this.#streaming})`);
431
+ if (busy && target === "next-turn") {
432
+ this.#queueRemote(message, "followUp");
433
+ return;
434
+ }
435
+ if (busy || !wakeup) {
436
+ this.#queueRemote(message, "steer");
437
+ return;
438
+ }
439
+ this.#deliverPrompt(message);
440
+ }
441
+ #deliverPrompt(message) {
442
+ if (this.#disposed)
443
+ return;
444
+ trace(`#deliverPrompt turn=${this.#dashTurn + 1} text="${userMessageText(message).slice(0, 40)}"`);
445
+ // Reserve the turn synchronously: busy-flag semantics stay identical to the
446
+ // eager line, but NOTHING is appended yet — the log stays empty until the
447
+ // gateway session exists, so a failed cold start leaves no orphan turn.
448
+ this.#reserveTurn();
449
+ clearTimeout(this.#idleExitTimer);
450
+ this.#idleExitTimer = undefined;
451
+ this.#beginActivity();
452
+ void this.#startTurn(message);
453
+ }
454
+ /**
455
+ * Cold-start path for one reserved turn: materialize the gateway session
456
+ * (ensureStarted + createSession/resumeSession), commit the session-identity
457
+ * events ahead of the turn (the agent-preset stamp), then append turn/start +
458
+ * user/message and dispatch. A failed cold start synthesizes the failed turn
459
+ * (turn/end with the error reason) so the UI's error path renders it, then
460
+ * unwinds the reservation.
461
+ */
462
+ async #startTurn(message) {
463
+ try {
464
+ await this.#materializeSession();
465
+ await this.#bootstrapSessionIdentity();
466
+ }
467
+ catch (error) {
468
+ const failure = classifyTurnError(error, this.#failureContext());
469
+ trace(`#startTurn cold start failed [${failure.code}]: ${failure.message}`);
470
+ if (this.#turnOpen) {
471
+ this.session.append("turn/start", { turn: this.#dashTurn });
472
+ this.session.append("user/message", message, { surfaceOp: "append" });
473
+ this.#closeTurn({ kind: "error", error: failure });
474
+ }
475
+ this.#endActivity();
476
+ // A cold start against a dead client is the reported wedge (view-time
477
+ // eager spawn + old reaper): dispose so the next prompt re-resumes fresh.
478
+ this.#selfHealAfterClientTerminal();
479
+ return;
480
+ }
481
+ if (this.#disposed)
482
+ return;
483
+ this.session.append("turn/start", { turn: this.#dashTurn });
484
+ await this.#emitRequestContext();
485
+ this.session.append("user/message", message, { surfaceOp: "append" });
486
+ this.#armTurnDeadline();
487
+ void this.#client
488
+ .prompt(userMessageText(message))
489
+ .then(() => this.#flushFollowUps())
490
+ .catch((error) => this.#fail(error));
491
+ }
492
+ /**
493
+ * Lazy gateway-session materialization (ruling 1): `ensureStarted()` then, on
494
+ * the first prompt, `createSession` (fresh) or `resumeSession` (re-attach the
495
+ * recorded gateway session id). The model rides the resolved route for a
496
+ * fresh session (the gateway resolves the provider slug from the model id);
497
+ * a resumed session restores its own model context.
498
+ */
499
+ async #materializeSession() {
500
+ // Spawn-side retry (client-internal, pre-ready, bounded at
501
+ // HERMES_SPAWN_ATTEMPTS): the ready promise stays pending across python
502
+ // respawns, so one await covers every attempt (2026-09-18 ruling).
503
+ await this.#client.ensureStarted();
504
+ if (this.#client.sessionId !== null)
505
+ return;
506
+ const route = await this.#resolveRoute();
507
+ const sessionCtx = this.#runtimeInfo?.session;
508
+ if (sessionCtx !== undefined && sessionCtx.resumeStoredSessionId !== null) {
509
+ await this.#handshake(`session.resume`, () => this.#client.resumeSession(sessionCtx.resumeStoredSessionId));
510
+ return;
511
+ }
512
+ await this.#handshake(`session.create`, () => this.#client.createSession({
513
+ cwd: sessionCtx?.cwd ?? process.cwd(),
514
+ ...(route?.model !== undefined ? { model: route.model } : {}),
515
+ ...(sessionCtx?.title !== undefined && sessionCtx.title !== "" ? { title: sessionCtx.title } : {}),
516
+ }));
517
+ }
518
+ /**
519
+ * Handshake retry (2026-09-18 user ruling, ~3 attempts): transient failures
520
+ * (RPC timeouts, transport hiccups) retry on the SAME client; GatewayRpcError
521
+ * (the gateway ANSWERED — e.g. 4001/4006 session gone) and any terminal
522
+ * client state fail immediately. Turn prompts are never auto-retried.
523
+ */
524
+ async #handshake(label, op) {
525
+ for (let attempt = 1;; attempt++) {
526
+ try {
527
+ await op();
528
+ return;
529
+ }
530
+ catch (error) {
531
+ if (attempt >= HERMES_SPAWN_ATTEMPTS || error instanceof GatewayRpcError || !this.#client.usable)
532
+ throw error;
533
+ trace(`handshake ${label} attempt ${attempt}/${HERMES_SPAWN_ATTEMPTS} failed (${String(error).slice(0, 160)}) — retrying`);
534
+ }
535
+ }
536
+ }
537
+ /** Memoize the async route resolution (the catalog is a memoized promise). */
538
+ async #resolveRoute() {
539
+ if (this.#routeResolved)
540
+ return this.#resolvedRoute;
541
+ this.#routeResolved = true;
542
+ this.#resolvedRoute = await this.#runtimeInfo?.routeContext({ provider: this.options.provider, model: this.options.model });
543
+ return this.#resolvedRoute;
544
+ }
545
+ /**
546
+ * Stamp the Hermes system prompt once per session as the step's surface node.
547
+ * `system/message` is a step-scoped surface event, so this runs only after
548
+ * `step/start` committed. Skips when the log already carries one (resume),
549
+ * and while the gateway has not yet reported a non-empty `system_prompt` —
550
+ * the call is retried on later steps until the text is available.
551
+ */
552
+ #emitSystemMessage() {
553
+ if (this.#systemMessageDone)
554
+ return;
555
+ if (this.#runtimeInfo === undefined || !this.#turnOpen)
556
+ return;
557
+ this.#commitStepStart();
558
+ if (this.#step === 0)
559
+ return;
560
+ if (this.session.snapshotEvents().some((event) => event.type === "system/message")) {
561
+ this.#systemMessageDone = true;
562
+ return;
563
+ }
564
+ const text = this.#runtimeInfo.systemPrompt();
565
+ if (text === undefined || text.trim() === "")
566
+ return;
567
+ this.session.append("system/message", {
568
+ turn: this.#dashTurn,
569
+ step: this.#step,
570
+ message: createSystemMessage(text, "aw.agent-adapter-hermes"),
571
+ }, { surfaceOp: "append" });
572
+ this.#systemMessageDone = true;
573
+ const title = this.#runtimeInfo.sessionTitle();
574
+ if (title !== undefined && title !== "")
575
+ trace(`session title: ${title}`);
576
+ trace(`system/message stamped (${text.length} chars)`);
577
+ }
578
+ /**
579
+ * Stamp route metadata for the next request (`request/context`): the
580
+ * effective provider/model plus the catalog's context window when known.
581
+ * Turn-enclosed (no step required) and deduped by route key.
582
+ */
583
+ async #emitRequestContext() {
584
+ if (this.#runtimeInfo === undefined || !this.#turnOpen)
585
+ return;
586
+ const route = await this.#resolveRoute();
587
+ if (route === undefined)
588
+ return;
589
+ const key = `${route.provider}/${route.model}`;
590
+ if (key === this.#lastRouteKey)
591
+ return;
592
+ this.#lastRouteKey = key;
593
+ this.session.append("request/context", {
594
+ provider: route.provider,
595
+ model: route.model,
596
+ ...(route.contextWindow === undefined ? {} : { contextWindow: route.contextWindow }),
597
+ });
598
+ trace(`request/context ${key}${route.contextWindow === undefined ? "" : ` ctx=${route.contextWindow}`}`);
599
+ }
600
+ /**
601
+ * One-shot first-turn identity commit: the agent-preset stamp (idempotent).
602
+ * The Hermes system prompt is stamped separately by #emitSystemMessage once
603
+ * the gateway's `session.info` reports a non-empty `system_prompt`.
604
+ */
605
+ async #bootstrapSessionIdentity() {
606
+ if (this.#sessionIdentityCommitted)
607
+ return;
608
+ this.#sessionIdentityCommitted = true;
609
+ if (!this.session.snapshotEvents().some((event) => event.type === "agent-preset/selected" && event.data?.agentPreset === "hermes")) {
610
+ this.session.append("agent-preset/selected", { agentPreset: "hermes" });
611
+ }
612
+ }
613
+ /**
614
+ * Queue a message for client-side delivery. A `steer` entry dispatches now
615
+ * when already streaming (native `session.steer`); a `followUp` entry parks
616
+ * until the gateway is idle (never submit while busy).
617
+ */
618
+ #queueRemote(message, transport) {
619
+ if (this.#disposed)
620
+ return;
621
+ clearTimeout(this.#idleExitTimer);
622
+ this.#idleExitTimer = undefined;
623
+ this.#remoteQueue.push({ message, sent: false, transport });
624
+ if (this.#streaming && transport === "steer")
625
+ this.#flushSteer();
626
+ }
627
+ /** Flush parked steer entries (native steer; a prompt fallback on idle refusal). */
628
+ #flushSteer() {
629
+ for (const entry of this.#remoteQueue) {
630
+ if (entry.sent || entry.transport !== "steer")
631
+ continue;
632
+ entry.sent = true;
633
+ const text = userMessageText(entry.message);
634
+ trace(`-> steer "${text.slice(0, 40)}"`);
635
+ // Ruling 2: steer while streaming → client.steer; a gateway idle-steer
636
+ // refusal ({status:"rejected"}) falls back to a plain prompt.
637
+ void this.#client.steer(text).catch(() => this.#client.prompt(text).catch((error) => this.#fail(error)));
638
+ }
639
+ }
640
+ /** Flush ONE parked follow-up as a fresh prompt (the gateway is now idle). */
641
+ #flushFollowUps() {
642
+ const entry = this.#remoteQueue.find((candidate) => !candidate.sent && candidate.transport === "followUp");
643
+ if (entry === undefined)
644
+ return;
645
+ entry.sent = true;
646
+ this.#deliverPrompt(entry.message);
647
+ }
648
+ /**
649
+ * Reserve a turn WITHOUT appending anything: the prompt path reserves
650
+ * synchronously, then commits turn/start + user/message only after the
651
+ * cold start (#startTurn).
652
+ */
653
+ #reserveTurn() {
654
+ if (this.#turnOpen)
655
+ return;
656
+ this.#dashTurn = ++this.#lastTurn;
657
+ this.#step = 0;
658
+ this.#turnOpen = true;
659
+ }
660
+ #closeTurn(reason) {
661
+ if (!this.#turnOpen)
662
+ return;
663
+ this.#turnOpen = false;
664
+ this.#clearTurnDeadline();
665
+ this.session.append("turn/end", { turn: this.#dashTurn, reason });
666
+ }
667
+ /**
668
+ * Append the deferred `step/start` now that the step's first message has
669
+ * revealed which Dash turn owns it. On-demand open: the gateway may emit
670
+ * `tool.complete` without a preceding `message.start`, so a step opens at the
671
+ * first step-scoped use rather than only on a `message_start`.
672
+ */
673
+ #commitStepStart() {
674
+ if (!this.#turnOpen)
675
+ return;
676
+ if (!this.#stepStartPending && this.#step > 0)
677
+ return;
678
+ this.#stepStartPending = false;
679
+ this.#step += 1;
680
+ this.session.append("step/start", { turn: this.#dashTurn, step: this.#step });
681
+ trace(`step/start turn=${this.#dashTurn} step=${this.#step}`);
682
+ }
683
+ #beginActivity() {
684
+ this.#resolveActivityDone();
685
+ this.#activityDone = new Promise((resolve) => {
686
+ this.#resolveActivityDone = resolve;
687
+ });
688
+ }
689
+ #endActivity() {
690
+ this.#resolveActivityDone();
691
+ }
692
+ #markRunning() {
693
+ clearTimeout(this.#idleExitTimer);
694
+ this.#idleExitTimer = undefined;
695
+ if (this.#streaming)
696
+ return;
697
+ this.#streaming = true;
698
+ this.#dispatch.emit("agent/status", { status: "running" });
699
+ }
700
+ #markIdle() {
701
+ if (!this.#streaming)
702
+ return;
703
+ this.#streaming = false;
704
+ this.#dispatch.emit("agent/status", { status: "idle" });
705
+ this.#armIdleExit();
706
+ }
707
+ #armIdleExit() {
708
+ if (this.#disposed || this.#onIdleExit === undefined || HERMES_IDLE_EXIT_MS === 0)
709
+ return;
710
+ if (this.#remoteQueue.some((entry) => !entry.sent))
711
+ return;
712
+ clearTimeout(this.#idleExitTimer);
713
+ this.#idleExitTimer = setTimeout(() => {
714
+ this.#idleExitTimer = undefined;
715
+ void this.#revalidateIdleExit();
716
+ }, HERMES_IDLE_EXIT_MS);
717
+ this.#idleExitTimer.unref?.();
718
+ }
719
+ /**
720
+ * Quiescence re-validation at fire time. Hermes has no get_state/get_subagents
721
+ * surface, so local signals (#streaming + the remote queue) are authoritative.
722
+ */
723
+ async #revalidateIdleExit() {
724
+ if (this.#disposed || this.#streaming)
725
+ return this.#armIdleExit();
726
+ if (this.#remoteQueue.some((entry) => !entry.sent))
727
+ return this.#armIdleExit();
728
+ trace(`idle exit after ${HERMES_IDLE_EXIT_MS}ms — disposing agent ${this.id}`);
729
+ this.#onIdleExit?.();
730
+ }
731
+ #armTurnDeadline() {
732
+ this.#clearTurnDeadline();
733
+ if (HERMES_TURN_DEADLINE_MS <= 0)
734
+ return;
735
+ this.#turnDeadlineTimer = setTimeout(() => {
736
+ if (!this.#turnOpen)
737
+ return;
738
+ trace(`turn deadline exceeded after ${HERMES_TURN_DEADLINE_MS}ms — failing open turn`);
739
+ this.#closeTurn({ kind: "error", error: { message: `hermes turn deadline exceeded after ${HERMES_TURN_DEADLINE_MS}ms (no wire terminal arrived)`, code: "UNKNOWN" } });
740
+ // Stop the gateway turn too — otherwise the busy guard would refuse every
741
+ // later prompt and a late terminal could append step-scoped events after
742
+ // the closed turn (I1 fix r1).
743
+ void this.#client.interrupt().catch(() => { });
744
+ this.#endActivity();
745
+ }, HERMES_TURN_DEADLINE_MS);
746
+ this.#turnDeadlineTimer.unref?.();
747
+ }
748
+ #clearTurnDeadline() {
749
+ if (this.#turnDeadlineTimer !== undefined) {
750
+ clearTimeout(this.#turnDeadlineTimer);
751
+ this.#turnDeadlineTimer = undefined;
752
+ }
753
+ }
754
+ /** The classification context for turn failures: client liveness plus the
755
+ * durable gateway session key (for NATIVE_SESSION_GONE messages). */
756
+ #failureContext() {
757
+ return {
758
+ clientUsable: this.#client.usable,
759
+ storedSessionId: this.#runtimeInfo?.session.resumeStoredSessionId ?? this.#client.storedSessionId,
760
+ };
761
+ }
762
+ /**
763
+ * Self-heal (2026-09-17 ruling 4): a CLIENT-TERMINAL failure leaves this
764
+ * agent registered with a dead client — every later prompt would fail
765
+ * forever with a closed transport. Trigger the full idle-exit dispose (the
766
+ * host unregisters the agent and re-resumes a FRESH client on the next
767
+ * prompt). Ordinary per-turn failures (usable client) never dispose.
768
+ */
769
+ #selfHealAfterClientTerminal() {
770
+ if (this.#disposed || this.#client.usable)
771
+ return;
772
+ trace(`client terminal — idle-exit dispose for self-heal (agent ${this.id})`);
773
+ this.#onIdleExit?.();
774
+ }
775
+ #fail(error) {
776
+ this.#abandonStreamBridge();
777
+ if (this.#disposed)
778
+ return;
779
+ const failure = classifyTurnError(error, this.#failureContext());
780
+ trace(`#fail [${failure.code}] ${failure.message.slice(0, 200)}`);
781
+ this.#closeTurn({ kind: "error", error: failure });
782
+ this.#markIdle();
783
+ this.#endActivity();
784
+ this.#selfHealAfterClientTerminal();
785
+ }
786
+ /**
787
+ * Bridge one gateway approval (`approval.request`) through the Dash approval
788
+ * seam (`ctx.get("approval").request()`), mapping the outcome to the gateway
789
+ * choice. No approval service / throw / timeout → "deny" (fail closed).
790
+ */
791
+ async #handleApproval(req) {
792
+ const approval = this.ctx.get("approval");
793
+ if (approval === undefined) {
794
+ trace("approval: no approval service — deny (fail closed)");
795
+ return "deny";
796
+ }
797
+ const raw = req.raw;
798
+ const choices = Array.isArray(raw?.["choices"]) ? raw["choices"] : undefined;
799
+ const toolName = approvalToolName(req);
800
+ const reason = req.title !== "" ? req.title : undefined;
801
+ const controller = new AbortController();
802
+ try {
803
+ const outcome = await raceApproval(approval.request({
804
+ agent: this,
805
+ toolName,
806
+ ...(reason === undefined ? {} : { reason }),
807
+ signal: controller.signal,
808
+ }), controller, HERMES_APPROVAL_TIMEOUT_MS);
809
+ const choice = approvalChoiceFromOutcome(outcome, choices);
810
+ trace(`approval: ${toolName} → ${choice}`);
811
+ return choice;
812
+ }
813
+ catch {
814
+ trace(`approval: ${toolName} request failed — deny (fail closed)`);
815
+ return "deny";
816
+ }
817
+ }
818
+ #handleEvent(event) {
819
+ switch (event.type) {
820
+ case "agent_start":
821
+ trace("event agent_start");
822
+ this.#markRunning();
823
+ this.#flushSteer();
824
+ break;
825
+ case "turn_start":
826
+ trace("event turn_start");
827
+ this.#stepStartPending = true;
828
+ this.#pendingFailure = null;
829
+ this.#commitStepStart();
830
+ this.#emitSystemMessage();
831
+ break;
832
+ case "message_start": {
833
+ const message = event.message;
834
+ if (message?.role === "assistant") {
835
+ // Reset per-message streaming accumulators.
836
+ this.#abandonStreamBridge();
837
+ }
838
+ // role === "toolResult" is bridged in message_end (deduped vs tool_execution_end).
839
+ this.#commitStepStart();
840
+ this.#emitSystemMessage();
841
+ break;
842
+ }
843
+ case "message_update": {
844
+ const delta = event.assistantMessageEvent;
845
+ if (delta !== undefined)
846
+ this.#handleUpdate(delta);
847
+ break;
848
+ }
849
+ case "message_end": {
850
+ // Late terminal after the turn closed (deadline/interrupt): never append
851
+ // step-scoped surface events past turn/end.
852
+ if (!this.#turnOpen)
853
+ break;
854
+ const message = event.message;
855
+ if (message === undefined)
856
+ break;
857
+ const role = message.role;
858
+ if (role === "assistant") {
859
+ const failure = wireFailure(message);
860
+ if (failure !== undefined) {
861
+ this.#pendingFailure = failure;
862
+ this.#abandonStreamBridge();
863
+ }
864
+ else {
865
+ // Hermes attaches the raw usage to message_end (message.complete);
866
+ // the assistant message carries it directly — no one-turn lag.
867
+ this.#appendAssistantMessage(message, convertUsage(event.usage));
868
+ }
869
+ }
870
+ else if (role === "toolResult") {
871
+ this.#appendToolResultMessage(message);
872
+ }
873
+ else if (role !== "user") {
874
+ process.stderr.write(`hermes wire: unhandled message_end role "${role}"\n`);
875
+ }
876
+ break;
877
+ }
878
+ case "tool_execution_start": {
879
+ if (!this.#turnOpen)
880
+ break;
881
+ const callId = String(event.toolCallId ?? "");
882
+ const name = String(event.toolName ?? "");
883
+ this.#commitStepStart();
884
+ this.session.append("tool/call", {
885
+ turn: this.#dashTurn,
886
+ step: this.#step,
887
+ callId: ToolCallId(callId),
888
+ name,
889
+ arguments: String(event.argumentsJson ?? JSON.stringify(event.args ?? {})),
890
+ });
891
+ break;
892
+ }
893
+ case "tool_execution_end":
894
+ if (!this.#turnOpen)
895
+ break;
896
+ this.#appendToolResult(event);
897
+ break;
898
+ case "turn_end":
899
+ trace("event turn_end");
900
+ this.#emitSystemMessage();
901
+ if (this.#turnOpen && this.#step > 0) {
902
+ this.session.append("step/end", { turn: this.#dashTurn, step: this.#step });
903
+ }
904
+ // Usage rides message_end (converted there); turn_end's `data.status` is trace-only.
905
+ {
906
+ const status = event.data?.status;
907
+ if (typeof status === "string" && status !== "")
908
+ trace(`turn status: ${status}`);
909
+ }
910
+ break;
911
+ case "agent_end": {
912
+ trace(`event agent_end cancelCause=${String(this.#cancelCause)}`);
913
+ const pendingFailure = this.#pendingFailure;
914
+ this.#pendingFailure = null;
915
+ this.#closeTurn(this.#cancelCause !== null
916
+ ? { kind: "aborted", reason: this.#cancelCause }
917
+ : pendingFailure !== null
918
+ ? { kind: "error", error: pendingFailure }
919
+ : { kind: "completed" });
920
+ this.#cancelCause = null;
921
+ this.#markIdle();
922
+ this.#endActivity();
923
+ break;
924
+ }
925
+ case "session_title":
926
+ this.#mirrorTitle(typeof event.name === "string" ? event.name : undefined);
927
+ break;
928
+ case "todo_updated":
929
+ this.#writeTodos(event.todos);
930
+ break;
931
+ }
932
+ }
933
+ /**
934
+ * Mirror the gateway's session title into a `session/title` event (pi
935
+ * pattern). Dash does not generate titles for Hermes sessions; the gateway
936
+ * owns the title (`session.title` events). Empty or unchanged titles are
937
+ * ignored, and a title is never allowed to affect the turn.
938
+ */
939
+ #mirrorTitle(name) {
940
+ if (name === undefined || name.trim() === "" || name === this.#mirroredTitle)
941
+ return;
942
+ this.#mirroredTitle = name;
943
+ try {
944
+ this.session.append("session/title", {
945
+ title: name,
946
+ messageSeqs: [],
947
+ source: { kind: "provider", provider: "hermes" },
948
+ });
949
+ trace(`session/title ← hermes: "${name}"`);
950
+ }
951
+ catch {
952
+ // fail-soft: a title never affects the turn
953
+ }
954
+ }
955
+ /**
956
+ * Log-only `todo/write` whole-list snapshot (upstream tool-todo contract;
957
+ * latest write wins on replay). The projector already mapped the gateway's
958
+ * todo snapshot onto DSH todo items; only append inside an open turn (the
959
+ * upstream invariant refuses todo/write outside any open turn), and never
960
+ * let panel state break the turn.
961
+ */
962
+ #writeTodos(todos) {
963
+ if (!Array.isArray(todos) || !this.#turnOpen)
964
+ return;
965
+ try {
966
+ this.session.append("todo/write", { todos });
967
+ trace(`todo/write ${todos.length} item(s)`);
968
+ }
969
+ catch {
970
+ // fail-soft: the todo panel is presentation state
971
+ }
972
+ }
973
+ /** The open stream bridge for this message, starting one on first use. */
974
+ #ensureStreamBridge() {
975
+ if (this.#streamBridge === undefined || this.#streamBridge.ended) {
976
+ this.#streamBridge = new AssistantStreamBridge(this.session.id, ++this.#assistantAttemptCounter, () => ++this.#assistantStreamRevision, (frame) => this.#dispatch.emit("agent/assistant-stream", { frame }));
977
+ this.#streamBridge.start(this.#dashTurn, this.#step);
978
+ }
979
+ return this.#streamBridge;
980
+ }
981
+ /** Abandon any open stream bridge — failure paths never settle. */
982
+ #abandonStreamBridge() {
983
+ if (this.#streamBridge !== undefined && !this.#streamBridge.ended)
984
+ this.#streamBridge.abandon();
985
+ this.#streamBridge = undefined;
986
+ }
987
+ #handleUpdate(delta) {
988
+ switch (delta.type) {
989
+ case "text_delta":
990
+ if (typeof delta.delta === "string" && delta.delta !== "") {
991
+ const chunk = { type: "text-delta", index: delta.contentIndex ?? 0, text: delta.delta };
992
+ // v2: live text no longer appends `assistant/chunk` log events — the
993
+ // stream publishes as dense agent frames and embeds durably in the
994
+ // final assistant/message.
995
+ this.#ensureStreamBridge().push(chunk);
996
+ }
997
+ break;
998
+ default:
999
+ // thinking_*/toolcall_* are folded into the final message.
1000
+ break;
1001
+ }
1002
+ }
1003
+ #appendAssistantMessage(message, usage) {
1004
+ const content = convertContent(message.content);
1005
+ const assistant = createAssistantMessage({
1006
+ content,
1007
+ source: {
1008
+ provider: String(message.provider ?? this.options.provider ?? ""),
1009
+ model: String(message.model ?? this.options.model ?? ""),
1010
+ },
1011
+ });
1012
+ const bridge = this.#streamBridge;
1013
+ this.#commitStepStart();
1014
+ const event = this.session.append("assistant/message", {
1015
+ turn: this.#dashTurn,
1016
+ step: this.#step,
1017
+ message: assistant,
1018
+ stream: bridge?.records ?? [],
1019
+ ...(usage === undefined ? {} : { usage }),
1020
+ }, {
1021
+ surfaceOp: "append",
1022
+ });
1023
+ if (bridge !== undefined && !bridge.ended)
1024
+ bridge.settle(event.seq);
1025
+ this.#streamBridge = undefined;
1026
+ }
1027
+ #appendToolResult(event) {
1028
+ const callId = String(event.toolCallId ?? "");
1029
+ this.#bridgedToolResults.add(callId);
1030
+ const result = event.result;
1031
+ const isError = Boolean(event.isError ?? result?.isError ?? false);
1032
+ const message = createToolResultMessage({
1033
+ callId: ToolCallId(callId),
1034
+ content: convertContent(result?.content),
1035
+ isError,
1036
+ });
1037
+ this.session.append("tool/result", {
1038
+ turn: this.#dashTurn,
1039
+ step: this.#step,
1040
+ message,
1041
+ ...(isError ? { error: { name: "ToolExecutionError", code: "TOOL_ERROR" } } : {}),
1042
+ }, {
1043
+ surfaceOp: "append",
1044
+ });
1045
+ }
1046
+ #appendToolResultMessage(message) {
1047
+ const callId = String(message.toolCallId ?? "");
1048
+ // The normal path bridges via `tool_execution_end` first; skip the
1049
+ // `message_end(toolResult)` duplicate the wire always also emits.
1050
+ if (this.#bridgedToolResults.has(callId))
1051
+ return;
1052
+ this.#bridgedToolResults.add(callId);
1053
+ const isError = Boolean(message.isError ?? false);
1054
+ const result = createToolResultMessage({
1055
+ callId: ToolCallId(callId),
1056
+ content: convertContent(message.content),
1057
+ isError,
1058
+ });
1059
+ this.session.append("tool/result", {
1060
+ turn: this.#dashTurn,
1061
+ step: this.#step,
1062
+ message: result,
1063
+ ...(isError ? { error: { name: "ToolExecutionError", code: "TOOL_ERROR" } } : {}),
1064
+ }, {
1065
+ surfaceOp: "append",
1066
+ });
1067
+ }
1068
+ }