@cjhyy/code-shell-server 0.9.51 → 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 (93) 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/configuration.d.ts +11 -0
  33. package/dist/hub/configuration.js +34 -1
  34. package/dist/hub/model-parameter-metadata.d.ts +26 -0
  35. package/dist/hub/model-parameter-metadata.js +92 -0
  36. package/dist/hub/run-replay.js +1 -1
  37. package/dist/hub/session-management.js +1 -1
  38. package/dist/index.account.d.ts +12 -0
  39. package/dist/index.account.js +12 -0
  40. package/dist/index.mobile-remote.d.ts +2 -0
  41. package/dist/index.mobile-remote.js +1 -0
  42. package/dist/index.worker.d.ts +1 -0
  43. package/dist/index.worker.js +1 -0
  44. package/dist/mobile-remote/account-relay-access.d.ts +59 -0
  45. package/dist/mobile-remote/account-relay-access.js +174 -0
  46. package/dist/mobile-remote/approval-clock.d.ts +3 -0
  47. package/dist/mobile-remote/approval-clock.js +15 -0
  48. package/dist/mobile-remote/codex-room-agent.d.ts +6 -0
  49. package/dist/mobile-remote/codex-room-agent.js +17 -1
  50. package/dist/mobile-remote/mobile-chat-turn.d.ts +4 -0
  51. package/dist/mobile-remote/mobile-chat-turn.js +3 -0
  52. package/dist/mobile-remote/mobile-client-event-validator.js +47 -2
  53. package/dist/mobile-remote/mobile-upload-service.d.ts +9 -0
  54. package/dist/mobile-remote/mobile-upload-service.js +25 -0
  55. package/dist/mobile-remote/remote-host-manager.d.ts +21 -4
  56. package/dist/mobile-remote/remote-host-manager.js +142 -25
  57. package/dist/mobile-remote/resident-agent.d.ts +14 -0
  58. package/dist/mobile-remote/resident-agent.js +38 -11
  59. package/dist/mobile-remote/room-history.d.ts +7 -0
  60. package/dist/mobile-remote/room-history.js +56 -1
  61. package/dist/mobile-remote/room-manager.d.ts +216 -9
  62. package/dist/mobile-remote/room-manager.js +700 -65
  63. package/dist/mobile-remote/room-process-stop.d.ts +15 -0
  64. package/dist/mobile-remote/room-process-stop.js +41 -0
  65. package/dist/mobile-remote/trusted-device-store.d.ts +12 -0
  66. package/dist/mobile-remote/trusted-device-store.js +54 -1
  67. package/dist/mobile-remote/turn-queue.d.ts +183 -0
  68. package/dist/mobile-remote/turn-queue.js +422 -0
  69. package/dist/mobile-remote/types.d.ts +1 -1
  70. package/dist/panels/agent-task-host.js +6 -12
  71. package/dist/panels/agent-task-service.d.ts +2 -0
  72. package/dist/panels/agent-task-service.js +7 -0
  73. package/dist/panels/browser-capture.d.ts +1 -15
  74. package/dist/panels/browser-capture.js +1 -35
  75. package/dist/panels/directory-picker.d.ts +48 -0
  76. package/dist/panels/directory-picker.js +247 -0
  77. package/dist/panels/http.d.ts +15 -15
  78. package/dist/panels/management-http.d.ts +9 -9
  79. package/dist/panels/process-service.d.ts +5 -4
  80. package/dist/panels/process-service.js +54 -16
  81. package/dist/panels/runtime.js +148 -30
  82. package/dist/panels/tool-jobs-storage.d.ts +4 -0
  83. package/dist/panels/tool-jobs-storage.js +111 -34
  84. package/dist/panels/types.d.ts +1 -97
  85. package/dist/remote-relay/connector.d.ts +8 -0
  86. package/dist/remote-relay/connector.js +29 -2
  87. package/dist/remote-relay/protocol.d.ts +21 -0
  88. package/dist/remote-relay/protocol.js +31 -0
  89. package/dist/serve/cli.js +6 -0
  90. package/dist/serve/headless-server.js +24 -19
  91. package/dist/sessions-service.d.ts +4 -0
  92. package/dist/sessions-service.js +14 -1
  93. package/package.json +10 -6
@@ -1,5 +1,5 @@
1
1
  import { chmodSync, closeSync, existsSync, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, readdirSync, renameSync, writeFileSync, appendFileSync, rmSync, } from "node:fs";
2
- import { readRoomHistoryPage, scanRoomHistory, } from "./room-history.js";
2
+ import { readRoomHistoryPage, scanRoomHistory, scanRoomHistoryAsync, } from "./room-history.js";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { createRoomModelTrace } from "./room-model-tracing.js";
@@ -145,6 +145,9 @@ function parseRoomMessage(value) {
145
145
  "reason",
146
146
  "toolId",
147
147
  "transcriptSource",
148
+ "clientMessageId",
149
+ "clientDeviceId",
150
+ "clientIntentHash",
148
151
  ]) {
149
152
  const item = raw[field];
150
153
  if (item !== undefined && (typeof item !== "string" || item.includes("\0")))
@@ -231,6 +234,50 @@ function askUserQuestions(input) {
231
234
  return [];
232
235
  return obj.questions.flatMap((q) => parseAskUserQuestion(q) ?? []);
233
236
  }
237
+ const ROOM_BUSY_MESSAGES = {
238
+ queued: "该 CLI 会话有排队、失败或无法确认的消息,它们按当前权限模式提交;请先在队列中处理后再切换权限模式。",
239
+ switching: "该 CLI 会话正在切换权限模式,请稍后重试。",
240
+ retiring: "该 CLI 会话的旧进程仍未确认退出;为避免两个进程同时写入同一会话,暂不启动新进程,请稍后重试。",
241
+ turn: "该 CLI 会话仍有任务在运行或排队,请先停止或等待完成后再切换权限模式。",
242
+ approval: "该 CLI 会话有待处理的审批或提问,请先处理后再切换权限模式。",
243
+ };
244
+ export class RoomBusyError extends Error {
245
+ reason;
246
+ constructor(reason) {
247
+ super(ROOM_BUSY_MESSAGES[reason]);
248
+ this.reason = reason;
249
+ this.name = "RoomBusyError";
250
+ }
251
+ }
252
+ /** Stable code of RoomInputAcceptedError; callers branch on it, never on text. */
253
+ export const ROOM_INPUT_ACCEPTED = "ROOM_INPUT_ACCEPTED";
254
+ /**
255
+ * The CLI accepted the input, but recording it (history, trace, echo) failed.
256
+ * The turn may be running: it must never be retried as if it were rejected.
257
+ */
258
+ export class RoomInputAcceptedError extends Error {
259
+ code = ROOM_INPUT_ACCEPTED;
260
+ constructor(cause) {
261
+ super(`CLI 已接收这条消息,但会话记录保存失败(${cause instanceof Error ? cause.message : String(cause)})。请先核对 CLI 会话,不要重复发送。`, { cause });
262
+ this.name = "RoomInputAcceptedError";
263
+ }
264
+ }
265
+ /** The room or its process changed between planning and applying a mode change. */
266
+ export class RoomStateChangedError extends Error {
267
+ constructor(message = "CLI 会话状态已变化,请重新选择权限模式。") {
268
+ super(message);
269
+ this.name = "RoomStateChangedError";
270
+ }
271
+ }
272
+ function canonicalDirectory(path) {
273
+ const absolute = resolve(path);
274
+ try {
275
+ return realpathSync(absolute);
276
+ }
277
+ catch {
278
+ return absolute;
279
+ }
280
+ }
234
281
  const ROOM_ATTACHMENT_START = "<codeshell-image-attachments>";
235
282
  const ROOM_ATTACHMENT_END = "</codeshell-image-attachments>";
236
283
  function roomAttachmentSummary(attachments) {
@@ -276,6 +323,21 @@ export class RoomManager {
276
323
  /** Permission mode each live agent was spawned with. The mode is a spawn-time
277
324
  * CLI argument, so room metadata alone cannot prove what a process runs. */
278
325
  agentModes = new WeakMap();
326
+ /** Accepted inputs minus turn_end per room, for agents without isBusy(). A
327
+ * Claude resident process queues several inputs; each ends with a result. */
328
+ turnCounts = new Map();
329
+ /** In-flight mode handoff per room. close() deletes it, so a handoff that is
330
+ * waiting for the old process can never reopen a room the user closed. */
331
+ transitions = new Map();
332
+ /** Detached processes not yet proven exited. This is a process-safety fence,
333
+ * independent of any operation: close() and cancellation leave it in place,
334
+ * and no agent may start for the room until the process has really exited,
335
+ * so one external session never has two writers. */
336
+ retiring = new Map();
337
+ retiringProbes = new Map();
338
+ /** Bumped on every change to a room's process or mode, so a Host approval
339
+ * granted for one state cannot be applied to another. */
340
+ revisions = new Map();
279
341
  modelTraces = new Map();
280
342
  /** Rooms whose visible output is currently sourced from the external CLI's
281
343
  * transcript tail. While present, the resident agent's stdout mirror is
@@ -321,6 +383,9 @@ export class RoomManager {
321
383
  rootRealPath;
322
384
  rootIdentity;
323
385
  failedWrites = new Set();
386
+ preparingInputReceipts = new Map();
387
+ inputReceipts = new Map();
388
+ hostTurnHooks = {};
324
389
  /** Drop all pending AskUser entries for a room. Called when its agent goes
325
390
  * away (exit/close) so a request that can no longer be answered doesn't leak
326
391
  * in the map (small memory leak) and its approval card doesn't hang until the
@@ -455,6 +520,12 @@ export class RoomManager {
455
520
  }
456
521
  }
457
522
  createRoom(input) {
523
+ return this.createRoomWithId(this.mintRoomId(), input);
524
+ }
525
+ mintRoomId() {
526
+ return `room_${this.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
527
+ }
528
+ createRoomWithId(id, input) {
458
529
  if (!input ||
459
530
  typeof input.cwd !== "string" ||
460
531
  !input.cwd ||
@@ -477,7 +548,8 @@ export class RoomManager {
477
548
  (input.linkedSessionMode !== undefined && input.linkedSessionMode !== "observe-only")) {
478
549
  throw new Error("invalid room metadata");
479
550
  }
480
- const id = `room_${this.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
551
+ if (!isValidRoomId(id) || existsSync(this.roomDir(id)))
552
+ throw new RoomStateChangedError();
481
553
  const meta = {
482
554
  id,
483
555
  name: input.name ?? defaultRoomName(input.cwd),
@@ -573,7 +645,14 @@ export class RoomManager {
573
645
  const msg = { seq, ts, ...partial };
574
646
  try {
575
647
  this.appendRoomMessage(id, msg);
576
- this.messageStampByRoom.set(id, this.messageStamp(id));
648
+ const stamp = this.messageStamp(id);
649
+ this.messageStampByRoom.set(id, stamp);
650
+ const receipts = this.inputReceipts.get(id);
651
+ if (receipts) {
652
+ receipts.stamp = stamp;
653
+ if (msg.clientMessageId && msg.clientDeviceId && msg.clientIntentHash)
654
+ receipts.intents.set(JSON.stringify([msg.clientDeviceId, msg.clientMessageId]), msg.clientIntentHash);
655
+ }
577
656
  }
578
657
  catch (error) {
579
658
  // Sequence assignment is a commit effect, not an attempt counter. A
@@ -772,40 +851,431 @@ export class RoomManager {
772
851
  sources.add(source);
773
852
  }
774
853
  }
854
+ /** Monotonic per-room state revision; see `revisions`. */
855
+ roomRevision(id) {
856
+ return this.revisions.get(id) ?? 0;
857
+ }
858
+ bumpRevision(id) {
859
+ this.revisions.set(id, this.roomRevision(id) + 1);
860
+ }
861
+ /** The room bound to an external session. Reuse must match BOTH id and kind:
862
+ * a codex thread id and a claude session id share the `claudeSessionId`
863
+ * field, so a bare id match could hand back the other CLI's room. */
864
+ findSessionRoom(sessionId, kind) {
865
+ if (!sessionId)
866
+ return undefined;
867
+ return this.listRooms().find((r) => r.claudeSessionId === sessionId && r.kind === kind);
868
+ }
869
+ /** Spawn-time mode of the room's live process, if one is running. */
870
+ liveMode(id) {
871
+ const agent = this.agents.get(id);
872
+ return agent ? this.agentModes.get(agent) : undefined;
873
+ }
775
874
  /**
776
- * Open (or create) the room bound to a claude session id, deduping by that id
777
- * so a given claude session maps to exactly one room. Returns the room id.
875
+ * Why the room cannot change its process now. A mode change stops the
876
+ * process, so it must not interrupt queued/active turns, a pending tool
877
+ * approval or AskUserQuestion, or a decision that already left the approval
878
+ * bridge but has not been consumed here yet (its entry stays in the pending
879
+ * maps until respondApproval consumes it).
778
880
  */
779
- openForSession(claudeSessionId, cwd, mode, kind = "claude-code") {
780
- if (claudeSessionId)
781
- this.opts.assertExternalSessionAvailable?.(claudeSessionId, kind);
782
- // Reuse must match BOTH id and kind: a codex thread id and a claude session
783
- // id live in the same `claudeSessionId` field, so a bare id match could
784
- // otherwise hand back a claude room when a codex room was asked for.
785
- const existing = claudeSessionId
786
- ? this.listRooms().find((r) => r.claudeSessionId === claudeSessionId && r.kind === kind)
787
- : undefined;
788
- const meta = existing ??
789
- this.createRoom({
881
+ roomBusyReason(id) {
882
+ const busy = this.executionBusyReason(id);
883
+ if (busy)
884
+ return busy;
885
+ // A Host-held turn was submitted for the current mode. Changing the mode
886
+ // underneath it would run it with permissions it was not queued under.
887
+ return this.hostTurnHooks.hasPendingTurns?.(id) ? "queued" : undefined;
888
+ }
889
+ /**
890
+ * The room's own execution state, independent of any Host-held pending
891
+ * turns: an active or queued CLI input (Codex: a live turn child), a mode
892
+ * handoff, or an unanswered approval. A Host scheduler waits on this.
893
+ */
894
+ executionBusyReason(id) {
895
+ if (this.transitions.has(id))
896
+ return "switching";
897
+ if (this.deferredEmitRooms.has(id))
898
+ return "turn";
899
+ const agent = this.agents.get(id);
900
+ if (agent && (agent.isBusy ? agent.isBusy() : (this.turnCounts.get(id) ?? 0) > 0))
901
+ return "turn";
902
+ const prefix = `${id}:`;
903
+ for (const key of this.pendingAskUser.keys())
904
+ if (key.startsWith(prefix))
905
+ return "approval";
906
+ for (const key of this.pendingApprovalInputs.keys())
907
+ if (key.startsWith(prefix))
908
+ return "approval";
909
+ return undefined;
910
+ }
911
+ /**
912
+ * True while a new input must not be handed to the room: it is executing
913
+ * or awaiting an approval, switching modes, or a retired process may still
914
+ * write. Host pending turns are deliberately not counted here.
915
+ */
916
+ isExecutionBusy(id) {
917
+ return this.executionBusyReason(id) !== undefined || this.retiring.has(id);
918
+ }
919
+ /** Mode a new input would run under: the live process mode, else the stored one. */
920
+ effectiveMode(id) {
921
+ return this.liveMode(id) ?? this.getRoom(id)?.permissionMode;
922
+ }
923
+ /**
924
+ * Host pending-turn integration, installed once by the Host composition.
925
+ * `hasPendingTurns` blocks every mode change; `beforeClose` runs before an
926
+ * explicit close signals the process, so pending turns pause first.
927
+ */
928
+ setHostTurnHooks(hooks) {
929
+ this.hostTurnHooks = hooks;
930
+ }
931
+ /**
932
+ * Plan opening an external session's room under `mode` without changing
933
+ * anything. Throws for a cwd that differs from the room's bound directory,
934
+ * an occupied external session, or a busy room whose mode would change.
935
+ */
936
+ planSessionOpen(input) {
937
+ const { sessionId, kind, mode } = input;
938
+ if (typeof sessionId !== "string" || !sessionId)
939
+ throw new Error("CLI session id is required");
940
+ this.opts.assertExternalSessionAvailable?.(sessionId, kind);
941
+ const cwd = canonicalDirectory(input.cwd);
942
+ const existing = this.findSessionRoom(sessionId, kind);
943
+ if (!existing) {
944
+ return {
945
+ roomId: this.mintRoomId(),
946
+ created: true,
947
+ sessionId,
790
948
  cwd,
791
949
  kind,
792
- permissionMode: mode,
793
- claudeSessionId: claudeSessionId || undefined,
950
+ toMode: mode,
951
+ change: true,
952
+ live: false,
953
+ observing: false,
954
+ revision: 0,
955
+ };
956
+ }
957
+ // The approval and the process must name the same directory.
958
+ if (canonicalDirectory(existing.cwd) !== cwd)
959
+ throw new Error("该 CLI 会话已绑定到另一个目录,不能在当前目录打开。");
960
+ const liveMode = this.liveMode(existing.id);
961
+ const observing = existing.linkedSessionMode === "observe-only";
962
+ const change = !observing &&
963
+ (existing.permissionMode !== mode || (liveMode !== undefined && liveMode !== mode));
964
+ if (this.transitions.has(existing.id))
965
+ throw new RoomBusyError("switching");
966
+ // Refused before any Host approval is asked for. settleSessionRoom()
967
+ // re-probes the retired process, so a later attempt succeeds once it exits.
968
+ if (!observing && this.retiring.has(existing.id))
969
+ throw new RoomBusyError("retiring");
970
+ if (change) {
971
+ const busy = this.roomBusyReason(existing.id);
972
+ if (busy)
973
+ throw new RoomBusyError(busy);
974
+ }
975
+ return {
976
+ roomId: existing.id,
977
+ created: false,
978
+ sessionId,
979
+ cwd,
980
+ kind,
981
+ fromMode: liveMode ?? existing.permissionMode,
982
+ toMode: mode,
983
+ change,
984
+ live: liveMode !== undefined,
985
+ observing,
986
+ revision: this.roomRevision(existing.id),
987
+ };
988
+ }
989
+ /**
990
+ * Apply a plan from planSessionOpen. `authorize` runs before anything is
991
+ * changed and again right before the replacement process is created; a
992
+ * throw leaves metadata untouched (or restores it) and never starts a CLI.
993
+ * `revalidate` re-checks asynchronous Host facts after every wait for a
994
+ * retired process, before that final `authorize`, while the room's handoff
995
+ * is still owned by this call.
996
+ */
997
+ async applySessionOpen(plan, options = {}) {
998
+ const authorize = () => options.authorize?.();
999
+ if (plan.created) {
1000
+ if (this.findSessionRoom(plan.sessionId, plan.kind) || this.getRoom(plan.roomId))
1001
+ throw new RoomStateChangedError();
1002
+ authorize();
1003
+ this.opts.assertExternalSessionAvailable?.(plan.sessionId, plan.kind);
1004
+ this.createRoomWithId(plan.roomId, {
1005
+ cwd: plan.cwd,
1006
+ kind: plan.kind,
1007
+ permissionMode: plan.toMode,
1008
+ claudeSessionId: plan.sessionId,
794
1009
  });
795
- // permissionMode is a spawn-time CLI arg (--permission-mode), so it can't be
796
- // changed on a live process. If the caller reopens an existing room under a
797
- // DIFFERENT mode, persist the new mode and restart the resident agent;
798
- // otherwise reopening keeps the running process and the picked mode would be
799
- // silently ignored (the "bypassPermissions still prompts" bug).
800
- if (existing && existing.permissionMode !== mode) {
801
- this.writeRoomMeta(meta.id, { ...meta, permissionMode: mode });
802
- this.close(meta.id); // stop the old-mode process so open() respawns fresh
803
- }
804
- // Metadata alone cannot prove the live process mode (a concurrent takeover
805
- // may have rewritten it), so also compare the agent's spawn-time mode.
806
- this.retireAgentUnlessMode(meta.id, mode);
807
- const { status } = this.open(meta.id);
808
- return { roomId: meta.id, status };
1010
+ this.bumpRevision(plan.roomId);
1011
+ try {
1012
+ const status = await this.startAgent(plan.roomId);
1013
+ return { roomId: plan.roomId, status, mode: plan.toMode, changed: true };
1014
+ }
1015
+ catch (error) {
1016
+ // The room now exists but nothing runs in it; every view must see that.
1017
+ this.notifyRoomState(plan.roomId);
1018
+ throw error;
1019
+ }
1020
+ }
1021
+ // Plans are checked against the retired-process fence again after it had a
1022
+ // chance to be proven gone; an unproven process still refuses the start.
1023
+ // No await (and so no gap) unless a retired process is actually fenced.
1024
+ if (this.transitions.has(plan.roomId))
1025
+ throw new RoomBusyError("switching");
1026
+ if (this.retiring.has(plan.roomId)) {
1027
+ if (!(await this.settleRetiring(plan.roomId)))
1028
+ throw new RoomBusyError("retiring");
1029
+ await options.revalidate?.();
1030
+ }
1031
+ const meta = this.getRoom(plan.roomId);
1032
+ if (!meta ||
1033
+ meta.claudeSessionId !== plan.sessionId ||
1034
+ meta.kind !== plan.kind ||
1035
+ canonicalDirectory(meta.cwd) !== plan.cwd ||
1036
+ this.roomRevision(plan.roomId) !== plan.revision)
1037
+ throw new RoomStateChangedError();
1038
+ if (plan.observing) {
1039
+ authorize();
1040
+ return {
1041
+ roomId: plan.roomId,
1042
+ status: "observing",
1043
+ mode: meta.permissionMode,
1044
+ changed: false,
1045
+ };
1046
+ }
1047
+ if (!plan.change) {
1048
+ if (this.transitions.has(plan.roomId))
1049
+ throw new RoomBusyError("switching");
1050
+ authorize();
1051
+ // Reuses a live process untouched; starts a dormant one in its own mode.
1052
+ const status = await this.startAgent(plan.roomId);
1053
+ return { roomId: plan.roomId, status, mode: meta.permissionMode, changed: false };
1054
+ }
1055
+ const status = await this.transitionRoom(plan.roomId, {
1056
+ toMode: plan.toMode,
1057
+ expectedMode: meta.permissionMode,
1058
+ authorize,
1059
+ ...(options.revalidate ? { revalidate: options.revalidate } : {}),
1060
+ });
1061
+ return { roomId: plan.roomId, status, mode: plan.toMode, changed: true };
1062
+ }
1063
+ /**
1064
+ * Open (or create) the room bound to an external session id, deduping by
1065
+ * that id so a given session maps to exactly one room. A different mode is
1066
+ * applied by a guarded process handoff. Hosts that must authorize a mode
1067
+ * use planSessionOpen/applySessionOpen directly.
1068
+ */
1069
+ async openForSession(claudeSessionId, cwd, mode, kind = "claude-code") {
1070
+ // Inline (not settleSessionRoom) so an unfenced room adds no await gap.
1071
+ const existing = this.findSessionRoom(claudeSessionId, kind);
1072
+ if (existing && this.retiring.has(existing.id) && !this.transitions.has(existing.id))
1073
+ await this.settleRetiring(existing.id);
1074
+ const plan = this.planSessionOpen({ sessionId: claudeSessionId, cwd, kind, mode });
1075
+ const result = await this.applySessionOpen(plan);
1076
+ return { roomId: result.roomId, status: result.status, mode: result.mode };
1077
+ }
1078
+ /**
1079
+ * Re-probe a retired process of the session's room (see `retiring`), so a
1080
+ * plan made right afterwards is refused only while that process is really
1081
+ * still alive. Never starts anything.
1082
+ */
1083
+ async settleSessionRoom(sessionId, kind) {
1084
+ const room = this.findSessionRoom(sessionId, kind);
1085
+ // A handoff in progress owns its retired process; the plan reports it busy.
1086
+ if (room && this.retiring.has(room.id) && !this.transitions.has(room.id))
1087
+ await this.settleRetiring(room.id);
1088
+ }
1089
+ /** Resolves true once no retired process of the room can still be alive. */
1090
+ settleRetiring(id) {
1091
+ const agent = this.retiring.get(id);
1092
+ if (!agent)
1093
+ return Promise.resolve(true);
1094
+ const inFlight = this.retiringProbes.get(id);
1095
+ if (inFlight)
1096
+ return inFlight;
1097
+ const probe = this.probeRetired(id, agent).finally(() => {
1098
+ if (this.retiringProbes.get(id) === probe)
1099
+ this.retiringProbes.delete(id);
1100
+ });
1101
+ this.retiringProbes.set(id, probe);
1102
+ return probe;
1103
+ }
1104
+ async probeRetired(id, agent) {
1105
+ let exited;
1106
+ try {
1107
+ if (agent.stopAndWait) {
1108
+ exited = await agent.stopAndWait(this.opts.processExitTimeoutMs ?? 5_000);
1109
+ }
1110
+ else {
1111
+ agent.stop();
1112
+ exited = true;
1113
+ }
1114
+ }
1115
+ catch {
1116
+ exited = false;
1117
+ }
1118
+ if (exited && this.retiring.get(id) === agent) {
1119
+ this.retiring.delete(id);
1120
+ // The shared probe owns publication, so concurrent waiters notify once.
1121
+ if (!this.transitions.has(id))
1122
+ this.notifyRoomState(id);
1123
+ }
1124
+ return !this.retiring.has(id);
1125
+ }
1126
+ /** Synchronous entry points cannot wait: refuse, and re-probe in the
1127
+ * background so the next attempt succeeds once the process is gone. */
1128
+ assertNotRetiring(id) {
1129
+ if (!this.retiring.has(id))
1130
+ return;
1131
+ this.watchRetired(id);
1132
+ throw new RoomBusyError("retiring");
1133
+ }
1134
+ /** Request an exit probe; the shared probe publishes a proven exit once.
1135
+ * A handoff that still owns the room reports its own outcome. */
1136
+ watchRetired(id) {
1137
+ void this.settleRetiring(id);
1138
+ }
1139
+ notifyRoomState(id) {
1140
+ try {
1141
+ this.opts.onRoomStateChanged?.(id);
1142
+ }
1143
+ catch {
1144
+ // A Host broadcast failure must not change the room's outcome.
1145
+ }
1146
+ }
1147
+ /**
1148
+ * Replace a room's process with one in `toMode`. permissionMode is a
1149
+ * spawn-time CLI argument (`--permission-mode` / codex sandbox flags), so a
1150
+ * live process cannot change it. Order matters:
1151
+ * 1. refuse a busy room, before touching anything;
1152
+ * 2. detach the old process, so its late output/exit belongs to nobody, and
1153
+ * wait (bounded) until it has really exited — never two writers;
1154
+ * 3. re-authorize, then persist the mode and start the replacement;
1155
+ * 4. on any failure restore the previous mode. The old process is already
1156
+ * gone at that point, so the room reports not running instead of
1157
+ * pretending the old state came back, and every view is told so.
1158
+ * The detached process stays in `retiring` until its exit is proven: a
1159
+ * timeout, close() or cancellation ends this operation but not the fence.
1160
+ * Transcript followers are not ended: the external session continues.
1161
+ */
1162
+ async transitionRoom(id, input) {
1163
+ const busy = this.roomBusyReason(id);
1164
+ if (busy)
1165
+ throw new RoomBusyError(busy);
1166
+ if (this.retiring.has(id))
1167
+ throw new RoomBusyError("retiring");
1168
+ // A background task writing this external session refuses the switch
1169
+ // before the live process is touched, not after it has been stopped.
1170
+ const before = this.getRoom(id);
1171
+ if (before?.claudeSessionId)
1172
+ this.opts.assertExternalSessionAvailable?.(before.claudeSessionId, before.kind);
1173
+ input.authorize();
1174
+ const token = Symbol(id);
1175
+ this.transitions.set(id, token);
1176
+ this.bumpRevision(id);
1177
+ let snapshot;
1178
+ let metadataWritten = false;
1179
+ /** False only while the detached process may still be alive. */
1180
+ let oldProcessGone = true;
1181
+ let failed = false;
1182
+ try {
1183
+ const agent = this.agents.get(id);
1184
+ if (agent) {
1185
+ // Fence first: from here until its exit is proven nothing may start.
1186
+ this.retiring.set(id, agent);
1187
+ oldProcessGone = false;
1188
+ this.agents.delete(id);
1189
+ this.turnCounts.delete(id);
1190
+ this.modelTraces.get(id)?.finish("cancelled");
1191
+ this.modelTraces.delete(id);
1192
+ this.pendingTranscriptUserEchoes.delete(id);
1193
+ oldProcessGone = await this.settleRetiring(id);
1194
+ if (!oldProcessGone)
1195
+ throw new Error("旧 CLI 进程未能在时限内退出;为避免两个进程同时写入同一会话,没有启动新模式。");
1196
+ // Facts proven before the wait may have changed during it. The token
1197
+ // still owns the room, so nothing else can start while this awaits.
1198
+ if (this.transitions.get(id) === token)
1199
+ await input.revalidate?.();
1200
+ }
1201
+ if (this.transitions.get(id) !== token)
1202
+ throw new RoomStateChangedError("CLI 会话已被关闭,权限模式切换已取消。");
1203
+ // The requester and its grant are re-checked right before the
1204
+ // replacement exists; a cancellation leaves the room really stopped.
1205
+ input.authorize();
1206
+ snapshot = this.getRoom(id);
1207
+ if (!snapshot || snapshot.permissionMode !== input.expectedMode)
1208
+ throw new RoomStateChangedError();
1209
+ this.writeRoomMeta(id, { ...snapshot, ...input.patch, permissionMode: input.toMode });
1210
+ metadataWritten = true;
1211
+ const status = await this.startAgent(id, token);
1212
+ if (status !== "running")
1213
+ throw new Error("新模式的 CLI 进程未能启动。");
1214
+ return status;
1215
+ }
1216
+ catch (error) {
1217
+ // A process that may still be alive is not reported as stopped; the
1218
+ // fence is re-probed and the room republished once it is proven gone.
1219
+ if (!oldProcessGone)
1220
+ this.watchRetired(id);
1221
+ if (metadataWritten && snapshot) {
1222
+ try {
1223
+ const current = this.getRoom(id);
1224
+ if (current)
1225
+ this.writeRoomMeta(id, {
1226
+ ...current,
1227
+ cwd: snapshot.cwd,
1228
+ permissionMode: snapshot.permissionMode,
1229
+ linkedSessionMode: snapshot.linkedSessionMode,
1230
+ });
1231
+ }
1232
+ catch {
1233
+ // Surface the original failure; the room reports not running.
1234
+ }
1235
+ }
1236
+ failed = true;
1237
+ throw error;
1238
+ }
1239
+ finally {
1240
+ if (this.transitions.get(id) === token)
1241
+ this.transitions.delete(id);
1242
+ this.bumpRevision(id);
1243
+ // Only the requester receives the error; every other phone and Desktop
1244
+ // window learns the room's real (stopped, restored-mode) state here.
1245
+ if (failed && oldProcessGone)
1246
+ this.notifyRoomState(id);
1247
+ }
1248
+ }
1249
+ /** Start (or reuse) the room's agent and wait until a spawned process exists. */
1250
+ async startAgent(id, transition) {
1251
+ if (transition !== undefined && this.transitions.get(id) !== transition)
1252
+ throw new RoomStateChangedError("CLI 会话已被关闭,权限模式切换已取消。");
1253
+ if (transition === undefined && this.transitions.has(id))
1254
+ throw new RoomBusyError("switching");
1255
+ const { status } = this.openAgent(id);
1256
+ if (status !== "running")
1257
+ return status;
1258
+ const agent = this.agents.get(id);
1259
+ if (!agent?.whenStarted)
1260
+ return status;
1261
+ try {
1262
+ await agent.whenStarted();
1263
+ }
1264
+ catch (error) {
1265
+ if (this.agents.get(id) === agent) {
1266
+ this.agents.delete(id);
1267
+ this.turnCounts.delete(id);
1268
+ try {
1269
+ agent.stop();
1270
+ }
1271
+ catch {
1272
+ // Preserve the spawn error.
1273
+ }
1274
+ this.bumpRevision(id);
1275
+ }
1276
+ throw new Error(`CLI 进程启动失败:${error instanceof Error ? error.message : String(error)}`, { cause: error });
1277
+ }
1278
+ return this.agents.get(id) === agent && agent.isRunning() ? "running" : "missing";
809
1279
  }
810
1280
  /** Bind an external CLI transcript to a room for observation only.
811
1281
  *
@@ -851,9 +1321,13 @@ export class RoomManager {
851
1321
  * This is the linked-session operation that may create/start an agent. The
852
1322
  * external identity is revalidated at takeover time so stale links cannot
853
1323
  * silently start a different session. */
854
- takeOverLinkedSession(roomId, externalSessionId, cwd, kind) {
1324
+ async takeOverLinkedSession(roomId, externalSessionId, cwd, kind) {
855
1325
  const target = this.requireLinkedSessionTarget({ externalSessionId, cwd, kind });
856
1326
  this.opts.assertExternalSessionAvailable?.(target.externalSessionId, target.kind);
1327
+ if (this.transitions.has(roomId))
1328
+ throw new RoomBusyError("switching");
1329
+ if (this.retiring.has(roomId) && !(await this.settleRetiring(roomId)))
1330
+ throw new RoomBusyError("retiring");
857
1331
  const meta = this.getRoom(roomId);
858
1332
  if (!meta ||
859
1333
  meta.claudeSessionId !== target.externalSessionId ||
@@ -861,24 +1335,33 @@ export class RoomManager {
861
1335
  resolve(meta.cwd) !== target.cwd) {
862
1336
  throw new Error("linked session does not match the requested room");
863
1337
  }
864
- const controllableMeta = {
865
- ...meta,
866
- cwd: target.cwd,
867
- // Observe-only navigation never asks the user to approve a stored
868
- // permission mode. A prior bypassPermissions value must not be inherited
869
- // silently when takeover starts a new process.
870
- permissionMode: "default",
871
- linkedSessionMode: undefined,
872
- };
873
- this.writeRoomMeta(roomId, controllableMeta);
874
- // Another view may have taken over and reopened this session under a
875
- // different mode. Never report default while feeding that process.
876
- this.retireAgentUnlessMode(roomId, controllableMeta.permissionMode);
877
- const { status } = this.open(roomId);
1338
+ if (this.transitions.has(roomId))
1339
+ throw new RoomBusyError("switching");
1340
+ // Observe-only navigation never asks the user to approve a stored
1341
+ // permission mode. A prior bypassPermissions value must not be inherited
1342
+ // silently when takeover starts a new process.
1343
+ const patch = { cwd: target.cwd, linkedSessionMode: undefined };
1344
+ const liveMode = this.liveMode(roomId);
1345
+ let status;
1346
+ if (liveMode !== undefined && liveMode !== "default") {
1347
+ // Another view runs this session under a different mode. Replace that
1348
+ // process only when it is idle, and only after it has exited.
1349
+ status = await this.transitionRoom(roomId, {
1350
+ toMode: "default",
1351
+ expectedMode: meta.permissionMode,
1352
+ authorize: () => undefined,
1353
+ patch,
1354
+ });
1355
+ }
1356
+ else {
1357
+ this.writeRoomMeta(roomId, { ...meta, ...patch, permissionMode: "default" });
1358
+ this.bumpRevision(roomId);
1359
+ status = await this.startAgent(roomId);
1360
+ }
878
1361
  if (status !== "running") {
879
1362
  throw new Error("linked session room is unavailable");
880
1363
  }
881
- return { roomId, status, mode: controllableMeta.permissionMode, cwd: controllableMeta.cwd };
1364
+ return { roomId, status, mode: "default", cwd: target.cwd };
882
1365
  }
883
1366
  requireLinkedSessionTarget(target) {
884
1367
  if (!target.externalSessionId.trim() ||
@@ -966,14 +1449,25 @@ export class RoomManager {
966
1449
  /** Open a room: start its resident agent if not already running. Observe-only
967
1450
  * linked rooms fail closed until takeOverLinkedSession clears the boundary. */
968
1451
  open(id) {
1452
+ // A mode handoff owns the room until its replacement process exists.
1453
+ if (this.transitions.has(id))
1454
+ throw new RoomBusyError("switching");
1455
+ return this.openAgent(id);
1456
+ }
1457
+ openAgent(id) {
969
1458
  const meta = this.getRoom(id);
970
1459
  if (!meta)
971
1460
  return { status: "missing" };
972
1461
  if (meta.linkedSessionMode === "observe-only")
973
1462
  return { status: "observing" };
974
1463
  if (!this.agents.has(id)) {
1464
+ // Every start path ends here: no second writer while a detached
1465
+ // process of this room may still be alive.
1466
+ this.assertNotRetiring(id);
975
1467
  if (meta.claudeSessionId)
976
1468
  this.opts.assertExternalSessionAvailable?.(meta.claudeSessionId, meta.kind);
1469
+ // Host policy, e.g. a stored bypassPermissions needs a live Host grant.
1470
+ this.opts.authorizeStart?.(meta);
977
1471
  let agent = undefined;
978
1472
  agent = this.opts.createAgent(meta, (event) => {
979
1473
  // stop() sends a signal; an old process can still drain output or emit
@@ -984,12 +1478,15 @@ export class RoomManager {
984
1478
  });
985
1479
  this.agents.set(id, agent);
986
1480
  this.agentModes.set(agent, meta.permissionMode);
1481
+ this.turnCounts.delete(id);
1482
+ this.bumpRevision(id);
987
1483
  try {
988
1484
  agent.start();
989
1485
  }
990
1486
  catch (error) {
991
1487
  if (this.agents.get(id) === agent)
992
1488
  this.agents.delete(id);
1489
+ this.bumpRevision(id);
993
1490
  this.clearPendingApprovals(id);
994
1491
  try {
995
1492
  agent.stop();
@@ -1006,12 +1503,6 @@ export class RoomManager {
1006
1503
  }
1007
1504
  return { status: "running" };
1008
1505
  }
1009
- /** Stop a live agent whose spawn-time permission mode differs from `mode`. */
1010
- retireAgentUnlessMode(id, mode) {
1011
- const agent = this.agents.get(id);
1012
- if (agent && this.agentModes.get(agent) !== mode)
1013
- this.close(id);
1014
- }
1015
1506
  onAgentEvent(id, event) {
1016
1507
  // Buffered while send() is deciding acceptance — replayed right after the
1017
1508
  // user turn is appended, so a synchronous agent reply cannot precede it.
@@ -1021,6 +1512,19 @@ export class RoomManager {
1021
1512
  this.deferredEmits.set(id, queued);
1022
1513
  return;
1023
1514
  }
1515
+ // Turn accounting must see every event, including the turn_end that the
1516
+ // transcript-follow filter below hides from the stdout mirror.
1517
+ if (event.type === "turn_end") {
1518
+ const remaining = (this.turnCounts.get(id) ?? 0) - 1;
1519
+ if (remaining > 0)
1520
+ this.turnCounts.set(id, remaining);
1521
+ else
1522
+ this.turnCounts.delete(id);
1523
+ }
1524
+ else if (event.type === "exit") {
1525
+ this.turnCounts.delete(id);
1526
+ this.bumpRevision(id);
1527
+ }
1024
1528
  const trace = this.modelTraces.get(id);
1025
1529
  trace?.observe(event);
1026
1530
  if (trace?.ended)
@@ -1099,13 +1603,22 @@ export class RoomManager {
1099
1603
  ?.respondControl?.(event.requestId, { behavior: "allow", updatedInput: event.input });
1100
1604
  break;
1101
1605
  }
1102
- this.append(id, {
1103
- from: "agent",
1104
- type: "approval",
1105
- tool: event.toolName,
1106
- summary: event.description ?? "",
1107
- });
1108
- this.pendingApprovalInputs.set(`${id}:${event.requestId}`, event.input);
1606
+ // Record the pending approval BEFORE the message is published: anything
1607
+ // reacting to it (e.g. a Host scheduler) must already see the room busy.
1608
+ const approvalKey = `${id}:${event.requestId}`;
1609
+ this.pendingApprovalInputs.set(approvalKey, event.input);
1610
+ try {
1611
+ this.append(id, {
1612
+ from: "agent",
1613
+ type: "approval",
1614
+ tool: event.toolName,
1615
+ summary: event.description ?? "",
1616
+ });
1617
+ }
1618
+ catch (error) {
1619
+ this.pendingApprovalInputs.delete(approvalKey);
1620
+ throw error;
1621
+ }
1109
1622
  this.opts.onApprovalRequest?.(id, event);
1110
1623
  break;
1111
1624
  }
@@ -1125,11 +1638,81 @@ export class RoomManager {
1125
1638
  break;
1126
1639
  }
1127
1640
  }
1641
+ /** Prepare cold receipts outside the synchronous send path; concurrent intents share one scan. */
1642
+ async prepareInputReceipts(id) {
1643
+ if (this.inputReceipts.has(id)) {
1644
+ this.currentInputReceipts(id);
1645
+ return;
1646
+ }
1647
+ const pending = this.preparingInputReceipts.get(id);
1648
+ if (pending)
1649
+ return pending;
1650
+ const work = this.buildInputReceipts(id).finally(() => {
1651
+ if (this.preparingInputReceipts.get(id) === work)
1652
+ this.preparingInputReceipts.delete(id);
1653
+ });
1654
+ this.preparingInputReceipts.set(id, work);
1655
+ return work;
1656
+ }
1657
+ async buildInputReceipts(id) {
1658
+ if (this.failedWrites.has(id))
1659
+ throw new Error("room persistence failed");
1660
+ const path = this.msgPath(id);
1661
+ const intents = new Map();
1662
+ const proof = existsSync(path)
1663
+ ? await scanRoomHistoryAsync(path, parseRoomMessage, (message) => {
1664
+ if (!message.clientMessageId)
1665
+ return;
1666
+ if (!message.clientDeviceId || !message.clientIntentHash)
1667
+ throw new Error("room input receipt is incomplete");
1668
+ const key = JSON.stringify([message.clientDeviceId, message.clientMessageId]);
1669
+ const previous = intents.get(key);
1670
+ if (previous && previous !== message.clientIntentHash)
1671
+ throw new Error("conflicting room input receipts");
1672
+ intents.set(key, message.clientIntentHash);
1673
+ })
1674
+ : { sequence: 0, stamp: "" };
1675
+ if ((existsSync(path) ? this.messageStamp(id) : "") !== proof.stamp)
1676
+ throw new Error("room input history changed; reload before retrying");
1677
+ this.inputReceipts.set(id, { stamp: proof.stamp, intents });
1678
+ // Avoid a second synchronous history scan when the cold Room first appends.
1679
+ if (proof.stamp && !this.lastSeqByRoom.has(id)) {
1680
+ this.lastSeqByRoom.set(id, proof.sequence);
1681
+ this.messageStampByRoom.set(id, proof.stamp);
1682
+ }
1683
+ }
1684
+ currentInputReceipts(id) {
1685
+ const cached = this.inputReceipts.get(id);
1686
+ if (!cached)
1687
+ throw new Error("room input receipts must be prepared before send");
1688
+ const path = this.msgPath(id);
1689
+ if (cached.stamp !== (existsSync(path) ? this.messageStamp(id) : ""))
1690
+ throw new Error("room input history changed; reload before retrying");
1691
+ return cached;
1692
+ }
1693
+ /** A replay never opens a new CLI, reclaims an attachment, or reuses a changed intent. */
1694
+ hasAcceptedInput(id, receipt) {
1695
+ if (!receipt.clientMessageId ||
1696
+ receipt.clientMessageId.length > 200 ||
1697
+ !receipt.clientDeviceId ||
1698
+ receipt.clientDeviceId.length > 256 ||
1699
+ !/^[a-f0-9]{64}$/.test(receipt.clientIntentHash))
1700
+ throw new Error("invalid room input receipt");
1701
+ if (this.failedWrites.has(id))
1702
+ throw new Error("room persistence failed");
1703
+ const cached = this.currentInputReceipts(id);
1704
+ const known = cached.intents.get(JSON.stringify([receipt.clientDeviceId, receipt.clientMessageId]));
1705
+ if (known && known !== receipt.clientIntentHash)
1706
+ throw new Error("同一输入的内容已改变,请核对草稿后作为新消息发送。");
1707
+ return known !== undefined;
1708
+ }
1128
1709
  /** Post a user message: persist it, ensure agent running, feed it. */
1129
- send(id, text, attachments = []) {
1710
+ send(id, text, attachments = [], receipt) {
1130
1711
  const meta = this.getRoom(id);
1131
1712
  if (!meta)
1132
1713
  return false;
1714
+ if (receipt && this.hasAcceptedInput(id, receipt))
1715
+ return true;
1133
1716
  const displayText = text.trim();
1134
1717
  if (!displayText && attachments.length === 0)
1135
1718
  return false;
@@ -1174,6 +1757,21 @@ export class RoomManager {
1174
1757
  // Rejected: nothing about this turn reaches history or the phone.
1175
1758
  return false;
1176
1759
  }
1760
+ // Counted before the buffered events replay, so a synchronous turn_end
1761
+ // settles exactly this input.
1762
+ if (this.agents.get(id) === agent)
1763
+ this.turnCounts.set(id, (this.turnCounts.get(id) ?? 0) + 1);
1764
+ try {
1765
+ this.recordAcceptedInput(id, meta, agentText, displayText, summaries, buffered, receipt);
1766
+ }
1767
+ catch (error) {
1768
+ // The CLI already has this input. Reporting a plain failure would invite
1769
+ // a second submission of the same turn.
1770
+ throw new RoomInputAcceptedError(error);
1771
+ }
1772
+ return true;
1773
+ }
1774
+ recordAcceptedInput(id, meta, agentText, displayText, summaries, buffered, receipt) {
1177
1775
  if (this.opts.modelTracing) {
1178
1776
  try {
1179
1777
  let trace = this.modelTraces.get(id);
@@ -1197,6 +1795,7 @@ export class RoomManager {
1197
1795
  from: "user",
1198
1796
  type: "text",
1199
1797
  text: displayText,
1798
+ ...(receipt ?? {}),
1200
1799
  ...(summaries.length ? { attachments: summaries } : {}),
1201
1800
  });
1202
1801
  if (this.transcriptFollowedRooms.has(id)) {
@@ -1208,26 +1807,57 @@ export class RoomManager {
1208
1807
  // after the user turn.
1209
1808
  for (const event of buffered)
1210
1809
  this.onAgentEvent(id, event);
1211
- return true;
1212
1810
  }
1213
1811
  close(id) {
1812
+ // Stopping is the current turn only: Host-held turns pause BEFORE the
1813
+ // process is signalled, so the idle boundary cannot start the next one.
1814
+ try {
1815
+ this.hostTurnHooks.beforeClose?.(id);
1816
+ }
1817
+ catch {
1818
+ // Bookkeeping must never keep a process alive.
1819
+ }
1820
+ // An explicit close cancels a pending mode handoff: the handoff checks its
1821
+ // token before starting the replacement, so it can never reopen the room.
1822
+ // The retired-process fence is deliberately kept: until the old process is
1823
+ // proven gone, an immediate reopen or send is refused, not double-started.
1824
+ this.transitions.delete(id);
1825
+ this.bumpRevision(id);
1214
1826
  // A room going quiet must record its REAL last activity, not the last
1215
1827
  // throttled write.
1216
1828
  this.flushLastActive(id);
1217
1829
  const agent = this.agents.get(id);
1218
1830
  this.modelTraces.get(id)?.finish("cancelled");
1219
1831
  this.modelTraces.delete(id);
1832
+ // stop() only signals: the process can outlive it by the SIGTERM grace
1833
+ // period. Fence the room before signalling so an immediate send/open/
1834
+ // reopen cannot start a second writer on the same external session. An
1835
+ // agent without stopAndWait() has no exit to wait for (stop() is its exit).
1836
+ const fenced = !!agent?.stopAndWait && !this.retiring.has(id);
1837
+ if (fenced)
1838
+ this.retiring.set(id, agent);
1220
1839
  try {
1221
1840
  agent?.stop();
1222
1841
  }
1223
1842
  finally {
1224
1843
  if (!agent || this.agents.get(id) === agent)
1225
1844
  this.agents.delete(id);
1845
+ this.turnCounts.delete(id);
1226
1846
  this.clearPendingApprovals(id);
1227
1847
  this.pendingTranscriptUserEchoes.delete(id);
1228
1848
  this.deferredEmitRooms.delete(id);
1229
1849
  this.deferredEmits.delete(id);
1230
1850
  this.opts.onRoomEnded?.(id);
1851
+ try {
1852
+ this.opts.onRoomClosed?.(id);
1853
+ }
1854
+ catch {
1855
+ // A Host bookkeeping failure must not keep the process alive.
1856
+ }
1857
+ // Prove the exit in the background; the room is republished once it is
1858
+ // gone. Until then isOpen() stays true and every start is refused.
1859
+ if (fenced)
1860
+ this.watchRetired(id);
1231
1861
  }
1232
1862
  }
1233
1863
  closeAll() {
@@ -1245,6 +1875,8 @@ export class RoomManager {
1245
1875
  }
1246
1876
  }
1247
1877
  this.agents.clear();
1878
+ this.transitions.clear();
1879
+ this.turnCounts.clear();
1248
1880
  this.pendingAskUser.clear();
1249
1881
  this.pendingApprovalInputs.clear();
1250
1882
  this.pendingTranscriptUserEchoes.clear();
@@ -1262,8 +1894,10 @@ export class RoomManager {
1262
1894
  if (firstError)
1263
1895
  throw firstError;
1264
1896
  }
1897
+ /** True while the room's process runs, or a retired one is not yet proven
1898
+ * gone (a view must not be told the room stopped while it may still write). */
1265
1899
  isOpen(id) {
1266
- return this.agents.get(id)?.isRunning() ?? false;
1900
+ return (this.agents.get(id)?.isRunning() ?? false) || this.retiring.has(id);
1267
1901
  }
1268
1902
  /**
1269
1903
  * Delete rooms whose last activity is older than maxAgeMs (idle-based GC,
@@ -1281,13 +1915,14 @@ export class RoomManager {
1281
1915
  for (const meta of this.listRooms()) {
1282
1916
  if (meta.lastActiveAt > cutoff)
1283
1917
  continue;
1284
- if (this.isOpen(meta.id))
1918
+ if (this.isOpen(meta.id) || this.transitions.has(meta.id))
1285
1919
  continue; // never reap a live session
1286
1920
  rmSync(this.roomDir(meta.id), { recursive: true, force: true });
1287
1921
  this.agents.delete(meta.id);
1288
1922
  // Drop the cached seq with the files, so a room id that somehow comes back
1289
1923
  // re-derives its counter from disk instead of continuing a dead sequence.
1290
1924
  this.lastSeqByRoom.delete(meta.id);
1925
+ this.inputReceipts.delete(meta.id);
1291
1926
  removed.push(meta.id);
1292
1927
  }
1293
1928
  return removed;