@cjhyy/code-shell-server 0.9.52 → 0.10.1

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 (85) hide show
  1. package/dist/account/cli.d.ts +23 -0
  2. package/dist/account/cli.js +249 -0
  3. package/dist/account/cloud-contract.d.ts +39 -0
  4. package/dist/account/cloud-contract.js +134 -0
  5. package/dist/account/cloud-types.d.ts +47 -0
  6. package/dist/account/cloud-types.js +1 -0
  7. package/dist/account/feature-service.d.ts +47 -0
  8. package/dist/account/feature-service.js +184 -0
  9. package/dist/account/http.d.ts +17 -0
  10. package/dist/account/http.js +158 -0
  11. package/dist/account/links.d.ts +13 -0
  12. package/dist/account/links.js +137 -0
  13. package/dist/account/manager.d.ts +96 -0
  14. package/dist/account/manager.js +976 -0
  15. package/dist/account/native-lock.d.ts +6 -0
  16. package/dist/account/native-lock.js +26 -0
  17. package/dist/account/native-store.d.ts +32 -0
  18. package/dist/account/native-store.js +162 -0
  19. package/dist/account/profile.d.ts +4 -0
  20. package/dist/account/profile.js +46 -0
  21. package/dist/account/store.d.ts +34 -0
  22. package/dist/account/store.js +118 -0
  23. package/dist/account/types.d.ts +138 -0
  24. package/dist/account/types.js +2 -0
  25. package/dist/desktop-web/http-api.d.ts +4 -0
  26. package/dist/desktop-web/http-api.js +12 -4
  27. package/dist/environment-identity.d.ts +1 -1
  28. package/dist/environment-identity.js +2 -1
  29. package/dist/headless-worker-env.d.ts +2 -0
  30. package/dist/headless-worker-env.js +15 -0
  31. package/dist/hub/approval-lease.js +6 -1
  32. package/dist/hub/run-replay.js +1 -1
  33. package/dist/hub/session-management.js +1 -1
  34. package/dist/index.account.d.ts +12 -0
  35. package/dist/index.account.js +12 -0
  36. package/dist/index.mobile-remote.d.ts +2 -0
  37. package/dist/index.mobile-remote.js +1 -0
  38. package/dist/index.worker.d.ts +1 -0
  39. package/dist/index.worker.js +1 -0
  40. package/dist/mobile-remote/account-relay-access.d.ts +59 -0
  41. package/dist/mobile-remote/account-relay-access.js +174 -0
  42. package/dist/mobile-remote/approval-clock.d.ts +3 -0
  43. package/dist/mobile-remote/approval-clock.js +15 -0
  44. package/dist/mobile-remote/codex-room-agent.d.ts +6 -0
  45. package/dist/mobile-remote/codex-room-agent.js +17 -1
  46. package/dist/mobile-remote/mobile-client-event-validator.js +40 -1
  47. package/dist/mobile-remote/mobile-upload-service.d.ts +9 -0
  48. package/dist/mobile-remote/mobile-upload-service.js +25 -0
  49. package/dist/mobile-remote/remote-host-manager.d.ts +19 -4
  50. package/dist/mobile-remote/remote-host-manager.js +124 -17
  51. package/dist/mobile-remote/resident-agent.d.ts +14 -0
  52. package/dist/mobile-remote/resident-agent.js +38 -11
  53. package/dist/mobile-remote/room-history.d.ts +7 -0
  54. package/dist/mobile-remote/room-history.js +56 -1
  55. package/dist/mobile-remote/room-manager.d.ts +216 -9
  56. package/dist/mobile-remote/room-manager.js +700 -65
  57. package/dist/mobile-remote/room-process-stop.d.ts +15 -0
  58. package/dist/mobile-remote/room-process-stop.js +41 -0
  59. package/dist/mobile-remote/trusted-device-store.d.ts +12 -0
  60. package/dist/mobile-remote/trusted-device-store.js +54 -1
  61. package/dist/mobile-remote/turn-queue.d.ts +183 -0
  62. package/dist/mobile-remote/turn-queue.js +422 -0
  63. package/dist/mobile-remote/types.d.ts +1 -1
  64. package/dist/panels/agent-task-host.js +6 -12
  65. package/dist/panels/agent-task-service.d.ts +2 -0
  66. package/dist/panels/agent-task-service.js +7 -0
  67. package/dist/panels/browser-capture.d.ts +1 -15
  68. package/dist/panels/browser-capture.js +1 -35
  69. package/dist/panels/http.d.ts +15 -15
  70. package/dist/panels/management-http.d.ts +9 -9
  71. package/dist/panels/process-service.d.ts +5 -4
  72. package/dist/panels/process-service.js +54 -16
  73. package/dist/panels/runtime.js +25 -14
  74. package/dist/panels/tool-jobs-storage.d.ts +4 -0
  75. package/dist/panels/tool-jobs-storage.js +111 -34
  76. package/dist/panels/types.d.ts +1 -97
  77. package/dist/remote-relay/connector.d.ts +8 -0
  78. package/dist/remote-relay/connector.js +29 -2
  79. package/dist/remote-relay/protocol.d.ts +21 -0
  80. package/dist/remote-relay/protocol.js +31 -0
  81. package/dist/serve/cli.js +6 -0
  82. package/dist/serve/headless-server.js +24 -19
  83. package/dist/sessions-service.d.ts +4 -0
  84. package/dist/sessions-service.js +14 -1
  85. package/package.json +10 -6
@@ -1,3 +1,4 @@
1
+ import { stampApprovalClock } from "./approval-clock.js";
1
2
  import { createServer } from "node:http";
2
3
  import { networkInterfaces } from "node:os";
3
4
  import { EventEmitter } from "node:events";
@@ -8,6 +9,7 @@ import { serveMobile } from "./mobile-static.js";
8
9
  import { PairingTokenManager } from "./pairing.js";
9
10
  import { parseMobileClientEvent } from "./mobile-client-event-validator.js";
10
11
  import { isRelayOrigin } from "../remote-relay/protocol.js";
12
+ import { AccountRelayAccessGate, readRelayHandoff, } from "./account-relay-access.js";
11
13
  /** Outbound bytes one paired socket may leave unread before it is retired. */
12
14
  const MAX_SOCKET_BACKLOG_BYTES = 4 * 1024 * 1024;
13
15
  /** The query suffix ("" or "?…") when the raw request target is exactly the
@@ -69,18 +71,62 @@ export class RemoteHostManager extends EventEmitter {
69
71
  passcode;
70
72
  socketAccessValidity = new WeakMap();
71
73
  accessSweep;
74
+ accountAccess;
75
+ accountSocketDevices = new WeakMap();
72
76
  /** Where the built mobile app lives (out/mobile). */
73
77
  mobileRootDir;
74
78
  /** Dev proxy target for /mobile (HMR); undefined in prod. */
75
79
  mobileDevUrl;
80
+ turnQueueEnabled = false;
76
81
  constructor(opts) {
77
82
  super();
78
83
  this.opts = opts;
84
+ this.accountAccess = new AccountRelayAccessGate(opts.devices, (id) => this.revokeDevice(id));
79
85
  // Bundled main is out/main/index.mjs → mobile app is the sibling out/mobile.
80
86
  const here = dirname(fileURLToPath(import.meta.url));
81
87
  this.mobileRootDir = opts.mobileRootDir ?? resolve(here, "../mobile");
82
88
  this.mobileDevUrl = opts.mobileDevUrl ?? process.env.MOBILE_DEV_URL;
83
89
  }
90
+ /** Install main-owned directory redemption; replacing it synchronously revokes old sessions. */
91
+ setAccountRelayAccess(provider) {
92
+ this.accountAccess.setProvider(provider);
93
+ }
94
+ /** The attached command Host installed a pending-turn FIFO; advertise it on (re)auth. */
95
+ advertiseTurnQueue() {
96
+ this.turnQueueEnabled = true;
97
+ }
98
+ /** A paired (or live account) device that has not been revoked or removed. */
99
+ isTrustedDevice(deviceId) {
100
+ try {
101
+ return this.opts.devices
102
+ .listDevices()
103
+ .some((device) => device.id === deviceId && !device.revokedAt);
104
+ }
105
+ catch {
106
+ // An unreadable store proves nothing; fail closed.
107
+ return false;
108
+ }
109
+ }
110
+ get turnQueueAdvertised() {
111
+ return this.turnQueueEnabled || this.opts.turnQueue === true;
112
+ }
113
+ relayRequestOrigin(request, requireOrigin = false) {
114
+ return !!(this.publicBaseUrl &&
115
+ request.headers.host === new URL(this.publicBaseUrl).host &&
116
+ (request.headers.origin === this.publicBaseUrl ||
117
+ (!requireOrigin && request.headers.origin === undefined)) &&
118
+ request.headers["sec-fetch-site"] !== "cross-site");
119
+ }
120
+ relayJson(response, status, body) {
121
+ if (response.writableEnded || response.destroyed)
122
+ return;
123
+ response.writeHead(status, {
124
+ "content-type": "application/json; charset=utf-8",
125
+ "cache-control": "no-store",
126
+ "x-content-type-options": "nosniff",
127
+ });
128
+ response.end(JSON.stringify(body));
129
+ }
84
130
  /** Device ids with at least one live socket right now. */
85
131
  onlineDeviceIds() {
86
132
  return [...this.onlineCounts.keys()];
@@ -129,11 +175,13 @@ export class RemoteHostManager extends EventEmitter {
129
175
  async startOnce(options) {
130
176
  const relay = options.mode === "relay";
131
177
  const publicAccess = relay || options.mode === "tunnel";
132
- if (publicAccess && !options.passcode?.isSet()) {
178
+ if (publicAccess && !options.passcode?.isSet() && !(relay && this.accountAccess.available)) {
133
179
  throw new Error(`${relay ? "Relay" : "Tunnel"} mode requires an initialized access passcode`);
134
180
  }
135
181
  if (relay)
136
182
  this.publicBaseUrl = undefined;
183
+ // Keep the gate object even when account auto-start precedes passcode setup.
184
+ // Its current configuration is consulted for each HTTP/WS admission.
137
185
  this.passcode = publicAccess ? options.passcode : undefined;
138
186
  const gate = this.passcode;
139
187
  this.opts.webApi?.start();
@@ -148,11 +196,47 @@ export class RemoteHostManager extends EventEmitter {
148
196
  req.resume();
149
197
  return;
150
198
  }
199
+ if (relay && pathname === "/api/v1/relay/session") {
200
+ if (req.method !== "POST" ||
201
+ !this.accountAccess.available ||
202
+ !this.relayRequestOrigin(req, true)) {
203
+ this.relayJson(res, 403, { error: "Account relay session is unavailable" });
204
+ req.resume();
205
+ return;
206
+ }
207
+ const lifetime = this.relayLifetime;
208
+ void readRelayHandoff(req)
209
+ .then((handoff) => {
210
+ if (!lifetime || lifetime.signal.aborted || lifetime !== this.relayLifetime)
211
+ throw new Error("Relay Host stopped");
212
+ return this.accountAccess.exchange(handoff);
213
+ })
214
+ .then((session) => this.relayJson(res, 200, session))
215
+ .catch(() => this.relayJson(res, 401, { error: "Account relay handoff expired or invalid" }));
216
+ return;
217
+ }
151
218
  const serve = () => {
152
219
  // All ordinary routes retain the tunnel passcode gate. Only a Host-verified
153
220
  // short-lived Panel asset capability may use independent authorization.
154
- if (gate && !gate.gate(req, res))
221
+ const account = relay && this.relayRequestOrigin(req) ? this.accountAccess.capture(req) : undefined;
222
+ if (!account) {
223
+ if (relay && (req.headers["x-codeshell-host-access"] !== undefined || !gate?.isSet())) {
224
+ this.relayJson(res, 401, { error: "Account relay authorization is required" });
225
+ req.resume();
226
+ return;
227
+ }
228
+ if (gate && !gate.gate(req, res))
229
+ return;
230
+ }
231
+ if (relay && pathname === "/api/v1/relay/projects") {
232
+ if (req.method !== "GET" || !account) {
233
+ this.relayJson(res, 401, { error: "Account relay authorization is required" });
234
+ req.resume();
235
+ return;
236
+ }
237
+ void this.accountAccess.projects(account).then((projects) => this.relayJson(res, 200, projects), () => this.relayJson(res, 401, { error: "Account relay authorization expired" }));
155
238
  return;
239
+ }
156
240
  if (req.url === "/health") {
157
241
  res.writeHead(200, { "content-type": "application/json" });
158
242
  res.end(JSON.stringify({ ok: true }));
@@ -166,7 +250,11 @@ export class RemoteHostManager extends EventEmitter {
166
250
  return;
167
251
  }
168
252
  void this.opts.webApi
169
- .handle(req, res, { baseUrl, isAccessAuthorized: gate?.captureValidity() })
253
+ .handle(req, res, {
254
+ baseUrl,
255
+ isAccessAuthorized: account?.isAuthorized ?? gate?.captureValidity(),
256
+ ...(account ? { accessDeviceId: account.deviceId } : {}),
257
+ })
170
258
  .then((handled) => {
171
259
  if (!handled && !res.writableEnded) {
172
260
  res.writeHead(404);
@@ -234,13 +322,13 @@ export class RemoteHostManager extends EventEmitter {
234
322
  // In tunnel mode we must gate the WS handshake too. `noServer` lets us run
235
323
  // the passcode check during the HTTP `upgrade` event before completing the
236
324
  // handshake; in lan mode behaviour is unchanged (the same path/server bind).
237
- this.wss = gate
325
+ this.wss = publicAccess
238
326
  ? new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 })
239
327
  : new WebSocketServer({ server, path: "/ws", maxPayload: 1024 * 1024 });
240
328
  // ws re-emits its HTTP server's bind errors. Without an error listener a
241
329
  // handled EADDRINUSE rejection still becomes an uncaught EventEmitter error.
242
330
  this.wss.on("error", (error) => this.emit("host-error", error));
243
- if (gate) {
331
+ if (publicAccess) {
244
332
  server.on("upgrade", (req, socket, head) => {
245
333
  let isWebSocketPath = false;
246
334
  try {
@@ -253,21 +341,23 @@ export class RemoteHostManager extends EventEmitter {
253
341
  socket.destroy();
254
342
  return;
255
343
  }
256
- if (relay &&
257
- (!this.publicBaseUrl ||
258
- req.headers.origin !== this.publicBaseUrl ||
259
- req.headers.host !== new URL(this.publicBaseUrl).host)) {
344
+ if (relay && !this.relayRequestOrigin(req, true)) {
260
345
  socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
261
346
  return;
262
347
  }
263
- if (!gate.allows(req)) {
348
+ const account = relay ? this.accountAccess.capture(req) : undefined;
349
+ if (!account &&
350
+ ((relay && req.headers["x-codeshell-host-access"] !== undefined) ||
351
+ !gate?.allows(req))) {
264
352
  socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
265
353
  socket.destroy();
266
354
  return;
267
355
  }
268
- const isAccessAuthorized = gate.captureValidity();
356
+ const isAccessAuthorized = account?.isAuthorized ?? gate.captureValidity();
269
357
  this.wss.handleUpgrade(req, socket, head, (ws) => {
270
358
  this.socketAccessValidity.set(ws, isAccessAuthorized);
359
+ if (account)
360
+ this.accountSocketDevices.set(ws, account.deviceId);
271
361
  this.wss.emit("connection", ws, req);
272
362
  });
273
363
  });
@@ -298,12 +388,19 @@ export class RemoteHostManager extends EventEmitter {
298
388
  this.sendBounded(ws, JSON.stringify({ type: "error", message: "Invalid client event" }));
299
389
  return;
300
390
  }
391
+ const accountDevice = this.accountSocketDevices.get(ws);
392
+ if (accountDevice &&
393
+ (event.type === "pair.complete" ||
394
+ (event.type === "auth.device" && event.deviceId !== accountDevice))) {
395
+ this.sendBounded(ws, JSON.stringify({ type: "auth.failed", message: "Account device mismatch" }));
396
+ return;
397
+ }
301
398
  // Pairing/auth are handled inline and bind device identity to this
302
399
  // socket. Everything else is gated: an unauthenticated socket cannot
303
400
  // send chat/approval/run/job events (design §6.1).
304
401
  let reply;
305
402
  try {
306
- reply = this.handleClientEvent(event);
403
+ reply = this.handleClientEvent(event, accountDevice);
307
404
  }
308
405
  catch {
309
406
  this.sendBounded(ws, JSON.stringify({ type: "error", message: "Client event failed" }));
@@ -390,7 +487,7 @@ export class RemoteHostManager extends EventEmitter {
390
487
  url: `http://${bindHost}:${port}`,
391
488
  mode: options.mode ?? "lan",
392
489
  };
393
- if (gate) {
490
+ if (publicAccess) {
394
491
  const sockets = this.wss;
395
492
  // Idle public tabs must also relinquish viewer-owned state after rotation.
396
493
  // Traffic is checked synchronously in both directions, without rehashing
@@ -453,7 +550,7 @@ export class RemoteHostManager extends EventEmitter {
453
550
  * trusted-device store (which rejects revoked devices), so the remote host
454
551
  * never executes tools itself — it only gates transport.
455
552
  */
456
- handleClientEvent(input) {
553
+ handleClientEvent(input, accountDeviceId) {
457
554
  const event = parseMobileClientEvent(input);
458
555
  if (!event)
459
556
  return { type: "error", message: "Invalid client event" };
@@ -474,11 +571,12 @@ export class RemoteHostManager extends EventEmitter {
474
571
  return {
475
572
  type: "pair.ok",
476
573
  device,
477
- ...(this.opts.outputJournal || this.opts.nativeModelSelection
574
+ ...(this.opts.outputJournal || this.opts.nativeModelSelection || this.turnQueueAdvertised
478
575
  ? {
479
576
  capabilities: {
480
577
  ...(this.opts.outputJournal ? { outputJournal: 1 } : {}),
481
578
  ...(this.opts.nativeModelSelection ? { nativeModelSelection: 1 } : {}),
579
+ ...(this.turnQueueAdvertised ? { turnQueue: 1 } : {}),
482
580
  ...(this.opts.outputJournal && this.opts.historyView
483
581
  ? { historyView: 1 }
484
582
  : {}),
@@ -494,6 +592,8 @@ export class RemoteHostManager extends EventEmitter {
494
592
  };
495
593
  }
496
594
  if (event.type === "auth.device") {
595
+ if (this.opts.devices.isEphemeralDevice(event.deviceId) && event.deviceId !== accountDeviceId)
596
+ return { type: "auth.failed", message: "Account device requires its Host access grant" };
497
597
  let device;
498
598
  try {
499
599
  device = this.opts.devices.authenticate(event.deviceId, event.secretHash);
@@ -506,11 +606,12 @@ export class RemoteHostManager extends EventEmitter {
506
606
  return {
507
607
  type: "auth.ok",
508
608
  device,
509
- ...(this.opts.outputJournal || this.opts.nativeModelSelection
609
+ ...(this.opts.outputJournal || this.opts.nativeModelSelection || this.turnQueueAdvertised
510
610
  ? {
511
611
  capabilities: {
512
612
  ...(this.opts.outputJournal ? { outputJournal: 1 } : {}),
513
613
  ...(this.opts.nativeModelSelection ? { nativeModelSelection: 1 } : {}),
614
+ ...(this.turnQueueAdvertised ? { turnQueue: 1 } : {}),
514
615
  ...(this.opts.outputJournal && this.opts.historyView
515
616
  ? { historyView: 1 }
516
617
  : {}),
@@ -558,6 +659,7 @@ export class RemoteHostManager extends EventEmitter {
558
659
  }
559
660
  /** Preserve the full worker envelope while retaining exact authenticated-tab routing. */
560
661
  sendRawToViewer(viewerId, payload) {
662
+ payload = stampApprovalClock(payload);
561
663
  for (const client of this.wss?.clients ?? []) {
562
664
  if (client.readyState === client.OPEN &&
563
665
  this.authed.has(client) &&
@@ -571,6 +673,7 @@ export class RemoteHostManager extends EventEmitter {
571
673
  }
572
674
  /** Send a raw JSON-RPC worker line to ONLY one authenticated device. */
573
675
  sendRawToDevice(deviceId, payload) {
676
+ payload = stampApprovalClock(payload);
574
677
  for (const client of this.wss?.clients ?? []) {
575
678
  if (client.readyState === client.OPEN && this.authed.get(client) === deviceId) {
576
679
  this.sendBounded(client, payload);
@@ -581,6 +684,7 @@ export class RemoteHostManager extends EventEmitter {
581
684
  * every authenticated mobile socket. Unauthenticated sockets are skipped so
582
685
  * a half-paired client never sees session output. */
583
686
  broadcastRaw(payload) {
687
+ payload = stampApprovalClock(payload);
584
688
  for (const client of this.wss?.clients ?? []) {
585
689
  if (client.readyState === client.OPEN && this.authed.has(client)) {
586
690
  this.sendBounded(client, payload);
@@ -614,7 +718,7 @@ export class RemoteHostManager extends EventEmitter {
614
718
  this.releaseSocket(client);
615
719
  // Distinct from paired-device rejection (4401/4403): the browser keeps its
616
720
  // identity and offers a fresh HTTP challenge instead of silently retrying.
617
- client.close(4407, "Access passcode changed");
721
+ client.close(4407, "Host access expired");
618
722
  const deadline = setTimeout(() => client.terminate(), 1000);
619
723
  deadline.unref();
620
724
  client.once("close", () => clearTimeout(deadline));
@@ -647,6 +751,8 @@ export class RemoteHostManager extends EventEmitter {
647
751
  }
648
752
  /** Call after revoking/removing a trusted device to release both transports immediately. */
649
753
  revokeDevice(deviceId) {
754
+ if (this.accountAccess.revokeDevice(deviceId))
755
+ return;
650
756
  void this.opts.uploads?.revokeDevice(deviceId).catch((error) => {
651
757
  this.emit("host-error", error instanceof Error ? error.message : String(error));
652
758
  });
@@ -662,6 +768,7 @@ export class RemoteHostManager extends EventEmitter {
662
768
  // Listeners synchronously fence and destroy outbound sockets before the
663
769
  // loopback listener can close or its port can be reused by another Host.
664
770
  this.relayLifetime?.abort();
771
+ this.accountAccess.clear();
665
772
  if (this.stopping)
666
773
  return this.stopping;
667
774
  const attempt = Promise.resolve()
@@ -87,7 +87,13 @@ export interface ResidentAgentOptions {
87
87
  export declare class ResidentAgentProcess {
88
88
  private readonly opts;
89
89
  private child?;
90
+ /** The most recently spawned process, kept after stop() so a caller can
91
+ * wait for it to actually exit before starting a replacement. */
92
+ private lastChild?;
93
+ private spawned?;
90
94
  constructor(opts: ResidentAgentOptions);
95
+ /** Exact argv for one resident process. Pure → unit-testable. */
96
+ static argsFor(opts: Pick<ResidentAgentOptions, "permissionMode" | "resumeSessionId" | "appendSystemPrompt">): string[];
91
97
  start(): void;
92
98
  /** Feed one user turn. Returns false if the process isn't running. */
93
99
  send(text: string): boolean;
@@ -95,4 +101,12 @@ export declare class ResidentAgentProcess {
95
101
  respondControl(requestId: string, decision: ControlDecision): void;
96
102
  isRunning(): boolean;
97
103
  stop(): void;
104
+ /** Resolves once the spawned process exists; rejects when the spawn failed. */
105
+ whenStarted(): Promise<void>;
106
+ /**
107
+ * Stop, then resolve true only after the last spawned process has exited.
108
+ * A process already retired by a pipe error is still waited for: it may be
109
+ * alive for up to the SIGKILL grace period.
110
+ */
111
+ stopAndWait(timeoutMs: number): Promise<boolean>;
98
112
  }
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { releaseStdioAfterExit, stopRoomProcess } from "./room-process-stop.js";
2
+ import { releaseStdioAfterExit, stopRoomProcess, stopRoomProcessAndWait, waitForRoomProcessSpawn, } from "./room-process-stop.js";
3
3
  import { createInterface } from "node:readline";
4
4
  import { pathWithCommonBins } from "./path-bins.js";
5
5
  /**
@@ -119,12 +119,15 @@ export function parseStreamJsonLine(line) {
119
119
  export class ResidentAgentProcess {
120
120
  opts;
121
121
  child;
122
+ /** The most recently spawned process, kept after stop() so a caller can
123
+ * wait for it to actually exit before starting a replacement. */
124
+ lastChild;
125
+ spawned;
122
126
  constructor(opts) {
123
127
  this.opts = opts;
124
128
  }
125
- start() {
126
- if (this.child)
127
- return;
129
+ /** Exact argv for one resident process. Pure → unit-testable. */
130
+ static argsFor(opts) {
128
131
  const args = [
129
132
  "--print",
130
133
  "--verbose",
@@ -135,13 +138,17 @@ export class ResidentAgentProcess {
135
138
  "--permission-prompt-tool",
136
139
  "stdio",
137
140
  ];
138
- if (this.opts.appendSystemPrompt) {
139
- args.push("--append-system-prompt", this.opts.appendSystemPrompt);
140
- }
141
- if (this.opts.resumeSessionId) {
142
- args.push("--resume", this.opts.resumeSessionId);
143
- }
144
- args.push("--permission-mode", this.opts.permissionMode);
141
+ if (opts.appendSystemPrompt)
142
+ args.push("--append-system-prompt", opts.appendSystemPrompt);
143
+ if (opts.resumeSessionId)
144
+ args.push("--resume", opts.resumeSessionId);
145
+ args.push("--permission-mode", opts.permissionMode);
146
+ return args;
147
+ }
148
+ start() {
149
+ if (this.child)
150
+ return;
151
+ const args = ResidentAgentProcess.argsFor(this.opts);
145
152
  const child = spawn(this.opts.command, args, {
146
153
  cwd: this.opts.cwd,
147
154
  env: { ...process.env, PATH: pathWithCommonBins() },
@@ -149,6 +156,10 @@ export class ResidentAgentProcess {
149
156
  stdio: ["pipe", "pipe", "pipe"],
150
157
  });
151
158
  this.child = child;
159
+ this.lastChild = child;
160
+ this.spawned = waitForRoomProcessSpawn(child);
161
+ // Observed by whenStarted(); an unobserved failed spawn is reported via onEvent.
162
+ this.spawned.catch(() => undefined);
152
163
  // Settle on `close` so trailing output is read, without waiting on grandchildren.
153
164
  releaseStdioAfterExit(child);
154
165
  if (child.stdout) {
@@ -231,4 +242,20 @@ export class ResidentAgentProcess {
231
242
  if (child)
232
243
  stopRoomProcess(child);
233
244
  }
245
+ /** Resolves once the spawned process exists; rejects when the spawn failed. */
246
+ whenStarted() {
247
+ return this.spawned ?? Promise.resolve();
248
+ }
249
+ /**
250
+ * Stop, then resolve true only after the last spawned process has exited.
251
+ * A process already retired by a pipe error is still waited for: it may be
252
+ * alive for up to the SIGKILL grace period.
253
+ */
254
+ stopAndWait(timeoutMs) {
255
+ const child = this.child ?? this.lastChild;
256
+ this.child = undefined;
257
+ if (!child)
258
+ return Promise.resolve(true);
259
+ return stopRoomProcessAndWait(child, timeoutMs);
260
+ }
234
261
  }
@@ -14,6 +14,13 @@ export interface RoomHistoryRequest {
14
14
  export declare function scanRoomHistory<T extends {
15
15
  seq: number;
16
16
  }>(file: string, parse: (value: unknown) => T | undefined, visit?: (message: T) => void): number;
17
+ /** Same strict snapshot proof as scanRoomHistory, yielding between bounded reads. */
18
+ export declare function scanRoomHistoryAsync<T extends {
19
+ seq: number;
20
+ }>(file: string, parse: (value: unknown) => T | undefined, visit?: (message: T) => void): Promise<{
21
+ sequence: number;
22
+ stamp: string;
23
+ }>;
17
24
  /** Durable immutable copies keep a frozen page boundary valid across Host restarts. */
18
25
  export declare function readRoomHistoryPage<T extends {
19
26
  seq: number;
@@ -1,12 +1,15 @@
1
1
  import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync, writeSync, } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { lstat, open } from "node:fs/promises";
4
5
  const MAX_BYTES = 256 * 1024 * 1024;
5
6
  const MAX_LINE = 4 * 1024 * 1024;
6
7
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
7
8
  const decoder = new TextDecoder("utf-8", { fatal: true });
8
9
  function regular(path) {
9
- const stat = lstatSync(path);
10
+ return assertRegular(lstatSync(path));
11
+ }
12
+ function assertRegular(stat) {
10
13
  if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) {
11
14
  throw new Error("room history must be a private regular file");
12
15
  }
@@ -99,6 +102,58 @@ export function scanRoomHistory(file, parse, visit) {
99
102
  closeSync(fd);
100
103
  }
101
104
  }
105
+ /** Same strict snapshot proof as scanRoomHistory, yielding between bounded reads. */
106
+ export async function scanRoomHistoryAsync(file, parse, visit) {
107
+ const initial = assertRegular(await lstat(file));
108
+ const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
109
+ try {
110
+ const before = assertRegular(await handle.stat());
111
+ if (stamp(initial) !== stamp(before))
112
+ throw new Error("room history changed while opening");
113
+ if (before.size > MAX_BYTES)
114
+ throw new Error("room history exceeds 256 MiB; archive it before continuing");
115
+ let offset = 0, seq = 0, partialBytes = 0;
116
+ let parts = [];
117
+ while (offset < before.size) {
118
+ const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, before.size - offset));
119
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, offset);
120
+ if (!bytesRead)
121
+ throw new Error("room history was truncated while reading");
122
+ offset += bytesRead;
123
+ let from = 0;
124
+ while (from < bytesRead) {
125
+ const next = chunk.indexOf(0x0a, from);
126
+ const newline = next >= 0 && next < bytesRead ? next : -1;
127
+ const end = newline < 0 ? bytesRead : newline;
128
+ const piece = chunk.subarray(from, end);
129
+ partialBytes += piece.length;
130
+ if (partialBytes > MAX_LINE)
131
+ throw new Error("room history record exceeds size limit");
132
+ parts.push(piece);
133
+ if (newline < 0)
134
+ break;
135
+ const bytes = parts.length === 1 ? parts[0] : Buffer.concat(parts, partialBytes);
136
+ const message = parse(JSON.parse(decoder.decode(bytes)));
137
+ if (!message || message.seq !== seq + 1)
138
+ throw new Error(`room history is corrupt at sequence ${seq + 1}`);
139
+ seq = message.seq;
140
+ visit?.(message);
141
+ parts = [];
142
+ partialBytes = 0;
143
+ from = newline + 1;
144
+ }
145
+ }
146
+ if (partialBytes)
147
+ throw new Error("room history has an incomplete final record");
148
+ if (stamp(before) !== stamp(await handle.stat()) ||
149
+ stamp(before) !== stamp(assertRegular(await lstat(file))))
150
+ throw new Error("room history changed while scanning");
151
+ return { sequence: seq, stamp: stamp(before) };
152
+ }
153
+ finally {
154
+ await handle.close();
155
+ }
156
+ }
102
157
  function validateManifest(value, roomId, snapshotId) {
103
158
  if (!value ||
104
159
  value.version !== 1 ||