@chances-ai/wire 24.3.0 → 25.0.0
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/dist/rpc/acp/engine-driver.d.ts +43 -1
- package/dist/rpc/acp/engine-driver.d.ts.map +1 -1
- package/dist/rpc/acp/engine-driver.js +94 -10
- package/dist/rpc/acp/engine-driver.js.map +1 -1
- package/dist/rpc/acp/event-map.d.ts +20 -0
- package/dist/rpc/acp/event-map.d.ts.map +1 -1
- package/dist/rpc/acp/event-map.js +41 -0
- package/dist/rpc/acp/event-map.js.map +1 -1
- package/dist/rpc/acp/terminal.d.ts +37 -0
- package/dist/rpc/acp/terminal.d.ts.map +1 -0
- package/dist/rpc/acp/terminal.js +46 -0
- package/dist/rpc/acp/terminal.js.map +1 -0
- package/dist/rpc/acp/workspace-edits.d.ts +41 -0
- package/dist/rpc/acp/workspace-edits.d.ts.map +1 -0
- package/dist/rpc/acp/workspace-edits.js +75 -0
- package/dist/rpc/acp/workspace-edits.js.map +1 -0
- package/dist/rpc/driver.d.ts +56 -1
- package/dist/rpc/driver.d.ts.map +1 -1
- package/dist/rpc/index.d.ts +5 -1
- package/dist/rpc/index.d.ts.map +1 -1
- package/dist/rpc/index.js +2 -0
- package/dist/rpc/index.js.map +1 -1
- package/dist/serve/acp-session-host.d.ts +127 -72
- package/dist/serve/acp-session-host.d.ts.map +1 -1
- package/dist/serve/acp-session-host.js +534 -140
- package/dist/serve/acp-session-host.js.map +1 -1
- package/dist/serve/relay-frames.d.ts +7 -0
- package/dist/serve/relay-frames.d.ts.map +1 -1
- package/dist/serve/relay-frames.js +15 -0
- package/dist/serve/relay-frames.js.map +1 -1
- package/dist/serve/relay.d.ts +12 -0
- package/dist/serve/relay.d.ts.map +1 -1
- package/dist/serve/relay.js +43 -2
- package/dist/serve/relay.js.map +1 -1
- package/dist/serve/ws-transport.d.ts.map +1 -1
- package/dist/serve/ws-transport.js +7 -0
- package/dist/serve/ws-transport.js.map +1 -1
- package/package.json +4 -4
|
@@ -1,251 +1,646 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* (v20 M3 / docs/6.4a §3; v21 M4 lease
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* (v20 M3 / docs/6.4a §3; v21 M4 lease; **7.5 §14 TRUE-concurrent SessionRouter**)
|
|
3
|
+
* `AcpSessionHost` — the ACP relay's connection-level host. Originally ONE engine
|
|
4
|
+
* session per process; as of 7.5 §14 it is a **SessionRouter** that owns N live
|
|
5
|
+
* `SessionUnit`s (each = its own `ReplayHub` ring + `AcpEngineDriver` turn-runner +
|
|
6
|
+
* `MessageQueue` + isolated engine), so several sessions run concurrent turns
|
|
7
|
+
* without cross-talk. The class name is kept (the relay imports it) — it IS the
|
|
8
|
+
* session host, now multi-session.
|
|
6
9
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* re-initializes (client-core, c4).
|
|
10
|
+
* **Layering (the split that makes concurrency safe):**
|
|
11
|
+
* - *Connection-level* (this object): the controller lease, the per-socket socket
|
|
12
|
+
* map, the interactive terminals (workspace-level, not chat-session-bound), the
|
|
13
|
+
* off-ring read queries / `apply_edit`, `take_control`, and the session
|
|
14
|
+
* lifecycle (`session/new` / `session/load` / `list_sessions`). It also tracks
|
|
15
|
+
* each client's ACTIVE session (which unit's stream that device sees).
|
|
16
|
+
* - *Session-level* (a `SessionUnit`): the turn loop — `session/prompt` →
|
|
17
|
+
* `engine.runTurn` → that session's bus → `session/update` → that unit's
|
|
18
|
+
* `ReplayHub` → fanned ONLY to sockets whose active session is this unit.
|
|
17
19
|
*
|
|
18
|
-
* **
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* `controlEpoch`; the new holder + epoch are broadcast as a
|
|
26
|
-
* `_chances/unstable/control` notification. The gate lives HERE (it needs the
|
|
27
|
-
* socket→clientId map) so the driver stays a protocol-pure turn runner.
|
|
20
|
+
* **Per-session ring (decision §6.1 / §14, codex R3 Q4).** Each unit has its own
|
|
21
|
+
* `ReplayHub` (rseq/session): a device attaches to its active session's hub, so it
|
|
22
|
+
* receives only that session's stream and reconnect-replays only that session's
|
|
23
|
+
* gap. Connection-level broadcasts (lease state, `workspace_changed`) are written
|
|
24
|
+
* to EVERY unit's hub ({@link broadcastToAllUnits}) so they reach every socket —
|
|
25
|
+
* and for the N=1 degenerate (one unit) that is byte-identical to the pre-§14
|
|
26
|
+
* single-hub behaviour.
|
|
28
27
|
*
|
|
29
|
-
* **
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* there is no privilege escalation among your own devices. A truly-restricted
|
|
36
|
-
* viewer (read-only access shared with a THIRD party) needs server-issued identity
|
|
37
|
-
* and belongs to the multi-tenant Team era — out of scope here.
|
|
28
|
+
* **N=1 byte-identical (hard condition, codex R3 Q2).** The eagerly-built DEFAULT
|
|
29
|
+
* unit + the unchanged `AcpEngineDriver` per-session handlers mean a single-session
|
|
30
|
+
* connection (one `session/new`, prompts, reconnect) emits exactly the same bytes
|
|
31
|
+
* as before: initialize / session-new / prompt responses are produced by the SAME
|
|
32
|
+
* driver code, stamped through the (one) hub; the attach order (relay_welcome →
|
|
33
|
+
* replayed gap → re-sent open permissions → live socket) is preserved.
|
|
38
34
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
35
|
+
* **Active session is keyed by `clientId` (codex R3 Q3 note).** `clientId` is
|
|
36
|
+
* minted per `RpcClient` INSTANCE (it doubles as the JSON-RPC id-correlation
|
|
37
|
+
* prefix, so the wire already requires it unique per client). Two browser tabs are
|
|
38
|
+
* two `RpcClient`s ⇒ two clientIds; a reconnect reuses its clientId to resume its
|
|
39
|
+
* lease / terminals / active session — exactly what we want. The "two live sockets
|
|
40
|
+
* share one clientId" case codex flagged needs a non-conformant client (id
|
|
41
|
+
* correlation would already break), so per-client active-session is consistent with
|
|
42
|
+
* the existing trust model and avoids a redundant per-socket token.
|
|
43
|
+
*
|
|
44
|
+
* **Controller lease (v21 M4 §5).** Disabled by default ⇒ fan-out (any paired
|
|
45
|
+
* device drives; the engine serializes turns via `AGENT_BUSY`). When enabled, only
|
|
46
|
+
* the HOLDER may drive (prompt/cancel/new/load/set_model) or answer a permission;
|
|
47
|
+
* others are read-only VIEWERs (`not_controller`). The lease is COORDINATION among
|
|
48
|
+
* one user's equally-authed devices, NOT an authorization boundary (the pairing
|
|
49
|
+
* token is the security boundary; `clientId` is a self-reported coordination tag).
|
|
50
|
+
*
|
|
51
|
+
* The seq/replay layer (`ReplayHub`) is reused UNCHANGED — payload-agnostic rseq
|
|
52
|
+
* stamping on the serialized ACP JSON-RPC bytes.
|
|
41
53
|
*/
|
|
42
|
-
import { AcpEngineDriver, dispatchWorkspaceQuery, hostSupportsWorkspaceQueries, isWorkspaceQueryMethod, } from "../rpc/index.js";
|
|
54
|
+
import { AcpEngineDriver, TERMINAL_METHODS, dispatchWorkspaceEdit, dispatchWorkspaceQuery, hostSupportsTerminal, hostSupportsWorkspaceEdits, hostSupportsWorkspaceQueries, isTerminalMethod, isWorkspaceEditMethod, isWorkspaceQueryMethod, } from "../rpc/index.js";
|
|
43
55
|
import { ReplayHub } from "./replay-hub.js";
|
|
44
56
|
import { MessageQueue } from "./ws-transport.js";
|
|
45
57
|
/** Lease wire methods (chances extensions; a plain ACP client never sends them). */
|
|
46
58
|
const METHOD_TAKE_CONTROL = "_chances/unstable/take_control";
|
|
47
59
|
const METHOD_CONTROL_STATE = "_chances/unstable/control";
|
|
48
|
-
/**
|
|
60
|
+
/** (7.5 W1) Fanned after a successful `apply_edit` so OTHER devices viewing the
|
|
61
|
+
* same file re-query. A connection-level broadcast — NOT an AppEvent. */
|
|
62
|
+
const METHOD_WORKSPACE_CHANGED = "_chances/unstable/workspace_changed";
|
|
63
|
+
/** Session lifecycle (router-owned). */
|
|
64
|
+
const METHOD_NEW_SESSION = "session/new";
|
|
65
|
+
const METHOD_LOAD_SESSION = "session/load";
|
|
66
|
+
const METHOD_LIST_SESSIONS = "_chances/unstable/list_sessions";
|
|
67
|
+
/** JSON-RPC error codes. */
|
|
49
68
|
const NOT_CONTROLLER = -32010;
|
|
50
|
-
|
|
51
|
-
|
|
69
|
+
const ERR_INVALID_PARAMS = -32602;
|
|
70
|
+
/** (v23 M5) Bound a read-only workspace query so a hung git/fs can't park forever. */
|
|
52
71
|
const QUERY_TIMEOUT_MS = 10_000;
|
|
72
|
+
/** (7.5 W3) Per-terminal bounded output ring (frames) — cursor-style scrollback. */
|
|
73
|
+
const TERM_RING = 1000;
|
|
74
|
+
/** (codex R2 M3) Cap concurrent terminals per process (orphan-PTY leak guard). */
|
|
75
|
+
const MAX_TERMINALS = 8;
|
|
76
|
+
/** (7.5 §14) Cap concurrent LIVE sessions so `session/new` spam can't grow memory
|
|
77
|
+
* unbounded; opening past the cap evicts the OLDEST idle (non-busy) unit. */
|
|
78
|
+
const MAX_LIVE_SESSIONS = 16;
|
|
79
|
+
/** Placeholder handle held while a PTY spawns (codex R2 S4). */
|
|
80
|
+
const NOOP_HANDLE = { write: () => { }, resize: () => { }, close: () => { } };
|
|
53
81
|
export class AcpSessionHost {
|
|
54
|
-
hub;
|
|
55
|
-
queue = new MessageQueue();
|
|
56
|
-
driver;
|
|
57
82
|
host;
|
|
58
|
-
|
|
83
|
+
opts;
|
|
84
|
+
ringOpts;
|
|
85
|
+
driverCaps;
|
|
59
86
|
epochCounter = 0;
|
|
60
87
|
shuttingDown = false;
|
|
88
|
+
// -- (§14) the live sessions --
|
|
89
|
+
units = new Map();
|
|
90
|
+
defaultSessionId;
|
|
91
|
+
/** The FIRST `session/new` claims the eagerly-built default unit (N=1 byte
|
|
92
|
+
* identical); subsequent `session/new`s mint fresh units. */
|
|
93
|
+
defaultClaimed = false;
|
|
94
|
+
sessionSeq = 0;
|
|
61
95
|
// -- controller lease (§5) --
|
|
62
96
|
leaseEnabled;
|
|
63
97
|
holder = null;
|
|
64
98
|
controlEpoch = 0;
|
|
65
|
-
/** clientId →
|
|
66
|
-
|
|
99
|
+
/** clientId → its connection (socket + active session). */
|
|
100
|
+
conns = new Map();
|
|
101
|
+
// -- (7.5 W3) interactive terminals (connection/workspace-level) --
|
|
102
|
+
terminalEnabled;
|
|
103
|
+
terminalSeq = 0;
|
|
104
|
+
terminals = new Map();
|
|
67
105
|
constructor(opts) {
|
|
68
|
-
this.hub = new ReplayHub(opts.ring);
|
|
69
106
|
this.host = opts.host;
|
|
107
|
+
this.opts = opts;
|
|
108
|
+
this.ringOpts = opts.ring;
|
|
70
109
|
this.leaseEnabled = opts.controllerLease ?? false;
|
|
71
|
-
this.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
agent: opts.agent,
|
|
75
|
-
autoApprove: opts.autoApprove ?? false,
|
|
76
|
-
logSink: opts.logSink,
|
|
77
|
-
// (v23 M5) Advertise read-only workspace queries when the host supports
|
|
78
|
-
// them — this host intercepts + answers them off-ring (the driver itself
|
|
79
|
-
// never handles them), so the capability is honest only under the relay.
|
|
110
|
+
this.terminalEnabled = (opts.terminalEnabled ?? false) && hostSupportsTerminal(opts.host);
|
|
111
|
+
this.driverCaps = {
|
|
112
|
+
// (v23 M5) read-only queries — relay answers off-ring (driver advertises).
|
|
80
113
|
workspaceQueries: hostSupportsWorkspaceQueries(opts.host),
|
|
114
|
+
// (7.5 W1) client-initiated write — relay answers off-ring AFTER controller check.
|
|
115
|
+
workspaceEdits: hostSupportsWorkspaceEdits(opts.host),
|
|
116
|
+
// (7.5 W3) interactive PTY — only when enabled + supported.
|
|
117
|
+
terminal: this.terminalEnabled,
|
|
118
|
+
};
|
|
119
|
+
// Build the eagerly-created DEFAULT unit (N=1 byte-identical with pre-§14).
|
|
120
|
+
const unit = this.createUnit((resolver) => ({ built: this.host.build(resolver) }));
|
|
121
|
+
if (!unit)
|
|
122
|
+
throw new Error("AcpSessionHost: failed to build the default engine session");
|
|
123
|
+
this.defaultSessionId = unit.sessionId;
|
|
124
|
+
}
|
|
125
|
+
/** (§14) Spin up a new live session: its own hub + queue + driver. The driver's
|
|
126
|
+
* `run()` builds the engine through `loader` (its own resolver) in its
|
|
127
|
+
* synchronous prefix, so `driver.sessionId` is set by the time `run()` returns
|
|
128
|
+
* the promise — we can key the unit immediately. */
|
|
129
|
+
createUnit(loader) {
|
|
130
|
+
const hub = new ReplayHub(this.ringOpts);
|
|
131
|
+
const queue = new MessageQueue();
|
|
132
|
+
let built;
|
|
133
|
+
const driver = new AcpEngineDriver({
|
|
134
|
+
host: this.host,
|
|
135
|
+
sink: hub,
|
|
136
|
+
agent: this.opts.agent,
|
|
137
|
+
autoApprove: this.opts.autoApprove ?? false,
|
|
138
|
+
...(this.opts.logSink ? { logSink: this.opts.logSink } : {}),
|
|
139
|
+
workspaceQueries: this.driverCaps.workspaceQueries,
|
|
140
|
+
workspaceEdits: this.driverCaps.workspaceEdits,
|
|
141
|
+
terminal: this.driverCaps.terminal,
|
|
142
|
+
buildFn: (resolver) => {
|
|
143
|
+
const r = loader(resolver);
|
|
144
|
+
built = r.built;
|
|
145
|
+
return r;
|
|
146
|
+
},
|
|
81
147
|
});
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
|
|
148
|
+
const done = driver.run(queue).catch(() => 1);
|
|
149
|
+
// (codex R4 M3) `run()`'s synchronous prefix sets `driver.id` — UNLESS the
|
|
150
|
+
// loader/host.build threw before the first await (run() then returns a rejected
|
|
151
|
+
// promise + id stays ""). Fail fast: never key a unit on "" / leave it half-live.
|
|
152
|
+
if (!driver.id) {
|
|
153
|
+
queue.end();
|
|
154
|
+
void done;
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
const unit = {
|
|
158
|
+
sessionId: driver.id,
|
|
159
|
+
hub,
|
|
160
|
+
driver,
|
|
161
|
+
queue,
|
|
162
|
+
done,
|
|
163
|
+
...(built?.dispose ? { dispose: built.dispose.bind(built) } : {}),
|
|
164
|
+
};
|
|
165
|
+
this.units.set(unit.sessionId, unit);
|
|
166
|
+
this.evictIfOverCap();
|
|
167
|
+
return unit;
|
|
168
|
+
}
|
|
169
|
+
/** Evict the OLDEST idle unit when over the cap — so `session/new` spam can't
|
|
170
|
+
* leak. A victim must be non-default, non-busy, AND not currently VIEWED by any
|
|
171
|
+
* connection (codex R4 M2 — evicting a viewed session would orphan its conns,
|
|
172
|
+
* whose later messages would silently route to the default unit). Logs what was
|
|
173
|
+
* dropped (no silent truncation); if every over-cap unit is protected, we keep
|
|
174
|
+
* them (better a bounded overshoot than killing a live/viewed session). */
|
|
175
|
+
evictIfOverCap() {
|
|
176
|
+
while (this.units.size > MAX_LIVE_SESSIONS) {
|
|
177
|
+
const viewed = new Set([...this.conns.values()].map((c) => c.activeSessionId));
|
|
178
|
+
let victim;
|
|
179
|
+
for (const u of this.units.values()) {
|
|
180
|
+
if (u.sessionId === this.defaultSessionId || u.driver.busy || viewed.has(u.sessionId))
|
|
181
|
+
continue;
|
|
182
|
+
victim = u;
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
if (!victim)
|
|
186
|
+
break; // all busy / viewed / only default — don't force-kill one
|
|
187
|
+
this.opts.logSink?.(`relay: evicting idle session ${victim.sessionId} (over MAX_LIVE_SESSIONS)\n`);
|
|
188
|
+
this.units.delete(victim.sessionId);
|
|
189
|
+
victim.queue.end();
|
|
190
|
+
void victim.dispose?.();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
unitFor(sessionId) {
|
|
194
|
+
return (sessionId !== undefined && this.units.get(sessionId)) || this.units.get(this.defaultSessionId);
|
|
85
195
|
}
|
|
86
196
|
/**
|
|
87
|
-
* Attach a socket,
|
|
88
|
-
* going live (
|
|
89
|
-
*
|
|
90
|
-
* Wire order: `relay_welcome` → replayed gap →
|
|
197
|
+
* Attach a socket, replaying everything since `lastSeq` from its active session's
|
|
198
|
+
* ring then going live. `sessionId` (the `?session_id=` connect query) selects
|
|
199
|
+
* which unit to view (defaults to the client's last active, else the default
|
|
200
|
+
* unit — N=1 byte-identical). Wire order: `relay_welcome` → replayed gap →
|
|
201
|
+
* re-sent open permissions → [live].
|
|
91
202
|
*/
|
|
92
|
-
attach(socket, lastSeq, clientId) {
|
|
203
|
+
attach(socket, lastSeq, clientId, sessionId) {
|
|
93
204
|
const epoch = ++this.epochCounter;
|
|
94
|
-
const
|
|
205
|
+
const prevActive = clientId ? this.conns.get(clientId)?.activeSessionId : undefined;
|
|
206
|
+
const unit = this.unitFor(sessionId ?? prevActive);
|
|
207
|
+
const slice = unit.hub.replaySince(lastSeq);
|
|
95
208
|
const welcome = {
|
|
96
209
|
type: "relay_welcome",
|
|
97
210
|
epoch,
|
|
98
|
-
headSeq:
|
|
211
|
+
headSeq: unit.hub.headSeq,
|
|
99
212
|
reset: slice.reset,
|
|
100
|
-
busy:
|
|
101
|
-
pendingPermissionIds:
|
|
213
|
+
busy: unit.driver.busy,
|
|
214
|
+
pendingPermissionIds: unit.driver.pendingPermissionIds(),
|
|
102
215
|
lease: this.leaseEnabled,
|
|
103
216
|
holder: this.holder,
|
|
104
217
|
controlEpoch: this.controlEpoch,
|
|
105
218
|
};
|
|
106
|
-
if (!
|
|
219
|
+
if (!unit.hub.sendTo(socket, frameLine(welcome)))
|
|
107
220
|
return deadAttachment(epoch);
|
|
108
221
|
for (const line of slice.frames) {
|
|
109
|
-
if (!
|
|
222
|
+
if (!unit.hub.sendTo(socket, line))
|
|
110
223
|
return deadAttachment(epoch);
|
|
111
224
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
for (const open of this.driver.openPermissionFrames()) {
|
|
115
|
-
if (!this.hub.sendTo(socket, open))
|
|
225
|
+
for (const open of unit.driver.openPermissionFrames()) {
|
|
226
|
+
if (!unit.hub.sendTo(socket, open))
|
|
116
227
|
return deadAttachment(epoch);
|
|
117
228
|
}
|
|
118
|
-
|
|
119
|
-
if (clientId)
|
|
120
|
-
this.
|
|
229
|
+
unit.hub.addSocket(socket);
|
|
230
|
+
if (clientId) {
|
|
231
|
+
this.conns.set(clientId, { socket, activeSessionId: unit.sessionId });
|
|
232
|
+
if (this.terminalEnabled)
|
|
233
|
+
this.resubscribeTerminals(clientId, socket);
|
|
234
|
+
}
|
|
121
235
|
return {
|
|
122
236
|
epoch,
|
|
123
237
|
detach: () => {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (clientId && this.socketsByClient.get(clientId) === socket)
|
|
130
|
-
this.socketsByClient.delete(clientId);
|
|
238
|
+
unit.hub.removeSocket(socket);
|
|
239
|
+
if (clientId && this.conns.get(clientId)?.socket === socket)
|
|
240
|
+
this.conns.delete(clientId);
|
|
241
|
+
if (clientId)
|
|
242
|
+
this.detachTerminals(clientId);
|
|
131
243
|
},
|
|
132
244
|
};
|
|
133
245
|
}
|
|
134
246
|
/**
|
|
135
|
-
* One inbound text message (ACP JSON-RPC).
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* attributes the command to a client for the lease.
|
|
247
|
+
* One inbound text message (ACP JSON-RPC). Connection-level methods (off-ring
|
|
248
|
+
* queries/edit/terminal, the lease, the session lifecycle) are handled here;
|
|
249
|
+
* everything else is routed to the client's ACTIVE session unit's queue.
|
|
139
250
|
*/
|
|
140
251
|
onMessage(text, fromClientId) {
|
|
141
252
|
for (const part of text.split("\n")) {
|
|
142
253
|
const line = part.trim();
|
|
143
254
|
if (!line)
|
|
144
255
|
continue;
|
|
145
|
-
// (v23 M5) Read-only workspace queries (file tree / read / git) are
|
|
146
|
-
// intercepted BEFORE the lease gate — they are non-privileged, so a viewer
|
|
147
|
-
// may browse — and answered targeted + off-ring (never enqueued, never
|
|
148
|
-
// stamped into the replay ring; a big `read_file` must not fan out to every
|
|
149
|
-
// device or get replayed on reconnect).
|
|
150
256
|
const msg = tryParseMessage(line);
|
|
151
|
-
|
|
257
|
+
const method = msg && typeof msg.method === "string" ? msg.method : undefined;
|
|
258
|
+
// (v23 M5) Read-only workspace queries — non-privileged (viewers browse),
|
|
259
|
+
// answered targeted + off-ring (never enqueued / never stamped).
|
|
260
|
+
if (method && isWorkspaceQueryMethod(method)) {
|
|
152
261
|
void this.handleQuery(msg, fromClientId);
|
|
153
262
|
continue;
|
|
154
263
|
}
|
|
155
|
-
|
|
156
|
-
|
|
264
|
+
// (7.5 W1) apply_edit — PRIVILEGED off-ring write (controller-gated).
|
|
265
|
+
if (method && isWorkspaceEditMethod(method)) {
|
|
266
|
+
if (this.requireController(msg, fromClientId))
|
|
267
|
+
void this.handleEdit(msg, fromClientId);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
// (7.5 W3) interactive terminal — PRIVILEGED off-ring.
|
|
271
|
+
if (method && this.terminalEnabled && isTerminalMethod(method)) {
|
|
272
|
+
if (this.requireController(msg, fromClientId))
|
|
273
|
+
void this.handleTerminal(msg, fromClientId);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
// (§14) Session lifecycle — router-owned (creates/switches units).
|
|
277
|
+
if (method === METHOD_NEW_SESSION || method === METHOD_LOAD_SESSION || method === METHOD_LIST_SESSIONS) {
|
|
278
|
+
// new/load are privileged (they switch the active engine); list is read-only.
|
|
279
|
+
if (method === METHOD_LIST_SESSIONS) {
|
|
280
|
+
this.handleListSessions(msg, fromClientId);
|
|
281
|
+
}
|
|
282
|
+
else if (this.requireController(msg, fromClientId)) {
|
|
283
|
+
if (method === METHOD_NEW_SESSION)
|
|
284
|
+
this.handleNewSession(msg, fromClientId);
|
|
285
|
+
else
|
|
286
|
+
this.handleLoadSession(msg, fromClientId);
|
|
287
|
+
}
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
// Everything else: the lease gate, then route to the active session unit.
|
|
291
|
+
if (this.gate(line, fromClientId)) {
|
|
292
|
+
this.routeToActive(line, msg, fromClientId);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** Route a session-level line (prompt, cancel, permission answer, set_model,
|
|
297
|
+
* set_options, initialize, get_state, get_models, ping) to the client's active
|
|
298
|
+
* session unit. A `params.sessionId`, when present, addresses a specific unit
|
|
299
|
+
* (prompt/cancel carry it — codex R3 Q3); a JSON-RPC RESPONSE (no method, has id
|
|
300
|
+
* — a permission answer) is broadcast to ALL units so the owning driver resolves
|
|
301
|
+
* it and the rest ignore the foreign id. */
|
|
302
|
+
routeToActive(line, msg, fromClientId) {
|
|
303
|
+
const isResponse = msg !== null && msg.method === undefined && msg.id !== undefined;
|
|
304
|
+
if (isResponse) {
|
|
305
|
+
for (const unit of this.units.values())
|
|
306
|
+
unit.queue.push(line);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const sid = typeof msg?.params?.sessionId === "string"
|
|
310
|
+
? (msg.params.sessionId)
|
|
311
|
+
: undefined;
|
|
312
|
+
const active = fromClientId ? this.conns.get(fromClientId)?.activeSessionId : undefined;
|
|
313
|
+
this.unitFor(sid ?? active).queue.push(line);
|
|
314
|
+
}
|
|
315
|
+
/** (§14) `session/new`: the FIRST claims the default unit (N=1 byte-identical);
|
|
316
|
+
* later ones mint a fresh unit. Switch the requesting client's view to it, then
|
|
317
|
+
* push the `session/new` line to that unit's driver so IT responds (eager
|
|
318
|
+
* sessionId) — reusing the driver's exact response bytes. */
|
|
319
|
+
handleNewSession(msg, fromClientId) {
|
|
320
|
+
let unit;
|
|
321
|
+
if (!this.defaultClaimed) {
|
|
322
|
+
this.defaultClaimed = true;
|
|
323
|
+
unit = this.units.get(this.defaultSessionId);
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
const created = this.createUnit((resolver) => ({ built: this.host.build(resolver) }));
|
|
327
|
+
if (!created) {
|
|
328
|
+
// (codex R4 M3) build failed → don't switch to an empty session.
|
|
329
|
+
if (msg.id !== undefined)
|
|
330
|
+
this.replyTo(fromClientId, errorFrame(msg.id, -32000, "failed to create session"));
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
unit = created;
|
|
334
|
+
}
|
|
335
|
+
this.switchClientTo(fromClientId, unit, /*replay*/ false);
|
|
336
|
+
unit.queue.push(JSON.stringify(msg));
|
|
337
|
+
}
|
|
338
|
+
/** (§14) `session/load`: rejoin a live unit, or resume one from the store into a
|
|
339
|
+
* fresh unit, then switch the client's view (replaying that unit's ring) and
|
|
340
|
+
* ack. Unknown id → invalidParams. */
|
|
341
|
+
handleLoadSession(msg, fromClientId) {
|
|
342
|
+
const id = msg.params?.sessionId;
|
|
343
|
+
if (typeof id !== "string" || !this.host.loadSession) {
|
|
344
|
+
if (msg.id !== undefined)
|
|
345
|
+
this.replyTo(fromClientId, errorFrame(msg.id, ERR_INVALID_PARAMS, "loadSession unsupported or missing sessionId"));
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const live = this.units.get(id);
|
|
349
|
+
if (live) {
|
|
350
|
+
this.switchClientTo(fromClientId, live, /*replay*/ true);
|
|
351
|
+
if (msg.id !== undefined)
|
|
352
|
+
live.hub.write(frameLine(resultFrame(msg.id, {})));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
// Not live → must exist in the store to resume.
|
|
356
|
+
const known = this.host.listSessions?.().some((s) => s.id === id) ?? false;
|
|
357
|
+
if (!known) {
|
|
358
|
+
if (msg.id !== undefined)
|
|
359
|
+
this.replyTo(fromClientId, errorFrame(msg.id, ERR_INVALID_PARAMS, "unknown session"));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const loadFn = this.host.loadSession.bind(this.host);
|
|
363
|
+
const unit = this.createUnit((resolver) => {
|
|
364
|
+
const loaded = loadFn(id, resolver);
|
|
365
|
+
if (!loaded)
|
|
366
|
+
throw new Error("unknown session");
|
|
367
|
+
return { built: loaded.engine, history: loaded.history };
|
|
368
|
+
});
|
|
369
|
+
if (!unit) {
|
|
370
|
+
// (codex R4 M3) resume build failed/raced — createUnit already cleaned up.
|
|
371
|
+
if (msg.id !== undefined)
|
|
372
|
+
this.replyTo(fromClientId, errorFrame(msg.id, ERR_INVALID_PARAMS, "unknown session"));
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
this.switchClientTo(fromClientId, unit, /*replay*/ true);
|
|
376
|
+
if (msg.id !== undefined)
|
|
377
|
+
unit.hub.write(frameLine(resultFrame(msg.id, {})));
|
|
378
|
+
}
|
|
379
|
+
/** (§14) `list_sessions`: read-only, merge live + persisted, off-ring reply. */
|
|
380
|
+
handleListSessions(msg, fromClientId) {
|
|
381
|
+
if (msg.id === undefined)
|
|
382
|
+
return;
|
|
383
|
+
// Mark a session busy when its LIVE unit has a turn in flight, so a sidebar can
|
|
384
|
+
// flag a working/awaiting-permission background session (codex R3 Q3).
|
|
385
|
+
const busyOf = (id) => this.units.get(id)?.driver.busy ?? false;
|
|
386
|
+
const fromStore = this.host.listSessions?.().map((s) => ({ ...s, busy: busyOf(s.id) })) ?? [];
|
|
387
|
+
const seen = new Set(fromStore.map((s) => s.id));
|
|
388
|
+
// Surface any live-but-not-yet-persisted unit too (defensive — serve persists
|
|
389
|
+
// on create, so this is normally a no-op).
|
|
390
|
+
const live = [...this.units.keys()]
|
|
391
|
+
.filter((id) => !seen.has(id))
|
|
392
|
+
.map((id) => ({ id, title: id, updatedAt: "", busy: busyOf(id) }));
|
|
393
|
+
this.replyTo(fromClientId, resultFrame(msg.id, { sessions: [...fromStore, ...live] }));
|
|
394
|
+
}
|
|
395
|
+
/** Move a client's view to `unit`: detach its socket from the old unit's hub,
|
|
396
|
+
* attach to the new one (optionally replaying the new unit's ring so a
|
|
397
|
+
* session/load device sees the rehydrated transcript), update active session. */
|
|
398
|
+
switchClientTo(clientId, unit, replay) {
|
|
399
|
+
if (!clientId)
|
|
400
|
+
return;
|
|
401
|
+
const conn = this.conns.get(clientId);
|
|
402
|
+
if (conn) {
|
|
403
|
+
if (conn.activeSessionId !== unit.sessionId) {
|
|
404
|
+
const old = this.units.get(conn.activeSessionId);
|
|
405
|
+
old?.hub.removeSocket(conn.socket);
|
|
406
|
+
if (replay) {
|
|
407
|
+
for (const line of unit.hub.replaySince(0).frames)
|
|
408
|
+
unit.hub.sendTo(conn.socket, line);
|
|
409
|
+
// (codex R4 S1) Re-send still-open permission asks so a switched-to busy
|
|
410
|
+
// session's parked permission stays answerable even if the ring evicted
|
|
411
|
+
// its original request frame (mirrors the attach path).
|
|
412
|
+
for (const open of unit.driver.openPermissionFrames())
|
|
413
|
+
unit.hub.sendTo(conn.socket, open);
|
|
414
|
+
}
|
|
415
|
+
unit.hub.addSocket(conn.socket);
|
|
416
|
+
conn.activeSessionId = unit.sessionId;
|
|
417
|
+
}
|
|
157
418
|
}
|
|
158
419
|
}
|
|
159
|
-
|
|
160
|
-
* primitives and `replyTo` the originating socket only (off-ring). Needs an id
|
|
161
|
-
* (to respond) and a clientId (to target); a query missing either is dropped
|
|
162
|
-
* (a conformant client always sends both). */
|
|
420
|
+
// -- off-ring handlers (connection-level; reply to the requesting socket) -----
|
|
163
421
|
async handleQuery(msg, fromClientId) {
|
|
164
422
|
if (msg.id === undefined || fromClientId === undefined)
|
|
165
423
|
return;
|
|
166
424
|
const outcome = await dispatchWorkspaceQuery(this.host, msg.method, msg.params, AbortSignal.timeout(QUERY_TIMEOUT_MS));
|
|
167
|
-
this.replyTo(fromClientId, outcome.error ?
|
|
425
|
+
this.replyTo(fromClientId, outcome.error ? errorObj(msg.id, outcome.error) : resultFrame(msg.id, outcome.result));
|
|
168
426
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
427
|
+
async handleEdit(msg, fromClientId) {
|
|
428
|
+
if (msg.id === undefined || fromClientId === undefined)
|
|
429
|
+
return;
|
|
430
|
+
const outcome = await dispatchWorkspaceEdit(this.host, msg.params, AbortSignal.timeout(QUERY_TIMEOUT_MS));
|
|
431
|
+
this.replyTo(fromClientId, outcome.error ? errorObj(msg.id, outcome.error) : resultFrame(msg.id, outcome.result));
|
|
432
|
+
if (!outcome.error) {
|
|
433
|
+
const path = outcome.result?.path;
|
|
434
|
+
// Connection-level: fan to EVERY unit's hub so all devices re-query.
|
|
435
|
+
if (path !== undefined) {
|
|
436
|
+
this.broadcastToAllUnits(JSON.stringify({ jsonrpc: "2.0", method: METHOD_WORKSPACE_CHANGED, params: { path } }) + "\n");
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
async handleTerminal(msg, fromClientId) {
|
|
441
|
+
if (fromClientId === undefined)
|
|
442
|
+
return;
|
|
443
|
+
const p = (msg.params ?? {});
|
|
444
|
+
const entryOf = () => (typeof p.terminalId === "string" ? this.terminals.get(p.terminalId) : undefined);
|
|
445
|
+
switch (msg.method) {
|
|
446
|
+
case TERMINAL_METHODS.open: {
|
|
447
|
+
if (msg.id === undefined || !this.host.openTerminal)
|
|
448
|
+
return;
|
|
449
|
+
const cols = typeof p.cols === "number" ? p.cols : 80;
|
|
450
|
+
const rows = typeof p.rows === "number" ? p.rows : 24;
|
|
451
|
+
while (this.terminals.size >= MAX_TERMINALS) {
|
|
452
|
+
const oldest = this.terminals.keys().next().value;
|
|
453
|
+
if (oldest === undefined)
|
|
454
|
+
break;
|
|
455
|
+
this.closeTerminal(oldest);
|
|
456
|
+
}
|
|
457
|
+
const id = `term_${++this.terminalSeq}`;
|
|
458
|
+
const entry = { handle: NOOP_HANDLE, owner: fromClientId, subscribers: new Set([fromClientId]), buf: [], headSeq: 0 };
|
|
459
|
+
this.terminals.set(id, entry);
|
|
460
|
+
try {
|
|
461
|
+
entry.handle = await this.host.openTerminal({ cols, rows, cwd: typeof p.cwd === "string" ? p.cwd : undefined, command: typeof p.command === "string" ? p.command : undefined }, (data) => this.onTerminalOutput(id, data));
|
|
462
|
+
this.replyTo(fromClientId, resultFrame(msg.id, { terminalId: id }));
|
|
463
|
+
}
|
|
464
|
+
catch (e) {
|
|
465
|
+
this.terminals.delete(id);
|
|
466
|
+
this.replyTo(fromClientId, errorFrame(msg.id, -32000, e.message));
|
|
467
|
+
}
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
case TERMINAL_METHODS.input: {
|
|
471
|
+
const entry = entryOf();
|
|
472
|
+
if (entry && typeof p.data === "string")
|
|
473
|
+
entry.handle.write(p.data);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
case TERMINAL_METHODS.resize: {
|
|
477
|
+
const entry = entryOf();
|
|
478
|
+
if (entry && typeof p.cols === "number" && typeof p.rows === "number")
|
|
479
|
+
entry.handle.resize(p.cols, p.rows);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
case TERMINAL_METHODS.close: {
|
|
483
|
+
if (typeof p.terminalId === "string")
|
|
484
|
+
this.closeTerminal(p.terminalId);
|
|
485
|
+
if (msg.id !== undefined)
|
|
486
|
+
this.replyTo(fromClientId, resultFrame(msg.id, { ok: true }));
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
onTerminalOutput(terminalId, data) {
|
|
492
|
+
const entry = this.terminals.get(terminalId);
|
|
493
|
+
if (!entry)
|
|
494
|
+
return;
|
|
495
|
+
const seq = ++entry.headSeq;
|
|
496
|
+
entry.buf.push({ seq, data });
|
|
497
|
+
if (entry.buf.length > TERM_RING)
|
|
498
|
+
entry.buf.shift();
|
|
499
|
+
const frame = { jsonrpc: "2.0", method: TERMINAL_METHODS.output, params: { terminalId, data, seq } };
|
|
500
|
+
for (const clientId of [...entry.subscribers]) {
|
|
501
|
+
const socket = this.conns.get(clientId)?.socket;
|
|
502
|
+
if (!socket || !socket.send(JSON.stringify(frame) + "\n"))
|
|
503
|
+
entry.subscribers.delete(clientId);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
closeTerminal(terminalId) {
|
|
507
|
+
const entry = this.terminals.get(terminalId);
|
|
508
|
+
if (!entry)
|
|
509
|
+
return;
|
|
510
|
+
try {
|
|
511
|
+
entry.handle.close();
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
/* already exited */
|
|
515
|
+
}
|
|
516
|
+
this.terminals.delete(terminalId);
|
|
517
|
+
}
|
|
518
|
+
detachTerminals(clientId) {
|
|
519
|
+
for (const entry of this.terminals.values())
|
|
520
|
+
entry.subscribers.delete(clientId);
|
|
521
|
+
}
|
|
522
|
+
resubscribeTerminals(clientId, socket) {
|
|
523
|
+
for (const [id, entry] of this.terminals) {
|
|
524
|
+
if (entry.owner !== clientId)
|
|
525
|
+
continue;
|
|
526
|
+
entry.subscribers.add(clientId);
|
|
527
|
+
for (const f of entry.buf) {
|
|
528
|
+
socket.send(JSON.stringify({ jsonrpc: "2.0", method: TERMINAL_METHODS.output, params: { terminalId: id, data: f.data, seq: f.seq } }) + "\n");
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
// -- controller lease ---------------------------------------------------------
|
|
533
|
+
/** Lease gate for QUEUED commands (prompt, cancel, set_model, set_options,
|
|
534
|
+
* permission answers). True ⇒ proceed; false ⇒ host-handled (handoff) or
|
|
535
|
+
* rejected (`not_controller`). */
|
|
175
536
|
gate(line, fromClientId) {
|
|
176
537
|
if (!this.leaseEnabled)
|
|
177
|
-
return true;
|
|
538
|
+
return true;
|
|
178
539
|
const msg = tryParseMessage(line);
|
|
179
540
|
if (!msg)
|
|
180
|
-
return true;
|
|
181
|
-
// Handoff: any authed client may take control (single-user multi-device).
|
|
541
|
+
return true;
|
|
182
542
|
if (msg.method === METHOD_TAKE_CONTROL) {
|
|
183
543
|
if (fromClientId) {
|
|
184
544
|
this.holder = fromClientId;
|
|
185
545
|
this.controlEpoch++;
|
|
186
546
|
this.broadcastControlState();
|
|
187
|
-
if (msg.id !== undefined)
|
|
188
|
-
this.replyTo(fromClientId,
|
|
189
|
-
}
|
|
547
|
+
if (msg.id !== undefined)
|
|
548
|
+
this.replyTo(fromClientId, resultFrame(msg.id, { holder: this.holder, controlEpoch: this.controlEpoch }));
|
|
190
549
|
}
|
|
191
|
-
return false;
|
|
550
|
+
return false;
|
|
192
551
|
}
|
|
193
552
|
if (!isPrivileged(msg))
|
|
194
|
-
return true;
|
|
195
|
-
|
|
196
|
-
|
|
553
|
+
return true;
|
|
554
|
+
return this.requireController(msg, fromClientId);
|
|
555
|
+
}
|
|
556
|
+
/** Controller check for a PRIVILEGED command. True ⇒ allowed; false ⇒ rejected
|
|
557
|
+
* (`not_controller` sent). Shared by {@link gate} AND the privileged off-ring
|
|
558
|
+
* intercepts (apply_edit / terminal / session new+load) so none bypass the lease. */
|
|
559
|
+
requireController(msg, fromClientId) {
|
|
560
|
+
if (!this.leaseEnabled)
|
|
561
|
+
return true;
|
|
197
562
|
if (this.holder === null && fromClientId) {
|
|
198
563
|
this.holder = fromClientId;
|
|
199
564
|
this.broadcastControlState();
|
|
200
565
|
}
|
|
201
566
|
if (fromClientId === undefined || fromClientId !== this.holder) {
|
|
202
567
|
if (fromClientId !== undefined && msg.id !== undefined) {
|
|
203
|
-
this.replyTo(fromClientId,
|
|
204
|
-
jsonrpc: "2.0",
|
|
205
|
-
id: msg.id,
|
|
206
|
-
error: { code: NOT_CONTROLLER, message: "not the controller — a lease is active; take control first" },
|
|
207
|
-
});
|
|
568
|
+
this.replyTo(fromClientId, errorFrame(msg.id, NOT_CONTROLLER, "not the controller — a lease is active; take control first"));
|
|
208
569
|
}
|
|
209
|
-
return false;
|
|
570
|
+
return false;
|
|
210
571
|
}
|
|
211
572
|
return true;
|
|
212
573
|
}
|
|
213
|
-
/**
|
|
574
|
+
/** Connection-level broadcast: write `frame` to EVERY unit's hub (so it reaches
|
|
575
|
+
* every socket regardless of active session). N=1 ⇒ the single hub ⇒ byte
|
|
576
|
+
* identical to the pre-§14 single-hub `hub.write`. */
|
|
577
|
+
broadcastToAllUnits(frame) {
|
|
578
|
+
for (const unit of this.units.values())
|
|
579
|
+
unit.hub.write(frame);
|
|
580
|
+
}
|
|
214
581
|
broadcastControlState() {
|
|
215
|
-
|
|
216
|
-
this.hub.write(frame);
|
|
582
|
+
this.broadcastToAllUnits(JSON.stringify({ jsonrpc: "2.0", method: METHOD_CONTROL_STATE, params: { lease: this.leaseEnabled, holder: this.holder, controlEpoch: this.controlEpoch } }) + "\n");
|
|
217
583
|
}
|
|
218
|
-
/** Send a single frame to ONE client (targeted; not fanned
|
|
584
|
+
/** Send a single frame to ONE client's socket (targeted; not fanned, not stamped). */
|
|
219
585
|
replyTo(clientId, obj) {
|
|
220
|
-
|
|
586
|
+
if (clientId === undefined)
|
|
587
|
+
return;
|
|
588
|
+
const socket = this.conns.get(clientId)?.socket;
|
|
221
589
|
if (socket)
|
|
222
|
-
|
|
590
|
+
socket.send(JSON.stringify(obj) + "\n");
|
|
223
591
|
}
|
|
592
|
+
// -- diagnostics (default-unit-scoped for N=1 back-compat) --------------------
|
|
224
593
|
get headSeq() {
|
|
225
|
-
return this.hub.headSeq;
|
|
594
|
+
return this.units.get(this.defaultSessionId).hub.headSeq;
|
|
226
595
|
}
|
|
227
596
|
get socketCount() {
|
|
228
|
-
|
|
597
|
+
let n = 0;
|
|
598
|
+
for (const u of this.units.values())
|
|
599
|
+
n += u.hub.socketCount;
|
|
600
|
+
return n;
|
|
229
601
|
}
|
|
230
602
|
get busy() {
|
|
231
|
-
|
|
603
|
+
for (const u of this.units.values())
|
|
604
|
+
if (u.driver.busy)
|
|
605
|
+
return true;
|
|
606
|
+
return false;
|
|
232
607
|
}
|
|
233
|
-
/** Current lease holder (clientId) or null. Exposed for tests/diagnostics. */
|
|
234
608
|
get controllerHolder() {
|
|
235
609
|
return this.holder;
|
|
236
610
|
}
|
|
237
|
-
/**
|
|
611
|
+
/** Number of live sessions (diagnostics/tests). */
|
|
612
|
+
get sessionCount() {
|
|
613
|
+
return this.units.size;
|
|
614
|
+
}
|
|
238
615
|
async shutdown() {
|
|
239
616
|
if (!this.shuttingDown) {
|
|
240
617
|
this.shuttingDown = true;
|
|
241
|
-
this.
|
|
618
|
+
for (const id of [...this.terminals.keys()])
|
|
619
|
+
this.closeTerminal(id);
|
|
620
|
+
for (const unit of this.units.values())
|
|
621
|
+
unit.queue.end();
|
|
622
|
+
}
|
|
623
|
+
// Await every unit's graceful driver shutdown, then dispose its services.
|
|
624
|
+
let code = 0;
|
|
625
|
+
for (const unit of this.units.values()) {
|
|
626
|
+
code = (await unit.done) || code;
|
|
627
|
+
await unit.dispose?.();
|
|
242
628
|
}
|
|
243
|
-
return
|
|
629
|
+
return code;
|
|
244
630
|
}
|
|
245
631
|
}
|
|
246
632
|
function frameLine(frame) {
|
|
247
633
|
return JSON.stringify(frame) + "\n";
|
|
248
634
|
}
|
|
635
|
+
function resultFrame(id, result) {
|
|
636
|
+
return { jsonrpc: "2.0", id, result };
|
|
637
|
+
}
|
|
638
|
+
function errorObj(id, error) {
|
|
639
|
+
return { jsonrpc: "2.0", id, error };
|
|
640
|
+
}
|
|
641
|
+
function errorFrame(id, code, message) {
|
|
642
|
+
return { jsonrpc: "2.0", id, error: { code, message } };
|
|
643
|
+
}
|
|
249
644
|
function deadAttachment(epoch) {
|
|
250
645
|
return { epoch, detach: () => { } };
|
|
251
646
|
}
|
|
@@ -258,11 +653,10 @@ function tryParseMessage(line) {
|
|
|
258
653
|
return null;
|
|
259
654
|
}
|
|
260
655
|
}
|
|
261
|
-
/** A command that DRIVES
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
* viewer can still connect + observe. */
|
|
656
|
+
/** A command that DRIVES a session (only the lease holder may send it). Read-only
|
|
657
|
+
* methods (initialize/get_state/get_models/ping) are NOT privileged so a viewer
|
|
658
|
+
* can connect + observe. session/new/load are handled by the router (which gates
|
|
659
|
+
* them directly), so they are not listed here. */
|
|
266
660
|
function isPrivileged(msg) {
|
|
267
661
|
const m = msg.method;
|
|
268
662
|
if (m === "session/prompt" || m === "session/cancel")
|