@cjhyy/code-shell-server 0.9.46 → 0.9.48

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 (38) hide show
  1. package/README.md +4 -0
  2. package/dist/hub/auth-store.d.ts +2 -0
  3. package/dist/hub/auth-store.js +26 -7
  4. package/dist/mobile-remote/codex-room-agent.d.ts +0 -6
  5. package/dist/mobile-remote/codex-room-agent.js +32 -32
  6. package/dist/mobile-remote/mobile-chat-turn.d.ts +6 -0
  7. package/dist/mobile-remote/mobile-chat-turn.js +15 -2
  8. package/dist/mobile-remote/mobile-client-event-validator.js +8 -0
  9. package/dist/mobile-remote/mobile-run-dispatch.d.ts +1 -0
  10. package/dist/mobile-remote/mobile-run-dispatch.js +13 -5
  11. package/dist/mobile-remote/mobile-static.d.ts +5 -6
  12. package/dist/mobile-remote/mobile-static.js +13 -14
  13. package/dist/mobile-remote/remote-host-manager.d.ts +2 -0
  14. package/dist/mobile-remote/remote-host-manager.js +27 -5
  15. package/dist/mobile-remote/resident-agent.js +27 -22
  16. package/dist/mobile-remote/room-manager.d.ts +4 -0
  17. package/dist/mobile-remote/room-manager.js +9 -2
  18. package/dist/mobile-remote/room-process-stop.d.ts +11 -0
  19. package/dist/mobile-remote/room-process-stop.js +46 -0
  20. package/dist/panels/agent-task-service.d.ts +2 -1
  21. package/dist/panels/agent-task-service.js +10 -3
  22. package/dist/panels/execution-gate.d.ts +22 -2
  23. package/dist/panels/execution-gate.js +42 -6
  24. package/dist/panels/http.d.ts +1 -0
  25. package/dist/panels/hub-automations.js +1 -0
  26. package/dist/panels/management-http.d.ts +1 -0
  27. package/dist/panels/management.d.ts +3 -0
  28. package/dist/panels/management.js +63 -15
  29. package/dist/panels/package-mutation.d.ts +1 -1
  30. package/dist/panels/package-mutation.js +3 -10
  31. package/dist/panels/resources/uploads.js +15 -6
  32. package/dist/panels/runtime.js +12 -2
  33. package/dist/panels/task-cookies.js +8 -16
  34. package/dist/panels/tool-jobs.js +7 -2
  35. package/dist/serve/headless-server.js +113 -79
  36. package/dist/static-files.d.ts +7 -0
  37. package/dist/static-files.js +114 -0
  38. package/package.json +6 -6
package/README.md CHANGED
@@ -83,6 +83,10 @@ Desktop Web 和 Hub 在注销或显式撤销会话时,会关闭该会话尚未
83
83
  响应,包括正在下载的文件,并停止源文件读取、释放句柄。其他会话的下载不受影响;
84
84
  已经返回接受结果的后台任务仍由原来的任务生命周期管理。注销请求本身仍返回成功。
85
85
 
86
+ Hub 认证每次读取磁盘上的当前会话;只有需要更新活动时间时才获取写锁,并在锁内
87
+ 重新验证撤销与过期状态。Hub 与 Mobile 静态文件按打开时的大小流式发送,`HEAD`
88
+ 只返回元数据;客户端断开会停止读取。首页和 SPA 回退也遵守静态目录边界。
89
+
86
90
  ## code-shell-serve — 个人 Hub Web host
87
91
 
88
92
  在任意机器上把一个 workspace 变成浏览器可访问的 CodeShell:
@@ -40,6 +40,7 @@ export declare class HubAuthStore {
40
40
  }): Promise<HubSessionGrant>;
41
41
  login(input: HubLoginInput, replaceToken?: string): Promise<HubSessionGrant>;
42
42
  authenticate(token: string | undefined): HubSession | null;
43
+ private findSession;
43
44
  listSessions(): HubSession[];
44
45
  /** Offline restore must not resurrect sessions revoked after the snapshot. */
45
46
  revokeAllSessions(): void;
@@ -49,6 +50,7 @@ export declare class HubAuthStore {
49
50
  private toPublic;
50
51
  private mutate;
51
52
  private ensureDirectory;
53
+ private assertDirectory;
52
54
  private read;
53
55
  private write;
54
56
  }
@@ -93,20 +93,36 @@ export class HubAuthStore {
93
93
  authenticate(token) {
94
94
  if (!token || !TOKEN_PATTERN.test(token))
95
95
  return null;
96
+ // Writers publish by atomic rename. Read the committed record on every
97
+ // request, but only contend for the writer lock when activity needs saving.
98
+ this.assertDirectory();
99
+ const tokenHash = hashToken(token);
100
+ const record = this.read();
101
+ const now = this.now();
102
+ const session = this.findSession(record, tokenHash, now);
103
+ if (!session)
104
+ return null;
105
+ if (now - session.lastSeenAt < 60_000)
106
+ return this.toPublic(record, session);
96
107
  return this.mutate((record) => {
97
108
  const now = this.now();
98
- const tokenHash = hashToken(token);
99
- const session = record.sessions.find((row) => safeHashEqual(row.tokenHash, tokenHash));
100
- if (!record.account || !session || session.expiresAt <= now || session.createdAt > now) {
109
+ // Recheck after taking the lock: a peer may have revoked, replaced or
110
+ // refreshed the session since the unlocked read. Never write that snapshot.
111
+ const session = this.findSession(record, tokenHash, now);
112
+ if (!session)
101
113
  return { result: null, changed: false };
102
- }
103
- // Persist activity at most once per minute; validation always reads disk.
104
114
  const changed = now - session.lastSeenAt >= 60_000;
105
115
  if (changed)
106
116
  session.lastSeenAt = now;
107
117
  return { result: this.toPublic(record, session), changed };
108
118
  });
109
119
  }
120
+ findSession(record, tokenHash, now) {
121
+ if (!record.account)
122
+ return null;
123
+ const session = record.sessions.find((row) => safeHashEqual(row.tokenHash, tokenHash));
124
+ return session && session.expiresAt > now && session.createdAt <= now ? session : null;
125
+ }
110
126
  listSessions() {
111
127
  const record = this.read();
112
128
  const now = this.now();
@@ -194,10 +210,13 @@ export class HubAuthStore {
194
210
  ensureDirectory() {
195
211
  const parent = dirname(this.filePath);
196
212
  mkdirSync(parent, { recursive: true, mode: 0o700 });
197
- const metadata = lstatSync(parent);
213
+ this.assertDirectory();
214
+ chmodSync(parent, 0o700);
215
+ }
216
+ assertDirectory() {
217
+ const metadata = lstatSync(dirname(this.filePath));
198
218
  if (!metadata.isDirectory() || metadata.isSymbolicLink())
199
219
  throw new Error("Invalid Hub auth directory");
200
- chmodSync(parent, 0o700);
201
220
  }
202
221
  read(allowMissing = false) {
203
222
  let descriptor;
@@ -76,12 +76,6 @@ export declare class CodexRoomAgent implements RoomAgent {
76
76
  private child?;
77
77
  private threadId?;
78
78
  private started;
79
- /** True once the CURRENT turn emitted a sealing event (turn_end via
80
- * turn.completed, or an error). Reset at the start of each send(). */
81
- private turnSealed;
82
- /** True while stop() is tearing the room down, so the exit handler doesn't
83
- * mistake an intentional SIGTERM for a crash. */
84
- private stopping;
85
79
  constructor(opts: CodexRoomAgentOptions);
86
80
  /** No persistent process to launch; a turn's process is spawned on send(). */
87
81
  start(): void;
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { releaseStdioAfterExit, stopRoomProcess } from "./room-process-stop.js";
2
3
  import { createInterface } from "node:readline";
3
4
  import { pathWithCommonBins } from "./path-bins.js";
4
5
  import { parseCodexJsonLine, extractThreadId } from "./codex-parse.js";
@@ -86,12 +87,6 @@ export class CodexRoomAgent {
86
87
  child;
87
88
  threadId;
88
89
  started = false;
89
- /** True once the CURRENT turn emitted a sealing event (turn_end via
90
- * turn.completed, or an error). Reset at the start of each send(). */
91
- turnSealed = false;
92
- /** True while stop() is tearing the room down, so the exit handler doesn't
93
- * mistake an intentional SIGTERM for a crash. */
94
- stopping = false;
95
90
  constructor(opts) {
96
91
  this.opts = opts;
97
92
  this.threadId = opts.resumeThreadId;
@@ -105,7 +100,7 @@ export class CodexRoomAgent {
105
100
  return false;
106
101
  if (this.child)
107
102
  return false; // a turn is already running; one turn at a time
108
- this.turnSealed = false; // fresh turn — not yet sealed
103
+ let turnSealed = false; // This child owns only its own turn and callbacks.
109
104
  const args = codexArgsForTurn({ mode: this.opts.permissionMode, threadId: this.threadId });
110
105
  const child = spawn(this.opts.command, args, {
111
106
  cwd: this.opts.cwd,
@@ -114,11 +109,13 @@ export class CodexRoomAgent {
114
109
  stdio: ["pipe", "pipe", "pipe"],
115
110
  });
116
111
  this.child = child;
117
- // Feed the user's prompt over stdin (codex exec reads it because argv ends `-`).
118
- child.stdin?.end(text);
112
+ // Settle on `close` so trailing output is read, without waiting on grandchildren.
113
+ releaseStdioAfterExit(child);
119
114
  if (child.stdout) {
120
115
  const rl = createInterface({ input: child.stdout });
121
116
  rl.on("line", (line) => {
117
+ if (this.child !== child)
118
+ return;
122
119
  // Capture the thread id (parseCodexJsonLine intentionally drops
123
120
  // thread.started — it's resume-control info, not a render event) so we
124
121
  // can resume on the next turn and persist it across restarts.
@@ -131,7 +128,7 @@ export class CodexRoomAgent {
131
128
  // turn_end / error are the reducer's sealing events — once one fires
132
129
  // for this turn, the exit handler need not synthesize a fallback.
133
130
  if (ev.type === "turn_end" || ev.type === "error")
134
- this.turnSealed = true;
131
+ turnSealed = true;
135
132
  this.opts.onEvent(ev);
136
133
  }
137
134
  });
@@ -146,17 +143,32 @@ export class CodexRoomAgent {
146
143
  errRl.on("line", (line) => stderrLines.push(line));
147
144
  }
148
145
  child.on("error", (err) => {
146
+ if (this.child !== child)
147
+ return;
148
+ this.child = undefined;
149
149
  const isMissing = err.code === "ENOENT";
150
- this.turnSealed = true; // this error seals the run in the reducer
150
+ turnSealed = true; // this error seals the run in the reducer
151
151
  this.opts.onEvent({
152
152
  type: "error",
153
153
  error: isMissing
154
154
  ? `未找到命令 "${this.opts.command}"。请先安装 Codex CLI 并确保它在 PATH 中。`
155
155
  : err.message,
156
156
  });
157
+ });
158
+ child.stdin?.on("error", (error) => {
159
+ if (this.child !== child)
160
+ return;
157
161
  this.child = undefined;
162
+ stopRoomProcess(child);
163
+ this.opts.onEvent({
164
+ type: "error",
165
+ error: `Codex input pipe closed${error.code ? ` (${error.code})` : ""}`,
166
+ });
158
167
  });
159
- child.on("exit", (code, signal) => {
168
+ child.on("close", (code, signal) => {
169
+ if (this.child !== child)
170
+ return;
171
+ this.child = undefined;
160
172
  // The turn's process is done; the room stays "running" (ready for the next
161
173
  // turn). We do NOT emit `exit` here — that would tear the room down in the
162
174
  // UI after every turn. turn.completed already produced a `turn_end`.
@@ -164,7 +176,7 @@ export class CodexRoomAgent {
164
176
  // is surfaced now so the room shows *why* it failed.
165
177
  const stderrErr = codexStderrError(stderrLines, code);
166
178
  if (stderrErr) {
167
- this.turnSealed = true;
179
+ turnSealed = true;
168
180
  this.opts.onEvent({ type: "error", error: stderrErr });
169
181
  }
170
182
  // FALLBACK SEAL (#6): if the process died without ever sealing the turn
@@ -174,14 +186,16 @@ export class CodexRoomAgent {
174
186
  const seal = sealEventOnExit({
175
187
  code,
176
188
  signal,
177
- turnSealed: this.turnSealed,
178
- stopping: this.stopping,
189
+ turnSealed,
190
+ stopping: false,
179
191
  hasStderrError: stderrErr != null,
180
192
  });
181
193
  if (seal)
182
194
  this.opts.onEvent(seal);
183
- this.child = undefined;
184
195
  });
196
+ // Register stdin's error owner before writing: early CLI exits fail writes
197
+ // asynchronously even when end() itself returns successfully.
198
+ child.stdin?.end(text);
185
199
  return true;
186
200
  }
187
201
  /** The room is "running" once started — independent of whether a turn's
@@ -191,23 +205,9 @@ export class CodexRoomAgent {
191
205
  }
192
206
  stop() {
193
207
  this.started = false;
194
- this.stopping = true; // the upcoming SIGTERM exit is intentional, not a crash
195
208
  const child = this.child;
196
209
  this.child = undefined;
197
- if (!child?.pid || child.pid <= 1) {
198
- child?.kill("SIGTERM");
199
- return;
200
- }
201
- try {
202
- process.kill(-child.pid, "SIGTERM");
203
- }
204
- catch {
205
- try {
206
- child.kill("SIGTERM");
207
- }
208
- catch {
209
- /* already gone */
210
- }
211
- }
210
+ if (child)
211
+ stopRoomProcess(child);
212
212
  }
213
213
  }
@@ -33,6 +33,12 @@ export type DispatchMobileChatTurnResult = {
33
33
  } | {
34
34
  ok: false;
35
35
  message: string;
36
+ uncertain?: false;
37
+ } | {
38
+ ok: false;
39
+ message: string;
40
+ uncertain: true;
41
+ clientMessageId: string;
36
42
  };
37
43
  /** Transaction boundary for mobile chat: workspace → canonical stage → worker ack → finalize. */
38
44
  export declare function dispatchMobileChatTurn(input: DispatchMobileChatTurnInput): Promise<DispatchMobileChatTurnResult>;
@@ -63,11 +63,24 @@ export async function dispatchMobileChatTurn(input) {
63
63
  : await injectMobileRunAndAwaitAcceptance(input.bridge, request, input.meta);
64
64
  }
65
65
  catch {
66
- acceptance = { ok: false, message: "The selected runtime rejected the submission" };
66
+ acceptance = {
67
+ ok: false,
68
+ uncertain: true,
69
+ message: "The selected runtime did not confirm the submission",
70
+ };
67
71
  }
68
72
  if (!acceptance.ok) {
73
+ // Worker input refers to the canonical staged files, not these temporary
74
+ // upload spools. Keep the staged files for a possibly accepted run and
75
+ // release only the claims so the same intent can retry its attachments.
76
+ // A late acceptance may never be observed, so uncertain files also need
77
+ // sent retention rather than expiring as abandoned drafts after one day.
78
+ if (acceptance.uncertain)
79
+ await (input.markSent ?? markAttachmentsSent)(cwd, input.sessionId, materialized.metas).catch(() => undefined);
69
80
  await settleClaims(input.uploads, input.deviceId, materialized.claims, "release");
70
- return { ok: false, message: acceptance.message };
81
+ return acceptance.uncertain
82
+ ? { ok: false, message: acceptance.message, uncertain: true, clientMessageId }
83
+ : { ok: false, message: acceptance.message };
71
84
  }
72
85
  await (input.markSent ?? markAttachmentsSent)(cwd, input.sessionId, materialized.metas).catch(() => undefined);
73
86
  await settleClaims(input.uploads, input.deviceId, materialized.claims, "finalize");
@@ -52,6 +52,14 @@ export function parseMobileClientEvent(value) {
52
52
  case "session.recovery.cancel":
53
53
  valid = true;
54
54
  break;
55
+ case "session.legacyBase":
56
+ valid =
57
+ boundedString(event.sessionId, 128) &&
58
+ boundedString(event.recoveryId, 128) &&
59
+ boundedString(event.requestId, 128) &&
60
+ ((boundedString(event.throughEventId, 512) && event.cursor === undefined) ||
61
+ (boundedString(event.cursor, 2048) && event.throughEventId === undefined));
62
+ break;
55
63
  case "session.outputJournal":
56
64
  valid =
57
65
  boundedString(event.sessionId, 128) &&
@@ -13,6 +13,7 @@ export type MobileRunAcceptance = {
13
13
  ok: false;
14
14
  message: string;
15
15
  code?: number;
16
+ uncertain?: true;
16
17
  };
17
18
  /** Inject agent/run and wait for its request-correlated validation/queue acknowledgement. */
18
19
  export declare function injectMobileRunAndAwaitAcceptance(bridge: MobileRunBridge, request: MobileRunRequest, meta: WorkerFrameMeta, timeoutMs?: number): Promise<MobileRunAcceptance>;
@@ -2,7 +2,6 @@
2
2
  export function injectMobileRunAndAwaitAcceptance(bridge, request, meta, timeoutMs = 5_000) {
3
3
  return new Promise((resolve) => {
4
4
  let settled = false;
5
- let timer;
6
5
  const finish = (result) => {
7
6
  if (settled)
8
7
  return;
@@ -40,17 +39,26 @@ export function injectMobileRunAndAwaitAcceptance(bridge, request, meta, timeout
40
39
  // Not JSON or not part of this request.
41
40
  }
42
41
  });
43
- timer = setTimeout(() => finish({ ok: false, message: "worker did not acknowledge the run" }), timeoutMs);
42
+ const timer = setTimeout(() => finish({ ok: false, uncertain: true, message: "worker did not acknowledge the run" }), timeoutMs);
43
+ let injecting = false;
44
44
  try {
45
- bridge.injectWorkerMessage(JSON.stringify({
45
+ const line = JSON.stringify({
46
46
  jsonrpc: "2.0",
47
47
  id: request.id,
48
48
  method: "agent/run",
49
49
  params: request.params,
50
- }), meta);
50
+ });
51
+ injecting = true;
52
+ bridge.injectWorkerMessage(line, meta);
51
53
  }
52
54
  catch (error) {
53
- finish({ ok: false, message: error instanceof Error ? error.message : String(error) });
55
+ // An injection can fail after queuing bytes. Only serialization failure
56
+ // proves that this request never reached the transport.
57
+ finish({
58
+ ok: false,
59
+ message: error instanceof Error ? error.message : String(error),
60
+ ...(injecting ? { uncertain: true } : {}),
61
+ });
54
62
  }
55
63
  });
56
64
  }
@@ -38,12 +38,11 @@ export declare function mobileEntryRedirect(reqUrl: string): string | null;
38
38
  * can see through it. Guarding that would need inode/device policy, and anyone
39
39
  * able to hardlink into the served root can already write files there.
40
40
  *
41
- * The returned path is the REQUESTED one, not the realpath: callers stream it
42
- * directly and the resolved-name contract is relied on elsewhere (on macOS the
43
- * realpath of a /var root is /private/var, which would change every result).
44
- * That leaves the same synchronous stat-then-read window this module always
45
- * had; closing it properly needs an open-then-fstat rework of both callers,
46
- * which is out of scope for this fix. */
41
+ * The returned path is the REQUESTED one, not the realpath: the resolved-name
42
+ * contract is relied on elsewhere (on macOS the realpath of a /var root is
43
+ * /private/var, which would change every result). This is only a routing check;
44
+ * both HTTP callers open and revalidate the selected file through
45
+ * streamStaticFile before returning bytes. */
47
46
  export declare function resolveSafe(root: string, subPath: string): string | null;
48
47
  export interface MobileStaticOptions {
49
48
  /** Absolute path to the built mobile app (out/mobile). */
@@ -1,7 +1,7 @@
1
- import { createReadStream, existsSync, realpathSync, statSync } from "node:fs";
1
+ import { existsSync, realpathSync, statSync } from "node:fs";
2
2
  import { isAbsolute, join, normalize, relative, resolve, sep, extname } from "node:path";
3
3
  import { request as httpRequest } from "node:http";
4
- import { contentTypeFor } from "../static-files.js";
4
+ import { contentTypeFor, streamStaticFile } from "../static-files.js";
5
5
  /**
6
6
  * Serves the built mobile web app (out/mobile) as static assets for the
7
7
  * `/mobile` route family, replacing the old inline `mobileRemoteHtml()` string.
@@ -61,12 +61,11 @@ function isContained(root, target) {
61
61
  * can see through it. Guarding that would need inode/device policy, and anyone
62
62
  * able to hardlink into the served root can already write files there.
63
63
  *
64
- * The returned path is the REQUESTED one, not the realpath: callers stream it
65
- * directly and the resolved-name contract is relied on elsewhere (on macOS the
66
- * realpath of a /var root is /private/var, which would change every result).
67
- * That leaves the same synchronous stat-then-read window this module always
68
- * had; closing it properly needs an open-then-fstat rework of both callers,
69
- * which is out of scope for this fix. */
64
+ * The returned path is the REQUESTED one, not the realpath: the resolved-name
65
+ * contract is relied on elsewhere (on macOS the realpath of a /var root is
66
+ * /private/var, which would change every result). This is only a routing check;
67
+ * both HTTP callers open and revalidate the selected file through
68
+ * streamStaticFile before returning bytes. */
70
69
  export function resolveSafe(root, subPath) {
71
70
  const rootResolved = resolve(root);
72
71
  const rel = normalize(subPath || "index.html");
@@ -77,11 +76,11 @@ export function resolveSafe(root, subPath) {
77
76
  const full = resolve(join(rootResolved, rel));
78
77
  if (!isContained(rootResolved, full))
79
78
  return null;
80
- if (!existsSync(full) || !statSync(full).isFile())
81
- return null;
82
79
  // realpathSync throws on a dangling link or a race-deleted file; a missing
83
80
  // target is a 404 through the normal contract, never a 500.
84
81
  try {
82
+ if (!existsSync(full) || !statSync(full).isFile())
83
+ return null;
85
84
  if (!isContained(realpathSync(rootResolved), realpathSync(full)))
86
85
  return null;
87
86
  }
@@ -143,15 +142,15 @@ export function serveMobile(req, res, opts) {
143
142
  // stale index.html would reference deleted assets → 404 → blank page until a
144
143
  // hard refresh.
145
144
  const isHtml = type.startsWith("text/html");
146
- res.writeHead(200, {
147
- "content-type": type,
148
- "cache-control": isHtml
145
+ void streamStaticFile(req, res, {
146
+ rootDir: opts.rootDir,
147
+ filePath: file,
148
+ cacheControl: isHtml
149
149
  ? "no-store"
150
150
  : sub.startsWith("assets/")
151
151
  ? "public, max-age=31536000, immutable"
152
152
  : "no-cache",
153
153
  });
154
- createReadStream(file).pipe(res);
155
154
  }
156
155
  /** Dev-only: forward the request to the mobile vite dev server. The mobile vite
157
156
  * config sets base "/mobile/", so vite serves its HMR client, module graph, and
@@ -37,6 +37,8 @@ export interface RemoteHostStarted {
37
37
  export interface RemoteHostManagerOptions {
38
38
  devices: TrustedDeviceStore;
39
39
  outputJournal?: boolean;
40
+ /** Enable only when the attached selected-session host implements legacy base pages. */
41
+ legacyBasePages?: boolean;
40
42
  onClientEvent: (event: unknown, ws: WebSocket) => void | Promise<void>;
41
43
  /**
42
44
  * Absolute path to the built mobile app (out/mobile). Defaults to the
@@ -126,6 +126,16 @@ export class RemoteHostManager extends EventEmitter {
126
126
  const gate = this.passcode;
127
127
  this.opts.webApi?.start();
128
128
  const server = createServer((req, res) => {
129
+ let pathname;
130
+ try {
131
+ pathname = new URL(req.url ?? "/", "http://localhost").pathname;
132
+ }
133
+ catch {
134
+ res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
135
+ res.end("invalid request target");
136
+ req.resume();
137
+ return;
138
+ }
129
139
  // Tunnel mode: a passcode gate sits in front of EVERY route. The gate
130
140
  // either allows (returns true) or writes its own 401/403 challenge.
131
141
  if (gate && !gate.gate(req, res))
@@ -158,9 +168,7 @@ export class RemoteHostManager extends EventEmitter {
158
168
  });
159
169
  return;
160
170
  }
161
- const uploadPath = req.url
162
- ? new URL(req.url, "http://localhost").pathname.match(/^\/api\/mobile\/uploads\/([A-Za-z0-9_-]+)$/)
163
- : null;
171
+ const uploadPath = pathname.match(/^\/api\/mobile\/uploads\/([A-Za-z0-9_-]+)$/);
164
172
  if (uploadPath && this.opts.uploads) {
165
173
  void this.opts.uploads.acceptPut(uploadPath[1], req, res);
166
174
  return;
@@ -411,7 +419,14 @@ export class RemoteHostManager extends EventEmitter {
411
419
  return {
412
420
  type: "pair.ok",
413
421
  device,
414
- ...(this.opts.outputJournal ? { capabilities: { outputJournal: 1 } } : {}),
422
+ ...(this.opts.outputJournal
423
+ ? {
424
+ capabilities: {
425
+ outputJournal: 1,
426
+ ...(this.opts.legacyBasePages ? { legacyBasePages: 1 } : {}),
427
+ },
428
+ }
429
+ : {}),
415
430
  };
416
431
  }
417
432
  if (event.type === "auth.device") {
@@ -427,7 +442,14 @@ export class RemoteHostManager extends EventEmitter {
427
442
  return {
428
443
  type: "auth.ok",
429
444
  device,
430
- ...(this.opts.outputJournal ? { capabilities: { outputJournal: 1 } } : {}),
445
+ ...(this.opts.outputJournal
446
+ ? {
447
+ capabilities: {
448
+ outputJournal: 1,
449
+ ...(this.opts.legacyBasePages ? { legacyBasePages: 1 } : {}),
450
+ },
451
+ }
452
+ : {}),
431
453
  };
432
454
  }
433
455
  return undefined;
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { releaseStdioAfterExit, stopRoomProcess } from "./room-process-stop.js";
2
3
  import { createInterface } from "node:readline";
3
4
  import { pathWithCommonBins } from "./path-bins.js";
4
5
  /**
@@ -148,14 +149,20 @@ export class ResidentAgentProcess {
148
149
  stdio: ["pipe", "pipe", "pipe"],
149
150
  });
150
151
  this.child = child;
152
+ // Settle on `close` so trailing output is read, without waiting on grandchildren.
153
+ releaseStdioAfterExit(child);
151
154
  if (child.stdout) {
152
155
  const rl = createInterface({ input: child.stdout });
153
156
  rl.on("line", (line) => {
157
+ if (this.child !== child)
158
+ return;
154
159
  for (const ev of parseStreamJsonLine(line))
155
160
  this.opts.onEvent(ev);
156
161
  });
157
162
  }
158
163
  child.stderr?.on("data", (chunk) => {
164
+ if (this.child !== child)
165
+ return;
159
166
  // claude prints non-fatal warnings to stderr; surface as error events
160
167
  // only if they look like real errors (cheap heuristic).
161
168
  const text = String(chunk);
@@ -167,11 +174,14 @@ export class ResidentAgentProcess {
167
174
  if (exited)
168
175
  return;
169
176
  exited = true;
170
- if (this.child === child)
171
- this.child = undefined;
177
+ if (this.child !== child)
178
+ return;
179
+ this.child = undefined;
172
180
  this.opts.onEvent({ type: "exit", code, signal });
173
181
  };
174
182
  child.on("error", (err) => {
183
+ if (this.child !== child)
184
+ return;
175
185
  const isMissing = err.code === "ENOENT";
176
186
  this.opts.onEvent({
177
187
  type: "error",
@@ -184,7 +194,19 @@ export class ResidentAgentProcess {
184
194
  if (child.pid === undefined)
185
195
  finish(null, null);
186
196
  });
187
- child.on("exit", (code, signal) => finish(code, signal));
197
+ child.stdin?.on("error", (error) => {
198
+ if (this.child !== child)
199
+ return;
200
+ // Pipe errors are emitted by stdin, separately from ChildProcess.error.
201
+ // Retire this room immediately; its eventual exit must not close a retry.
202
+ stopRoomProcess(child);
203
+ this.opts.onEvent({
204
+ type: "error",
205
+ error: `Claude Code input pipe closed${error.code ? ` (${error.code})` : ""}`,
206
+ });
207
+ finish(null, null);
208
+ });
209
+ child.on("close", (code, signal) => finish(code, signal));
188
210
  }
189
211
  /** Feed one user turn. Returns false if the process isn't running. */
190
212
  send(text) {
@@ -206,24 +228,7 @@ export class ResidentAgentProcess {
206
228
  stop() {
207
229
  const child = this.child;
208
230
  this.child = undefined;
209
- // Guard pid > 1: process.kill(-1) would SIGTERM every process the user can
210
- // reach, and -0 the caller's own group. `!child?.pid` already catches
211
- // undefined/0; the `> 1` also rules out the (theoretical) pid-1 case so this
212
- // negative-pid group kill is consistent with killProcessGroup's guard.
213
- if (!child?.pid || child.pid <= 1) {
214
- child?.kill("SIGTERM");
215
- return;
216
- }
217
- try {
218
- process.kill(-child.pid, "SIGTERM");
219
- }
220
- catch {
221
- try {
222
- child.kill("SIGTERM");
223
- }
224
- catch {
225
- /* already gone */
226
- }
227
- }
231
+ if (child)
232
+ stopRoomProcess(child);
228
233
  }
229
234
  }
@@ -119,6 +119,10 @@ export interface RoomManagerOptions {
119
119
  * Returning null means the transcript is absent. The returned identity is
120
120
  * checked again here so a stale or mismatched locator fails closed. */
121
121
  resolveLinkedSession?: (target: Readonly<LinkedSessionTarget>) => LinkedSessionTarget | null | undefined;
122
+ /** Throws while a background task (DriveAgent) may still be writing this
123
+ * external session. Checked before any resident agent can start on it, so
124
+ * desktop and phone entry points share one ownership gate. */
125
+ assertExternalSessionAvailable?: (externalSessionId: string, kind: RoomKind) => void;
122
126
  /** Called whenever a room gains a new persisted message (push to phone). */
123
127
  onMessage: (roomId: string, msg: RoomMessage) => void;
124
128
  /** Called when a room's resident agent requests tool-use approval. For
@@ -777,6 +777,8 @@ export class RoomManager {
777
777
  * so a given claude session maps to exactly one room. Returns the room id.
778
778
  */
779
779
  openForSession(claudeSessionId, cwd, mode, kind = "claude-code") {
780
+ if (claudeSessionId)
781
+ this.opts.assertExternalSessionAvailable?.(claudeSessionId, kind);
780
782
  // Reuse must match BOTH id and kind: a codex thread id and a claude session
781
783
  // id live in the same `claudeSessionId` field, so a bare id match could
782
784
  // otherwise hand back a claude room when a codex room was asked for.
@@ -851,6 +853,7 @@ export class RoomManager {
851
853
  * silently start a different session. */
852
854
  takeOverLinkedSession(roomId, externalSessionId, cwd, kind) {
853
855
  const target = this.requireLinkedSessionTarget({ externalSessionId, cwd, kind });
856
+ this.opts.assertExternalSessionAvailable?.(target.externalSessionId, target.kind);
854
857
  const meta = this.getRoom(roomId);
855
858
  if (!meta ||
856
859
  meta.claudeSessionId !== target.externalSessionId ||
@@ -890,8 +893,10 @@ export class RoomManager {
890
893
  catch {
891
894
  throw new Error("linked session transcript is unavailable");
892
895
  }
893
- if (!resolvedTarget ||
894
- typeof resolvedTarget.externalSessionId !== "string" ||
896
+ if (!resolvedTarget) {
897
+ throw new Error("linked session transcript was not found for the requested session");
898
+ }
899
+ if (typeof resolvedTarget.externalSessionId !== "string" ||
895
900
  typeof resolvedTarget.cwd !== "string" ||
896
901
  resolvedTarget.externalSessionId !== target.externalSessionId ||
897
902
  resolvedTarget.kind !== target.kind ||
@@ -967,6 +972,8 @@ export class RoomManager {
967
972
  if (meta.linkedSessionMode === "observe-only")
968
973
  return { status: "observing" };
969
974
  if (!this.agents.has(id)) {
975
+ if (meta.claudeSessionId)
976
+ this.opts.assertExternalSessionAvailable?.(meta.claudeSessionId, meta.kind);
970
977
  let agent = undefined;
971
978
  agent = this.opts.createAgent(meta, (event) => {
972
979
  // stop() sends a signal; an old process can still drain output or emit
@@ -0,0 +1,11 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+ /** Retire an unusable room transport without leaving an unresponsive CLI alive. */
3
+ export declare function stopRoomProcess(child: ChildProcess): void;
4
+ /**
5
+ * `close` waits for every holder of the child's stdout/stderr. A grandchild the
6
+ * CLI backgrounded with inherited stdio (a dev server, say) keeps them open
7
+ * after the CLI exits, so the room would never settle. Once the CLI has exited
8
+ * and its buffered output had a grace period to drain, release the pipes;
9
+ * the grandchild itself is left running.
10
+ */
11
+ export declare function releaseStdioAfterExit(child: ChildProcess, graceMs?: number): void;