super-dsh 0.3.3-c → 0.3.4-a

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.
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env python3
2
+ """superd hermes WS-attach bridge: stdio JSON-RPC (adapter contract) <-> the profile's gateway WS.
3
+
4
+ The standalone `tui_gateway.entry` stdio server can no longer co-own a Hermes home: a running
5
+ gateway holds `gateway.lock` and refuses a second owner ("attach to it instead"). The sanctioned
6
+ attach is the canonical WebSocket (`ui-tui` attaches over it; `hermes_cli.gateway_client` is the
7
+ product client module). This bridge keeps the adapter's stdio ndjson JSON-RPC contract byte-stable
8
+ while the frames ride that WebSocket:
9
+
10
+ stdin line -> `GatewayClient.rpc` (ids mapped 1:1; the gateway reader drops bare replies whose
11
+ ids it does not own, so requests MUST ride the product client), reply -> stdout
12
+ ws event -> stdout line (`{"method":"event","params":{...}}` wire events via the event queue)
13
+ on connect -> one synthesized `gateway.ready` event (the stdio contract; the canonical gateway
14
+ has no ready frame - readiness is the discovery grant + open socket)
15
+
16
+ The adapter was built against the STANDALONE serve; the ws authority path re-shapes exactly five
17
+ verbs (contract files: `tui_gateway/contracts/canonical*.py`, `gateway/session_controls.py`,
18
+ `gateway/session_busy_controls.py`; every params model is `extra="forbid"`). The bridge reconciles:
19
+
20
+ session.create drop `cols` (standalone-only field; CanonicalCreateParams forbids it)
21
+ prompt.submit inject `submission_id` (authority refuses without a client request id; the
22
+ adapter never sends one) and translate the AdmissionStatus receipt into the
23
+ standalone `{"status":"streaming"}` ack the adapter's busy path requires;
24
+ `{voice_stopped:true}` becomes a JSON-RPC error (a DSH turn is never voice)
25
+ session.steer inject `execution_generation` (CanonicalCorrectionParams requires it)
26
+ session.interrupt inject `execution_generation` (CanonicalControlParams requires it)
27
+ approval.respond translate `{request_id, choice}` into the authority's EXACT
28
+ `{prompt_id, execution_generation, choice}` set, with the generation tracked
29
+ from the `approval.request` event payload
30
+
31
+ The generation tracker seeds from the session.create/session.resume snapshot and refreshes from
32
+ every live event frame (`execution_generation` rides the frame top level, the CanonicalReplayEvent
33
+ shape), every `prompt.submit` receipt, `approval.request` payload and control ack that carries one.
34
+ Unknown-on-authority methods (`session.usage` / `session.status` / `session.compress` /
35
+ `session.close`) answer the gateway's own -32601 verbatim; the adapter treats close as
36
+ fire-and-forget and the other two degrade to tool errors without touching the turn log.
37
+
38
+ Events: frames for foreign sessions are dropped once a session is bound (the adapter demuxes on
39
+ the live id anyway; filtering here keeps foreign approval requests away from the bridge layer).
40
+ `connect_gateway()` attaches to the home's running owner, or starts one (idle-exit) when none
41
+ runs, so the same bridge serves both machines with a persistent gateway and machines where the
42
+ adapter previously owned the home. Failure to obtain a gateway exits 2 with the
43
+ `[bridge-attach-unavailable]` stderr marker (maps onto the adapter's existing spawn-failure UX).
44
+
45
+ Env: PYTHONPATH / HERMES_PYTHON_SRC_ROOT (set by the adapter) put `hermes_cli` on the path.
46
+ HERMES_HOME stays unset (default profile home), matching the stdio spawn's strip list.
47
+ """
48
+ import asyncio
49
+ import json
50
+ import os
51
+ import sys
52
+ import uuid
53
+ from pathlib import Path
54
+
55
+
56
+ def _bootstrap_deps() -> None:
57
+ """Boot the committed dependency generation, exactly like gateway_bootstrap.py does for the TUI."""
58
+ root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "")
59
+ if root:
60
+ sys.path.insert(0, root)
61
+ try:
62
+ from pm.environments import NoCommittedEnvironment, activate_dependencies # type: ignore
63
+
64
+ try:
65
+ activate_dependencies(Path(root))
66
+ except NoCommittedEnvironment:
67
+ pass
68
+ except ImportError:
69
+ pass # a venv python already carries the deps
70
+
71
+
72
+ class BridgeAttachUnavailable(Exception):
73
+ """No owner to attach and none startable (or the client module is unusable)."""
74
+
75
+
76
+ class BridgeState:
77
+ """One adapter connection's session-bound translation state."""
78
+
79
+ def __init__(self) -> None:
80
+ self.session_id = None
81
+ self.generation = 0
82
+ self.prompt_generations = {}
83
+
84
+ # -- request side ---------------------------------------------------------
85
+ def translate_request(self, method, params):
86
+ """Return (method, params) as the authority contract requires them."""
87
+ if method == "session.create":
88
+ # Standalone-only field; CanonicalCreateParams is extra="forbid".
89
+ params = {k: v for k, v in params.items() if k != "cols"}
90
+ elif method == "prompt.submit":
91
+ # Authority refusal without a client-chosen request id (replay guard).
92
+ if not params.get("submission_id") and not params.get("input_id"):
93
+ params = {**params, "submission_id": f"superd-{uuid.uuid4().hex}"}
94
+ elif method in ("session.steer", "session.interrupt"):
95
+ if "execution_generation" not in params:
96
+ params = {**params, "execution_generation": self.generation}
97
+ print(f"[bridge-ctl] {method} injected_gen={params.get('execution_generation')} tracked={self.generation}", file=sys.stderr)
98
+ elif method == "approval.respond":
99
+ params = self._translate_approval(params)
100
+ return method, params
101
+
102
+ def _translate_approval(self, params):
103
+ if "prompt_id" in params and "request_id" not in params:
104
+ return params # already canonical
105
+ request_id = params.get("request_id")
106
+ generation = self.prompt_generations.get(request_id, self.generation)
107
+ return {"session_id": params.get("session_id"), "prompt_id": request_id,
108
+ "execution_generation": generation, "choice": params.get("choice")}
109
+
110
+ # -- reply side -----------------------------------------------------------
111
+ def translate_result(self, method, params, result):
112
+ """Return the reply the standalone-shaped adapter expects, or an error dict."""
113
+ if method in ("session.create", "session.resume") and isinstance(result, dict):
114
+ sid = result.get("session_id") or result.get("stored_session_id")
115
+ if isinstance(sid, str) and sid:
116
+ self.session_id = sid
117
+ gen = result.get("execution_generation")
118
+ if isinstance(gen, int):
119
+ self.generation = gen
120
+ return result
121
+ if method == "prompt.submit":
122
+ if not isinstance(result, dict):
123
+ return result
124
+ if result.get("voice_stopped") is True:
125
+ return {"__error__": {"code": -32001, "message": "voice stopped: nothing was admitted"}}
126
+ gen = result.get("execution_generation")
127
+ if isinstance(gen, int):
128
+ self.generation = gen
129
+ # Standalone ack contract: status "streaming" (the adapter rejects anything else);
130
+ # the canonical AdmissionStatus rides along for tracing.
131
+ return {"status": "streaming", "receipt": result}
132
+ if isinstance(result, dict) and method in ("session.steer", "session.interrupt"):
133
+ gen = result.get("execution_generation")
134
+ if isinstance(gen, int):
135
+ self.generation = gen
136
+ return result
137
+ return result
138
+
139
+ # -- event side -----------------------------------------------------------
140
+ def observe_event(self, params):
141
+ """Track generations/pending prompts; return False when the frame must not reach stdout."""
142
+ if not isinstance(params, dict):
143
+ return True
144
+ sid = params.get("session_id")
145
+ if self.session_id is not None and isinstance(sid, str) and sid and sid != self.session_id:
146
+ return False # foreign session: never forward (adapter demux would drop it anyway)
147
+ gen = params.get("execution_generation") # live frames carry it top-level (CanonicalReplayEvent shape)
148
+ if isinstance(gen, int):
149
+ self.generation = gen
150
+ payload = params.get("payload")
151
+ if not isinstance(payload, dict):
152
+ return True
153
+ gen = payload.get("execution_generation")
154
+ if isinstance(gen, int):
155
+ self.generation = gen
156
+ if params.get("type") == "approval.request" and isinstance(payload.get("prompt_id"), str):
157
+ self.prompt_generations[payload["prompt_id"]] = gen if isinstance(gen, int) else self.generation
158
+ if len(self.prompt_generations) > 64: # settled prompts are answered once; bound the map
159
+ self.prompt_generations.pop(next(iter(self.prompt_generations)))
160
+ return True
161
+
162
+
163
+ async def _answer(client, state, rid, method, params) -> None:
164
+ """One adapter request through the product client (the gateway reader drops
165
+ bare replies whose ids it does not own, so raw forwarding cannot work)."""
166
+ from hermes_cli.gateway_client import GatewayRPCError # type: ignore
167
+ try:
168
+ method, params = state.translate_request(method, params)
169
+ result = await client.rpc(method, _timeout=float("inf"), **params)
170
+ translated = state.translate_result(method, params, result)
171
+ if isinstance(translated, dict) and "__error__" in translated:
172
+ frame = {"jsonrpc": "2.0", "id": rid, "error": translated["__error__"]}
173
+ else:
174
+ frame = {"jsonrpc": "2.0", "id": rid, "result": translated}
175
+ except GatewayRPCError as exc:
176
+ code = exc.code if isinstance(exc.code, int) else -32000
177
+ frame = {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": str(exc)}}
178
+ except Exception as exc: # transport died mid-call: surface as the turn error
179
+ frame = {"jsonrpc": "2.0", "id": rid, "error": {"code": -32603, "message": f"{type(exc).__name__}: {exc}"}}
180
+ sys.stdout.write(json.dumps(frame, separators=(",", ":")) + "\n")
181
+ sys.stdout.flush()
182
+
183
+
184
+ async def _stdin_pump(client, state) -> None:
185
+ loop = asyncio.get_running_loop()
186
+ reader = asyncio.StreamReader()
187
+ protocol = asyncio.StreamReaderProtocol(reader)
188
+ await loop.connect_read_pipe(lambda: protocol, sys.stdin)
189
+ inflight = set()
190
+ while True:
191
+ line = await reader.readline()
192
+ if not line:
193
+ return # adapter closed its side: the ws close ends the process
194
+ text = line.decode("utf-8", "replace").strip()
195
+ if not text:
196
+ continue
197
+ req = None
198
+ try:
199
+ req = json.loads(text)
200
+ rid = req.get("id")
201
+ method = req.get("method")
202
+ params = req.get("params")
203
+ if not isinstance(method, str) or (params is not None and not isinstance(params, dict)):
204
+ raise ValueError("request needs a string method and object params")
205
+ task = asyncio.create_task(_answer(client, state, rid, method, params or {}))
206
+ inflight.add(task)
207
+ task.add_done_callback(inflight.discard)
208
+ except Exception as exc:
209
+ broken_id = req.get("id") if isinstance(req, dict) else None
210
+ sys.stdout.write(json.dumps(
211
+ {"jsonrpc": "2.0", "id": broken_id, "error": {"code": -32700, "message": str(exc)}}) + "\n")
212
+ sys.stdout.flush()
213
+
214
+
215
+ async def _event_pump(client, state) -> None:
216
+ while True:
217
+ frame = await client.events.get()
218
+ params = frame.get("params") if isinstance(frame, dict) else None
219
+ if not state.observe_event(params if isinstance(params, dict) else {}):
220
+ continue
221
+ sys.stdout.write(json.dumps(frame, separators=(",", ":")) + "\n")
222
+ sys.stdout.flush()
223
+
224
+
225
+ async def _run() -> int:
226
+ try:
227
+ from hermes_cli.gateway_client import connect_gateway # type: ignore
228
+ except Exception as exc: # ImportError and activation failures alike
229
+ raise BridgeAttachUnavailable(f"gateway client module unavailable: {exc}") from exc
230
+
231
+ try:
232
+ async with connect_gateway() as client:
233
+ state = BridgeState()
234
+ pump_events = asyncio.create_task(_event_pump(client, state))
235
+ # Readiness contract first: the adapter queues every RPC behind gateway.ready.
236
+ sys.stdout.write(json.dumps({
237
+ "jsonrpc": "2.0", "method": "event",
238
+ "params": {"type": "gateway.ready", "payload": {"transport": "ws-attach"}},
239
+ }) + "\n")
240
+ sys.stdout.flush()
241
+ pump_stdin = asyncio.create_task(_stdin_pump(client, state))
242
+ done, pending = await asyncio.wait({pump_events, pump_stdin}, return_when=asyncio.FIRST_COMPLETED)
243
+ for task in pending:
244
+ task.cancel()
245
+ for task in done:
246
+ exc = task.exception()
247
+ if exc is not None:
248
+ raise exc
249
+ except BridgeAttachUnavailable:
250
+ raise
251
+ except Exception as exc: # GatewayUnavailableError / GatewayClientError / transport failures
252
+ raise BridgeAttachUnavailable(f"{type(exc).__name__}: {exc}") from exc
253
+ return 0
254
+
255
+
256
+ def main() -> int:
257
+ _bootstrap_deps()
258
+ try:
259
+ return asyncio.run(_run())
260
+ except BridgeAttachUnavailable as exc:
261
+ # No owner and none startable: the marker lets the adapter map this onto
262
+ # the ordinary spawn-failure surface (message lands in the turn error).
263
+ print(f"[bridge-attach-unavailable] {exc}", file=sys.stderr)
264
+ return 2
265
+ except KeyboardInterrupt:
266
+ return 0
267
+ except Exception as exc: # transport/protocol failures: ordinary child failure
268
+ print(f"[bridge-exit] {type(exc).__name__}: {exc}", file=sys.stderr)
269
+ return 1
270
+
271
+
272
+ if __name__ == "__main__":
273
+ sys.exit(main())
@@ -273,6 +273,10 @@ export class HermesAgent {
273
273
  #step = 0;
274
274
  #lastTurn = 0;
275
275
  #turnOpen = false;
276
+ /** True once the reserved turn's `turn/start` (+ user/message) actually committed to the log. */
277
+ #turnStarted = false;
278
+ /** The reserved turn's prompt, held until its opening pair commits (or the turn unwinds). */
279
+ #reservedMessage = undefined;
276
280
  #cancelCause = null;
277
281
  /** Terminal model failure of the open turn (`message_end` stopReason "error"); consumed by `agent_end`. */
278
282
  #pendingFailure = null;
@@ -478,7 +482,9 @@ export class HermesAgent {
478
482
  // Reserve the turn synchronously: busy-flag semantics stay identical to the
479
483
  // eager line, but NOTHING is appended yet — the log stays empty until the
480
484
  // gateway session exists, so a failed cold start leaves no orphan turn.
481
- this.#reserveTurn();
485
+ // The message rides the reservation so a failure that closes the turn
486
+ // inside the reserved window can still commit the opening pair.
487
+ this.#reserveTurn(message);
482
488
  clearTimeout(this.#idleExitTimer);
483
489
  this.#idleExitTimer = undefined;
484
490
  this.#beginActivity();
@@ -501,8 +507,8 @@ export class HermesAgent {
501
507
  const failure = classifyTurnError(error, this.#failureContext());
502
508
  trace(`#startTurn cold start failed [${failure.code}]: ${failure.message}`);
503
509
  if (this.#turnOpen) {
504
- this.session.append("turn/start", { turn: this.#dashTurn });
505
- this.session.append("user/message", message, { surfaceOp: "append" });
510
+ // #closeTurn commits the opening pair (turn/start + user/message) when
511
+ // the turn never got past its reservation — the single append path.
506
512
  this.#closeTurn({ kind: "error", error: failure });
507
513
  }
508
514
  this.#endActivity();
@@ -514,6 +520,8 @@ export class HermesAgent {
514
520
  if (this.#disposed)
515
521
  return;
516
522
  this.session.append("turn/start", { turn: this.#dashTurn });
523
+ this.#turnStarted = true;
524
+ this.#reservedMessage = undefined;
517
525
  await this.#emitRequestContext();
518
526
  const event = this.session.append("user/message", message, { surfaceOp: "append" });
519
527
  this.#titleSourceSeq ??= event.seq;
@@ -689,17 +697,32 @@ export class HermesAgent {
689
697
  * synchronously, then commits turn/start + user/message only after the
690
698
  * cold start (#startTurn).
691
699
  */
692
- #reserveTurn() {
700
+ #reserveTurn(message) {
693
701
  if (this.#turnOpen)
694
702
  return;
695
703
  this.#dashTurn = ++this.#lastTurn;
696
704
  this.#step = 0;
697
705
  this.#turnOpen = true;
706
+ this.#turnStarted = false;
707
+ this.#reservedMessage = message;
698
708
  }
699
709
  #closeTurn(reason) {
700
710
  if (!this.#turnOpen)
701
711
  return;
712
+ if (!this.#turnStarted) {
713
+ // Failure closed the turn while it was still only RESERVED (pre
714
+ // `turn/start`; the cold-start window). Commit the opening pair first:
715
+ // a `turn/end` without `turn/start` is a corrupt v4 log (2026-11-02 —
716
+ // `client.onFailure` beat the cold-start catch to `#closeTurn` when the
717
+ // gateway child exited during `#materializeSession`).
718
+ this.session.append("turn/start", { turn: this.#dashTurn });
719
+ if (this.#reservedMessage !== undefined) {
720
+ this.session.append("user/message", this.#reservedMessage, { surfaceOp: "append" });
721
+ }
722
+ this.#turnStarted = true;
723
+ }
702
724
  this.#turnOpen = false;
725
+ this.#reservedMessage = undefined;
703
726
  this.#clearTurnDeadline();
704
727
  this.session.append("turn/end", { turn: this.#dashTurn, reason });
705
728
  }
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * HermesGatewayClient — the reusable TUI-gateway client seam (AW-H Task 3).
3
3
  *
4
- * One client = one spawned gateway child (`python -m tui_gateway.entry`,
5
- * stdio ndjson JSON-RPC) = one bound gateway session. Spawn/env/ready patterns
4
+ * One client = one gateway child bound to one gateway session. Transports:
5
+ * `stdio` (legacy default) spawns `python -m tui_gateway.entry` (owns the
6
+ * Hermes home); `attach` (SUPERD_HERMES_TRANSPORT=attach) spawns the
7
+ * WS-attach bridge — same stdio ndjson JSON-RPC contract, frames relayed to
8
+ * the profile's canonical gateway WebSocket. Spawn/env/ready patterns
6
9
  * follow the upstream reference client `ui-tui/src/gatewayClient.ts` and the
7
10
  * Task 1 spike (`src/spike.ts`, live-pinned protocol facts):
8
11
  *
@@ -64,6 +67,7 @@ import { spawn, execFile } from "node:child_process";
64
67
  import { existsSync, readFileSync } from "node:fs";
65
68
  import { homedir } from "node:os";
66
69
  import { delimiter, resolve, join } from "node:path";
70
+ import { fileURLToPath } from "node:url";
67
71
  import { promisify } from "node:util";
68
72
  import { prepareHermesNativePlugin } from "@pgmi-builds/superd-hub/foreign-native";
69
73
  import { createInterface } from "node:readline";
@@ -166,6 +170,28 @@ export function loadHermesDotEnv() {
166
170
  return {};
167
171
  }
168
172
  }
173
+ /** Gateway child transport. `stdio` = the legacy in-child `tui_gateway.entry`
174
+ * server (owns the Hermes home exclusively). `attach` = the WS-attach bridge
175
+ * (`bridge/hermes-ws-bridge.py`): frames ride the profile's canonical gateway
176
+ * WebSocket via `hermes_cli.gateway_client.connect_gateway`, which attaches
177
+ * to the running owner or starts an idle-exit one — REQUIRED whenever the
178
+ * user's native Hermes gateway already owns `~/.hermes` (2026-11-02: every
179
+ * stdio spawn died with `[gateway-exit] refused: ... owned by a running
180
+ * gateway`, and the reserved-turn failure window corrupted session logs).
181
+ * Selection: `SUPERD_HERMES_TRANSPORT` process env wins, then the native
182
+ * `~/.hermes/.env` key (hot-editable without touching the host process),
183
+ * else `stdio`. Unknown values fall back to `stdio`. */
184
+ export function resolveHermesTransport(dotEnv = {}) {
185
+ const raw = process.env.SUPERD_HERMES_TRANSPORT?.trim() || dotEnv.SUPERD_HERMES_TRANSPORT?.trim() || "stdio";
186
+ return raw === "attach" ? "attach" : "stdio";
187
+ }
188
+ /** The WS-attach bridge script, shipped at the package root (`bridge/`). */
189
+ export function hermesBridgeScriptPath() {
190
+ const path = fileURLToPath(new URL("../bridge/hermes-ws-bridge.py", import.meta.url));
191
+ if (!existsSync(path))
192
+ throw new Error(`hermes ws-attach bridge missing: ${path}`);
193
+ return path;
194
+ }
169
195
  function asRecord(value) {
170
196
  if (value === null || typeof value !== "object" || Array.isArray(value))
171
197
  return null;
@@ -244,6 +270,8 @@ export class HermesGatewayClient {
244
270
  const attempt = this.#spawnAttempts;
245
271
  const root = resolveHermesRoot();
246
272
  const python = resolveHermesPython(root);
273
+ const dotEnv = loadHermesDotEnv();
274
+ const transport = resolveHermesTransport(dotEnv);
247
275
  const env = { ...(opts.env ?? process.env) };
248
276
  if (this.#hubSession !== undefined)
249
277
  env.SUPERD_HUB_SESSION = this.#hubSession;
@@ -253,7 +281,7 @@ export class HermesGatewayClient {
253
281
  // pi runtimes load their own native `.env`; the hermes gateway child does
254
282
  // not, and the harness env carries no provider keys — without this fill
255
283
  // every provider request dies as quota/auth regardless of model.
256
- for (const [dotKey, dotValue] of Object.entries(loadHermesDotEnv())) {
284
+ for (const [dotKey, dotValue] of Object.entries(dotEnv)) {
257
285
  if (env[dotKey] === undefined || env[dotKey] === "")
258
286
  env[dotKey] = dotValue;
259
287
  }
@@ -266,12 +294,11 @@ export class HermesGatewayClient {
266
294
  delete env.HERMES_CWD;
267
295
  delete env.HERMES_TUI_GATEWAY_URL;
268
296
  delete env.HERMES_TUI_SIDECAR_URL;
269
- this.#child = gatewayFactory({
270
- command: python,
271
- args: ["-m", "tui_gateway.entry"],
272
- cwd: opts.cwd ?? root,
273
- env,
274
- });
297
+ const childSpec = transport === "attach"
298
+ ? { command: python, args: [hermesBridgeScriptPath()], cwd: opts.cwd ?? root, env }
299
+ : { command: python, args: ["-m", "tui_gateway.entry"], cwd: opts.cwd ?? root, env };
300
+ this.#trace(`spawn transport=${transport} cmd=${childSpec.command} args=${JSON.stringify(childSpec.args)}`);
301
+ this.#child = gatewayFactory(childSpec);
275
302
  // The ready gate is created ONCE and survives every pre-ready respawn —
276
303
  // replacing it would orphan callers awaiting the earlier object.
277
304
  if (this.#readyPromise === null) {
@@ -283,7 +310,9 @@ export class HermesGatewayClient {
283
310
  // surface an unhandledRejection when this gate rejects (timeout/close).
284
311
  this.#readyPromise.catch(() => { });
285
312
  }
286
- const readyTimeoutMs = opts.readyTimeoutMs ?? READY_TIMEOUT_MS;
313
+ // attach: gateway discovery (5s) or an idle-exit daemon start (ensure
314
+ // timeout 30s) can exceed the stdio boot budget before gateway.ready.
315
+ const readyTimeoutMs = opts.readyTimeoutMs ?? (transport === "attach" ? 45_000 : READY_TIMEOUT_MS);
287
316
  this.#readyTimer = setTimeout(() => {
288
317
  this.#preReadyFailure(new Error(`timed out waiting for gateway.ready after ${readyTimeoutMs}ms (spawn attempt ${attempt}/${HERMES_SPAWN_ATTEMPTS}); stderr tail:\n${this.#stderrTail()}`));
289
318
  }, readyTimeoutMs);
@@ -364,9 +364,18 @@ export class HermesProvider extends Service {
364
364
  throw new Error(`cannot resume session "${id}": no Hermes gateway session is recorded for this Dash session id`);
365
365
  }
366
366
  const persistence = this.requirePersistence("resume");
367
- if ((record.gatewayStoredSessionId === null || record.gatewayStoredSessionId === undefined)
368
- && (await readStoredEvents(persistence, id, options.signal)).some(event => event.type === "turn/start" || event.type === "user/message" || event.type === "assistant/message")) {
369
- throw new Error(`cannot resume session "${id}": no durable gateway session id is recorded (this record predates durable-id capture; re-create the session)`);
367
+ if (record.gatewayStoredSessionId === null || record.gatewayStoredSessionId === undefined) {
368
+ const events = await readStoredEvents(persistence, id, options.signal);
369
+ if (events.some(event => event.type === "turn/start" || event.type === "user/message" || event.type === "assistant/message")) {
370
+ if (events.some(event => event.type === "assistant/message")) {
371
+ // Completed turns but no durable id: a record from before the adapter captured ids.
372
+ throw new Error(`cannot resume session "${id}": no durable gateway session id is recorded (this record predates durable-id capture; re-create the session)`);
373
+ }
374
+ // Turn content with no assistant output and no durable id: the gateway never produced a
375
+ // session for this record (cold-start failure — e.g. the native home was owned by a
376
+ // running gateway). Nothing in it reached the model, so resume has nothing to attach to.
377
+ throw new Error(`cannot resume session "${id}": no durable gateway session id is recorded (the Hermes gateway failed before this session was created; its recorded turn never reached the model — re-create the session)`);
378
+ }
370
379
  }
371
380
  // Spawn cwd comes from the recorded pairing — re-realpath and re-verify.
372
381
  const spawnCwd = validatedCwd(record.cwd);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "super-dsh",
3
- "version": "0.3.3-c",
3
+ "version": "0.3.4-a",
4
4
  "description": "Super D for the DeepSeek Harness (DSH) — one dsh plugin that mounts the superd hub (runtime selector, roster, per-runtime agents) and joins local foreign-agent runtimes (OMP, Codex, Claude, Pi, Hermes, Antigravity) into the DSH Web UI. Each runtime keeps its native home and session store; the hub only bridges.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -61,6 +61,7 @@
61
61
  "agent-hermes/dist",
62
62
  "agent-hermes/package.json",
63
63
  "agent-hermes/cordis.patch.yml",
64
+ "agent-hermes/bridge",
64
65
  "agent-agy/dist",
65
66
  "agent-agy/package.json",
66
67
  "agent-agy/cordis.patch.yml",