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.
- package/README.md +15 -14
- package/agent-agy/dist/world-plugin.js +1 -0
- package/agent-agy/package.json +6 -6
- package/agent-agy-sdk/dist/world-plugin.js +1 -0
- package/agent-agy-sdk/package.json +5 -5
- package/agent-claude/package.json +8 -8
- package/agent-codex/dist/world-plugin.js +1 -0
- package/agent-codex/package.json +7 -7
- package/agent-codex-sdk/dist/world-plugin.js +1 -0
- package/agent-codex-sdk/package.json +7 -7
- package/agent-hermes/bridge/hermes-ws-bridge.py +273 -0
- package/agent-hermes/dist/agent.js +27 -4
- package/agent-hermes/dist/hermes-client.js +55 -10
- package/agent-hermes/dist/index.js +12 -3
- package/agent-hermes/dist/world-plugin.js +1 -0
- package/agent-hermes/package.json +7 -7
- package/agent-omp/dist/world-plugin.js +1 -0
- package/agent-omp/package.json +8 -8
- package/agent-omp-sdk/dist/world-plugin.js +1 -0
- package/agent-omp-sdk/package.json +8 -8
- package/agent-pi/dist/world-plugin.js +1 -0
- package/agent-pi/package.json +7 -7
- package/client/index.js +106 -249
- package/cordis.patch.yml +0 -6
- package/package.json +23 -27
- package/remote/plugin.d.ts +93 -20
- package/remote/plugin.js +921 -452
- package/remote/provenance.json +15 -13
- package/superd-hub/dist/fusion-transport.js +0 -1
- package/superd-hub/dist/world-join.js +1 -0
- package/superd-hub/dist/world-provision.d.ts +4 -0
- package/superd-hub/dist/world-provision.js +37 -0
- package/superd-hub/lib/client/index.js +106 -249
- package/superd-hub/package.json +11 -11
- package/superd-hub/dist/bundle-setup.d.ts +0 -27
- package/superd-hub/dist/bundle-setup.js +0 -94
- package/superd-hub/dist/client/companion-api.d.ts +0 -5
- package/superd-hub/dist/client/companion-api.js +0 -30
- package/superd-hub/dist/companion-contract.d.ts +0 -9
- package/superd-hub/dist/companion-contract.js +0 -1
- package/superd-hub/dist/companions.d.ts +0 -30
- 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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
package/agent-agy/package.json
CHANGED
|
@@ -25,12 +25,12 @@
|
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
27
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
28
|
-
"@deepseek-ai/dsh-agent": ">=0.2.
|
|
29
|
-
"@deepseek-ai/dsh-llm": ">=0.2.
|
|
30
|
-
"@deepseek-ai/dsh-scope": ">=0.2.
|
|
31
|
-
"@deepseek-ai/dsh-session": ">=0.2.
|
|
32
|
-
"@deepseek-ai/dsh-session-persistence": ">=0.2.
|
|
33
|
-
"@deepseek-ai/dsh-typert-protocol": ">=0.2.
|
|
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": {
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
29
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
30
|
-
"@deepseek-ai/dsh-agent": ">=0.2.
|
|
31
|
-
"@deepseek-ai/dsh-llm": ">=0.2.
|
|
32
|
-
"@deepseek-ai/dsh-scope": ">=0.2.
|
|
33
|
-
"@deepseek-ai/dsh-session": ">=0.2.
|
|
34
|
-
"@deepseek-ai/dsh-session-persistence": ">=0.2.
|
|
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.
|
|
27
|
-
"@deepseek-ai/dsh-commands": ">=0.2.
|
|
28
|
-
"@deepseek-ai/dsh-llm": ">=0.2.
|
|
29
|
-
"@deepseek-ai/dsh-session": ">=0.2.
|
|
30
|
-
"@deepseek-ai/dsh-session-persistence": ">=0.2.
|
|
31
|
-
"@deepseek-ai/dsh-typert-protocol": ">=0.2.
|
|
32
|
-
"@deepseek-ai/dsh-user-approval": ">=0.2.
|
|
33
|
-
"@deepseek-ai/dsh-user-questions": ">=0.2.
|
|
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": {
|
package/agent-codex/package.json
CHANGED
|
@@ -32,13 +32,13 @@
|
|
|
32
32
|
|
|
33
33
|
"peerDependencies": {
|
|
34
34
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
35
|
-
"@deepseek-ai/dsh-agent": ">=0.2.
|
|
36
|
-
"@deepseek-ai/dsh-agent-presets": ">=0.2.
|
|
37
|
-
"@deepseek-ai/dsh-llm": ">=0.2.
|
|
38
|
-
"@deepseek-ai/dsh-scope": ">=0.2.
|
|
39
|
-
"@deepseek-ai/dsh-session": ">=0.2.
|
|
40
|
-
"@deepseek-ai/dsh-session-persistence": ">=0.2.
|
|
41
|
-
"@deepseek-ai/dsh-typert-protocol": ">=0.2.
|
|
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": {
|
|
@@ -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.
|
|
30
|
-
"@deepseek-ai/dsh-agent-presets": ">=0.2.
|
|
31
|
-
"@deepseek-ai/dsh-llm": ">=0.2.
|
|
32
|
-
"@deepseek-ai/dsh-scope": ">=0.2.
|
|
33
|
-
"@deepseek-ai/dsh-session": ">=0.2.
|
|
34
|
-
"@deepseek-ai/dsh-session-persistence": ">=0.2.
|
|
35
|
-
"@deepseek-ai/dsh-typert-protocol": ">=0.2.
|
|
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
|
-
|
|
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
|
-
|
|
505
|
-
|
|
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
|
}
|