@chances-ai/wire 24.3.0 → 26.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.
Files changed (46) hide show
  1. package/dist/rpc/acp/engine-driver.d.ts +43 -1
  2. package/dist/rpc/acp/engine-driver.d.ts.map +1 -1
  3. package/dist/rpc/acp/engine-driver.js +94 -10
  4. package/dist/rpc/acp/engine-driver.js.map +1 -1
  5. package/dist/rpc/acp/event-map.d.ts +20 -0
  6. package/dist/rpc/acp/event-map.d.ts.map +1 -1
  7. package/dist/rpc/acp/event-map.js +41 -0
  8. package/dist/rpc/acp/event-map.js.map +1 -1
  9. package/dist/rpc/acp/terminal.d.ts +37 -0
  10. package/dist/rpc/acp/terminal.d.ts.map +1 -0
  11. package/dist/rpc/acp/terminal.js +46 -0
  12. package/dist/rpc/acp/terminal.js.map +1 -0
  13. package/dist/rpc/acp/workspace-edits.d.ts +41 -0
  14. package/dist/rpc/acp/workspace-edits.d.ts.map +1 -0
  15. package/dist/rpc/acp/workspace-edits.js +75 -0
  16. package/dist/rpc/acp/workspace-edits.js.map +1 -0
  17. package/dist/rpc/driver.d.ts +56 -1
  18. package/dist/rpc/driver.d.ts.map +1 -1
  19. package/dist/rpc/index.d.ts +5 -1
  20. package/dist/rpc/index.d.ts.map +1 -1
  21. package/dist/rpc/index.js +2 -0
  22. package/dist/rpc/index.js.map +1 -1
  23. package/dist/serve/acp-session-host.d.ts +127 -72
  24. package/dist/serve/acp-session-host.d.ts.map +1 -1
  25. package/dist/serve/acp-session-host.js +534 -140
  26. package/dist/serve/acp-session-host.js.map +1 -1
  27. package/dist/serve/auth.d.ts +18 -0
  28. package/dist/serve/auth.d.ts.map +1 -1
  29. package/dist/serve/auth.js +32 -1
  30. package/dist/serve/auth.js.map +1 -1
  31. package/dist/serve/index.d.ts +1 -1
  32. package/dist/serve/index.d.ts.map +1 -1
  33. package/dist/serve/index.js +1 -1
  34. package/dist/serve/index.js.map +1 -1
  35. package/dist/serve/relay-frames.d.ts +7 -0
  36. package/dist/serve/relay-frames.d.ts.map +1 -1
  37. package/dist/serve/relay-frames.js +15 -0
  38. package/dist/serve/relay-frames.js.map +1 -1
  39. package/dist/serve/relay.d.ts +20 -0
  40. package/dist/serve/relay.d.ts.map +1 -1
  41. package/dist/serve/relay.js +59 -10
  42. package/dist/serve/relay.js.map +1 -1
  43. package/dist/serve/ws-transport.d.ts.map +1 -1
  44. package/dist/serve/ws-transport.js +7 -0
  45. package/dist/serve/ws-transport.js.map +1 -1
  46. package/package.json +4 -4
@@ -1,251 +1,646 @@
1
1
  /**
2
- * (v20 M3 / docs/6.4a §3; v21 M4 lease / docs/6.5 §5) `AcpSessionHost` — the ACP
3
- * twin of the bespoke session host. ONE persistent engine session per process,
4
- * sockets attach/detach over a seq-stamping `ReplayHub` fan-out; the inner engine
5
- * is the `AcpEngineDriver` (ACP JSON-RPC wire).
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
- * Two deliberate differences from the M2 design:
8
- * 1. **State source.** The reconnect snapshot (`busy` / `pendingPermissionIds`)
9
- * is read straight off the driver (authoritative). The `ReplayHub` stays a
10
- * pure payload-agnostic seq/replay/fan-out layer it does NOT parse the
11
- * outbound bytes for session state (a byte-snoop lived there in the
12
- * chances-rpc era; removed with the M3 hard cutover, since ACP frames' first
13
- * key is `jsonrpc`/`rseq`, never `type`).
14
- * 2. **No `ready` re-send.** ACP is client-initiated: a fresh client sends
15
- * `initialize`/`session/new` and the driver replies; a cold-reloaded client
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
- * **Controller lease (v21 M4 §5).** Disabled by default fan-out: any paired
19
- * device drives, the engine serializes turns via `AGENT_BUSY` (the M2 model).
20
- * When enabled, this host gates every inbound command by its source clientId —
21
- * only the lease HOLDER may drive (`session/prompt`/`cancel`), answer a
22
- * permission, or change the model; everyone else is a read-only VIEWER and gets a
23
- * typed `not_controller`. `_chances/unstable/take_control` hands control over
24
- * (any authed client may claim it — single-user multi-device) and bumps
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
- * **Threat model (codex M4-review).** The lease is COORDINATION among one user's
30
- * equally-authed devices, NOT an authorization boundary. The security boundary is
31
- * the pairing token (every connection is already token-authed); `clientId` is a
32
- * self-reported coordination tag, not a trusted identity. A device could spoof
33
- * another's clientId to "impersonate" the holder but it gains nothing
34
- * `take_control` doesn't already grant (any authed client may claim control), so
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
- * The seq/replay layer (`ReplayHub`) is reused UNCHANGED it stamps `rseq` on
40
- * the serialized ACP JSON-RPC bytes (payload-agnostic, docs/6.2 §3.2).
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
- /** JSON-RPC error for a viewer's privileged command while a lease is held. */
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
- /** (v23 M5) Bound a read-only workspace query so a hung git/fs can't park a
51
- * fire-and-forget handler forever. */
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
- done;
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 → socket, for TARGETED lease replies (not_controller / ack). */
66
- socketsByClient = new Map();
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.driver = new AcpEngineDriver({
72
- host: opts.host,
73
- sink: this.hub,
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
- // Build + drive the one session immediately; `run` reads the persistent
83
- // queue until it ends (process shutdown). Fire-and-forget; run catches.
84
- this.done = this.driver.run(this.queue).catch(() => 1);
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, synchronously replaying everything since `lastSeq` then
88
- * going live (same disjoint-and-ordered guarantee as the M2 host). `clientId`
89
- * (from the `?client_id=` connect query) attributes this socket for the lease.
90
- * Wire order: `relay_welcome` → replayed gap → re-sent open permissions → [live].
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 slice = this.hub.replaySince(lastSeq);
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: this.hub.headSeq,
211
+ headSeq: unit.hub.headSeq,
99
212
  reset: slice.reset,
100
- busy: this.driver.busy,
101
- pendingPermissionIds: this.driver.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 (!this.hub.sendTo(socket, frameLine(welcome)))
219
+ if (!unit.hub.sendTo(socket, frameLine(welcome)))
107
220
  return deadAttachment(epoch);
108
221
  for (const line of slice.frames) {
109
- if (!this.hub.sendTo(socket, line))
222
+ if (!unit.hub.sendTo(socket, line))
110
223
  return deadAttachment(epoch);
111
224
  }
112
- // Re-send still-open permission asks (the ring may have evicted them); the
113
- // client de-dups by permission id, so one also present in the replay is a no-op.
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
- this.hub.addSocket(socket);
119
- if (clientId)
120
- this.socketsByClient.set(clientId, socket);
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
- this.hub.removeSocket(socket);
125
- // Only drop the mapping if it still points at THIS socket — a reconnect
126
- // with the same clientId installs a new socket we must not evict. The
127
- // holder is intentionally retained: the same client reconnecting resumes
128
- // control; another device can always `take_control` if it left for good.
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). Split LF-batched lines; each line
136
- * passes the lease {@link gate} (a no-op when the lease is disabled) before
137
- * reaching the ONE persistent queue. `fromClientId` (the attach's clientId)
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
- if (msg && typeof msg.method === "string" && isWorkspaceQueryMethod(msg.method)) {
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
- if (this.gate(line, fromClientId))
156
- this.queue.push(line);
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
- /** Answer one read-only workspace query: dispatch to the host's query
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 ? { jsonrpc: "2.0", id: msg.id, error: outcome.error } : { jsonrpc: "2.0", id: msg.id, result: outcome.result });
425
+ this.replyTo(fromClientId, outcome.error ? errorObj(msg.id, outcome.error) : resultFrame(msg.id, outcome.result));
168
426
  }
169
- /**
170
- * Controller-lease gate. Returns true the line may proceed to the driver;
171
- * false ⇒ the host handled it (a handoff) or rejected it (a viewer's privileged
172
- * command a `not_controller` reply was sent to the source). A no-op (always
173
- * true) when the lease is disabled.
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; // default: fan-out, anyone drives
538
+ return true;
178
539
  const msg = tryParseMessage(line);
179
540
  if (!msg)
180
- return true; // unparseable → let the driver reject it
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, { jsonrpc: "2.0", id: msg.id, result: { holder: this.holder, controlEpoch: this.controlEpoch } });
189
- }
547
+ if (msg.id !== undefined)
548
+ this.replyTo(fromClientId, resultFrame(msg.id, { holder: this.holder, controlEpoch: this.controlEpoch }));
190
549
  }
191
- return false; // host-handled; never a driver command
550
+ return false;
192
551
  }
193
552
  if (!isPrivileged(msg))
194
- return true; // read-only (initialize/get_state/ping) → viewers allowed
195
- // Privileged (drive a turn / answer a permission / change model). Auto-claim
196
- // an unheld lease for the first driver, then enforce the holder.
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; // a stale command from a former holder lands here too (id ≠ holder)
570
+ return false;
210
571
  }
211
572
  return true;
212
573
  }
213
- /** Broadcast the current lease state to every socket (stamped + fanned out). */
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
- const frame = JSON.stringify({ jsonrpc: "2.0", method: METHOD_CONTROL_STATE, params: { lease: this.leaseEnabled, holder: this.holder, controlEpoch: this.controlEpoch } }) + "\n";
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 out, not stamped). */
584
+ /** Send a single frame to ONE client's socket (targeted; not fanned, not stamped). */
219
585
  replyTo(clientId, obj) {
220
- const socket = this.socketsByClient.get(clientId);
586
+ if (clientId === undefined)
587
+ return;
588
+ const socket = this.conns.get(clientId)?.socket;
221
589
  if (socket)
222
- this.hub.sendTo(socket, JSON.stringify(obj) + "\n");
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
- return this.hub.socketCount;
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
- return this.driver.busy;
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
- /** End the persistent queue → `run` returns → graceful driver shutdown. */
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.queue.end();
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 this.done;
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 the session (only the lease holder may send it): drive a
262
- * turn, change the model/options, or ANSWER a permission (a JSON-RPC response —
263
- * no method, an id, and a result/error). Read-only methods
264
- * (initialize/session.new/get_state/get_models/ping) are NOT privileged so a
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")