@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.
Files changed (70) hide show
  1. package/README.md +77 -0
  2. package/dist/attachment-service.d.ts +80 -0
  3. package/dist/attachment-service.js +587 -0
  4. package/dist/bin/code-shell-serve.d.ts +2 -0
  5. package/dist/bin/code-shell-serve.js +7 -0
  6. package/dist/client-message-id.d.ts +1 -0
  7. package/dist/client-message-id.js +8 -0
  8. package/dist/image-byte-probe.d.ts +17 -0
  9. package/dist/image-byte-probe.js +330 -0
  10. package/dist/index.d.ts +10 -0
  11. package/dist/index.js +10 -0
  12. package/dist/index.mobile-remote.d.ts +21 -0
  13. package/dist/index.mobile-remote.js +21 -0
  14. package/dist/index.serve.d.ts +3 -0
  15. package/dist/index.serve.js +3 -0
  16. package/dist/index.storage.d.ts +10 -0
  17. package/dist/index.storage.js +10 -0
  18. package/dist/index.worker.d.ts +2 -0
  19. package/dist/index.worker.js +2 -0
  20. package/dist/mobile-remote/access-passcode.d.ts +80 -0
  21. package/dist/mobile-remote/access-passcode.js +354 -0
  22. package/dist/mobile-remote/cloudflared-binary.d.ts +76 -0
  23. package/dist/mobile-remote/cloudflared-binary.js +242 -0
  24. package/dist/mobile-remote/codex-parse.d.ts +34 -0
  25. package/dist/mobile-remote/codex-parse.js +131 -0
  26. package/dist/mobile-remote/codex-room-agent.d.ts +94 -0
  27. package/dist/mobile-remote/codex-room-agent.js +213 -0
  28. package/dist/mobile-remote/mobile-attachments.d.ts +21 -0
  29. package/dist/mobile-remote/mobile-attachments.js +119 -0
  30. package/dist/mobile-remote/mobile-chat-turn.d.ts +32 -0
  31. package/dist/mobile-remote/mobile-chat-turn.js +67 -0
  32. package/dist/mobile-remote/mobile-history.d.ts +22 -0
  33. package/dist/mobile-remote/mobile-history.js +27 -0
  34. package/dist/mobile-remote/mobile-run-dispatch.d.ts +17 -0
  35. package/dist/mobile-remote/mobile-run-dispatch.js +56 -0
  36. package/dist/mobile-remote/mobile-static.d.ts +43 -0
  37. package/dist/mobile-remote/mobile-static.js +154 -0
  38. package/dist/mobile-remote/mobile-upload-service.d.ts +57 -0
  39. package/dist/mobile-remote/mobile-upload-service.js +370 -0
  40. package/dist/mobile-remote/pairing.d.ts +8 -0
  41. package/dist/mobile-remote/pairing.js +23 -0
  42. package/dist/mobile-remote/path-bins.d.ts +7 -0
  43. package/dist/mobile-remote/path-bins.js +16 -0
  44. package/dist/mobile-remote/pending-approvals.d.ts +14 -0
  45. package/dist/mobile-remote/pending-approvals.js +54 -0
  46. package/dist/mobile-remote/remote-host-manager.d.ts +117 -0
  47. package/dist/mobile-remote/remote-host-manager.js +345 -0
  48. package/dist/mobile-remote/resident-agent.d.ts +98 -0
  49. package/dist/mobile-remote/resident-agent.js +216 -0
  50. package/dist/mobile-remote/room-manager.d.ts +286 -0
  51. package/dist/mobile-remote/room-manager.js +765 -0
  52. package/dist/mobile-remote/trusted-device-store.d.ts +19 -0
  53. package/dist/mobile-remote/trusted-device-store.js +118 -0
  54. package/dist/mobile-remote/tunnel-manager.d.ts +105 -0
  55. package/dist/mobile-remote/tunnel-manager.js +375 -0
  56. package/dist/mobile-remote/types.d.ts +1 -0
  57. package/dist/mobile-remote/types.js +1 -0
  58. package/dist/mobile-remote/viewer-identity.d.ts +5 -0
  59. package/dist/mobile-remote/viewer-identity.js +5 -0
  60. package/dist/serve/cli.d.ts +15 -0
  61. package/dist/serve/cli.js +115 -0
  62. package/dist/serve/headless-server.d.ts +40 -0
  63. package/dist/serve/headless-server.js +276 -0
  64. package/dist/sessions-service.d.ts +73 -0
  65. package/dist/sessions-service.js +303 -0
  66. package/dist/static-files.d.ts +1 -0
  67. package/dist/static-files.js +25 -0
  68. package/dist/worker-bridge-core.d.ts +154 -0
  69. package/dist/worker-bridge-core.js +315 -0
  70. 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
+ }