@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.
- package/dist/account/cli.d.ts +23 -0
- package/dist/account/cli.js +249 -0
- package/dist/account/cloud-contract.d.ts +39 -0
- package/dist/account/cloud-contract.js +134 -0
- package/dist/account/cloud-types.d.ts +47 -0
- package/dist/account/cloud-types.js +1 -0
- package/dist/account/feature-service.d.ts +47 -0
- package/dist/account/feature-service.js +184 -0
- package/dist/account/http.d.ts +17 -0
- package/dist/account/http.js +158 -0
- package/dist/account/links.d.ts +13 -0
- package/dist/account/links.js +137 -0
- package/dist/account/manager.d.ts +96 -0
- package/dist/account/manager.js +976 -0
- package/dist/account/native-lock.d.ts +6 -0
- package/dist/account/native-lock.js +26 -0
- package/dist/account/native-store.d.ts +32 -0
- package/dist/account/native-store.js +162 -0
- package/dist/account/profile.d.ts +4 -0
- package/dist/account/profile.js +46 -0
- package/dist/account/store.d.ts +34 -0
- package/dist/account/store.js +118 -0
- package/dist/account/types.d.ts +138 -0
- package/dist/account/types.js +2 -0
- package/dist/desktop-web/http-api.d.ts +4 -0
- package/dist/desktop-web/http-api.js +12 -4
- package/dist/environment-identity.d.ts +1 -1
- package/dist/environment-identity.js +2 -1
- package/dist/headless-worker-env.d.ts +2 -0
- package/dist/headless-worker-env.js +15 -0
- package/dist/hub/approval-lease.js +6 -1
- package/dist/hub/run-replay.js +1 -1
- package/dist/hub/session-management.js +1 -1
- package/dist/index.account.d.ts +12 -0
- package/dist/index.account.js +12 -0
- package/dist/index.mobile-remote.d.ts +2 -0
- package/dist/index.mobile-remote.js +1 -0
- package/dist/index.worker.d.ts +1 -0
- package/dist/index.worker.js +1 -0
- package/dist/mobile-remote/account-relay-access.d.ts +59 -0
- package/dist/mobile-remote/account-relay-access.js +174 -0
- package/dist/mobile-remote/approval-clock.d.ts +3 -0
- package/dist/mobile-remote/approval-clock.js +15 -0
- package/dist/mobile-remote/codex-room-agent.d.ts +6 -0
- package/dist/mobile-remote/codex-room-agent.js +17 -1
- package/dist/mobile-remote/mobile-client-event-validator.js +40 -1
- package/dist/mobile-remote/mobile-upload-service.d.ts +9 -0
- package/dist/mobile-remote/mobile-upload-service.js +25 -0
- package/dist/mobile-remote/remote-host-manager.d.ts +19 -4
- package/dist/mobile-remote/remote-host-manager.js +124 -17
- package/dist/mobile-remote/resident-agent.d.ts +14 -0
- package/dist/mobile-remote/resident-agent.js +38 -11
- package/dist/mobile-remote/room-history.d.ts +7 -0
- package/dist/mobile-remote/room-history.js +56 -1
- package/dist/mobile-remote/room-manager.d.ts +216 -9
- package/dist/mobile-remote/room-manager.js +700 -65
- package/dist/mobile-remote/room-process-stop.d.ts +15 -0
- package/dist/mobile-remote/room-process-stop.js +41 -0
- package/dist/mobile-remote/trusted-device-store.d.ts +12 -0
- package/dist/mobile-remote/trusted-device-store.js +54 -1
- package/dist/mobile-remote/turn-queue.d.ts +183 -0
- package/dist/mobile-remote/turn-queue.js +422 -0
- package/dist/mobile-remote/types.d.ts +1 -1
- package/dist/panels/agent-task-host.js +6 -12
- package/dist/panels/agent-task-service.d.ts +2 -0
- package/dist/panels/agent-task-service.js +7 -0
- package/dist/panels/browser-capture.d.ts +1 -15
- package/dist/panels/browser-capture.js +1 -35
- package/dist/panels/http.d.ts +15 -15
- package/dist/panels/management-http.d.ts +9 -9
- package/dist/panels/process-service.d.ts +5 -4
- package/dist/panels/process-service.js +54 -16
- package/dist/panels/runtime.js +25 -14
- package/dist/panels/tool-jobs-storage.d.ts +4 -0
- package/dist/panels/tool-jobs-storage.js +111 -34
- package/dist/panels/types.d.ts +1 -97
- package/dist/remote-relay/connector.d.ts +8 -0
- package/dist/remote-relay/connector.js +29 -2
- package/dist/remote-relay/protocol.d.ts +21 -0
- package/dist/remote-relay/protocol.js +31 -0
- package/dist/serve/cli.js +6 -0
- package/dist/serve/headless-server.js +24 -19
- package/dist/sessions-service.d.ts +4 -0
- package/dist/sessions-service.js +14 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
777
|
-
* so
|
|
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
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
793
|
-
|
|
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
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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:
|
|
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
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
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;
|