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.
- package/agent-agy/bridge/agy_bridge.py +250 -0
- package/agent-agy/bridge/gemini_openai_server.mjs +98 -0
- package/agent-agy/cordis.patch.yml +49 -0
- package/agent-agy/dist/adapter.d.ts +15 -0
- package/agent-agy/dist/adapter.js +42 -0
- package/agent-agy/dist/agent-preset-agy.d.ts +47 -0
- package/agent-agy/dist/agent-preset-agy.js +169 -0
- package/agent-agy/dist/agent-preset-projection.d.ts +31 -0
- package/agent-agy/dist/agent-preset-projection.js +33 -0
- package/agent-agy/dist/agent.d.ts +137 -0
- package/agent-agy/dist/agent.js +493 -0
- package/agent-agy/dist/agy-cli-client.d.ts +137 -0
- package/agent-agy/dist/agy-cli-client.js +301 -0
- package/agent-agy/dist/agy-client.d.ts +160 -0
- package/agent-agy/dist/agy-client.js +252 -0
- package/agent-agy/dist/agy-sessions.d.ts +24 -0
- package/agent-agy/dist/agy-sessions.js +116 -0
- package/agent-agy/dist/agy-store.d.ts +39 -0
- package/agent-agy/dist/agy-store.js +83 -0
- package/agent-agy/dist/inbox.d.ts +26 -0
- package/agent-agy/dist/inbox.js +53 -0
- package/agent-agy/dist/index.d.ts +80 -0
- package/agent-agy/dist/index.js +505 -0
- package/agent-agy/dist/models.d.ts +41 -0
- package/agent-agy/dist/models.js +74 -0
- package/agent-agy/dist/world-plugin.d.ts +9 -0
- package/agent-agy/dist/world-plugin.js +97 -0
- package/agent-agy/package.json +64 -0
- package/agent-claude/cordis.patch.yml +42 -0
- package/agent-claude/dist/adapter.js +62 -0
- package/agent-claude/dist/agent-preset-claude.js +169 -0
- package/agent-claude/dist/agent-preset-projection.js +30 -0
- package/agent-claude/dist/agent.js +1271 -0
- package/agent-claude/dist/claude-client.js +357 -0
- package/agent-claude/dist/claude-events.js +223 -0
- package/agent-claude/dist/claude-home.js +29 -0
- package/agent-claude/dist/commands.js +182 -0
- package/agent-claude/dist/content.js +134 -0
- package/agent-claude/dist/inbox.js +53 -0
- package/agent-claude/dist/index.js +555 -0
- package/agent-claude/dist/input-queue.js +64 -0
- package/agent-claude/dist/interaction.js +271 -0
- package/agent-claude/dist/models.js +158 -0
- package/agent-claude/dist/permission.js +120 -0
- package/agent-claude/dist/session-id.js +20 -0
- package/agent-claude/dist/session-map.js +165 -0
- package/agent-claude/dist/world-plugin.js +14 -0
- package/agent-claude/package.json +74 -0
- package/agent-codex/README.md +74 -0
- package/agent-codex/cordis.patch.yml +77 -0
- package/agent-codex/dist/adapter.js +55 -0
- package/agent-codex/dist/agent-preset-codex.js +171 -0
- package/agent-codex/dist/agent-preset-projection.js +37 -0
- package/agent-codex/dist/agent.js +1056 -0
- package/agent-codex/dist/app-home.js +65 -0
- package/agent-codex/dist/codex-client.js +469 -0
- package/agent-codex/dist/codex-events.js +297 -0
- package/agent-codex/dist/codex-store.js +190 -0
- package/agent-codex/dist/inbox.js +53 -0
- package/agent-codex/dist/index.js +605 -0
- package/agent-codex/dist/models.js +152 -0
- package/agent-codex/dist/permission.js +71 -0
- package/agent-codex/dist/session-map.js +170 -0
- package/agent-codex/dist/spike.js +46 -0
- package/agent-codex/dist/world-plugin.js +96 -0
- package/agent-codex/package.json +78 -0
- package/agent-hermes/README.md +144 -0
- package/agent-hermes/cordis.patch.yml +83 -0
- package/agent-hermes/dist/adapter.js +83 -0
- package/agent-hermes/dist/agent-preset-hermes.js +171 -0
- package/agent-hermes/dist/agent-preset-projection.js +34 -0
- package/agent-hermes/dist/agent.js +1068 -0
- package/agent-hermes/dist/hermes-client.js +953 -0
- package/agent-hermes/dist/hermes-events.js +289 -0
- package/agent-hermes/dist/hermes-store.js +158 -0
- package/agent-hermes/dist/inbox.js +53 -0
- package/agent-hermes/dist/index.js +541 -0
- package/agent-hermes/dist/models.js +324 -0
- package/agent-hermes/dist/permission.js +60 -0
- package/agent-hermes/dist/world-plugin.js +101 -0
- package/agent-hermes/package.json +76 -0
- package/agent-hub/cordis.patch.yml +12 -0
- package/agent-hub/dist/agent-roster.d.ts +34 -0
- package/agent-hub/dist/agent-roster.js +27 -0
- package/agent-hub/dist/carrier.d.ts +43 -0
- package/agent-hub/dist/carrier.js +241 -0
- package/agent-hub/dist/client-shim.d.ts +28 -0
- package/agent-hub/dist/client-shim.js +251 -0
- package/agent-hub/dist/envelope.d.ts +54 -0
- package/agent-hub/dist/envelope.js +92 -0
- package/agent-hub/dist/gateway.d.ts +28 -0
- package/agent-hub/dist/gateway.js +18 -0
- package/agent-hub/dist/index-pass.d.ts +22 -0
- package/agent-hub/dist/index-pass.js +47 -0
- package/agent-hub/dist/index.d.ts +29 -0
- package/agent-hub/dist/index.js +29 -0
- package/agent-hub/dist/labels.d.ts +45 -0
- package/agent-hub/dist/labels.js +83 -0
- package/agent-hub/dist/ownership.d.ts +37 -0
- package/agent-hub/dist/ownership.js +54 -0
- package/agent-hub/dist/roster.d.ts +28 -0
- package/agent-hub/dist/roster.js +38 -0
- package/agent-hub/dist/rpc.d.ts +38 -0
- package/agent-hub/dist/rpc.js +52 -0
- package/agent-hub/dist/spawn-world.d.ts +46 -0
- package/agent-hub/dist/spawn-world.js +72 -0
- package/agent-hub/dist/targets.d.ts +33 -0
- package/agent-hub/dist/targets.js +30 -0
- package/agent-hub/dist/world-entry.d.ts +31 -0
- package/agent-hub/dist/world-entry.js +51 -0
- package/agent-hub/dist/world-host.d.ts +69 -0
- package/agent-hub/dist/world-host.js +44 -0
- package/agent-hub/dist/world-join.d.ts +16 -0
- package/agent-hub/dist/world-join.js +122 -0
- package/agent-hub/dist/world-mount.d.ts +52 -0
- package/agent-hub/dist/world-mount.js +52 -0
- package/agent-hub/dist/world-mux.d.ts +72 -0
- package/agent-hub/dist/world-mux.js +224 -0
- package/agent-hub/dist/world-provision.d.ts +20 -0
- package/agent-hub/dist/world-provision.js +122 -0
- package/agent-hub/dist/world-web-server.d.ts +162 -0
- package/agent-hub/dist/world-web-server.js +248 -0
- package/agent-hub/lib/client/index.js +242 -0
- package/agent-hub/package.json +65 -0
- package/agent-omp/README.md +77 -0
- package/agent-omp/cordis.patch.yml +92 -0
- package/agent-omp/dist/adapter.js +36 -0
- package/agent-omp/dist/agent-preset-omp.js +171 -0
- package/agent-omp/dist/agent-preset-projection.js +36 -0
- package/agent-omp/dist/agent.js +1145 -0
- package/agent-omp/dist/app-home.js +86 -0
- package/agent-omp/dist/discovery.js +116 -0
- package/agent-omp/dist/inbox.js +53 -0
- package/agent-omp/dist/index.js +574 -0
- package/agent-omp/dist/knobs.js +50 -0
- package/agent-omp/dist/lazy-rpc.js +161 -0
- package/agent-omp/dist/mobile/gesture.js +148 -0
- package/agent-omp/dist/mobile/zoom-guard.js +294 -0
- package/agent-omp/dist/mobile-boot.js +38 -0
- package/agent-omp/dist/models.js +299 -0
- package/agent-omp/dist/omp-cli.js +147 -0
- package/agent-omp/dist/omp-disk-discovery.js +178 -0
- package/agent-omp/dist/omp-store.js +518 -0
- package/agent-omp/dist/pairing.js +45 -0
- package/agent-omp/dist/permission.js +96 -0
- package/agent-omp/dist/protocol.js +13 -0
- package/agent-omp/dist/rpc-types.js +1 -0
- package/agent-omp/dist/sdk-client.js +378 -0
- package/agent-omp/dist/sidecar-client.js +195 -0
- package/agent-omp/dist/store/adapter.js +47 -0
- package/agent-omp/dist/store/db.js +210 -0
- package/agent-omp/dist/store/index.js +59 -0
- package/agent-omp/dist/store/reconcile.js +51 -0
- package/agent-omp/dist/store/schema.js +40 -0
- package/agent-omp/dist/world-plugin.js +96 -0
- package/agent-omp/package.json +97 -0
- package/agent-omp/sidecar/main.ts +481 -0
- package/agent-pi/README.md +67 -0
- package/agent-pi/cordis.patch.yml +80 -0
- package/agent-pi/dist/adapter.js +53 -0
- package/agent-pi/dist/agent-preset-pi.js +169 -0
- package/agent-pi/dist/agent-preset-projection.js +29 -0
- package/agent-pi/dist/agent.js +949 -0
- package/agent-pi/dist/inbox.js +53 -0
- package/agent-pi/dist/index.js +595 -0
- package/agent-pi/dist/knobs.js +24 -0
- package/agent-pi/dist/models.js +200 -0
- package/agent-pi/dist/permission.js +66 -0
- package/agent-pi/dist/pi-client.js +224 -0
- package/agent-pi/dist/pi-events.js +155 -0
- package/agent-pi/dist/pi-home.js +55 -0
- package/agent-pi/dist/safe-json.js +25 -0
- package/agent-pi/dist/session-map.js +119 -0
- package/agent-pi/dist/world-plugin.js +97 -0
- package/agent-pi/package.json +77 -0
- package/cordis.patch.yml +22 -19
- package/package.json +88 -25
- package/client.js +0 -4
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HermesGatewayClient — the reusable TUI-gateway client seam (AW-H Task 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
|
|
6
|
+
* follow the upstream reference client `ui-tui/src/gatewayClient.ts` and the
|
|
7
|
+
* Task 1 spike (`src/spike.ts`, live-pinned protocol facts):
|
|
8
|
+
*
|
|
9
|
+
* req -> {"id":"r<n>","jsonrpc":"2.0","method":...,"params":{...}}\n
|
|
10
|
+
* resp <- {"jsonrpc":"2.0","id":"r<n>","result":...}|{"error":{code,message}}
|
|
11
|
+
* event <- {"jsonrpc":"2.0","method":"event","params":{"type",…,"session_id"?,"payload"?}}
|
|
12
|
+
*
|
|
13
|
+
* Event frames are demultiplexed (this session's frames only — the ~60s
|
|
14
|
+
* `sessions.changed` poller carries `session_id:""` and never matches), fed
|
|
15
|
+
* through the PURE Task 2 projector `projectGatewayEvent` with a CLIENT-OWNED
|
|
16
|
+
* read-only `ProjectionCtx`, and streamed to `on()` listeners in the omp wire
|
|
17
|
+
* vocabulary — so Task 7's agent.ts port compiles against this client the way
|
|
18
|
+
* it compiles against the codex one (setCodexFactory-style injection seam
|
|
19
|
+
* included: {@link setGatewayFactory}).
|
|
20
|
+
*
|
|
21
|
+
* Client-owned accumulation the purity contract delegates here (Task 2 §5):
|
|
22
|
+
* - ctx transitions: message.start→{turnOpen,openAssistant}; healed
|
|
23
|
+
* message.delta→openAssistant; message.complete terminal→both false;
|
|
24
|
+
* error event→turnOpen=false.
|
|
25
|
+
* - orphan tool.complete: gateway emission gates differ between start and
|
|
26
|
+
* complete (server.py _on_tool_start ~8138 vs _on_tool_complete ~8185),
|
|
27
|
+
* so a tool.complete may arrive without its tool.start — synthesize the
|
|
28
|
+
* missing `tool_execution_start` from the complete payload (seen-id set
|
|
29
|
+
* lives HERE, the projector stays pure).
|
|
30
|
+
* - thinking accumulation: thinking.delta/reasoning.delta text is buffered;
|
|
31
|
+
* at message.complete the buffer is injected into the payload copy handed
|
|
32
|
+
* to the projector when the gateway did NOT re-attach `reasoning` (live
|
|
33
|
+
* fixture fact) — gateway `reasoning` always wins when present.
|
|
34
|
+
*
|
|
35
|
+
* Turn boundary (Task 1 §9.2, controller ruling): `prompt(text)` resolves on
|
|
36
|
+
* the settled pair — `message.complete` followed by `session.info` with
|
|
37
|
+
* `payload.running === false` — or rejects on a gateway `error` event, child
|
|
38
|
+
* exit/failure, an RPC error on the submit (4090/5072 … — refused BEFORE any
|
|
39
|
+
* turn), or a non-streaming ack. REQUEST_TIMEOUT_MS (120s, TUI parity) guards
|
|
40
|
+
* the submit ACK only; the turn itself is governed by the pair. `prompt`
|
|
41
|
+
* REJECTS while a turn is in flight (busy guard — the agent layer owns
|
|
42
|
+
* queueing; the gateway's default busy policy is interrupt, so the client
|
|
43
|
+
* must never deliver prompt.submit into a busy gateway).
|
|
44
|
+
*
|
|
45
|
+
* Approval bridge: `approval.request` events (payload per server.py:3036
|
|
46
|
+
* `_approval_request_payload` over tools/approval.py pending_data — request_id
|
|
47
|
+
* uuid-hex, redacted command, description, pattern_key(s), choices) go to the
|
|
48
|
+
* FIRST registered `onApproval` listener; its answer ("once"|"always"|"deny")
|
|
49
|
+
* is sent via `approval.respond {session_id, choice, request_id?}`. Missing
|
|
50
|
+
* listener / throw / void answer / 30s timeout → `deny` (fail closed).
|
|
51
|
+
*
|
|
52
|
+
* Resume replay suppression: events for our session arriving BEFORE the
|
|
53
|
+
* `session.resume` response are dropped (replay — the DSH log is the replay
|
|
54
|
+
* authority). `defer_history` is deliberately NOT passed; the pre-response
|
|
55
|
+
* drop window is the only mechanism.
|
|
56
|
+
*
|
|
57
|
+
* Env: full `{...process.env}` pass-through (+`PYTHONPATH` root prepend,
|
|
58
|
+
* +`HERMES_PYTHON_SRC_ROOT` — both spike-proven required when the child cwd
|
|
59
|
+
* is not the hermes root); `HERMES_HOME` is NEVER set and always stripped,
|
|
60
|
+
* as are `HERMES_CWD`/`HERMES_TUI_GATEWAY_URL`/`HERMES_TUI_SIDECAR_URL`
|
|
61
|
+
* (keep the spawned stdio transport the only transport).
|
|
62
|
+
*/
|
|
63
|
+
import { spawn } from "node:child_process";
|
|
64
|
+
import { existsSync } from "node:fs";
|
|
65
|
+
import { homedir } from "node:os";
|
|
66
|
+
import { delimiter, resolve } from "node:path";
|
|
67
|
+
import { createInterface } from "node:readline";
|
|
68
|
+
import { projectGatewayEvent } from "./hermes-events.js";
|
|
69
|
+
const READY_TIMEOUT_MS = 15_000; // TUI parity (STARTUP_TIMEOUT_MS)
|
|
70
|
+
const REQUEST_TIMEOUT_MS = 120_000; // per-RPC cap (upstream REQUEST_TIMEOUT_MS)
|
|
71
|
+
const APPROVAL_TIMEOUT_MS = 30_000; // approval listener deadline → deny
|
|
72
|
+
const KILL_GRACE_MS = 3_000; // SIGTERM → SIGKILL fallback
|
|
73
|
+
const STDERR_RING = 200; // child stderr ring buffer for failure messages
|
|
74
|
+
function parseSpawnGrace(raw) {
|
|
75
|
+
if (raw === undefined || raw.trim() === "")
|
|
76
|
+
return 600_000;
|
|
77
|
+
const value = Number(raw);
|
|
78
|
+
return Number.isFinite(value) && value >= 0 ? value : 600_000;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Idle-PROBE reaper grace (env `HERMES_SPAWN_GRACE_MS`, default 10 min).
|
|
82
|
+
* Exported because the catalog probe opts in explicitly via
|
|
83
|
+
* `spawn({spawnGraceMs})`; AGENT-HELD clients never arm the reaper
|
|
84
|
+
* (2026-09-17 ruling 4: a view-time eager resume may sit unprompted far
|
|
85
|
+
* longer than the grace — reaping it stamps a terminal failure on a
|
|
86
|
+
* registered agent and wedges every later prompt). `0` disables.
|
|
87
|
+
*/
|
|
88
|
+
export const HERMES_SPAWN_GRACE_MS = parseSpawnGrace(process.env.HERMES_SPAWN_GRACE_MS);
|
|
89
|
+
/** Total spawn attempts for the pre-ready retry (2026-09-18 user ruling: ~3). */
|
|
90
|
+
export const HERMES_SPAWN_ATTEMPTS = 3;
|
|
91
|
+
/** RPC error frame surfaced as a rejected promise (code 4090/5072/4001/…). */
|
|
92
|
+
export class GatewayRpcError extends Error {
|
|
93
|
+
code;
|
|
94
|
+
method;
|
|
95
|
+
constructor(method, message, code) {
|
|
96
|
+
super(`${method}: ${message}${code !== undefined ? ` (code ${code})` : ""}`);
|
|
97
|
+
this.name = "GatewayRpcError";
|
|
98
|
+
this.method = method;
|
|
99
|
+
this.code = code;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const defaultGatewayFactory = (options) => spawn(options.command, options.args, {
|
|
103
|
+
cwd: options.cwd,
|
|
104
|
+
env: options.env,
|
|
105
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
106
|
+
});
|
|
107
|
+
let gatewayFactory = defaultGatewayFactory;
|
|
108
|
+
/** Replace the process spawner (tests inject a fake child). `null` restores default. */
|
|
109
|
+
export function setGatewayFactory(factory) {
|
|
110
|
+
gatewayFactory = factory ?? defaultGatewayFactory;
|
|
111
|
+
}
|
|
112
|
+
/** Brief-pinned order: AW_HERMES_PYTHON → <root>/venv/bin/python → <root>/.venv/bin/python → PATH python3. */
|
|
113
|
+
export function resolveHermesPython(root) {
|
|
114
|
+
const configured = process.env.AW_HERMES_PYTHON?.trim();
|
|
115
|
+
if (configured)
|
|
116
|
+
return configured;
|
|
117
|
+
for (const p of [resolve(root, "venv/bin/python"), resolve(root, ".venv/bin/python")]) {
|
|
118
|
+
if (existsSync(p))
|
|
119
|
+
return p;
|
|
120
|
+
}
|
|
121
|
+
return "python3";
|
|
122
|
+
}
|
|
123
|
+
/** Hermes root: AW_HERMES_ROOT → ~/.hermes/hermes-agent (native home — never redirected). */
|
|
124
|
+
export function resolveHermesRoot() {
|
|
125
|
+
return process.env.AW_HERMES_ROOT?.trim() || resolve(homedir(), ".hermes/hermes-agent");
|
|
126
|
+
}
|
|
127
|
+
function asRecord(value) {
|
|
128
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
129
|
+
return null;
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
function asText(value) {
|
|
133
|
+
return typeof value === "string" ? value : "";
|
|
134
|
+
}
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Client
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
export class HermesGatewayClient {
|
|
139
|
+
#child = null;
|
|
140
|
+
#listeners = new Set();
|
|
141
|
+
#approvalListeners = new Set();
|
|
142
|
+
#failureListeners = new Set();
|
|
143
|
+
#adoptedListeners = new Set();
|
|
144
|
+
#pending = new Map();
|
|
145
|
+
#seenToolIds = new Set();
|
|
146
|
+
#stderrLines = [];
|
|
147
|
+
#requestTimeoutMs;
|
|
148
|
+
#approvalTimeoutMs;
|
|
149
|
+
#reqSeq = 0;
|
|
150
|
+
#readyResolve;
|
|
151
|
+
#readyReject;
|
|
152
|
+
#readyPromise = null;
|
|
153
|
+
#lazy = false;
|
|
154
|
+
#readySettled = false;
|
|
155
|
+
/** Pre-ready respawn attempts (full-lazy ruling: bounded at SPAWN_ATTEMPTS). */
|
|
156
|
+
#spawnAttempts = 0;
|
|
157
|
+
#readyTimer = null;
|
|
158
|
+
#killTimer = null;
|
|
159
|
+
#failure = null;
|
|
160
|
+
#closed = false;
|
|
161
|
+
#sessionId = null;
|
|
162
|
+
/** Durable state.db key from session.create/resume (`stored_session_id`). */
|
|
163
|
+
#storedSessionId = null;
|
|
164
|
+
#ctx = { turnOpen: false, openAssistant: false };
|
|
165
|
+
#thinking = "";
|
|
166
|
+
#turnInFlight = false;
|
|
167
|
+
#turnCompleteSeen = false;
|
|
168
|
+
#turnSettledInfoSeen = false;
|
|
169
|
+
#turnWaiter = null;
|
|
170
|
+
#replayDrop = false;
|
|
171
|
+
/** Last non-empty session.info `system_prompt` (fixture-verified capture). */
|
|
172
|
+
#systemPrompt = "";
|
|
173
|
+
/** Last non-empty session.info `title` (client-side capture). */
|
|
174
|
+
#title = "";
|
|
175
|
+
/**
|
|
176
|
+
* Idle-PROBE reaper (opt-in via spawnGraceMs): fires when no gateway session
|
|
177
|
+
* materialized within the grace. Agent-held clients never arm it (ruling 4).
|
|
178
|
+
*/
|
|
179
|
+
#spawnGraceTimer = null;
|
|
180
|
+
constructor(opts) {
|
|
181
|
+
this.#requestTimeoutMs = opts.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
182
|
+
this.#approvalTimeoutMs = opts.approvalTimeoutMs ?? APPROVAL_TIMEOUT_MS;
|
|
183
|
+
this.#lazy = opts.lazy === true;
|
|
184
|
+
this.#spawnOpts = opts;
|
|
185
|
+
if (!this.#lazy)
|
|
186
|
+
this.#materializeChild();
|
|
187
|
+
}
|
|
188
|
+
/** Captured spawn options for deferred materialization (lazy clients). */
|
|
189
|
+
#spawnOpts;
|
|
190
|
+
/** Start the python child + wire its streams and ready gate. Idempotent-ish:
|
|
191
|
+
* called once, either from the constructor (eager) or the first
|
|
192
|
+
* `ensureStarted()` (lazy). */
|
|
193
|
+
#materializeChild() {
|
|
194
|
+
const opts = this.#spawnOpts;
|
|
195
|
+
this.#spawnAttempts += 1;
|
|
196
|
+
const attempt = this.#spawnAttempts;
|
|
197
|
+
const root = resolveHermesRoot();
|
|
198
|
+
const python = resolveHermesPython(root);
|
|
199
|
+
const env = { ...(opts.env ?? process.env) };
|
|
200
|
+
const prevPath = env.PYTHONPATH?.trim();
|
|
201
|
+
env.PYTHONPATH = prevPath ? `${root}${delimiter}${prevPath}` : root;
|
|
202
|
+
env.HERMES_PYTHON_SRC_ROOT = root;
|
|
203
|
+
// Never set HERMES_HOME (native home stays native); strip every transport
|
|
204
|
+
// selector that would divert from the spawned stdio child (spike constraint).
|
|
205
|
+
delete env.HERMES_HOME;
|
|
206
|
+
delete env.HERMES_CWD;
|
|
207
|
+
delete env.HERMES_TUI_GATEWAY_URL;
|
|
208
|
+
delete env.HERMES_TUI_SIDECAR_URL;
|
|
209
|
+
this.#child = gatewayFactory({
|
|
210
|
+
command: python,
|
|
211
|
+
args: ["-m", "tui_gateway.entry"],
|
|
212
|
+
cwd: opts.cwd ?? root,
|
|
213
|
+
env,
|
|
214
|
+
});
|
|
215
|
+
// The ready gate is created ONCE and survives every pre-ready respawn —
|
|
216
|
+
// replacing it would orphan callers awaiting the earlier object.
|
|
217
|
+
if (this.#readyPromise === null) {
|
|
218
|
+
this.#readyPromise = new Promise((resolvePromise, rejectPromise) => {
|
|
219
|
+
this.#readyResolve = resolvePromise;
|
|
220
|
+
this.#readyReject = rejectPromise;
|
|
221
|
+
});
|
|
222
|
+
// Mark handled eagerly: a caller that never awaits ensureStarted() must not
|
|
223
|
+
// surface an unhandledRejection when this gate rejects (timeout/close).
|
|
224
|
+
this.#readyPromise.catch(() => { });
|
|
225
|
+
}
|
|
226
|
+
const readyTimeoutMs = opts.readyTimeoutMs ?? READY_TIMEOUT_MS;
|
|
227
|
+
this.#readyTimer = setTimeout(() => {
|
|
228
|
+
this.#preReadyFailure(new Error(`timed out waiting for gateway.ready after ${readyTimeoutMs}ms (spawn attempt ${attempt}/${HERMES_SPAWN_ATTEMPTS}); stderr tail:\n${this.#stderrTail()}`));
|
|
229
|
+
}, readyTimeoutMs);
|
|
230
|
+
// Timers represent pending client work and hold the event loop (upstream TUI
|
|
231
|
+
// parity — no unref; a drained loop with a pending RPC is a real hang to surface).
|
|
232
|
+
if (this.#child.stdout !== null) {
|
|
233
|
+
createInterface({ input: this.#child.stdout }).on("line", (raw) => this.#onStdoutLine(raw));
|
|
234
|
+
}
|
|
235
|
+
if (this.#child.stderr !== null) {
|
|
236
|
+
createInterface({ input: this.#child.stderr }).on("line", (raw) => this.#pushStderr(raw));
|
|
237
|
+
}
|
|
238
|
+
this.#child.on("error", (error) => this.#preReadyFailure(new Error(`gateway child error: ${error.message}`)));
|
|
239
|
+
this.#child.on("exit", (code) => {
|
|
240
|
+
if (this.#killTimer !== null) {
|
|
241
|
+
clearTimeout(this.#killTimer);
|
|
242
|
+
this.#killTimer = null;
|
|
243
|
+
}
|
|
244
|
+
this.#preReadyFailure(new Error(`gateway child exited (code=${code ?? "null"}); stderr tail:\n${this.#stderrTail()}`));
|
|
245
|
+
});
|
|
246
|
+
// Idle-probe reaper (I3 ruling, rescoped 2026-09-17): ONLY the self-closing
|
|
247
|
+
// catalog-probe client arms it (explicit spawnGraceMs). A client held by an
|
|
248
|
+
// agent stays alive until the agent's own lifecycle disposes it — its
|
|
249
|
+
// gateway session materializes at the FIRST prompt, which may legitimately
|
|
250
|
+
// sit far beyond any grace (view-time eager resume).
|
|
251
|
+
const spawnGraceMs = opts.spawnGraceMs ?? 0;
|
|
252
|
+
if (spawnGraceMs > 0) {
|
|
253
|
+
this.#spawnGraceTimer = setTimeout(() => {
|
|
254
|
+
this.#spawnGraceTimer = null;
|
|
255
|
+
if (this.#sessionId === null && this.#failure === null && !this.#closed) {
|
|
256
|
+
this.#trace(`spawn grace expired (${spawnGraceMs}ms) without a gateway session — closing idle probe child`);
|
|
257
|
+
this.close();
|
|
258
|
+
}
|
|
259
|
+
}, spawnGraceMs);
|
|
260
|
+
this.#spawnGraceTimer.unref?.();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/** Spawn the gateway child. Requests made before gateway.ready are queued
|
|
264
|
+
* behind `ensureStarted()` — nothing is written to stdin before ready. */
|
|
265
|
+
static spawn(opts = {}) {
|
|
266
|
+
return new HermesGatewayClient(opts);
|
|
267
|
+
}
|
|
268
|
+
// -- listener surfaces -----------------------------------------------------
|
|
269
|
+
/** Register a wire-event listener (omp vocabulary); returns the unsubscriber. */
|
|
270
|
+
on(listener) {
|
|
271
|
+
this.#listeners.add(listener);
|
|
272
|
+
return () => {
|
|
273
|
+
this.#listeners.delete(listener);
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/** Register an approval listener (async, first-registered wins); returns the unsubscriber. */
|
|
277
|
+
onApproval(listener) {
|
|
278
|
+
this.#approvalListeners.add(listener);
|
|
279
|
+
return () => {
|
|
280
|
+
this.#approvalListeners.delete(listener);
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Register an adoption listener: fired ONCE when session.create/resume
|
|
285
|
+
* adopts a gateway session, with the raw response — the surface that makes
|
|
286
|
+
* a reminted `stored_session_id` visible so the provider's identity map can
|
|
287
|
+
* follow the drift (2026-09-17 ruling). Returns the unsubscriber.
|
|
288
|
+
*/
|
|
289
|
+
onAdopted(listener) {
|
|
290
|
+
this.#adoptedListeners.add(listener);
|
|
291
|
+
return () => {
|
|
292
|
+
this.#adoptedListeners.delete(listener);
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
/** Register a failure listener (child exit/error, ready timeout, close); returns the unsubscriber. */
|
|
296
|
+
onFailure(listener) {
|
|
297
|
+
this.#failureListeners.add(listener);
|
|
298
|
+
return () => {
|
|
299
|
+
this.#failureListeners.delete(listener);
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
/** The bound live gateway session id (null until createSession/resumeSession). */
|
|
303
|
+
get sessionId() {
|
|
304
|
+
return this.#sessionId;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* The durable gateway session key (`stored_session_id` from session.create /
|
|
308
|
+
* session.resume) — the state.db key that survives a gateway restart.
|
|
309
|
+
* `null` until a session materializes.
|
|
310
|
+
*/
|
|
311
|
+
get storedSessionId() {
|
|
312
|
+
return this.#storedSessionId;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* The last non-empty `system_prompt` captured from a `session.info` payload
|
|
316
|
+
* (fixture-verified: the gateway attaches it to the settled session.info,
|
|
317
|
+
* empty before the first turn). Task 7's agent stamps it as the
|
|
318
|
+
* `system/message` surface node once non-empty. Empty until the gateway
|
|
319
|
+
* reports one.
|
|
320
|
+
*/
|
|
321
|
+
get systemPrompt() {
|
|
322
|
+
return this.#systemPrompt;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* The last non-empty `title` captured from a `session.info` payload
|
|
326
|
+
* (client-side capture: feeds session.create's title and tracing; the
|
|
327
|
+
* gateway's dedicated `session.title` events mirror through the agent).
|
|
328
|
+
*/
|
|
329
|
+
get title() {
|
|
330
|
+
return this.#title;
|
|
331
|
+
}
|
|
332
|
+
/** The first terminal failure stamped on this client (child exit/error,
|
|
333
|
+
* ready timeout, close), or null while the client is still usable. */
|
|
334
|
+
get failure() {
|
|
335
|
+
return this.#failure;
|
|
336
|
+
}
|
|
337
|
+
/** True once close() has run. */
|
|
338
|
+
get closed() {
|
|
339
|
+
return this.#closed;
|
|
340
|
+
}
|
|
341
|
+
/** Whether the client can still serve RPCs: no terminal failure, not closed.
|
|
342
|
+
* Every request path refuses a client this getter reports as false — the
|
|
343
|
+
* agent's failure path uses it to distinguish client-terminal failures
|
|
344
|
+
* (self-heal: full dispose so the host re-resumes fresh) from ordinary
|
|
345
|
+
* per-turn errors. */
|
|
346
|
+
get usable() {
|
|
347
|
+
return this.#failure === null && !this.#closed;
|
|
348
|
+
}
|
|
349
|
+
// -- lifecycle --------------------------------------------------------------
|
|
350
|
+
/** Resolve once gateway.ready arrived (15s timeout → reject + onFailure).
|
|
351
|
+
* A lazy client materializes its python child HERE (full-lazy ruling:
|
|
352
|
+
* view-time resume spawns nothing). */
|
|
353
|
+
ensureStarted() {
|
|
354
|
+
if (this.#readyPromise === null) {
|
|
355
|
+
if (this.#closed || this.#failure !== null) {
|
|
356
|
+
return Promise.reject(this.#failure ?? new Error("hermes gateway client closed"));
|
|
357
|
+
}
|
|
358
|
+
this.#materializeChild();
|
|
359
|
+
}
|
|
360
|
+
return this.#readyPromise;
|
|
361
|
+
}
|
|
362
|
+
/** Create the bound session (single-session client — one create OR one resume). */
|
|
363
|
+
async createSession(opts) {
|
|
364
|
+
this.#ensureUsable();
|
|
365
|
+
if (this.#sessionId !== null)
|
|
366
|
+
throw new Error("single-session client: a session is already bound");
|
|
367
|
+
const params = { cwd: opts.cwd, cols: 120 };
|
|
368
|
+
if (opts.title !== undefined)
|
|
369
|
+
params.title = opts.title;
|
|
370
|
+
if (opts.model !== undefined)
|
|
371
|
+
params.model = opts.model;
|
|
372
|
+
if (opts.provider !== undefined)
|
|
373
|
+
params.provider = opts.provider;
|
|
374
|
+
const info = this.#adoptSession(await this.#request("session.create", params));
|
|
375
|
+
return info;
|
|
376
|
+
}
|
|
377
|
+
/** Resume a stored session. Events for our session arriving BEFORE the
|
|
378
|
+
* response are dropped (replay suppression — no defer_history passed).
|
|
379
|
+
* 4001/4006 RPC errors reject (fail closed — the session is gone). */
|
|
380
|
+
async resumeSession(sessionId) {
|
|
381
|
+
this.#ensureUsable();
|
|
382
|
+
if (this.#sessionId !== null)
|
|
383
|
+
throw new Error("single-session client: a session is already bound");
|
|
384
|
+
if (typeof sessionId !== "string" || sessionId.trim() === "")
|
|
385
|
+
throw new Error("session id is required to resume");
|
|
386
|
+
this.#replayDrop = true;
|
|
387
|
+
try {
|
|
388
|
+
const info = this.#adoptSession(await this.#request("session.resume", { session_id: sessionId, cols: 120 }));
|
|
389
|
+
return info;
|
|
390
|
+
}
|
|
391
|
+
finally {
|
|
392
|
+
this.#replayDrop = false;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/** Submit a prompt turn. Resolves on the settled pair (message.complete →
|
|
396
|
+
* session.info running=false); rejects on gateway error event, child
|
|
397
|
+
* exit/failure, submit RPC error (pre-turn), non-streaming ack, or close.
|
|
398
|
+
* REJECTS while a turn is in flight (busy guard — REJECT policy, the agent
|
|
399
|
+
* layer owns queueing; the gateway default busy policy is interrupt). */
|
|
400
|
+
async prompt(text) {
|
|
401
|
+
this.#ensureUsable();
|
|
402
|
+
this.#ensureSession();
|
|
403
|
+
if (this.#turnInFlight || this.#ctx.turnOpen) {
|
|
404
|
+
throw new Error("hermes gateway busy: a turn is already in flight (prompt rejected — the agent layer owns queueing)");
|
|
405
|
+
}
|
|
406
|
+
this.#turnInFlight = true;
|
|
407
|
+
this.#turnCompleteSeen = false;
|
|
408
|
+
this.#turnSettledInfoSeen = false;
|
|
409
|
+
const turn = new Promise((resolvePromise, rejectPromise) => {
|
|
410
|
+
this.#turnWaiter = { resolve: resolvePromise, reject: rejectPromise };
|
|
411
|
+
});
|
|
412
|
+
try {
|
|
413
|
+
const ack = asRecord(await this.#request("prompt.submit", { session_id: this.#sessionId, text }));
|
|
414
|
+
if (ack === null || ack.status !== "streaming") {
|
|
415
|
+
throw new Error(`unexpected prompt.submit ack: ${JSON.stringify(ack)}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
// Turn never started — release the guard and surface the submit failure.
|
|
420
|
+
// #fail (child exit/error, close mid-submit) may ALREADY have rejected
|
|
421
|
+
// this local `turn` via the waiter while the ACK was pending; this path
|
|
422
|
+
// throws instead of returning it, so mark that rejection handled — an
|
|
423
|
+
// orphaned rejected promise would otherwise surface as a process-level
|
|
424
|
+
// unhandledRejection (review fix r1; regression-tested).
|
|
425
|
+
turn.catch(() => { });
|
|
426
|
+
this.#turnWaiter = null;
|
|
427
|
+
this.#turnInFlight = false;
|
|
428
|
+
this.#turnCompleteSeen = false;
|
|
429
|
+
this.#turnSettledInfoSeen = false;
|
|
430
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
431
|
+
}
|
|
432
|
+
// A gateway `error` event may already have settled the waiter mid-ACK —
|
|
433
|
+
// returning the turn promise propagates either outcome.
|
|
434
|
+
return turn;
|
|
435
|
+
}
|
|
436
|
+
/** Steer the in-flight turn (session.steer). A "rejected" ack throws. */
|
|
437
|
+
async steer(text) {
|
|
438
|
+
const result = asRecord(await this.#sessionRpc("session.steer", { text }));
|
|
439
|
+
if (result?.status === "rejected")
|
|
440
|
+
throw new Error(`steer rejected by gateway: ${JSON.stringify(result)}`);
|
|
441
|
+
}
|
|
442
|
+
/** Interrupt the current turn (session.interrupt). */
|
|
443
|
+
interrupt() {
|
|
444
|
+
return this.#sessionRpcVoid("session.interrupt");
|
|
445
|
+
}
|
|
446
|
+
/** Compress the session context (session.compress). */
|
|
447
|
+
compress() {
|
|
448
|
+
return this.#sessionRpcVoid("session.compress");
|
|
449
|
+
}
|
|
450
|
+
/** session.history — resume seed / durable transcript rows. */
|
|
451
|
+
history() {
|
|
452
|
+
return this.#sessionRpc("session.history");
|
|
453
|
+
}
|
|
454
|
+
/** session.usage — superset of {calls,input,output,total}. */
|
|
455
|
+
usage() {
|
|
456
|
+
return this.#sessionRpc("session.usage");
|
|
457
|
+
}
|
|
458
|
+
/** session.status — rendered text blob, display-only (Task 1 bonus fact). */
|
|
459
|
+
status() {
|
|
460
|
+
return this.#sessionRpc("session.status");
|
|
461
|
+
}
|
|
462
|
+
/** model.options — the Task 5 catalog source. */
|
|
463
|
+
modelOptions() {
|
|
464
|
+
this.#ensureUsable();
|
|
465
|
+
return this.#request("model.options", {});
|
|
466
|
+
}
|
|
467
|
+
/** Direct approval answer (agent-layer escape hatch alongside the listener bridge). */
|
|
468
|
+
async respondApproval(requestId, choice) {
|
|
469
|
+
if (choice !== "once" && choice !== "always" && choice !== "deny") {
|
|
470
|
+
throw new Error(`invalid approval choice: ${String(choice)}`);
|
|
471
|
+
}
|
|
472
|
+
const params = { choice };
|
|
473
|
+
if (requestId)
|
|
474
|
+
params.request_id = requestId;
|
|
475
|
+
await this.#sessionRpc("approval.respond", params);
|
|
476
|
+
}
|
|
477
|
+
/** Best-effort session.close write + kill the child (SIGTERM → 3s → SIGKILL).
|
|
478
|
+
* Rejects everything pending and fires onFailure (codex close parity). */
|
|
479
|
+
close() {
|
|
480
|
+
if (this.#closed)
|
|
481
|
+
return;
|
|
482
|
+
this.#closed = true;
|
|
483
|
+
this.#clearSpawnGrace();
|
|
484
|
+
// Fire-and-forget best effort: write the request directly (the RPC path
|
|
485
|
+
// would refuse under #closed and nothing will read the response anyway).
|
|
486
|
+
if (this.#sessionId !== null && this.#failure === null) {
|
|
487
|
+
this.#write({
|
|
488
|
+
jsonrpc: "2.0",
|
|
489
|
+
id: `r${++this.#reqSeq}`,
|
|
490
|
+
method: "session.close",
|
|
491
|
+
params: { session_id: this.#sessionId },
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
this.#fail(new Error("hermes gateway client closed"));
|
|
495
|
+
this.#killChild();
|
|
496
|
+
}
|
|
497
|
+
// -- internals: transport ---------------------------------------------------
|
|
498
|
+
#write(frame) {
|
|
499
|
+
try {
|
|
500
|
+
this.#child?.stdin?.write(`${JSON.stringify(frame)}\n`);
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
this.#trace(`stdin write failed: ${String(error)}`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
/** Queue behind gateway.ready (nothing is written before it), then one
|
|
507
|
+
* id-correlated request with the per-RPC timeout. */
|
|
508
|
+
async #request(method, params) {
|
|
509
|
+
await this.ensureStarted(); // queued pre-ready; rejects on failure
|
|
510
|
+
this.#ensureUsable();
|
|
511
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
512
|
+
const id = `r${++this.#reqSeq}`;
|
|
513
|
+
const timer = setTimeout(() => {
|
|
514
|
+
this.#pending.delete(id);
|
|
515
|
+
rejectPromise(new Error(`timeout: ${method}`));
|
|
516
|
+
}, this.#requestTimeoutMs);
|
|
517
|
+
this.#pending.set(id, { resolve: resolvePromise, reject: rejectPromise, timer, method });
|
|
518
|
+
this.#write({ jsonrpc: "2.0", id, method, params });
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
async #sessionRpc(method, extra = {}) {
|
|
522
|
+
this.#ensureUsable();
|
|
523
|
+
this.#ensureSession();
|
|
524
|
+
return this.#request(method, { session_id: this.#sessionId, ...extra });
|
|
525
|
+
}
|
|
526
|
+
async #sessionRpcVoid(method) {
|
|
527
|
+
await this.#sessionRpc(method);
|
|
528
|
+
}
|
|
529
|
+
#onStdoutLine(raw) {
|
|
530
|
+
let frame;
|
|
531
|
+
try {
|
|
532
|
+
frame = JSON.parse(raw);
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
this.#trace(`malformed stdout: ${raw.slice(0, 240)}`);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const record = asRecord(frame);
|
|
539
|
+
if (record === null) {
|
|
540
|
+
this.#trace(`non-record stdout frame: ${raw.slice(0, 240)}`);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const rawId = record.id;
|
|
544
|
+
const id = typeof rawId === "string" ? rawId : typeof rawId === "number" ? String(rawId) : undefined;
|
|
545
|
+
if (id !== undefined) {
|
|
546
|
+
const pending = this.#pending.get(id);
|
|
547
|
+
if (pending === undefined) {
|
|
548
|
+
this.#trace(`response for unknown id ${id} ignored`);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
this.#pending.delete(id);
|
|
552
|
+
clearTimeout(pending.timer);
|
|
553
|
+
if (record.error !== undefined) {
|
|
554
|
+
const err = asRecord(record.error);
|
|
555
|
+
pending.reject(new GatewayRpcError(pending.method, asText(err?.message) || "gateway rpc error", typeof err?.code === "number" ? err.code : undefined));
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
pending.resolve(record.result);
|
|
559
|
+
}
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (record.method === "event") {
|
|
563
|
+
const params = asRecord(record.params);
|
|
564
|
+
if (params === null) {
|
|
565
|
+
this.#trace("event frame without params ignored");
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
this.#handleEvent(params);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
this.#trace(`unroutable frame ignored: ${raw.slice(0, 240)}`);
|
|
572
|
+
}
|
|
573
|
+
// -- internals: event demux + projection ------------------------------------
|
|
574
|
+
#handleEvent(params) {
|
|
575
|
+
const type = asText(params.type);
|
|
576
|
+
if (type === "") {
|
|
577
|
+
this.#trace("event frame without type ignored");
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (type === "gateway.ready") {
|
|
581
|
+
if (!this.#readySettled) {
|
|
582
|
+
this.#readySettled = true;
|
|
583
|
+
if (this.#readyTimer !== null)
|
|
584
|
+
clearTimeout(this.#readyTimer);
|
|
585
|
+
this.#readyResolve();
|
|
586
|
+
}
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
// Session demux: session-less frames (the ~60s sessions.changed poller
|
|
590
|
+
// carries session_id:"") and foreign sessions are trace-dropped.
|
|
591
|
+
const sid = asText(params.session_id);
|
|
592
|
+
if (sid === "") {
|
|
593
|
+
this.#trace(`session-less event ${type} dropped`);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (this.#sessionId === null || sid !== this.#sessionId) {
|
|
597
|
+
this.#trace(`event ${type} for foreign session ${sid} dropped`);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
// Review Minor #2b: currently shadowed by the session-demux drop above
|
|
601
|
+
// (resume only runs with no session bound → #sessionId === null), but kept
|
|
602
|
+
// as the EXPLICIT replay-window contract guard should resume ever rebind.
|
|
603
|
+
if (this.#replayDrop) {
|
|
604
|
+
this.#trace(`event ${type} dropped inside resume replay window`);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const payload = params.payload;
|
|
608
|
+
if (type === "approval.request") {
|
|
609
|
+
void this.#bridgeApproval(payload);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (type === "session.info") {
|
|
613
|
+
// Half of the settled pair — the client's turn-boundary signal, never wire.
|
|
614
|
+
const info = asRecord(payload);
|
|
615
|
+
if (info !== null) {
|
|
616
|
+
// Lazy system-prompt / title capture (Task 7 ruling 4): the gateway
|
|
617
|
+
// attaches `system_prompt` (empty before the first turn) and `title` to
|
|
618
|
+
// session.info. The agent stamps the prompt as `system/message` once
|
|
619
|
+
// non-empty; the title feeds session.create + tracing (dedicated
|
|
620
|
+
// `session.title` events mirror as `session/title` through the agent).
|
|
621
|
+
const prompt = asText(info["system_prompt"]);
|
|
622
|
+
if (prompt !== "")
|
|
623
|
+
this.#systemPrompt = prompt;
|
|
624
|
+
const title = asText(info["title"]);
|
|
625
|
+
if (title !== "")
|
|
626
|
+
this.#title = title;
|
|
627
|
+
}
|
|
628
|
+
if (info?.running === false) {
|
|
629
|
+
this.#turnSettledInfoSeen = true;
|
|
630
|
+
this.#maybeSettleTurn();
|
|
631
|
+
}
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (type === "thinking.delta" || type === "reasoning.delta") {
|
|
635
|
+
// Live display is this accumulation; the durable block rides
|
|
636
|
+
// message.complete.reasoning (injected below when the gateway omits it).
|
|
637
|
+
const text = asRecord(payload)?.text;
|
|
638
|
+
if (typeof text === "string")
|
|
639
|
+
this.#thinking += text;
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (type === "message.start") {
|
|
643
|
+
// New turn bracket: reset the settle flags and the thinking buffer.
|
|
644
|
+
this.#turnCompleteSeen = false;
|
|
645
|
+
this.#turnSettledInfoSeen = false;
|
|
646
|
+
this.#thinking = "";
|
|
647
|
+
}
|
|
648
|
+
if (type === "tool.start") {
|
|
649
|
+
const p = asRecord(payload);
|
|
650
|
+
const toolId = asText(p?.tool_id);
|
|
651
|
+
if (toolId !== "")
|
|
652
|
+
this.#seenToolIds.add(toolId);
|
|
653
|
+
}
|
|
654
|
+
if (type === "tool.complete") {
|
|
655
|
+
// Orphan synthesis (Task 2 §5 seam): emission gates differ between
|
|
656
|
+
// start/complete, so an end can arrive without its start — build the
|
|
657
|
+
// missing tool_execution_start from the complete payload via the
|
|
658
|
+
// projector (the seen-id state lives here, the projector stays pure).
|
|
659
|
+
const p = asRecord(payload);
|
|
660
|
+
const toolId = asText(p?.tool_id);
|
|
661
|
+
if (toolId !== "" && !this.#seenToolIds.has(toolId)) {
|
|
662
|
+
this.#seenToolIds.add(toolId);
|
|
663
|
+
const syntheticStart = projectGatewayEvent("tool.start", p, this.#ctx);
|
|
664
|
+
if (syntheticStart !== null) {
|
|
665
|
+
for (const event of Array.isArray(syntheticStart) ? syntheticStart : [syntheticStart])
|
|
666
|
+
this.#emitWire(event);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
const payloadForProjection = type === "message.complete" ? this.#injectThinking(payload) : payload;
|
|
671
|
+
const out = projectGatewayEvent(type, payloadForProjection, this.#ctx);
|
|
672
|
+
if (out !== null) {
|
|
673
|
+
for (const event of Array.isArray(out) ? out : [out])
|
|
674
|
+
this.#emitWire(event);
|
|
675
|
+
}
|
|
676
|
+
// ctx transitions AFTER projecting (Task 2 contract).
|
|
677
|
+
if (type === "message.start" && out !== null) {
|
|
678
|
+
this.#ctx.turnOpen = true;
|
|
679
|
+
this.#ctx.openAssistant = true;
|
|
680
|
+
}
|
|
681
|
+
else if (type === "message.delta" && out !== null) {
|
|
682
|
+
this.#ctx.openAssistant = true; // covers the healed (window-closed) case
|
|
683
|
+
}
|
|
684
|
+
else if (type === "message.complete") {
|
|
685
|
+
this.#turnCompleteSeen = true; // protocol fact, projection-independent
|
|
686
|
+
this.#ctx.turnOpen = false;
|
|
687
|
+
this.#ctx.openAssistant = false;
|
|
688
|
+
this.#thinking = "";
|
|
689
|
+
this.#maybeSettleTurn();
|
|
690
|
+
}
|
|
691
|
+
else if (type === "error") {
|
|
692
|
+
this.#ctx.turnOpen = false;
|
|
693
|
+
const message = asText(asRecord(payload)?.message);
|
|
694
|
+
// Abnormal finish: settle any in-flight turn as a rejection — even when
|
|
695
|
+
// the projector saw no open turn (e.g. "agent init failed" BEFORE
|
|
696
|
+
// message.start: server.py:3624) the client knows its turn is in flight.
|
|
697
|
+
this.#settleTurn(new Error(message !== "" ? message : "hermes gateway error event"));
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
/** Clone the message.complete payload and inject the accumulated thinking
|
|
701
|
+
* when the gateway did not re-attach `reasoning` (live fixture fact);
|
|
702
|
+
* a gateway-provided non-empty reasoning always wins. */
|
|
703
|
+
#injectThinking(payload) {
|
|
704
|
+
const p = asRecord(payload);
|
|
705
|
+
if (p === null)
|
|
706
|
+
return payload;
|
|
707
|
+
const reasoning = asText(p.reasoning);
|
|
708
|
+
if (reasoning !== "" || this.#thinking === "")
|
|
709
|
+
return payload;
|
|
710
|
+
return { ...p, reasoning: this.#thinking };
|
|
711
|
+
}
|
|
712
|
+
#maybeSettleTurn() {
|
|
713
|
+
if (this.#turnCompleteSeen && this.#turnSettledInfoSeen)
|
|
714
|
+
this.#settleTurn();
|
|
715
|
+
}
|
|
716
|
+
#settleTurn(error) {
|
|
717
|
+
this.#turnCompleteSeen = false;
|
|
718
|
+
this.#turnSettledInfoSeen = false;
|
|
719
|
+
if (!this.#turnInFlight)
|
|
720
|
+
return;
|
|
721
|
+
this.#turnInFlight = false;
|
|
722
|
+
const waiter = this.#turnWaiter;
|
|
723
|
+
this.#turnWaiter = null;
|
|
724
|
+
if (waiter !== null) {
|
|
725
|
+
if (error !== undefined)
|
|
726
|
+
waiter.reject(error);
|
|
727
|
+
else
|
|
728
|
+
waiter.resolve();
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
#emitWire(event) {
|
|
732
|
+
if (this.#closed)
|
|
733
|
+
return;
|
|
734
|
+
for (const listener of [...this.#listeners]) {
|
|
735
|
+
try {
|
|
736
|
+
listener(event);
|
|
737
|
+
}
|
|
738
|
+
catch (error) {
|
|
739
|
+
this.#trace(`listener error: ${String(error)}`);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
// -- internals: approval bridge ----------------------------------------------
|
|
744
|
+
async #bridgeApproval(payload) {
|
|
745
|
+
const p = asRecord(payload) ?? {};
|
|
746
|
+
const request = {
|
|
747
|
+
requestId: asText(p.request_id),
|
|
748
|
+
title: asText(p.description),
|
|
749
|
+
...(typeof p.command === "string" ? { command: p.command } : {}),
|
|
750
|
+
raw: p,
|
|
751
|
+
};
|
|
752
|
+
const listener = [...this.#approvalListeners][0];
|
|
753
|
+
let answer = null;
|
|
754
|
+
if (listener !== undefined) {
|
|
755
|
+
// Race the listener against the deadline — and CLEAR the timer when the
|
|
756
|
+
// listener settles (a Promise.race loser keeps running; an armed 30s
|
|
757
|
+
// ref'd timer would leak per approval and hold the host loop).
|
|
758
|
+
answer = await new Promise((resolvePromise) => {
|
|
759
|
+
const timer = setTimeout(() => {
|
|
760
|
+
this.#trace(`approval listener timed out after ${this.#approvalTimeoutMs}ms`);
|
|
761
|
+
resolvePromise(null);
|
|
762
|
+
}, this.#approvalTimeoutMs);
|
|
763
|
+
void (async () => {
|
|
764
|
+
try {
|
|
765
|
+
resolvePromise(await listener(request));
|
|
766
|
+
}
|
|
767
|
+
catch (error) {
|
|
768
|
+
this.#trace(`approval listener threw: ${String(error)}`);
|
|
769
|
+
resolvePromise(null);
|
|
770
|
+
}
|
|
771
|
+
finally {
|
|
772
|
+
clearTimeout(timer);
|
|
773
|
+
}
|
|
774
|
+
})();
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
// Fail closed: missing listener / throw / void / invalid / timeout → deny.
|
|
778
|
+
const choice = answer === "once" || answer === "always" || answer === "deny" ? answer : "deny";
|
|
779
|
+
const params = { session_id: this.#sessionId, choice };
|
|
780
|
+
if (request.requestId !== "")
|
|
781
|
+
params.request_id = request.requestId;
|
|
782
|
+
try {
|
|
783
|
+
await this.#request("approval.respond", params);
|
|
784
|
+
}
|
|
785
|
+
catch (error) {
|
|
786
|
+
this.#trace(`approval.respond failed (deny already applied): ${String(error)}`);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
// -- internals: session + failure --------------------------------------------
|
|
790
|
+
#adoptSession(result) {
|
|
791
|
+
this.#clearSpawnGrace();
|
|
792
|
+
const info = asRecord(result);
|
|
793
|
+
if (info === null) {
|
|
794
|
+
throw new Error(`session response missing session_id: ${JSON.stringify(result)}`);
|
|
795
|
+
}
|
|
796
|
+
const liveId = asText(info.session_id);
|
|
797
|
+
const storedId = asText(info.stored_session_id);
|
|
798
|
+
if (liveId === "" && storedId === "") {
|
|
799
|
+
throw new Error(`session response missing session_id: ${JSON.stringify(result)}`);
|
|
800
|
+
}
|
|
801
|
+
// Events are demultiplexed on the LIVE runtime id (resume remints a NEW
|
|
802
|
+
// ephemeral id). Defensive: when the live id is absent, fall back to the
|
|
803
|
+
// durable stored key so the session filter still follows.
|
|
804
|
+
this.#sessionId = liveId !== "" ? liveId : storedId;
|
|
805
|
+
this.#storedSessionId = storedId !== "" ? storedId : null;
|
|
806
|
+
// Adoption is the materialization moment: tell the provider so its
|
|
807
|
+
// identity map can follow a reminted stored_session_id (drift upsert).
|
|
808
|
+
for (const listener of [...this.#adoptedListeners]) {
|
|
809
|
+
try {
|
|
810
|
+
listener(info);
|
|
811
|
+
}
|
|
812
|
+
catch (error) {
|
|
813
|
+
this.#trace(`adopted listener threw: ${String(error)}`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
return info;
|
|
817
|
+
}
|
|
818
|
+
/** Clear the idle-probe reaper (session materialized, or the client closed). */
|
|
819
|
+
#clearSpawnGrace() {
|
|
820
|
+
if (this.#spawnGraceTimer !== null) {
|
|
821
|
+
clearTimeout(this.#spawnGraceTimer);
|
|
822
|
+
this.#spawnGraceTimer = null;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
#ensureSession() {
|
|
826
|
+
if (this.#sessionId === null) {
|
|
827
|
+
throw new Error("no session bound — call createSession/resumeSession first");
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
#ensureUsable() {
|
|
831
|
+
if (this.#failure !== null)
|
|
832
|
+
throw this.#failure;
|
|
833
|
+
if (this.#closed)
|
|
834
|
+
throw new Error("hermes gateway client is closed");
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Failure BEFORE gateway.ready (spawn/ready phase): retry with a fresh
|
|
838
|
+
* python child — bounded at HERMES_SPAWN_ATTEMPTS — WITHOUT stamping the
|
|
839
|
+
* terminal failure or firing onFailure (the caller's `ensureStarted()`
|
|
840
|
+
* promise stays pending across retries; the client object never changes, so
|
|
841
|
+
* every holder's closures stay valid). After ready (or out of attempts)
|
|
842
|
+
* this is the legacy terminal `#fail`.
|
|
843
|
+
*/
|
|
844
|
+
#preReadyFailure(error) {
|
|
845
|
+
if (this.#readySettled || this.#closed || this.#failure !== null) {
|
|
846
|
+
this.#fail(error);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (this.#spawnAttempts < HERMES_SPAWN_ATTEMPTS) {
|
|
850
|
+
this.#trace(`pre-ready failure on spawn attempt ${this.#spawnAttempts}/${HERMES_SPAWN_ATTEMPTS} — respawning: ${error.message.slice(0, 200)}`);
|
|
851
|
+
this.#discardChildQuietly();
|
|
852
|
+
this.#materializeChild();
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
this.#fail(error);
|
|
856
|
+
}
|
|
857
|
+
/** Tear down the current child without any terminal bookkeeping (pre-ready
|
|
858
|
+
* retry path: timers cleared, child killed best-effort, no #fail, no
|
|
859
|
+
* onFailure, ready promise stays pending). */
|
|
860
|
+
#discardChildQuietly() {
|
|
861
|
+
if (this.#readyTimer !== null) {
|
|
862
|
+
clearTimeout(this.#readyTimer);
|
|
863
|
+
this.#readyTimer = null;
|
|
864
|
+
}
|
|
865
|
+
if (this.#killTimer !== null) {
|
|
866
|
+
clearTimeout(this.#killTimer);
|
|
867
|
+
this.#killTimer = null;
|
|
868
|
+
}
|
|
869
|
+
const child = this.#child;
|
|
870
|
+
this.#child = null;
|
|
871
|
+
if (child === null || child.exitCode !== null || child.killed)
|
|
872
|
+
return;
|
|
873
|
+
try {
|
|
874
|
+
child.kill("SIGKILL");
|
|
875
|
+
}
|
|
876
|
+
catch {
|
|
877
|
+
/* already gone */
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
#fail(error) {
|
|
881
|
+
if (this.#failure !== null)
|
|
882
|
+
return; // first failure wins; onFailure fires once
|
|
883
|
+
this.#failure = error;
|
|
884
|
+
if (this.#readyTimer !== null) {
|
|
885
|
+
clearTimeout(this.#readyTimer);
|
|
886
|
+
this.#readyTimer = null;
|
|
887
|
+
}
|
|
888
|
+
if (!this.#readySettled) {
|
|
889
|
+
this.#readySettled = true;
|
|
890
|
+
this.#readyReject(error);
|
|
891
|
+
}
|
|
892
|
+
for (const pending of this.#pending.values()) {
|
|
893
|
+
clearTimeout(pending.timer);
|
|
894
|
+
pending.reject(error);
|
|
895
|
+
}
|
|
896
|
+
this.#pending.clear();
|
|
897
|
+
this.#settleTurn(error);
|
|
898
|
+
for (const listener of [...this.#failureListeners]) {
|
|
899
|
+
try {
|
|
900
|
+
listener(error);
|
|
901
|
+
}
|
|
902
|
+
catch (listenerError) {
|
|
903
|
+
this.#trace(`failure listener threw: ${String(listenerError)}`);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
// A failed client must not leave the child running (ready-timeout path);
|
|
907
|
+
// killChild is guarded against the already-exited exit path.
|
|
908
|
+
this.#killChild();
|
|
909
|
+
}
|
|
910
|
+
#killChild() {
|
|
911
|
+
if (this.#child === null)
|
|
912
|
+
return;
|
|
913
|
+
if (this.#child.exitCode !== null || this.#child.killed)
|
|
914
|
+
return;
|
|
915
|
+
// Arm the SIGKILL fallback BEFORE kill(): a synchronous 'exit' (fake
|
|
916
|
+
// child, or an already-dying real one) clears the timer via the exit
|
|
917
|
+
// handler — arming after would leak a 3s timer past the exit.
|
|
918
|
+
this.#killTimer = setTimeout(() => {
|
|
919
|
+
this.#killTimer = null;
|
|
920
|
+
if (this.#child === null || this.#child.exitCode !== null)
|
|
921
|
+
return;
|
|
922
|
+
try {
|
|
923
|
+
this.#child.kill("SIGKILL");
|
|
924
|
+
}
|
|
925
|
+
catch {
|
|
926
|
+
/* already gone */
|
|
927
|
+
}
|
|
928
|
+
}, KILL_GRACE_MS);
|
|
929
|
+
try {
|
|
930
|
+
this.#child.kill("SIGTERM");
|
|
931
|
+
}
|
|
932
|
+
catch (error) {
|
|
933
|
+
this.#trace(`SIGTERM failed: ${String(error)}`);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
#pushStderr(line) {
|
|
937
|
+
const trimmed = line.trim();
|
|
938
|
+
if (trimmed === "")
|
|
939
|
+
return;
|
|
940
|
+
this.#stderrLines.push(trimmed.length > 4096 ? `${trimmed.slice(0, 4096)}… [truncated]` : trimmed);
|
|
941
|
+
if (this.#stderrLines.length > STDERR_RING)
|
|
942
|
+
this.#stderrLines.shift();
|
|
943
|
+
this.#trace(`stderr: ${trimmed.slice(0, 240)}`);
|
|
944
|
+
}
|
|
945
|
+
#stderrTail() {
|
|
946
|
+
return this.#stderrLines.slice(-20).join("\n");
|
|
947
|
+
}
|
|
948
|
+
#trace(line) {
|
|
949
|
+
if (process.env.AW_HERMES_TRACE === "1") {
|
|
950
|
+
process.stderr.write(`[hermes-client] ${line}\n`);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|