super-dsh 0.3.3 → 0.3.4-c

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 (42) hide show
  1. package/README.md +15 -14
  2. package/agent-agy/dist/world-plugin.js +1 -0
  3. package/agent-agy/package.json +6 -6
  4. package/agent-agy-sdk/dist/world-plugin.js +1 -0
  5. package/agent-agy-sdk/package.json +5 -5
  6. package/agent-claude/package.json +8 -8
  7. package/agent-codex/dist/world-plugin.js +1 -0
  8. package/agent-codex/package.json +7 -7
  9. package/agent-codex-sdk/dist/world-plugin.js +1 -0
  10. package/agent-codex-sdk/package.json +7 -7
  11. package/agent-hermes/bridge/hermes-ws-bridge.py +273 -0
  12. package/agent-hermes/dist/agent.js +27 -4
  13. package/agent-hermes/dist/hermes-client.js +55 -10
  14. package/agent-hermes/dist/index.js +12 -3
  15. package/agent-hermes/dist/world-plugin.js +1 -0
  16. package/agent-hermes/package.json +7 -7
  17. package/agent-omp/dist/world-plugin.js +1 -0
  18. package/agent-omp/package.json +8 -8
  19. package/agent-omp-sdk/dist/world-plugin.js +1 -0
  20. package/agent-omp-sdk/package.json +8 -8
  21. package/agent-pi/dist/world-plugin.js +1 -0
  22. package/agent-pi/package.json +7 -7
  23. package/client/index.js +106 -249
  24. package/cordis.patch.yml +0 -6
  25. package/package.json +23 -27
  26. package/remote/plugin.d.ts +93 -20
  27. package/remote/plugin.js +921 -452
  28. package/remote/provenance.json +15 -13
  29. package/superd-hub/dist/fusion-transport.js +0 -1
  30. package/superd-hub/dist/world-join.js +1 -0
  31. package/superd-hub/dist/world-provision.d.ts +4 -0
  32. package/superd-hub/dist/world-provision.js +37 -0
  33. package/superd-hub/lib/client/index.js +106 -249
  34. package/superd-hub/package.json +11 -11
  35. package/superd-hub/dist/bundle-setup.d.ts +0 -27
  36. package/superd-hub/dist/bundle-setup.js +0 -94
  37. package/superd-hub/dist/client/companion-api.d.ts +0 -5
  38. package/superd-hub/dist/client/companion-api.js +0 -30
  39. package/superd-hub/dist/companion-contract.d.ts +0 -9
  40. package/superd-hub/dist/companion-contract.js +0 -1
  41. package/superd-hub/dist/companions.d.ts +0 -30
  42. package/superd-hub/dist/companions.js +0 -211
package/README.md CHANGED
@@ -22,20 +22,21 @@ six primary `@pgmi-builds/agent-*` packages and three OPTIONAL SDK halves).
22
22
  Restart the profile after replacing a loaded package. Each foreign world's
23
23
  DSH home is created under `<DSH_HOME>/agents/<label>/` on first boot.
24
24
 
25
- ## Better Dash delivery
26
-
27
- Super installs [Better Dash](../better-dsh/) at runtime through the native DSH
28
- Plugin Manager, requesting its installed Super version first, then `better-dsh@latest`, then bare
29
- `better-dsh` after each preceding failure. The packages have no dependency edge or shared physical payload;
30
- both retain independent native Plugin Manager rows and component switches.
31
-
32
- First-use intent lives under `$DSH_HOME/superd/companions/`, so manual removal
33
- remains removed across restarts and upgrades. Hub Settings offers explicit
34
- reinstallation. Both packages ship at synchronized versions; publish Better
35
- before Super. Pi's prebuilt SDK retains its separate bundled delivery.
36
-
37
- Better's welcome policy uses the official client onboarding slot. Native
38
- WebServer and HMR stay in place; listener configuration belongs to deployment.
25
+ ## Better Dash
26
+
27
+ Better Dash and Super Dash are fully independent packages: neither installs,
28
+ promotes, or reinstalls the other ([ADR 0016](../docs/adr/0016-two-independent-packages-no-companion.md)).
29
+ Install each one yourself from the plugin market; the packages share no
30
+ dependency edge and no physical payload, and both keep independent native
31
+ Plugin Manager rows and component switches. Both packages ship at synchronized
32
+ versions; publish Better before Super. Pi's prebuilt SDK retains its separate
33
+ bundled delivery.
34
+
35
+ The model-facing `remote` / `remote_transfer` tools ship only in Better Dash.
36
+ When both packages are installed, Better's remote tool automatically detects
37
+ this hub and lists its machines as `hub:<m4>` targets. Better's welcome policy
38
+ uses the official client onboarding slot. Native WebServer and HMR stay in
39
+ place; listener configuration belongs to deployment.
39
40
 
40
41
  ## Runtimes
41
42
 
@@ -43,6 +43,7 @@ export function apply(ctx) {
43
43
  key: KEY,
44
44
  adapterPkg: '@pgmi-builds/agent-agy',
45
45
  worldHome,
46
+ host: ctx,
46
47
  });
47
48
  const world = ownWorld(ctx, { key: KEY, label: 'Antigravity' }, (ownerId) => spawnWorld({
48
49
  appName: `superd-${KEY}`,
@@ -25,12 +25,12 @@
25
25
  },
26
26
  "peerDependencies": {
27
27
  "@deepseek-ai/cordis": "^4.0.1",
28
- "@deepseek-ai/dsh-agent": ">=0.2.2-0 <1.0.0-0",
29
- "@deepseek-ai/dsh-llm": ">=0.2.2-0 <1.0.0-0",
30
- "@deepseek-ai/dsh-scope": ">=0.2.2-0 <1.0.0-0",
31
- "@deepseek-ai/dsh-session": ">=0.2.2-0 <1.0.0-0",
32
- "@deepseek-ai/dsh-session-persistence": ">=0.2.2-0 <1.0.0-0",
33
- "@deepseek-ai/dsh-typert-protocol": ">=0.2.2-0 <1.0.0-0",
28
+ "@deepseek-ai/dsh-agent": ">=0.2.0-0 <1.0.0-0",
29
+ "@deepseek-ai/dsh-llm": ">=0.2.0-0 <1.0.0-0",
30
+ "@deepseek-ai/dsh-scope": ">=0.2.0-0 <1.0.0-0",
31
+ "@deepseek-ai/dsh-session": ">=0.2.0-0 <1.0.0-0",
32
+ "@deepseek-ai/dsh-session-persistence": ">=0.2.0-0 <1.0.0-0",
33
+ "@deepseek-ai/dsh-typert-protocol": ">=0.2.0-0 <1.0.0-0",
34
34
  "@pgmi-builds/superd-hub": "*"
35
35
  },
36
36
  "peerDependenciesMeta": {
@@ -48,6 +48,7 @@ export function apply(ctx) {
48
48
  key: KEY,
49
49
  adapterPkg: '@pgmi-builds/agent-agy-sdk',
50
50
  worldHome,
51
+ host: ctx,
51
52
  });
52
53
  const world = ownWorld(ctx, { key: KEY, label: 'Antigravity (SDK)' }, (ownerId) => spawnWorld({
53
54
  appName: `superd-${KEY}`,
@@ -27,11 +27,11 @@
27
27
  },
28
28
  "peerDependencies": {
29
29
  "@deepseek-ai/cordis": "^4.0.1",
30
- "@deepseek-ai/dsh-agent": ">=0.2.2-0 <1.0.0-0",
31
- "@deepseek-ai/dsh-llm": ">=0.2.2-0 <1.0.0-0",
32
- "@deepseek-ai/dsh-scope": ">=0.2.2-0 <1.0.0-0",
33
- "@deepseek-ai/dsh-session": ">=0.2.2-0 <1.0.0-0",
34
- "@deepseek-ai/dsh-session-persistence": ">=0.2.2-0 <1.0.0-0"
30
+ "@deepseek-ai/dsh-agent": ">=0.2.0-0 <1.0.0-0",
31
+ "@deepseek-ai/dsh-llm": ">=0.2.0-0 <1.0.0-0",
32
+ "@deepseek-ai/dsh-scope": ">=0.2.0-0 <1.0.0-0",
33
+ "@deepseek-ai/dsh-session": ">=0.2.0-0 <1.0.0-0",
34
+ "@deepseek-ai/dsh-session-persistence": ">=0.2.0-0 <1.0.0-0"
35
35
  },
36
36
  "peerDependenciesMeta": {
37
37
  "@deepseek-ai/cordis": {
@@ -23,14 +23,14 @@
23
23
  },
24
24
  "peerDependencies": {
25
25
  "@deepseek-ai/cordis": "^4.0.1",
26
- "@deepseek-ai/dsh-agent": ">=0.2.2-0 <1.0.0-0",
27
- "@deepseek-ai/dsh-commands": ">=0.2.2-0 <1.0.0-0",
28
- "@deepseek-ai/dsh-llm": ">=0.2.2-0 <1.0.0-0",
29
- "@deepseek-ai/dsh-session": ">=0.2.2-0 <1.0.0-0",
30
- "@deepseek-ai/dsh-session-persistence": ">=0.2.2-0 <1.0.0-0",
31
- "@deepseek-ai/dsh-typert-protocol": ">=0.2.2-0 <1.0.0-0",
32
- "@deepseek-ai/dsh-user-approval": ">=0.2.2-0 <1.0.0-0",
33
- "@deepseek-ai/dsh-user-questions": ">=0.2.2-0 <1.0.0-0"
26
+ "@deepseek-ai/dsh-agent": ">=0.2.0-0 <1.0.0-0",
27
+ "@deepseek-ai/dsh-commands": ">=0.2.0-0 <1.0.0-0",
28
+ "@deepseek-ai/dsh-llm": ">=0.2.0-0 <1.0.0-0",
29
+ "@deepseek-ai/dsh-session": ">=0.2.0-0 <1.0.0-0",
30
+ "@deepseek-ai/dsh-session-persistence": ">=0.2.0-0 <1.0.0-0",
31
+ "@deepseek-ai/dsh-typert-protocol": ">=0.2.0-0 <1.0.0-0",
32
+ "@deepseek-ai/dsh-user-approval": ">=0.2.0-0 <1.0.0-0",
33
+ "@deepseek-ai/dsh-user-questions": ">=0.2.0-0 <1.0.0-0"
34
34
  },
35
35
  "peerDependenciesMeta": {
36
36
  "@deepseek-ai/cordis": {
@@ -42,6 +42,7 @@ export function apply(ctx) {
42
42
  key: KEY,
43
43
  adapterPkg: '@pgmi-builds/agent-codex',
44
44
  worldHome,
45
+ host: ctx,
45
46
  });
46
47
  const world = ownWorld(ctx, { key: KEY, label: 'Codex' }, (ownerId) => spawnWorld({
47
48
  appName: `superd-${KEY}`,
@@ -32,13 +32,13 @@
32
32
 
33
33
  "peerDependencies": {
34
34
  "@deepseek-ai/cordis": "^4.0.1",
35
- "@deepseek-ai/dsh-agent": ">=0.2.2-0 <1.0.0-0",
36
- "@deepseek-ai/dsh-agent-presets": ">=0.2.2-0 <1.0.0-0",
37
- "@deepseek-ai/dsh-llm": ">=0.2.2-0 <1.0.0-0",
38
- "@deepseek-ai/dsh-scope": ">=0.2.2-0 <1.0.0-0",
39
- "@deepseek-ai/dsh-session": ">=0.2.2-0 <1.0.0-0",
40
- "@deepseek-ai/dsh-session-persistence": ">=0.2.2-0 <1.0.0-0",
41
- "@deepseek-ai/dsh-typert-protocol": ">=0.2.2-0 <1.0.0-0",
35
+ "@deepseek-ai/dsh-agent": ">=0.2.0-0 <1.0.0-0",
36
+ "@deepseek-ai/dsh-agent-presets": ">=0.2.0-0 <1.0.0-0",
37
+ "@deepseek-ai/dsh-llm": ">=0.2.0-0 <1.0.0-0",
38
+ "@deepseek-ai/dsh-scope": ">=0.2.0-0 <1.0.0-0",
39
+ "@deepseek-ai/dsh-session": ">=0.2.0-0 <1.0.0-0",
40
+ "@deepseek-ai/dsh-session-persistence": ">=0.2.0-0 <1.0.0-0",
41
+ "@deepseek-ai/dsh-typert-protocol": ">=0.2.0-0 <1.0.0-0",
42
42
  "@pgmi-builds/superd-hub": "*"
43
43
  },
44
44
  "peerDependenciesMeta": {
@@ -43,6 +43,7 @@ export function apply(ctx) {
43
43
  key: KEY,
44
44
  adapterPkg: '@pgmi-builds/agent-codex-sdk',
45
45
  worldHome,
46
+ host: ctx,
46
47
  });
47
48
  const world = ownWorld(ctx, { key: KEY, label: 'Codex (SDK)' }, (ownerId) => spawnWorld({
48
49
  appName: `superd-${KEY}`,
@@ -26,13 +26,13 @@
26
26
  "dependencies": {},
27
27
  "peerDependencies": {
28
28
  "@deepseek-ai/cordis": "^4.0.1",
29
- "@deepseek-ai/dsh-agent": ">=0.2.2-0 <1.0.0-0",
30
- "@deepseek-ai/dsh-agent-presets": ">=0.2.2-0 <1.0.0-0",
31
- "@deepseek-ai/dsh-llm": ">=0.2.2-0 <1.0.0-0",
32
- "@deepseek-ai/dsh-scope": ">=0.2.2-0 <1.0.0-0",
33
- "@deepseek-ai/dsh-session": ">=0.2.2-0 <1.0.0-0",
34
- "@deepseek-ai/dsh-session-persistence": ">=0.2.2-0 <1.0.0-0",
35
- "@deepseek-ai/dsh-typert-protocol": ">=0.2.2-0 <1.0.0-0",
29
+ "@deepseek-ai/dsh-agent": ">=0.2.0-0 <1.0.0-0",
30
+ "@deepseek-ai/dsh-agent-presets": ">=0.2.0-0 <1.0.0-0",
31
+ "@deepseek-ai/dsh-llm": ">=0.2.0-0 <1.0.0-0",
32
+ "@deepseek-ai/dsh-scope": ">=0.2.0-0 <1.0.0-0",
33
+ "@deepseek-ai/dsh-session": ">=0.2.0-0 <1.0.0-0",
34
+ "@deepseek-ai/dsh-session-persistence": ">=0.2.0-0 <1.0.0-0",
35
+ "@deepseek-ai/dsh-typert-protocol": ">=0.2.0-0 <1.0.0-0",
36
36
  "@pgmi-builds/superd-hub": "*"
37
37
  },
38
38
  "peerDependenciesMeta": {
@@ -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
  }