@cjhyy/code-shell-server 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -0
- package/dist/attachment-service.d.ts +80 -0
- package/dist/attachment-service.js +587 -0
- package/dist/bin/code-shell-serve.d.ts +2 -0
- package/dist/bin/code-shell-serve.js +7 -0
- package/dist/client-message-id.d.ts +1 -0
- package/dist/client-message-id.js +8 -0
- package/dist/image-byte-probe.d.ts +17 -0
- package/dist/image-byte-probe.js +330 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/index.mobile-remote.d.ts +21 -0
- package/dist/index.mobile-remote.js +21 -0
- package/dist/index.serve.d.ts +3 -0
- package/dist/index.serve.js +3 -0
- package/dist/index.storage.d.ts +10 -0
- package/dist/index.storage.js +10 -0
- package/dist/index.worker.d.ts +2 -0
- package/dist/index.worker.js +2 -0
- package/dist/mobile-remote/access-passcode.d.ts +80 -0
- package/dist/mobile-remote/access-passcode.js +354 -0
- package/dist/mobile-remote/cloudflared-binary.d.ts +76 -0
- package/dist/mobile-remote/cloudflared-binary.js +242 -0
- package/dist/mobile-remote/codex-parse.d.ts +34 -0
- package/dist/mobile-remote/codex-parse.js +131 -0
- package/dist/mobile-remote/codex-room-agent.d.ts +94 -0
- package/dist/mobile-remote/codex-room-agent.js +213 -0
- package/dist/mobile-remote/mobile-attachments.d.ts +21 -0
- package/dist/mobile-remote/mobile-attachments.js +119 -0
- package/dist/mobile-remote/mobile-chat-turn.d.ts +32 -0
- package/dist/mobile-remote/mobile-chat-turn.js +67 -0
- package/dist/mobile-remote/mobile-history.d.ts +22 -0
- package/dist/mobile-remote/mobile-history.js +27 -0
- package/dist/mobile-remote/mobile-run-dispatch.d.ts +17 -0
- package/dist/mobile-remote/mobile-run-dispatch.js +56 -0
- package/dist/mobile-remote/mobile-static.d.ts +43 -0
- package/dist/mobile-remote/mobile-static.js +154 -0
- package/dist/mobile-remote/mobile-upload-service.d.ts +57 -0
- package/dist/mobile-remote/mobile-upload-service.js +370 -0
- package/dist/mobile-remote/pairing.d.ts +8 -0
- package/dist/mobile-remote/pairing.js +23 -0
- package/dist/mobile-remote/path-bins.d.ts +7 -0
- package/dist/mobile-remote/path-bins.js +16 -0
- package/dist/mobile-remote/pending-approvals.d.ts +14 -0
- package/dist/mobile-remote/pending-approvals.js +54 -0
- package/dist/mobile-remote/remote-host-manager.d.ts +117 -0
- package/dist/mobile-remote/remote-host-manager.js +345 -0
- package/dist/mobile-remote/resident-agent.d.ts +98 -0
- package/dist/mobile-remote/resident-agent.js +216 -0
- package/dist/mobile-remote/room-manager.d.ts +286 -0
- package/dist/mobile-remote/room-manager.js +765 -0
- package/dist/mobile-remote/trusted-device-store.d.ts +19 -0
- package/dist/mobile-remote/trusted-device-store.js +118 -0
- package/dist/mobile-remote/tunnel-manager.d.ts +105 -0
- package/dist/mobile-remote/tunnel-manager.js +375 -0
- package/dist/mobile-remote/types.d.ts +1 -0
- package/dist/mobile-remote/types.js +1 -0
- package/dist/mobile-remote/viewer-identity.d.ts +5 -0
- package/dist/mobile-remote/viewer-identity.js +5 -0
- package/dist/serve/cli.d.ts +15 -0
- package/dist/serve/cli.js +115 -0
- package/dist/serve/headless-server.d.ts +40 -0
- package/dist/serve/headless-server.js +276 -0
- package/dist/sessions-service.d.ts +73 -0
- package/dist/sessions-service.js +303 -0
- package/dist/static-files.d.ts +1 -0
- package/dist/static-files.js +25 -0
- package/dist/worker-bridge-core.d.ts +154 -0
- package/dist/worker-bridge-core.js +315 -0
- package/package.json +63 -0
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, appendFileSync, rmSync, } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* The exact shape createRoom() generates: `room_<base36>_<base36>`. Anything
|
|
5
|
+
* else — path separators, `..`, NUL, empty — is rejected before it can reach a
|
|
6
|
+
* filesystem path, closing the room-storage traversal via client-supplied
|
|
7
|
+
* event.roomId.
|
|
8
|
+
*/
|
|
9
|
+
const ROOM_ID_RE = /^room_[a-z0-9]+_[a-z0-9]+$/;
|
|
10
|
+
/**
|
|
11
|
+
* Minimum gap between room.json rewrites for `lastActiveAt`.
|
|
12
|
+
*
|
|
13
|
+
* The field only drives idle pruning, which is measured in hours, so writing it
|
|
14
|
+
* per message bought nothing and cost a full file rewrite each time.
|
|
15
|
+
*/
|
|
16
|
+
const LAST_ACTIVE_THROTTLE_MS = 10_000;
|
|
17
|
+
export function isValidRoomId(id) {
|
|
18
|
+
return typeof id === "string" && ROOM_ID_RE.test(id);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Tools whose `can_use_tool` control request is a structured host call, not a
|
|
22
|
+
* yes/no permission gate, AND for which we have nothing to collect from the
|
|
23
|
+
* user — so a plain allow/deny card is a dead end and we auto-allow instead
|
|
24
|
+
* (echoing the original input back as updatedInput). Skill is the only one:
|
|
25
|
+
* it just needs its args echoed.
|
|
26
|
+
*
|
|
27
|
+
* AskUserQuestion is handled separately (NOT here): it needs the user's actual
|
|
28
|
+
* choice, baked into updatedInput.answers — auto-allowing the unanswered input
|
|
29
|
+
* is what made claude report "The user did not answer the questions".
|
|
30
|
+
*/
|
|
31
|
+
const AUTO_ALLOW_TOOLS = new Set(["Skill"]);
|
|
32
|
+
/**
|
|
33
|
+
* Build the `updatedInput` that answers an AskUserQuestion `can_use_tool`
|
|
34
|
+
* request. Empirically (claude CLI, verified against the live control protocol
|
|
35
|
+
* + the Agent SDK docs), the answer must go in an `answers` RECORD keyed by each
|
|
36
|
+
* question's `question` text, with STRING values (arrays/objects fail schema
|
|
37
|
+
* validation). For multiSelect the caller joins chosen labels with ", ". The
|
|
38
|
+
* original `questions` array is passed through (claude validates against it).
|
|
39
|
+
* `answersByQuestion` maps question text → answer string.
|
|
40
|
+
*/
|
|
41
|
+
export function buildAskUserUpdatedInput(input, answersByQuestion) {
|
|
42
|
+
const obj = (input ?? {});
|
|
43
|
+
const questions = Array.isArray(obj.questions) ? obj.questions : [];
|
|
44
|
+
const answers = {};
|
|
45
|
+
for (const q of questions) {
|
|
46
|
+
const text = q?.question;
|
|
47
|
+
if (typeof text === "string" && typeof answersByQuestion[text] === "string") {
|
|
48
|
+
answers[text] = answersByQuestion[text];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { ...obj, answers };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Parse an AskUserQuestion input into the first question's prompt/header/options
|
|
55
|
+
* so the UI can render a choice card. The room UI answers one question at a time
|
|
56
|
+
* (the first); returns undefined for a non-AskUser / malformed input (no
|
|
57
|
+
* questions), which the caller treats as "fall back to auto-allow".
|
|
58
|
+
*/
|
|
59
|
+
export function askUserPrompt(input) {
|
|
60
|
+
const obj = (input ?? {});
|
|
61
|
+
const q0 = (Array.isArray(obj.questions) ? obj.questions[0] : undefined);
|
|
62
|
+
if (!q0 || typeof q0.question !== "string")
|
|
63
|
+
return undefined;
|
|
64
|
+
const options = Array.isArray(q0.options)
|
|
65
|
+
? q0.options
|
|
66
|
+
.map((o) => o?.label)
|
|
67
|
+
.filter((l) => typeof l === "string")
|
|
68
|
+
: [];
|
|
69
|
+
return {
|
|
70
|
+
question: q0.question,
|
|
71
|
+
header: typeof q0.header === "string" ? q0.header : undefined,
|
|
72
|
+
options,
|
|
73
|
+
multiSelect: q0.multiSelect === true,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const ROOM_ATTACHMENT_START = "<codeshell-image-attachments>";
|
|
77
|
+
const ROOM_ATTACHMENT_END = "</codeshell-image-attachments>";
|
|
78
|
+
function roomAttachmentSummary(attachments) {
|
|
79
|
+
return attachments.map((attachment) => ({
|
|
80
|
+
name: attachment.originalName ?? attachment.relPath ?? attachment.path,
|
|
81
|
+
mime: attachment.mime,
|
|
82
|
+
size: attachment.size,
|
|
83
|
+
path: attachment.relPath ?? attachment.path,
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
export function roomTurnText(text, attachments) {
|
|
87
|
+
if (attachments.length === 0)
|
|
88
|
+
return text;
|
|
89
|
+
const paths = attachments.map((attachment) => `- ${attachment.relPath ?? attachment.path} (${attachment.mime ?? "image"})`);
|
|
90
|
+
return [
|
|
91
|
+
text.trim(),
|
|
92
|
+
ROOM_ATTACHMENT_START,
|
|
93
|
+
"The following images are available as workspace-relative files:",
|
|
94
|
+
...paths,
|
|
95
|
+
ROOM_ATTACHMENT_END,
|
|
96
|
+
]
|
|
97
|
+
.filter(Boolean)
|
|
98
|
+
.join("\n");
|
|
99
|
+
}
|
|
100
|
+
export function stripRoomAttachmentBlock(text) {
|
|
101
|
+
const start = text.indexOf(ROOM_ATTACHMENT_START);
|
|
102
|
+
if (start < 0)
|
|
103
|
+
return text;
|
|
104
|
+
const end = text.indexOf(ROOM_ATTACHMENT_END, start);
|
|
105
|
+
return (end < 0
|
|
106
|
+
? text.slice(0, start)
|
|
107
|
+
: `${text.slice(0, start)}${text.slice(end + ROOM_ATTACHMENT_END.length)}`).trim();
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Owns room lifecycle and the on-disk message log (the authoritative source).
|
|
111
|
+
* A room's resident agent is started on open() and its normalized events are
|
|
112
|
+
* appended to messages.jsonl and mirrored via onMessage. seq is monotonic per
|
|
113
|
+
* room so the phone can sync incrementally.
|
|
114
|
+
*/
|
|
115
|
+
export class RoomManager {
|
|
116
|
+
opts;
|
|
117
|
+
agents = new Map();
|
|
118
|
+
/** Rooms whose visible output is currently sourced from the external CLI's
|
|
119
|
+
* transcript tail. While present, the resident agent's stdout mirror is
|
|
120
|
+
* suppressed so the same CLI output is not pushed twice. */
|
|
121
|
+
transcriptFollowedRooms = new Set();
|
|
122
|
+
/** Pending AskUserQuestion control requests, keyed by `${roomId}:${requestId}`.
|
|
123
|
+
* Holds the raw tool input so respondApproval can bake the user's answer into
|
|
124
|
+
* the `answers` record the CLI expects. Cleared on response. */
|
|
125
|
+
pendingAskUser = new Map();
|
|
126
|
+
/** User turns are echoed immediately for responsive room UX. When a
|
|
127
|
+
* transcript follower later sees the same CLI-written user line, this queue
|
|
128
|
+
* suppresses that duplicate. */
|
|
129
|
+
pendingTranscriptUserEchoes = new Map();
|
|
130
|
+
/**
|
|
131
|
+
* Rooms whose agent events must be buffered instead of handled immediately.
|
|
132
|
+
*
|
|
133
|
+
* Only set for the duration of `agent.send()` inside `send()`: acceptance has
|
|
134
|
+
* to be known before the user turn is persisted, but the user turn must still
|
|
135
|
+
* come FIRST in the history. An agent that emits synchronously from send()
|
|
136
|
+
* would otherwise have its reply appended ahead of the prompt it answers.
|
|
137
|
+
*/
|
|
138
|
+
deferredEmitRooms = new Set();
|
|
139
|
+
deferredEmits = new Map();
|
|
140
|
+
/**
|
|
141
|
+
* Last assigned message seq per room, so appends do not re-read the whole
|
|
142
|
+
* JSONL. Seeded lazily from disk on the first append after startup; dropped
|
|
143
|
+
* when the room's messages are deleted so the next append re-derives it.
|
|
144
|
+
*/
|
|
145
|
+
lastSeqByRoom = new Map();
|
|
146
|
+
/** Throttled lastActiveAt bookkeeping — see touchLastActive. */
|
|
147
|
+
pendingLastActive = new Map();
|
|
148
|
+
lastActiveWrittenAt = new Map();
|
|
149
|
+
now;
|
|
150
|
+
/** Drop all pending AskUser entries for a room. Called when its agent goes
|
|
151
|
+
* away (exit/close) so a request that can no longer be answered doesn't leak
|
|
152
|
+
* in the map (small memory leak) and its approval card doesn't hang until the
|
|
153
|
+
* 5-min timeout. Keys are `${roomId}:${requestId}`. */
|
|
154
|
+
clearPendingAskUser(roomId) {
|
|
155
|
+
const prefix = `${roomId}:`;
|
|
156
|
+
for (const key of this.pendingAskUser.keys()) {
|
|
157
|
+
if (key.startsWith(prefix))
|
|
158
|
+
this.pendingAskUser.delete(key);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
constructor(opts) {
|
|
162
|
+
this.opts = opts;
|
|
163
|
+
this.now = opts.now ?? (() => Date.now());
|
|
164
|
+
mkdirSync(opts.rootDir, { recursive: true });
|
|
165
|
+
}
|
|
166
|
+
roomDir(id) {
|
|
167
|
+
// roomId reaches path-building from client WS events (room.open/history/
|
|
168
|
+
// send/close all pass event.roomId straight through), so it is NOT always a
|
|
169
|
+
// system-generated id — an authenticated device could send "../../etc" and
|
|
170
|
+
// traverse out of rootDir. Enforce the generated shape (see createRoom:
|
|
171
|
+
// `room_<base36>_<base36>`) at this single chokepoint so every path-builder
|
|
172
|
+
// (metaPath/msgPath/getRoom/getMessages/open/send/close) is covered.
|
|
173
|
+
if (!isValidRoomId(id)) {
|
|
174
|
+
throw new Error(`invalid roomId: ${JSON.stringify(id)}`);
|
|
175
|
+
}
|
|
176
|
+
return join(this.opts.rootDir, id);
|
|
177
|
+
}
|
|
178
|
+
metaPath(id) {
|
|
179
|
+
return join(this.roomDir(id), "room.json");
|
|
180
|
+
}
|
|
181
|
+
msgPath(id) {
|
|
182
|
+
return join(this.roomDir(id), "messages.jsonl");
|
|
183
|
+
}
|
|
184
|
+
createRoom(input) {
|
|
185
|
+
const id = `room_${this.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
186
|
+
const meta = {
|
|
187
|
+
id,
|
|
188
|
+
name: input.name ?? input.cwd.split("/").filter(Boolean).pop() ?? "room",
|
|
189
|
+
cwd: input.cwd,
|
|
190
|
+
kind: input.kind ?? "claude-code",
|
|
191
|
+
permissionMode: input.permissionMode ?? "default",
|
|
192
|
+
createdAt: this.now(),
|
|
193
|
+
lastActiveAt: this.now(),
|
|
194
|
+
claudeSessionId: input.claudeSessionId,
|
|
195
|
+
linkedSessionMode: input.linkedSessionMode,
|
|
196
|
+
};
|
|
197
|
+
mkdirSync(this.roomDir(id), { recursive: true });
|
|
198
|
+
writeFileSync(this.metaPath(id), JSON.stringify(meta, null, 2), "utf-8");
|
|
199
|
+
// Audit anchor: the first line records how the room was opened (cwd +
|
|
200
|
+
// permission mode), so messages.jsonl is self-describing for "what could
|
|
201
|
+
// this room do" forensics.
|
|
202
|
+
this.append(id, {
|
|
203
|
+
from: "system",
|
|
204
|
+
type: "room_created",
|
|
205
|
+
text: `cwd=${meta.cwd} permission=${meta.permissionMode}`,
|
|
206
|
+
});
|
|
207
|
+
return meta;
|
|
208
|
+
}
|
|
209
|
+
listRooms() {
|
|
210
|
+
if (!existsSync(this.opts.rootDir))
|
|
211
|
+
return [];
|
|
212
|
+
const rooms = [];
|
|
213
|
+
for (const entry of readdirSync(this.opts.rootDir)) {
|
|
214
|
+
const p = join(this.opts.rootDir, entry, "room.json");
|
|
215
|
+
if (!existsSync(p))
|
|
216
|
+
continue;
|
|
217
|
+
try {
|
|
218
|
+
rooms.push(JSON.parse(readFileSync(p, "utf-8")));
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
/* skip corrupt */
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return rooms.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
|
225
|
+
}
|
|
226
|
+
getRoom(id) {
|
|
227
|
+
// Invalid ids resolve to "no such room" rather than throwing, so the WS
|
|
228
|
+
// event handlers (which call this first) degrade to a missing response
|
|
229
|
+
// instead of crashing on a malicious roomId.
|
|
230
|
+
if (!isValidRoomId(id))
|
|
231
|
+
return undefined;
|
|
232
|
+
const p = this.metaPath(id);
|
|
233
|
+
if (!existsSync(p))
|
|
234
|
+
return undefined;
|
|
235
|
+
try {
|
|
236
|
+
return JSON.parse(readFileSync(p, "utf-8"));
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
nextSeq(id) {
|
|
243
|
+
// Scan the file ONCE per room, then keep the counter in memory.
|
|
244
|
+
//
|
|
245
|
+
// This used to call getMessages(id, 0) on every append — a full synchronous
|
|
246
|
+
// read + JSON.parse of the entire messages.jsonl just to learn the last seq.
|
|
247
|
+
// Appending N messages therefore cost O(N²) parsing, all of it on the remote
|
|
248
|
+
// host's event loop: a long-lived room progressively slowed down new
|
|
249
|
+
// messages, heartbeats and every other WebSocket client. (Measured: ~32s to
|
|
250
|
+
// write 2,000 short messages.)
|
|
251
|
+
const cached = this.lastSeqByRoom.get(id);
|
|
252
|
+
if (cached !== undefined) {
|
|
253
|
+
const next = cached + 1;
|
|
254
|
+
this.lastSeqByRoom.set(id, next);
|
|
255
|
+
return next;
|
|
256
|
+
}
|
|
257
|
+
const msgs = this.getMessages(id, 0);
|
|
258
|
+
const next = msgs.length === 0 ? 1 : msgs[msgs.length - 1].seq + 1;
|
|
259
|
+
this.lastSeqByRoom.set(id, next);
|
|
260
|
+
return next;
|
|
261
|
+
}
|
|
262
|
+
append(id, partial) {
|
|
263
|
+
const ts = this.now();
|
|
264
|
+
const msg = { seq: this.nextSeq(id), ts, ...partial };
|
|
265
|
+
appendFileSync(this.msgPath(id), JSON.stringify(msg) + "\n", "utf-8");
|
|
266
|
+
// Touch lastActiveAt so idle-based pruning measures real activity, not just
|
|
267
|
+
// creation time — a room chatted with daily should never be reaped.
|
|
268
|
+
this.touchLastActive(id, ts);
|
|
269
|
+
this.opts.onMessage(id, msg);
|
|
270
|
+
return msg;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Refresh the room's `lastActiveAt`, throttled.
|
|
274
|
+
*
|
|
275
|
+
* This re-read and rewrote the whole room.json on EVERY appended message. The
|
|
276
|
+
* field only feeds idle-based pruning (measured in hours), so second-level
|
|
277
|
+
* precision is worthless while the write cost is paid per message — together
|
|
278
|
+
* with the old full-history seq scan it made a busy room progressively slower.
|
|
279
|
+
*
|
|
280
|
+
* Writes at most once per THROTTLE window per room; `flushLastActive` forces
|
|
281
|
+
* one out at close/prune time so a room that goes quiet still records its real
|
|
282
|
+
* last activity.
|
|
283
|
+
*/
|
|
284
|
+
touchLastActive(id, ts) {
|
|
285
|
+
this.pendingLastActive.set(id, ts);
|
|
286
|
+
const lastWrite = this.lastActiveWrittenAt.get(id) ?? 0;
|
|
287
|
+
if (ts - lastWrite < LAST_ACTIVE_THROTTLE_MS)
|
|
288
|
+
return;
|
|
289
|
+
this.flushLastActive(id);
|
|
290
|
+
}
|
|
291
|
+
/** Persist a pending lastActiveAt immediately, if there is one. */
|
|
292
|
+
flushLastActive(id) {
|
|
293
|
+
const ts = this.pendingLastActive.get(id);
|
|
294
|
+
if (ts === undefined)
|
|
295
|
+
return;
|
|
296
|
+
this.pendingLastActive.delete(id);
|
|
297
|
+
const meta = this.getRoom(id);
|
|
298
|
+
if (!meta)
|
|
299
|
+
return;
|
|
300
|
+
this.lastActiveWrittenAt.set(id, ts);
|
|
301
|
+
writeFileSync(this.metaPath(id), JSON.stringify({ ...meta, lastActiveAt: ts }, null, 2), "utf-8");
|
|
302
|
+
}
|
|
303
|
+
/** Persist the room's resume id (claude session_id / codex thread_id) so the
|
|
304
|
+
* next open() can continue the same conversation. No-op if unchanged. */
|
|
305
|
+
setRoomSessionId(id, sessionId) {
|
|
306
|
+
const meta = this.getRoom(id);
|
|
307
|
+
if (!meta || meta.claudeSessionId === sessionId)
|
|
308
|
+
return;
|
|
309
|
+
writeFileSync(this.metaPath(id), JSON.stringify({ ...meta, claudeSessionId: sessionId }, null, 2), "utf-8");
|
|
310
|
+
}
|
|
311
|
+
getMessages(id, sinceSeq = 0) {
|
|
312
|
+
if (!isValidRoomId(id))
|
|
313
|
+
return [];
|
|
314
|
+
const p = this.msgPath(id);
|
|
315
|
+
if (!existsSync(p))
|
|
316
|
+
return [];
|
|
317
|
+
const out = [];
|
|
318
|
+
for (const line of readFileSync(p, "utf-8").split("\n")) {
|
|
319
|
+
if (!line.trim())
|
|
320
|
+
continue;
|
|
321
|
+
try {
|
|
322
|
+
const m = JSON.parse(line);
|
|
323
|
+
if (m.seq > sinceSeq)
|
|
324
|
+
out.push(m);
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
/* skip */
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
latestSeq(id) {
|
|
333
|
+
return this.getMessages(id).at(-1)?.seq ?? 0;
|
|
334
|
+
}
|
|
335
|
+
beginTranscriptFollow(id) {
|
|
336
|
+
if (this.getRoom(id))
|
|
337
|
+
this.transcriptFollowedRooms.add(id);
|
|
338
|
+
}
|
|
339
|
+
endTranscriptFollow(id) {
|
|
340
|
+
this.transcriptFollowedRooms.delete(id);
|
|
341
|
+
}
|
|
342
|
+
/** Persist normalized transcript-tail messages through the same seq/history/
|
|
343
|
+
* broadcast path as resident-agent output. */
|
|
344
|
+
ingestTranscriptMessages(id, messages) {
|
|
345
|
+
if (!this.transcriptFollowedRooms.has(id) || !this.getRoom(id))
|
|
346
|
+
return;
|
|
347
|
+
for (const message of messages) {
|
|
348
|
+
if (message.from === "user" && typeof message.text === "string") {
|
|
349
|
+
const text = stripRoomAttachmentBlock(message.text);
|
|
350
|
+
const pending = this.pendingTranscriptUserEchoes.get(id);
|
|
351
|
+
const cutoff = this.now() - 60_000;
|
|
352
|
+
while (pending?.length && pending[0].ts < cutoff)
|
|
353
|
+
pending.shift();
|
|
354
|
+
const matched = pending?.[0]?.text === text;
|
|
355
|
+
if (matched)
|
|
356
|
+
pending.shift();
|
|
357
|
+
if (pending?.length === 0)
|
|
358
|
+
this.pendingTranscriptUserEchoes.delete(id);
|
|
359
|
+
// `send` already persisted and broadcast this exact user turn. The
|
|
360
|
+
// transcript remains authoritative for agent/tool output, but must not
|
|
361
|
+
// render a second copy of the prompt.
|
|
362
|
+
if (!matched)
|
|
363
|
+
this.append(id, { ...message, text });
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
this.append(id, message);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Open (or create) the room bound to a claude session id, deduping by that id
|
|
372
|
+
* so a given claude session maps to exactly one room. Returns the room id.
|
|
373
|
+
*/
|
|
374
|
+
openForSession(claudeSessionId, cwd, mode, kind = "claude-code") {
|
|
375
|
+
// Reuse must match BOTH id and kind: a codex thread id and a claude session
|
|
376
|
+
// id live in the same `claudeSessionId` field, so a bare id match could
|
|
377
|
+
// otherwise hand back a claude room when a codex room was asked for.
|
|
378
|
+
const existing = claudeSessionId
|
|
379
|
+
? this.listRooms().find((r) => r.claudeSessionId === claudeSessionId && r.kind === kind)
|
|
380
|
+
: undefined;
|
|
381
|
+
const meta = existing ??
|
|
382
|
+
this.createRoom({
|
|
383
|
+
cwd,
|
|
384
|
+
kind,
|
|
385
|
+
permissionMode: mode,
|
|
386
|
+
claudeSessionId: claudeSessionId || undefined,
|
|
387
|
+
});
|
|
388
|
+
// permissionMode is a spawn-time CLI arg (--permission-mode), so it can't be
|
|
389
|
+
// changed on a live process. If the caller reopens an existing room under a
|
|
390
|
+
// DIFFERENT mode, persist the new mode and restart the resident agent;
|
|
391
|
+
// otherwise reopening keeps the running process and the picked mode would be
|
|
392
|
+
// silently ignored (the "bypassPermissions still prompts" bug).
|
|
393
|
+
if (existing && existing.permissionMode !== mode) {
|
|
394
|
+
writeFileSync(this.metaPath(meta.id), JSON.stringify({ ...meta, permissionMode: mode }, null, 2), "utf-8");
|
|
395
|
+
this.close(meta.id); // stop the old-mode process so open() respawns fresh
|
|
396
|
+
}
|
|
397
|
+
const { status } = this.open(meta.id);
|
|
398
|
+
return { roomId: meta.id, status };
|
|
399
|
+
}
|
|
400
|
+
/** Bind an external CLI transcript to a room for observation only.
|
|
401
|
+
*
|
|
402
|
+
* This method never creates or starts a resident agent. The host must first
|
|
403
|
+
* prove that an on-disk transcript exists for the exact kind + session id +
|
|
404
|
+
* cwd tuple. A missing locator, missing transcript, or mismatched identity
|
|
405
|
+
* fails closed before a room is created.
|
|
406
|
+
*/
|
|
407
|
+
openLinkedSession(externalSessionId, cwd, kind) {
|
|
408
|
+
const target = this.requireLinkedSessionTarget({ externalSessionId, cwd, kind });
|
|
409
|
+
const existing = this.listRooms().find((room) => room.claudeSessionId === target.externalSessionId && room.kind === target.kind);
|
|
410
|
+
if (existing && resolve(existing.cwd) !== target.cwd) {
|
|
411
|
+
throw new Error("linked session cwd does not match the existing room");
|
|
412
|
+
}
|
|
413
|
+
const isRunning = existing ? this.isOpen(existing.id) : false;
|
|
414
|
+
let meta;
|
|
415
|
+
if (existing) {
|
|
416
|
+
meta = {
|
|
417
|
+
...existing,
|
|
418
|
+
cwd: target.cwd,
|
|
419
|
+
};
|
|
420
|
+
// Navigation must not turn an ordinary dormant room into a permanently
|
|
421
|
+
// observe-only room. Existing linked rooms keep their boundary; ordinary
|
|
422
|
+
// rooms are observed transiently and remain writable through the normal
|
|
423
|
+
// open flow.
|
|
424
|
+
if (meta.cwd !== existing.cwd) {
|
|
425
|
+
writeFileSync(this.metaPath(meta.id), JSON.stringify(meta, null, 2), "utf-8");
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
meta = this.createRoom({
|
|
430
|
+
cwd: target.cwd,
|
|
431
|
+
kind: target.kind,
|
|
432
|
+
permissionMode: "default",
|
|
433
|
+
claudeSessionId: target.externalSessionId,
|
|
434
|
+
linkedSessionMode: "observe-only",
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
const status = isRunning ? "running" : "observing";
|
|
438
|
+
return { roomId: meta.id, status, mode: meta.permissionMode, cwd: meta.cwd };
|
|
439
|
+
}
|
|
440
|
+
/** Explicitly take control of a previously observed external CLI room.
|
|
441
|
+
* This is the linked-session operation that may create/start an agent. The
|
|
442
|
+
* external identity is revalidated at takeover time so stale links cannot
|
|
443
|
+
* silently start a different session. */
|
|
444
|
+
takeOverLinkedSession(roomId, externalSessionId, cwd, kind) {
|
|
445
|
+
const target = this.requireLinkedSessionTarget({ externalSessionId, cwd, kind });
|
|
446
|
+
const meta = this.getRoom(roomId);
|
|
447
|
+
if (!meta ||
|
|
448
|
+
meta.claudeSessionId !== target.externalSessionId ||
|
|
449
|
+
meta.kind !== target.kind ||
|
|
450
|
+
resolve(meta.cwd) !== target.cwd) {
|
|
451
|
+
throw new Error("linked session does not match the requested room");
|
|
452
|
+
}
|
|
453
|
+
const controllableMeta = {
|
|
454
|
+
...meta,
|
|
455
|
+
cwd: target.cwd,
|
|
456
|
+
// Observe-only navigation never asks the user to approve a stored
|
|
457
|
+
// permission mode. A prior bypassPermissions value must not be inherited
|
|
458
|
+
// silently when takeover starts a new process.
|
|
459
|
+
permissionMode: "default",
|
|
460
|
+
linkedSessionMode: undefined,
|
|
461
|
+
};
|
|
462
|
+
writeFileSync(this.metaPath(roomId), JSON.stringify(controllableMeta, null, 2), "utf-8");
|
|
463
|
+
const { status } = this.open(roomId);
|
|
464
|
+
if (status !== "running") {
|
|
465
|
+
throw new Error("linked session room is unavailable");
|
|
466
|
+
}
|
|
467
|
+
return { roomId, status, mode: controllableMeta.permissionMode, cwd: controllableMeta.cwd };
|
|
468
|
+
}
|
|
469
|
+
requireLinkedSessionTarget(target) {
|
|
470
|
+
if (!target.externalSessionId.trim() ||
|
|
471
|
+
!target.cwd.trim() ||
|
|
472
|
+
(target.kind !== "claude-code" && target.kind !== "codex")) {
|
|
473
|
+
throw new Error("linked session id, cwd, and kind are required");
|
|
474
|
+
}
|
|
475
|
+
let resolvedTarget;
|
|
476
|
+
try {
|
|
477
|
+
resolvedTarget = this.opts.resolveLinkedSession?.(target);
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
throw new Error("linked session transcript is unavailable");
|
|
481
|
+
}
|
|
482
|
+
if (!resolvedTarget ||
|
|
483
|
+
typeof resolvedTarget.externalSessionId !== "string" ||
|
|
484
|
+
typeof resolvedTarget.cwd !== "string" ||
|
|
485
|
+
resolvedTarget.externalSessionId !== target.externalSessionId ||
|
|
486
|
+
resolvedTarget.kind !== target.kind ||
|
|
487
|
+
resolve(resolvedTarget.cwd) !== resolve(target.cwd)) {
|
|
488
|
+
throw new Error("linked session transcript does not match the requested session");
|
|
489
|
+
}
|
|
490
|
+
return { ...target, cwd: resolve(resolvedTarget.cwd) };
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Forward a phone-side approval decision to the room's resident agent.
|
|
494
|
+
* Returns false if the room has no live agent (or it can't take control
|
|
495
|
+
* responses).
|
|
496
|
+
*/
|
|
497
|
+
respondApproval(roomId, requestId, decision) {
|
|
498
|
+
// Reap the stashed AskUser input FIRST, before the agent-existence check.
|
|
499
|
+
// Otherwise, if the agent already exited, the early return below would skip
|
|
500
|
+
// the delete and leak the pending entry (and the approval card would hang
|
|
501
|
+
// until its timeout). Deleting up front is safe: a missing agent means the
|
|
502
|
+
// request can no longer be answered anyway.
|
|
503
|
+
const askKey = `${roomId}:${requestId}`;
|
|
504
|
+
const pending = this.pendingAskUser.get(askKey);
|
|
505
|
+
if (pending !== undefined)
|
|
506
|
+
this.pendingAskUser.delete(askKey);
|
|
507
|
+
const agent = this.agents.get(roomId);
|
|
508
|
+
if (!agent?.respondControl)
|
|
509
|
+
return false;
|
|
510
|
+
// AskUserQuestion: an "allow" carries the user's chosen answer string, which
|
|
511
|
+
// main (the single source of truth) bakes into the `answers` record keyed by
|
|
512
|
+
// question text — the only shape the CLI accepts. The raw input was stashed
|
|
513
|
+
// on approval_request. Deny passes through (claude treats it as "did not
|
|
514
|
+
// answer", same as the desktop CLI's own cancel).
|
|
515
|
+
if (pending !== undefined) {
|
|
516
|
+
if (decision.behavior === "deny") {
|
|
517
|
+
agent.respondControl(requestId, decision);
|
|
518
|
+
return true;
|
|
519
|
+
}
|
|
520
|
+
const prompt = askUserPrompt(pending);
|
|
521
|
+
const answersByQuestion = prompt ? { [prompt.question]: decision.answer ?? "" } : {};
|
|
522
|
+
agent.respondControl(requestId, {
|
|
523
|
+
behavior: "allow",
|
|
524
|
+
updatedInput: buildAskUserUpdatedInput(pending, answersByQuestion),
|
|
525
|
+
});
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
agent.respondControl(requestId, decision);
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
/** Open a room: start its resident agent if not already running. Observe-only
|
|
532
|
+
* linked rooms fail closed until takeOverLinkedSession clears the boundary. */
|
|
533
|
+
open(id) {
|
|
534
|
+
const meta = this.getRoom(id);
|
|
535
|
+
if (!meta)
|
|
536
|
+
return { status: "missing" };
|
|
537
|
+
if (meta.linkedSessionMode === "observe-only")
|
|
538
|
+
return { status: "observing" };
|
|
539
|
+
if (!this.agents.has(id)) {
|
|
540
|
+
const agent = this.opts.createAgent(meta, (event) => this.onAgentEvent(id, event));
|
|
541
|
+
this.agents.set(id, agent);
|
|
542
|
+
agent.start();
|
|
543
|
+
}
|
|
544
|
+
return { status: "running" };
|
|
545
|
+
}
|
|
546
|
+
onAgentEvent(id, event) {
|
|
547
|
+
// Buffered while send() is deciding acceptance — replayed right after the
|
|
548
|
+
// user turn is appended, so a synchronous agent reply cannot precede it.
|
|
549
|
+
if (this.deferredEmitRooms.has(id)) {
|
|
550
|
+
const queued = this.deferredEmits.get(id) ?? [];
|
|
551
|
+
queued.push(event);
|
|
552
|
+
this.deferredEmits.set(id, queued);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (this.transcriptFollowedRooms.has(id) &&
|
|
556
|
+
(event.type === "text" ||
|
|
557
|
+
event.type === "tool" ||
|
|
558
|
+
event.type === "tool_result" ||
|
|
559
|
+
event.type === "turn_end")) {
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
switch (event.type) {
|
|
563
|
+
case "text":
|
|
564
|
+
this.append(id, { from: "agent", type: "text", text: event.text });
|
|
565
|
+
break;
|
|
566
|
+
case "tool":
|
|
567
|
+
this.append(id, {
|
|
568
|
+
from: "agent",
|
|
569
|
+
type: "tool",
|
|
570
|
+
tool: event.tool,
|
|
571
|
+
summary: event.summary,
|
|
572
|
+
toolId: event.id,
|
|
573
|
+
args: event.input,
|
|
574
|
+
});
|
|
575
|
+
break;
|
|
576
|
+
case "tool_result":
|
|
577
|
+
this.append(id, {
|
|
578
|
+
from: "agent",
|
|
579
|
+
type: "tool_result",
|
|
580
|
+
summary: event.summary,
|
|
581
|
+
isError: event.isError,
|
|
582
|
+
toolId: event.id,
|
|
583
|
+
});
|
|
584
|
+
break;
|
|
585
|
+
case "turn_end":
|
|
586
|
+
this.append(id, { from: "agent", type: "turn_end", reason: event.reason });
|
|
587
|
+
break;
|
|
588
|
+
case "error":
|
|
589
|
+
this.append(id, { from: "system", type: "error", text: event.error });
|
|
590
|
+
break;
|
|
591
|
+
case "approval_request": {
|
|
592
|
+
// Skill routes through can_use_tool only to deliver its args (nothing to
|
|
593
|
+
// ask the user), and emits the request even under bypassPermissions —
|
|
594
|
+
// auto-allow it, echoing the input back, rather than show a dead-end
|
|
595
|
+
// card.
|
|
596
|
+
if (AUTO_ALLOW_TOOLS.has(event.toolName)) {
|
|
597
|
+
this.agents
|
|
598
|
+
.get(id)
|
|
599
|
+
?.respondControl?.(event.requestId, { behavior: "allow", updatedInput: event.input });
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
602
|
+
// AskUserQuestion is NOT a permission gate — it needs the user's actual
|
|
603
|
+
// choice. Parse the options (askUser) so the UI shows a choice card, and
|
|
604
|
+
// stash the raw input so respondApproval can bake the answer into the
|
|
605
|
+
// `answers` record the CLI requires. Auto-allowing the unanswered input
|
|
606
|
+
// is what made claude report "The user did not answer the questions".
|
|
607
|
+
if (event.toolName === "AskUserQuestion") {
|
|
608
|
+
const askUser = askUserPrompt(event.input);
|
|
609
|
+
if (askUser) {
|
|
610
|
+
this.pendingAskUser.set(`${id}:${event.requestId}`, event.input);
|
|
611
|
+
this.append(id, {
|
|
612
|
+
from: "agent",
|
|
613
|
+
type: "approval",
|
|
614
|
+
tool: event.toolName,
|
|
615
|
+
summary: askUser.question,
|
|
616
|
+
});
|
|
617
|
+
this.opts.onApprovalRequest?.(id, { ...event, askUser });
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
// Malformed AskUser (no questions) → auto-allow so the turn isn't
|
|
621
|
+
// wedged forever waiting on an answer that can't be collected.
|
|
622
|
+
this.agents
|
|
623
|
+
.get(id)
|
|
624
|
+
?.respondControl?.(event.requestId, { behavior: "allow", updatedInput: event.input });
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
this.append(id, {
|
|
628
|
+
from: "agent",
|
|
629
|
+
type: "approval",
|
|
630
|
+
tool: event.toolName,
|
|
631
|
+
summary: event.description ?? "",
|
|
632
|
+
});
|
|
633
|
+
this.opts.onApprovalRequest?.(id, event);
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
case "exit":
|
|
637
|
+
this.agents.delete(id);
|
|
638
|
+
this.clearPendingAskUser(id);
|
|
639
|
+
// Let the transcript follower synchronously drain final file bytes
|
|
640
|
+
// before appending the terminal marker. Otherwise an exit that beats
|
|
641
|
+
// watchFile's poll can drop the last assistant line or place it after
|
|
642
|
+
// agent_exit in the room stream.
|
|
643
|
+
this.opts.onRoomEnded?.(id);
|
|
644
|
+
this.append(id, {
|
|
645
|
+
from: "system",
|
|
646
|
+
type: "agent_exit",
|
|
647
|
+
reason: String(event.code ?? event.signal ?? ""),
|
|
648
|
+
});
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
/** Post a user message: persist it, ensure agent running, feed it. */
|
|
653
|
+
send(id, text, attachments = []) {
|
|
654
|
+
const meta = this.getRoom(id);
|
|
655
|
+
if (!meta)
|
|
656
|
+
return false;
|
|
657
|
+
const displayText = text.trim();
|
|
658
|
+
if (!displayText && attachments.length === 0)
|
|
659
|
+
return false;
|
|
660
|
+
if (this.open(id).status !== "running")
|
|
661
|
+
return false;
|
|
662
|
+
const summaries = roomAttachmentSummary(attachments);
|
|
663
|
+
const agentText = roomTurnText(displayText, attachments);
|
|
664
|
+
// Decide acceptance BEFORE persisting, but keep the user turn first in the
|
|
665
|
+
// history.
|
|
666
|
+
//
|
|
667
|
+
// The old order was append-then-send, with send()'s result returned straight
|
|
668
|
+
// to the phone. `CodexRoomAgent.send()` returns false while a previous turn
|
|
669
|
+
// is still running, so the phone showed "房间未就绪或已关闭" while the very
|
|
670
|
+
// same text was already in messages.jsonl and broadcast to every other
|
|
671
|
+
// client — a message that looks delivered but was never given to the agent.
|
|
672
|
+
// With attachments it was worse: the handler released the upload claim,
|
|
673
|
+
// leaving a history entry pointing at an attachment that never materialized.
|
|
674
|
+
//
|
|
675
|
+
// Simply moving append() after send() is not enough: an agent that emits its
|
|
676
|
+
// reply synchronously from send() would land the reply BEFORE the user turn.
|
|
677
|
+
// So buffer anything the agent emits during the call and flush it after the
|
|
678
|
+
// user message is appended. (The real Codex agent spawns a child and emits
|
|
679
|
+
// asynchronously, but ordering must not depend on that.)
|
|
680
|
+
const agent = this.agents.get(id);
|
|
681
|
+
if (!agent)
|
|
682
|
+
return false;
|
|
683
|
+
this.deferredEmitRooms.add(id);
|
|
684
|
+
let accepted = false;
|
|
685
|
+
try {
|
|
686
|
+
accepted = agent.send(agentText);
|
|
687
|
+
}
|
|
688
|
+
finally {
|
|
689
|
+
this.deferredEmitRooms.delete(id);
|
|
690
|
+
}
|
|
691
|
+
const buffered = this.deferredEmits.get(id) ?? [];
|
|
692
|
+
this.deferredEmits.delete(id);
|
|
693
|
+
if (!accepted) {
|
|
694
|
+
// Rejected: nothing about this turn reaches history or the phone.
|
|
695
|
+
return false;
|
|
696
|
+
}
|
|
697
|
+
this.append(id, {
|
|
698
|
+
from: "user",
|
|
699
|
+
type: "text",
|
|
700
|
+
text: displayText,
|
|
701
|
+
...(summaries.length ? { attachments: summaries } : {}),
|
|
702
|
+
});
|
|
703
|
+
if (this.transcriptFollowedRooms.has(id)) {
|
|
704
|
+
const pending = this.pendingTranscriptUserEchoes.get(id) ?? [];
|
|
705
|
+
pending.push({ text: displayText, ts: this.now() });
|
|
706
|
+
this.pendingTranscriptUserEchoes.set(id, pending);
|
|
707
|
+
}
|
|
708
|
+
// Replay whatever the agent produced synchronously, now correctly ordered
|
|
709
|
+
// after the user turn.
|
|
710
|
+
for (const event of buffered)
|
|
711
|
+
this.onAgentEvent(id, event);
|
|
712
|
+
return true;
|
|
713
|
+
}
|
|
714
|
+
close(id) {
|
|
715
|
+
// A room going quiet must record its REAL last activity, not the last
|
|
716
|
+
// throttled write.
|
|
717
|
+
this.flushLastActive(id);
|
|
718
|
+
this.agents.get(id)?.stop();
|
|
719
|
+
this.agents.delete(id);
|
|
720
|
+
this.clearPendingAskUser(id);
|
|
721
|
+
this.pendingTranscriptUserEchoes.delete(id);
|
|
722
|
+
this.opts.onRoomEnded?.(id);
|
|
723
|
+
}
|
|
724
|
+
closeAll() {
|
|
725
|
+
const ids = new Set([...this.agents.keys(), ...this.transcriptFollowedRooms]);
|
|
726
|
+
for (const agent of this.agents.values())
|
|
727
|
+
agent.stop();
|
|
728
|
+
this.agents.clear();
|
|
729
|
+
this.pendingAskUser.clear();
|
|
730
|
+
this.pendingTranscriptUserEchoes.clear();
|
|
731
|
+
for (const id of ids)
|
|
732
|
+
this.opts.onRoomEnded?.(id);
|
|
733
|
+
this.transcriptFollowedRooms.clear();
|
|
734
|
+
}
|
|
735
|
+
isOpen(id) {
|
|
736
|
+
return this.agents.get(id)?.isRunning() ?? false;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Delete rooms whose last activity is older than maxAgeMs (idle-based GC,
|
|
740
|
+
* replacing the removed one-shot /cc path's lack of cleanup). A room with a
|
|
741
|
+
* currently running resident agent is NEVER reaped, regardless of age — only
|
|
742
|
+
* truly dormant rooms (whole directory) are removed. Returns the ids deleted.
|
|
743
|
+
*/
|
|
744
|
+
pruneStaleRooms(maxAgeMs) {
|
|
745
|
+
// Flush pending timestamps first: pruning reads lastActiveAt off disk, and a
|
|
746
|
+
// throttled-but-unwritten value would make an active room look dormant.
|
|
747
|
+
for (const id of [...this.pendingLastActive.keys()])
|
|
748
|
+
this.flushLastActive(id);
|
|
749
|
+
const cutoff = this.now() - maxAgeMs;
|
|
750
|
+
const removed = [];
|
|
751
|
+
for (const meta of this.listRooms()) {
|
|
752
|
+
if (meta.lastActiveAt > cutoff)
|
|
753
|
+
continue;
|
|
754
|
+
if (this.isOpen(meta.id))
|
|
755
|
+
continue; // never reap a live session
|
|
756
|
+
rmSync(this.roomDir(meta.id), { recursive: true, force: true });
|
|
757
|
+
this.agents.delete(meta.id);
|
|
758
|
+
// Drop the cached seq with the files, so a room id that somehow comes back
|
|
759
|
+
// re-derives its counter from disk instead of continuing a dead sequence.
|
|
760
|
+
this.lastSeqByRoom.delete(meta.id);
|
|
761
|
+
removed.push(meta.id);
|
|
762
|
+
}
|
|
763
|
+
return removed;
|
|
764
|
+
}
|
|
765
|
+
}
|