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