@cjhyy/code-shell-server 0.8.9 → 0.8.10

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.
@@ -1,5 +1,6 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, appendFileSync, rmSync, } from "node:fs";
2
- import { join, resolve } from "node:path";
1
+ import { chmodSync, closeSync, existsSync, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, readdirSync, renameSync, writeFileSync, appendFileSync, rmSync, } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
4
  /**
4
5
  * The exact shape createRoom() generates: `room_<base36>_<base36>`. Anything
5
6
  * else — path separators, `..`, NUL, empty — is rejected before it can reach a
@@ -14,6 +15,112 @@ const ROOM_ID_RE = /^room_[a-z0-9]+_[a-z0-9]+$/;
14
15
  * per message bought nothing and cost a full file rewrite each time.
15
16
  */
16
17
  const LAST_ACTIVE_THROTTLE_MS = 10_000;
18
+ const MAX_ROOM_META_BYTES = 64 * 1024;
19
+ const MAX_ROOM_ENTRIES = 10_000;
20
+ const MAX_ROOM_MESSAGE_BYTES = 4 * 1024 * 1024;
21
+ const MAX_ROOM_HISTORY_SCAN_BYTES = 32 * 1024 * 1024;
22
+ const MAX_ROOM_HISTORY_MESSAGES = 10_000;
23
+ function isContained(root, target) {
24
+ const rel = relative(root, target);
25
+ return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
26
+ }
27
+ function readBoundedUtf8File(path, maxBytes) {
28
+ const fd = openSync(path, "r");
29
+ try {
30
+ const stat = fstatSync(fd);
31
+ if (!stat.isFile() || stat.size > maxBytes)
32
+ throw new Error("file exceeds the size limit");
33
+ const buffer = Buffer.allocUnsafe(maxBytes + 1);
34
+ let total = 0;
35
+ while (total < buffer.byteLength) {
36
+ const count = readSync(fd, buffer, total, buffer.byteLength - total, total);
37
+ if (count === 0)
38
+ break;
39
+ total += count;
40
+ }
41
+ if (total > maxBytes)
42
+ throw new Error("file exceeds the size limit");
43
+ return buffer.subarray(0, total).toString("utf8");
44
+ }
45
+ finally {
46
+ closeSync(fd);
47
+ }
48
+ }
49
+ function parseRoomMeta(value, expectedId) {
50
+ if (!value || typeof value !== "object" || Array.isArray(value))
51
+ return undefined;
52
+ const raw = value;
53
+ if (typeof raw.id !== "string" ||
54
+ !isValidRoomId(raw.id) ||
55
+ (expectedId !== undefined && raw.id !== expectedId) ||
56
+ typeof raw.name !== "string" ||
57
+ !raw.name ||
58
+ raw.name.length > 512 ||
59
+ raw.name.includes("\0") ||
60
+ typeof raw.cwd !== "string" ||
61
+ !raw.cwd ||
62
+ raw.cwd.length > 32_768 ||
63
+ raw.cwd.includes("\0") ||
64
+ (raw.kind !== "claude-code" && raw.kind !== "codex") ||
65
+ (raw.permissionMode !== "default" &&
66
+ raw.permissionMode !== "acceptEdits" &&
67
+ raw.permissionMode !== "bypassPermissions") ||
68
+ typeof raw.createdAt !== "number" ||
69
+ !Number.isFinite(raw.createdAt) ||
70
+ typeof raw.lastActiveAt !== "number" ||
71
+ !Number.isFinite(raw.lastActiveAt) ||
72
+ (raw.claudeSessionId !== undefined &&
73
+ (typeof raw.claudeSessionId !== "string" ||
74
+ raw.claudeSessionId.length > 4_096 ||
75
+ raw.claudeSessionId.includes("\0"))) ||
76
+ (raw.linkedSessionMode !== undefined && raw.linkedSessionMode !== "observe-only")) {
77
+ return undefined;
78
+ }
79
+ return {
80
+ id: raw.id,
81
+ name: raw.name,
82
+ cwd: raw.cwd,
83
+ kind: raw.kind,
84
+ permissionMode: raw.permissionMode,
85
+ createdAt: raw.createdAt,
86
+ lastActiveAt: raw.lastActiveAt,
87
+ ...(typeof raw.claudeSessionId === "string"
88
+ ? { claudeSessionId: raw.claudeSessionId }
89
+ : {}),
90
+ ...(raw.linkedSessionMode === "observe-only"
91
+ ? { linkedSessionMode: raw.linkedSessionMode }
92
+ : {}),
93
+ };
94
+ }
95
+ function parseRoomMessage(value) {
96
+ if (!value || typeof value !== "object" || Array.isArray(value))
97
+ return undefined;
98
+ const raw = value;
99
+ if (typeof raw.seq !== "number" ||
100
+ !Number.isSafeInteger(raw.seq) ||
101
+ raw.seq <= 0 ||
102
+ typeof raw.ts !== "number" ||
103
+ !Number.isFinite(raw.ts) ||
104
+ (raw.from !== "user" && raw.from !== "agent" && raw.from !== "system") ||
105
+ typeof raw.type !== "string" ||
106
+ !raw.type ||
107
+ raw.type.length > 128) {
108
+ return undefined;
109
+ }
110
+ for (const field of ["text", "tool", "summary", "reason", "toolId"]) {
111
+ const item = raw[field];
112
+ if (item !== undefined && (typeof item !== "string" || item.includes("\0")))
113
+ return undefined;
114
+ }
115
+ if (raw.isError !== undefined && typeof raw.isError !== "boolean")
116
+ return undefined;
117
+ if (raw.args !== undefined && (!raw.args || typeof raw.args !== "object" || Array.isArray(raw.args))) {
118
+ return undefined;
119
+ }
120
+ if (raw.attachments !== undefined && !Array.isArray(raw.attachments))
121
+ return undefined;
122
+ return raw;
123
+ }
17
124
  export function isValidRoomId(id) {
18
125
  return typeof id === "string" && ROOM_ID_RE.test(id);
19
126
  }
@@ -123,6 +230,9 @@ export class RoomManager {
123
230
  * Holds the raw tool input so respondApproval can bake the user's answer into
124
231
  * the `answers` record the CLI expects. Cleared on response. */
125
232
  pendingAskUser = new Map();
233
+ /** Original server-observed tool inputs for ordinary approvals. The response
234
+ * client may approve or deny, but must not replace the command/path it saw. */
235
+ pendingApprovalInputs = new Map();
126
236
  /** User turns are echoed immediately for responsive room UX. When a
127
237
  * transcript follower later sees the same CLI-written user line, this queue
128
238
  * suppresses that duplicate. */
@@ -147,21 +257,41 @@ export class RoomManager {
147
257
  pendingLastActive = new Map();
148
258
  lastActiveWrittenAt = new Map();
149
259
  now;
260
+ rootRealPath;
150
261
  /** Drop all pending AskUser entries for a room. Called when its agent goes
151
262
  * away (exit/close) so a request that can no longer be answered doesn't leak
152
263
  * in the map (small memory leak) and its approval card doesn't hang until the
153
264
  * 5-min timeout. Keys are `${roomId}:${requestId}`. */
154
- clearPendingAskUser(roomId) {
265
+ clearPendingApprovals(roomId) {
155
266
  const prefix = `${roomId}:`;
156
267
  for (const key of this.pendingAskUser.keys()) {
157
268
  if (key.startsWith(prefix))
158
269
  this.pendingAskUser.delete(key);
159
270
  }
271
+ for (const key of this.pendingApprovalInputs.keys()) {
272
+ if (key.startsWith(prefix))
273
+ this.pendingApprovalInputs.delete(key);
274
+ }
160
275
  }
161
276
  constructor(opts) {
162
277
  this.opts = opts;
163
278
  this.now = opts.now ?? (() => Date.now());
164
- mkdirSync(opts.rootDir, { recursive: true });
279
+ mkdirSync(opts.rootDir, { recursive: true, mode: 0o700 });
280
+ const rootInfo = lstatSync(opts.rootDir);
281
+ if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) {
282
+ throw new Error(`invalid room storage root: ${opts.rootDir}`);
283
+ }
284
+ this.rootRealPath = realpathSync(opts.rootDir);
285
+ if (process.platform !== "win32")
286
+ chmodSync(opts.rootDir, 0o700);
287
+ }
288
+ assertRootUnchanged() {
289
+ const info = lstatSync(this.opts.rootDir);
290
+ if (info.isSymbolicLink() ||
291
+ !info.isDirectory() ||
292
+ realpathSync(this.opts.rootDir) !== this.rootRealPath) {
293
+ throw new Error(`invalid room storage root: ${this.opts.rootDir}`);
294
+ }
165
295
  }
166
296
  roomDir(id) {
167
297
  // roomId reaches path-building from client WS events (room.open/history/
@@ -173,15 +303,99 @@ export class RoomManager {
173
303
  if (!isValidRoomId(id)) {
174
304
  throw new Error(`invalid roomId: ${JSON.stringify(id)}`);
175
305
  }
176
- return join(this.opts.rootDir, id);
306
+ this.assertRootUnchanged();
307
+ const dir = join(this.opts.rootDir, id);
308
+ if (existsSync(dir)) {
309
+ const info = lstatSync(dir);
310
+ if (info.isSymbolicLink() ||
311
+ !info.isDirectory() ||
312
+ !isContained(this.rootRealPath, realpathSync(dir))) {
313
+ throw new Error(`invalid room directory: ${dir}`);
314
+ }
315
+ }
316
+ return dir;
317
+ }
318
+ checkedRoomFile(id, name) {
319
+ const path = join(this.roomDir(id), name);
320
+ if (existsSync(path)) {
321
+ const info = lstatSync(path);
322
+ if (info.isSymbolicLink() || !info.isFile())
323
+ throw new Error(`invalid room file: ${path}`);
324
+ }
325
+ return path;
177
326
  }
178
327
  metaPath(id) {
179
- return join(this.roomDir(id), "room.json");
328
+ return this.checkedRoomFile(id, "room.json");
180
329
  }
181
330
  msgPath(id) {
182
- return join(this.roomDir(id), "messages.jsonl");
331
+ return this.checkedRoomFile(id, "messages.jsonl");
332
+ }
333
+ writeRoomMeta(id, meta) {
334
+ const target = this.metaPath(id);
335
+ const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
336
+ try {
337
+ writeFileSync(temporary, `${JSON.stringify(meta, null, 2)}\n`, {
338
+ encoding: "utf-8",
339
+ mode: 0o600,
340
+ });
341
+ renameSync(temporary, target);
342
+ }
343
+ finally {
344
+ try {
345
+ rmSync(temporary, { force: true });
346
+ }
347
+ catch {
348
+ // Preserve the original metadata persistence error.
349
+ }
350
+ }
351
+ }
352
+ appendRoomMessage(id, msg) {
353
+ const target = this.msgPath(id);
354
+ const serialized = JSON.stringify(msg);
355
+ if (Buffer.byteLength(serialized, "utf8") > MAX_ROOM_MESSAGE_BYTES) {
356
+ throw new Error("room message exceeds the size limit");
357
+ }
358
+ const fd = openSync(target, "a+", 0o600);
359
+ try {
360
+ if (process.platform !== "win32")
361
+ fchmodSync(fd, 0o600);
362
+ const size = fstatSync(fd).size;
363
+ let prefix = "";
364
+ if (size > 0) {
365
+ const lastByte = Buffer.allocUnsafe(1);
366
+ readSync(fd, lastByte, 0, 1, size - 1);
367
+ if (lastByte[0] !== 0x0a)
368
+ prefix = "\n";
369
+ }
370
+ appendFileSync(fd, `${prefix}${serialized}\n`, "utf-8");
371
+ }
372
+ finally {
373
+ closeSync(fd);
374
+ }
183
375
  }
184
376
  createRoom(input) {
377
+ if (!input ||
378
+ typeof input.cwd !== "string" ||
379
+ !input.cwd ||
380
+ input.cwd.length > 32_768 ||
381
+ input.cwd.includes("\0") ||
382
+ (input.name !== undefined &&
383
+ (typeof input.name !== "string" ||
384
+ !input.name.trim() ||
385
+ input.name.length > 512 ||
386
+ input.name.includes("\0"))) ||
387
+ (input.kind !== undefined && input.kind !== "claude-code" && input.kind !== "codex") ||
388
+ (input.permissionMode !== undefined &&
389
+ input.permissionMode !== "default" &&
390
+ input.permissionMode !== "acceptEdits" &&
391
+ input.permissionMode !== "bypassPermissions") ||
392
+ (input.claudeSessionId !== undefined &&
393
+ (typeof input.claudeSessionId !== "string" ||
394
+ input.claudeSessionId.length > 4_096 ||
395
+ input.claudeSessionId.includes("\0"))) ||
396
+ (input.linkedSessionMode !== undefined && input.linkedSessionMode !== "observe-only")) {
397
+ throw new Error("invalid room metadata");
398
+ }
185
399
  const id = `room_${this.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
186
400
  const meta = {
187
401
  id,
@@ -194,8 +408,8 @@ export class RoomManager {
194
408
  claudeSessionId: input.claudeSessionId,
195
409
  linkedSessionMode: input.linkedSessionMode,
196
410
  };
197
- mkdirSync(this.roomDir(id), { recursive: true });
198
- writeFileSync(this.metaPath(id), JSON.stringify(meta, null, 2), "utf-8");
411
+ mkdirSync(this.roomDir(id), { recursive: true, mode: 0o700 });
412
+ this.writeRoomMeta(id, meta);
199
413
  // Audit anchor: the first line records how the room was opened (cwd +
200
414
  // permission mode), so messages.jsonl is self-describing for "what could
201
415
  // this room do" forensics.
@@ -210,12 +424,17 @@ export class RoomManager {
210
424
  if (!existsSync(this.opts.rootDir))
211
425
  return [];
212
426
  const rooms = [];
213
- for (const entry of readdirSync(this.opts.rootDir)) {
214
- const p = join(this.opts.rootDir, entry, "room.json");
215
- if (!existsSync(p))
427
+ this.assertRootUnchanged();
428
+ for (const entry of readdirSync(this.opts.rootDir).slice(0, MAX_ROOM_ENTRIES)) {
429
+ if (!isValidRoomId(entry))
216
430
  continue;
217
431
  try {
218
- rooms.push(JSON.parse(readFileSync(p, "utf-8")));
432
+ const p = this.metaPath(entry);
433
+ if (!existsSync(p))
434
+ continue;
435
+ const room = parseRoomMeta(JSON.parse(readBoundedUtf8File(p, MAX_ROOM_META_BYTES)), entry);
436
+ if (room)
437
+ rooms.push(room);
219
438
  }
220
439
  catch {
221
440
  /* skip corrupt */
@@ -229,11 +448,11 @@ export class RoomManager {
229
448
  // instead of crashing on a malicious roomId.
230
449
  if (!isValidRoomId(id))
231
450
  return undefined;
232
- const p = this.metaPath(id);
233
- if (!existsSync(p))
234
- return undefined;
235
451
  try {
236
- return JSON.parse(readFileSync(p, "utf-8"));
452
+ const p = this.metaPath(id);
453
+ if (!existsSync(p))
454
+ return undefined;
455
+ return parseRoomMeta(JSON.parse(readBoundedUtf8File(p, MAX_ROOM_META_BYTES)), id);
237
456
  }
238
457
  catch {
239
458
  return undefined;
@@ -261,12 +480,33 @@ export class RoomManager {
261
480
  }
262
481
  append(id, partial) {
263
482
  const ts = this.now();
264
- const msg = { seq: this.nextSeq(id), ts, ...partial };
265
- appendFileSync(this.msgPath(id), JSON.stringify(msg) + "\n", "utf-8");
483
+ const previousCachedSeq = this.lastSeqByRoom.get(id);
484
+ const seq = this.nextSeq(id);
485
+ const msg = { seq, ts, ...partial };
486
+ try {
487
+ this.appendRoomMessage(id, msg);
488
+ }
489
+ catch (error) {
490
+ // Sequence assignment is a commit effect, not an attempt counter. A
491
+ // failed serialization/write must not leave a phantom gap in memory.
492
+ if (this.lastSeqByRoom.get(id) === seq) {
493
+ if (previousCachedSeq === undefined)
494
+ this.lastSeqByRoom.delete(id);
495
+ else
496
+ this.lastSeqByRoom.set(id, previousCachedSeq);
497
+ }
498
+ throw error;
499
+ }
266
500
  // Touch lastActiveAt so idle-based pruning measures real activity, not just
267
501
  // creation time — a room chatted with daily should never be reaped.
268
502
  this.touchLastActive(id, ts);
269
- this.opts.onMessage(id, msg);
503
+ try {
504
+ this.opts.onMessage(id, msg);
505
+ }
506
+ catch {
507
+ // Persistence is authoritative. A broken live-push subscriber must not
508
+ // make the caller retry a message that is already safely on disk.
509
+ }
270
510
  return msg;
271
511
  }
272
512
  /**
@@ -298,7 +538,7 @@ export class RoomManager {
298
538
  if (!meta)
299
539
  return;
300
540
  this.lastActiveWrittenAt.set(id, ts);
301
- writeFileSync(this.metaPath(id), JSON.stringify({ ...meta, lastActiveAt: ts }, null, 2), "utf-8");
541
+ this.writeRoomMeta(id, { ...meta, lastActiveAt: ts });
302
542
  }
303
543
  /** Persist the room's resume id (claude session_id / codex thread_id) so the
304
544
  * next open() can continue the same conversation. No-op if unchanged. */
@@ -306,28 +546,61 @@ export class RoomManager {
306
546
  const meta = this.getRoom(id);
307
547
  if (!meta || meta.claudeSessionId === sessionId)
308
548
  return;
309
- writeFileSync(this.metaPath(id), JSON.stringify({ ...meta, claudeSessionId: sessionId }, null, 2), "utf-8");
549
+ this.writeRoomMeta(id, { ...meta, claudeSessionId: sessionId });
310
550
  }
311
551
  getMessages(id, sinceSeq = 0) {
312
552
  if (!isValidRoomId(id))
313
553
  return [];
314
- const p = this.msgPath(id);
315
- if (!existsSync(p))
316
- return [];
317
- const out = [];
318
- for (const line of readFileSync(p, "utf-8").split("\n")) {
319
- if (!line.trim())
320
- continue;
321
- try {
322
- const m = JSON.parse(line);
323
- if (m.seq > sinceSeq)
324
- out.push(m);
554
+ const cursor = Number.isSafeInteger(sinceSeq) && sinceSeq >= 0 ? sinceSeq : 0;
555
+ let fd;
556
+ try {
557
+ const p = this.msgPath(id);
558
+ if (!existsSync(p))
559
+ return [];
560
+ fd = openSync(p, "r");
561
+ const size = fstatSync(fd).size;
562
+ const start = Math.max(0, size - MAX_ROOM_HISTORY_SCAN_BYTES);
563
+ const length = size - start;
564
+ const buffer = Buffer.allocUnsafe(length);
565
+ let total = 0;
566
+ while (total < length) {
567
+ const count = readSync(fd, buffer, total, length - total, start + total);
568
+ if (count === 0)
569
+ break;
570
+ total += count;
325
571
  }
326
- catch {
327
- /* skip */
572
+ let window = buffer.subarray(0, total);
573
+ if (start > 0) {
574
+ const firstNewline = window.indexOf(0x0a);
575
+ if (firstNewline < 0)
576
+ return [];
577
+ window = window.subarray(firstNewline + 1);
328
578
  }
579
+ const out = [];
580
+ for (const line of window.toString("utf8").split("\n")) {
581
+ if (!line.trim() || Buffer.byteLength(line, "utf8") > MAX_ROOM_MESSAGE_BYTES)
582
+ continue;
583
+ try {
584
+ const message = parseRoomMessage(JSON.parse(line));
585
+ if (message && message.seq > cursor)
586
+ out.push(message);
587
+ }
588
+ catch {
589
+ /* skip malformed/torn lines */
590
+ }
591
+ if (out.length > MAX_ROOM_HISTORY_MESSAGES * 2) {
592
+ out.splice(0, out.length - MAX_ROOM_HISTORY_MESSAGES);
593
+ }
594
+ }
595
+ return out.slice(-MAX_ROOM_HISTORY_MESSAGES);
596
+ }
597
+ catch {
598
+ return [];
599
+ }
600
+ finally {
601
+ if (fd !== undefined)
602
+ closeSync(fd);
329
603
  }
330
- return out;
331
604
  }
332
605
  latestSeq(id) {
333
606
  return this.getMessages(id).at(-1)?.seq ?? 0;
@@ -391,7 +664,7 @@ export class RoomManager {
391
664
  // otherwise reopening keeps the running process and the picked mode would be
392
665
  // silently ignored (the "bypassPermissions still prompts" bug).
393
666
  if (existing && existing.permissionMode !== mode) {
394
- writeFileSync(this.metaPath(meta.id), JSON.stringify({ ...meta, permissionMode: mode }, null, 2), "utf-8");
667
+ this.writeRoomMeta(meta.id, { ...meta, permissionMode: mode });
395
668
  this.close(meta.id); // stop the old-mode process so open() respawns fresh
396
669
  }
397
670
  const { status } = this.open(meta.id);
@@ -422,7 +695,7 @@ export class RoomManager {
422
695
  // rooms are observed transiently and remain writable through the normal
423
696
  // open flow.
424
697
  if (meta.cwd !== existing.cwd) {
425
- writeFileSync(this.metaPath(meta.id), JSON.stringify(meta, null, 2), "utf-8");
698
+ this.writeRoomMeta(meta.id, meta);
426
699
  }
427
700
  }
428
701
  else {
@@ -459,7 +732,7 @@ export class RoomManager {
459
732
  permissionMode: "default",
460
733
  linkedSessionMode: undefined,
461
734
  };
462
- writeFileSync(this.metaPath(roomId), JSON.stringify(controllableMeta, null, 2), "utf-8");
735
+ this.writeRoomMeta(roomId, controllableMeta);
463
736
  const { status } = this.open(roomId);
464
737
  if (status !== "running") {
465
738
  throw new Error("linked session room is unavailable");
@@ -501,9 +774,12 @@ export class RoomManager {
501
774
  // until its timeout). Deleting up front is safe: a missing agent means the
502
775
  // request can no longer be answered anyway.
503
776
  const askKey = `${roomId}:${requestId}`;
777
+ const hasPendingAsk = this.pendingAskUser.has(askKey);
504
778
  const pending = this.pendingAskUser.get(askKey);
505
- if (pending !== undefined)
506
- this.pendingAskUser.delete(askKey);
779
+ this.pendingAskUser.delete(askKey);
780
+ const hasOriginalInput = this.pendingApprovalInputs.has(askKey);
781
+ const originalInput = this.pendingApprovalInputs.get(askKey);
782
+ this.pendingApprovalInputs.delete(askKey);
507
783
  const agent = this.agents.get(roomId);
508
784
  if (!agent?.respondControl)
509
785
  return false;
@@ -512,7 +788,7 @@ export class RoomManager {
512
788
  // question text — the only shape the CLI accepts. The raw input was stashed
513
789
  // on approval_request. Deny passes through (claude treats it as "did not
514
790
  // answer", same as the desktop CLI's own cancel).
515
- if (pending !== undefined) {
791
+ if (hasPendingAsk) {
516
792
  if (decision.behavior === "deny") {
517
793
  agent.respondControl(requestId, decision);
518
794
  return true;
@@ -525,7 +801,15 @@ export class RoomManager {
525
801
  });
526
802
  return true;
527
803
  }
528
- agent.respondControl(requestId, decision);
804
+ if (decision.behavior === "allow" && hasOriginalInput) {
805
+ const updatedInput = originalInput && typeof originalInput === "object" && !Array.isArray(originalInput)
806
+ ? originalInput
807
+ : {};
808
+ agent.respondControl(requestId, { behavior: "allow", updatedInput });
809
+ }
810
+ else {
811
+ agent.respondControl(requestId, decision);
812
+ }
529
813
  return true;
530
814
  }
531
815
  /** Open a room: start its resident agent if not already running. Observe-only
@@ -539,7 +823,25 @@ export class RoomManager {
539
823
  if (!this.agents.has(id)) {
540
824
  const agent = this.opts.createAgent(meta, (event) => this.onAgentEvent(id, event));
541
825
  this.agents.set(id, agent);
542
- agent.start();
826
+ try {
827
+ agent.start();
828
+ }
829
+ catch (error) {
830
+ if (this.agents.get(id) === agent)
831
+ this.agents.delete(id);
832
+ this.clearPendingApprovals(id);
833
+ try {
834
+ agent.stop();
835
+ }
836
+ catch {
837
+ // Preserve the original start error.
838
+ }
839
+ throw error;
840
+ }
841
+ // A start implementation may synchronously emit exit. Do not report a
842
+ // running room after that callback already removed this exact agent.
843
+ if (this.agents.get(id) !== agent || !agent.isRunning())
844
+ return { status: "missing" };
543
845
  }
544
846
  return { status: "running" };
545
847
  }
@@ -630,12 +932,13 @@ export class RoomManager {
630
932
  tool: event.toolName,
631
933
  summary: event.description ?? "",
632
934
  });
935
+ this.pendingApprovalInputs.set(`${id}:${event.requestId}`, event.input);
633
936
  this.opts.onApprovalRequest?.(id, event);
634
937
  break;
635
938
  }
636
939
  case "exit":
637
940
  this.agents.delete(id);
638
- this.clearPendingAskUser(id);
941
+ this.clearPendingApprovals(id);
639
942
  // Let the transcript follower synchronously drain final file bytes
640
943
  // before appending the terminal marker. Otherwise an exit that beats
641
944
  // watchFile's poll can drop the last assistant line or place it after
@@ -681,10 +984,14 @@ export class RoomManager {
681
984
  if (!agent)
682
985
  return false;
683
986
  this.deferredEmitRooms.add(id);
684
- let accepted = false;
987
+ let accepted;
685
988
  try {
686
989
  accepted = agent.send(agentText);
687
990
  }
991
+ catch (error) {
992
+ this.deferredEmits.delete(id);
993
+ throw error;
994
+ }
688
995
  finally {
689
996
  this.deferredEmitRooms.delete(id);
690
997
  }
@@ -715,22 +1022,48 @@ export class RoomManager {
715
1022
  // A room going quiet must record its REAL last activity, not the last
716
1023
  // throttled write.
717
1024
  this.flushLastActive(id);
718
- this.agents.get(id)?.stop();
719
- this.agents.delete(id);
720
- this.clearPendingAskUser(id);
721
- this.pendingTranscriptUserEchoes.delete(id);
722
- this.opts.onRoomEnded?.(id);
1025
+ const agent = this.agents.get(id);
1026
+ try {
1027
+ agent?.stop();
1028
+ }
1029
+ finally {
1030
+ if (!agent || this.agents.get(id) === agent)
1031
+ this.agents.delete(id);
1032
+ this.clearPendingApprovals(id);
1033
+ this.pendingTranscriptUserEchoes.delete(id);
1034
+ this.deferredEmitRooms.delete(id);
1035
+ this.deferredEmits.delete(id);
1036
+ this.opts.onRoomEnded?.(id);
1037
+ }
723
1038
  }
724
1039
  closeAll() {
725
1040
  const ids = new Set([...this.agents.keys(), ...this.transcriptFollowedRooms]);
726
- for (const agent of this.agents.values())
727
- agent.stop();
1041
+ let firstError;
1042
+ for (const agent of this.agents.values()) {
1043
+ try {
1044
+ agent.stop();
1045
+ }
1046
+ catch (error) {
1047
+ firstError ??= error;
1048
+ }
1049
+ }
728
1050
  this.agents.clear();
729
1051
  this.pendingAskUser.clear();
1052
+ this.pendingApprovalInputs.clear();
730
1053
  this.pendingTranscriptUserEchoes.clear();
731
- for (const id of ids)
732
- this.opts.onRoomEnded?.(id);
1054
+ this.deferredEmitRooms.clear();
1055
+ this.deferredEmits.clear();
1056
+ for (const id of ids) {
1057
+ try {
1058
+ this.opts.onRoomEnded?.(id);
1059
+ }
1060
+ catch (error) {
1061
+ firstError ??= error;
1062
+ }
1063
+ }
733
1064
  this.transcriptFollowedRooms.clear();
1065
+ if (firstError)
1066
+ throw firstError;
734
1067
  }
735
1068
  isOpen(id) {
736
1069
  return this.agents.get(id)?.isRunning() ?? false;
@@ -13,7 +13,10 @@ export declare class TrustedDeviceStore {
13
13
  remove(id: string): boolean;
14
14
  /** Rename a device's display label. Rejects blank names and unknown ids. */
15
15
  rename(id: string, name: string): boolean;
16
+ /** Serialize read-modify-write across desktop/server processes. */
17
+ private mutate;
16
18
  private readAll;
17
19
  private writeAll;
20
+ private ensurePrivateParent;
18
21
  private toPublic;
19
22
  }