@cjhyy/code-shell-server 0.9.44 → 0.9.47

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 (94) hide show
  1. package/dist/attachment-service.d.ts +2 -0
  2. package/dist/attachment-service.js +40 -27
  3. package/dist/bin/code-shell-link-cleanup.d.ts +2 -0
  4. package/dist/bin/code-shell-link-cleanup.js +3 -0
  5. package/dist/desktop-web/http-api.d.ts +3 -0
  6. package/dist/desktop-web/http-api.js +16 -1
  7. package/dist/hub/configuration.d.ts +6 -3
  8. package/dist/hub/configuration.js +19 -6
  9. package/dist/hub/mcp-probe.d.ts +4 -24
  10. package/dist/hub/mcp-probe.js +18 -389
  11. package/dist/hub/model-probe.d.ts +2 -20
  12. package/dist/hub/model-probe.js +2 -193
  13. package/dist/hub/run-replay.d.ts +7 -1
  14. package/dist/hub/run-replay.js +10 -2
  15. package/dist/hub/session-management.js +18 -53
  16. package/dist/hub/skills-management.js +6 -8
  17. package/dist/hub/uploads.d.ts +10 -0
  18. package/dist/hub/uploads.js +107 -15
  19. package/dist/index.desktop-web.d.ts +1 -0
  20. package/dist/index.desktop-web.js +1 -0
  21. package/dist/index.links.d.ts +3 -0
  22. package/dist/index.links.js +2 -0
  23. package/dist/index.panels.d.ts +1 -0
  24. package/dist/index.panels.js +1 -0
  25. package/dist/index.serve.d.ts +1 -0
  26. package/dist/index.serve.js +1 -0
  27. package/dist/index.storage.d.ts +1 -0
  28. package/dist/index.storage.js +1 -0
  29. package/dist/links/catalog.js +2 -0
  30. package/dist/links/diagnostics.d.ts +60 -0
  31. package/dist/links/diagnostics.js +184 -0
  32. package/dist/links/http.d.ts +10 -8
  33. package/dist/links/http.js +144 -125
  34. package/dist/links/legacy-cleanup-cli.d.ts +3 -0
  35. package/dist/links/legacy-cleanup-cli.js +64 -0
  36. package/dist/links/providers/defaults.d.ts +5 -0
  37. package/dist/links/providers/defaults.js +782 -0
  38. package/dist/links/providers/index.d.ts +7 -0
  39. package/dist/links/providers/index.js +22 -0
  40. package/dist/links/remote-catalog.d.ts +10 -3
  41. package/dist/links/remote-catalog.js +76 -29
  42. package/dist/links/runtime/operation.d.ts +9 -0
  43. package/dist/links/runtime/operation.js +16 -0
  44. package/dist/links/service.d.ts +18 -2
  45. package/dist/links/service.js +593 -62
  46. package/dist/links/types.d.ts +21 -1
  47. package/dist/mobile-remote/codex-parse.d.ts +3 -1
  48. package/dist/mobile-remote/codex-parse.js +45 -11
  49. package/dist/mobile-remote/codex-room-agent.d.ts +0 -6
  50. package/dist/mobile-remote/codex-room-agent.js +30 -32
  51. package/dist/mobile-remote/mobile-chat-turn.d.ts +1 -0
  52. package/dist/mobile-remote/mobile-chat-turn.js +3 -0
  53. package/dist/mobile-remote/mobile-client-event-validator.js +21 -0
  54. package/dist/mobile-remote/mobile-history.d.ts +10 -1
  55. package/dist/mobile-remote/mobile-history.js +24 -16
  56. package/dist/mobile-remote/remote-host-manager.d.ts +10 -0
  57. package/dist/mobile-remote/remote-host-manager.js +50 -13
  58. package/dist/mobile-remote/resident-agent.js +38 -22
  59. package/dist/mobile-remote/room-manager.d.ts +23 -13
  60. package/dist/mobile-remote/room-manager.js +106 -22
  61. package/dist/mobile-remote/room-model-tracing.d.ts +18 -0
  62. package/dist/mobile-remote/room-model-tracing.js +196 -0
  63. package/dist/mobile-remote/room-process-stop.d.ts +3 -0
  64. package/dist/mobile-remote/room-process-stop.js +28 -0
  65. package/dist/model-trace-history.d.ts +29 -0
  66. package/dist/model-trace-history.js +198 -0
  67. package/dist/model-trace-http.d.ts +14 -0
  68. package/dist/model-trace-http.js +105 -0
  69. package/dist/panels/agent-task-host.js +1 -0
  70. package/dist/panels/agent-task-service.d.ts +2 -1
  71. package/dist/panels/agent-task-service.js +10 -3
  72. package/dist/panels/directory-bookmarks.js +62 -29
  73. package/dist/panels/execution-gate.d.ts +19 -1
  74. package/dist/panels/execution-gate.js +33 -5
  75. package/dist/panels/http.d.ts +1 -0
  76. package/dist/panels/hub-automations.js +1 -0
  77. package/dist/panels/hub-binding.js +5 -5
  78. package/dist/panels/model-trace-channel.d.ts +21 -0
  79. package/dist/panels/model-trace-channel.js +110 -0
  80. package/dist/panels/model-traces.d.ts +39 -0
  81. package/dist/panels/model-traces.js +222 -0
  82. package/dist/panels/process-service.d.ts +12 -2
  83. package/dist/panels/process-service.js +49 -6
  84. package/dist/panels/resources/uploads.js +15 -6
  85. package/dist/panels/runtime.d.ts +1 -0
  86. package/dist/panels/runtime.js +66 -8
  87. package/dist/panels/task-cookies.js +8 -16
  88. package/dist/panels/tool-jobs.d.ts +2 -0
  89. package/dist/panels/tool-jobs.js +28 -3
  90. package/dist/serve/cli.js +8 -1
  91. package/dist/serve/headless-server.js +68 -14
  92. package/dist/sessions-service.js +17 -27
  93. package/dist/worker-bridge-core.js +11 -0
  94. package/package.json +8 -7
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { stopRoomProcess } from "./room-process-stop.js";
2
3
  import { createInterface } from "node:readline";
3
4
  import { pathWithCommonBins } from "./path-bins.js";
4
5
  /**
@@ -49,6 +50,9 @@ export function parseStreamJsonLine(line) {
49
50
  catch {
50
51
  return [];
51
52
  }
53
+ // A valid but non-record line (e.g. `null`) carries no event.
54
+ if (!msg || typeof msg !== "object" || Array.isArray(msg))
55
+ return [];
52
56
  const out = [];
53
57
  if (msg.type === "control_request" && msg.request?.subtype === "can_use_tool") {
54
58
  return [
@@ -148,18 +152,34 @@ export class ResidentAgentProcess {
148
152
  if (child.stdout) {
149
153
  const rl = createInterface({ input: child.stdout });
150
154
  rl.on("line", (line) => {
155
+ if (this.child !== child)
156
+ return;
151
157
  for (const ev of parseStreamJsonLine(line))
152
158
  this.opts.onEvent(ev);
153
159
  });
154
160
  }
155
161
  child.stderr?.on("data", (chunk) => {
162
+ if (this.child !== child)
163
+ return;
156
164
  // claude prints non-fatal warnings to stderr; surface as error events
157
165
  // only if they look like real errors (cheap heuristic).
158
166
  const text = String(chunk);
159
167
  if (/error/i.test(text))
160
168
  this.opts.onEvent({ type: "error", error: text.trim().slice(0, 400) });
161
169
  });
170
+ let exited = false;
171
+ const finish = (code, signal) => {
172
+ if (exited)
173
+ return;
174
+ exited = true;
175
+ if (this.child !== child)
176
+ return;
177
+ this.child = undefined;
178
+ this.opts.onEvent({ type: "exit", code, signal });
179
+ };
162
180
  child.on("error", (err) => {
181
+ if (this.child !== child)
182
+ return;
163
183
  const isMissing = err.code === "ENOENT";
164
184
  this.opts.onEvent({
165
185
  type: "error",
@@ -167,11 +187,24 @@ export class ResidentAgentProcess {
167
187
  ? `未找到命令 "${this.opts.command}"。请先安装 Claude Code CLI 并确保它在 PATH 中。`
168
188
  : err.message,
169
189
  });
190
+ // A failed spawn (ENOENT/EACCES/missing cwd) emits `error` without
191
+ // `exit`. Retire that child so the room can start a replacement.
192
+ if (child.pid === undefined)
193
+ finish(null, null);
170
194
  });
171
- child.on("exit", (code, signal) => {
172
- this.child = undefined;
173
- this.opts.onEvent({ type: "exit", code, signal });
195
+ child.stdin?.on("error", (error) => {
196
+ if (this.child !== child)
197
+ return;
198
+ // Pipe errors are emitted by stdin, separately from ChildProcess.error.
199
+ // Retire this room immediately; its eventual exit must not close a retry.
200
+ stopRoomProcess(child);
201
+ this.opts.onEvent({
202
+ type: "error",
203
+ error: `Claude Code input pipe closed${error.code ? ` (${error.code})` : ""}`,
204
+ });
205
+ finish(null, null);
174
206
  });
207
+ child.on("close", (code, signal) => finish(code, signal));
175
208
  }
176
209
  /** Feed one user turn. Returns false if the process isn't running. */
177
210
  send(text) {
@@ -193,24 +226,7 @@ export class ResidentAgentProcess {
193
226
  stop() {
194
227
  const child = this.child;
195
228
  this.child = undefined;
196
- // Guard pid > 1: process.kill(-1) would SIGTERM every process the user can
197
- // reach, and -0 the caller's own group. `!child?.pid` already catches
198
- // undefined/0; the `> 1` also rules out the (theoretical) pid-1 case so this
199
- // negative-pid group kill is consistent with killProcessGroup's guard.
200
- if (!child?.pid || child.pid <= 1) {
201
- child?.kill("SIGTERM");
202
- return;
203
- }
204
- try {
205
- process.kill(-child.pid, "SIGTERM");
206
- }
207
- catch {
208
- try {
209
- child.kill("SIGTERM");
210
- }
211
- catch {
212
- /* already gone */
213
- }
214
- }
229
+ if (child)
230
+ stopRoomProcess(child);
215
231
  }
216
232
  }
@@ -14,18 +14,19 @@ export type RoomPermissionMode = "default" | "acceptEdits" | "bypassPermissions"
14
14
  * `answersByQuestion` maps question text → answer string.
15
15
  */
16
16
  export declare function buildAskUserUpdatedInput(input: unknown, answersByQuestion: Record<string, string>): Record<string, unknown>;
17
- /**
18
- * Parse an AskUserQuestion input into the first question's prompt/header/options
19
- * so the UI can render a choice card. The room UI answers one question at a time
20
- * (the first); returns undefined for a non-AskUser / malformed input (no
21
- * questions), which the caller treats as "fall back to auto-allow".
22
- */
23
- export declare function askUserPrompt(input: unknown): {
17
+ type AskUserQuestionPrompt = {
24
18
  question: string;
25
19
  header?: string;
26
20
  options: string[];
27
21
  multiSelect: boolean;
28
- } | undefined;
22
+ };
23
+ /**
24
+ * Parse an AskUserQuestion input into the first question's prompt/header/options
25
+ * so the UI can render a choice card. Returns undefined for a non-AskUser /
26
+ * malformed input (no questions), which the caller treats as "fall back to
27
+ * auto-allow".
28
+ */
29
+ export declare function askUserPrompt(input: unknown): AskUserQuestionPrompt | undefined;
29
30
  /** Which external CLI backs the room. "claude-code" drives `claude` (long-lived
30
31
  * stdin process with per-tool approval); "codex" drives `codex exec` (one
31
32
  * process per turn, resumed by thread id, no per-tool approval — the sandbox
@@ -109,6 +110,11 @@ export interface RoomAgentFactory {
109
110
  export interface RoomManagerOptions {
110
111
  rootDir: string;
111
112
  createAgent: RoomAgentFactory;
113
+ /** Explicit Host-owned model history target; never derived from room/client input. */
114
+ modelTracing?: {
115
+ storageDir: string;
116
+ parameters?: (room: Readonly<RoomMeta>) => Record<string, unknown>;
117
+ };
112
118
  /** Locate an already-running external CLI transcript without starting a CLI.
113
119
  * Returning null means the transcript is absent. The returned identity is
114
120
  * checked again here so a stale or mismatched locator fails closed. */
@@ -125,11 +131,8 @@ export interface RoomManagerOptions {
125
131
  displayName?: string;
126
132
  input: unknown;
127
133
  description?: string;
128
- askUser?: {
129
- question: string;
130
- header?: string;
131
- options: string[];
132
- multiSelect: boolean;
134
+ askUser?: AskUserQuestionPrompt & {
135
+ questions?: AskUserQuestionPrompt[];
133
136
  };
134
137
  }) => void;
135
138
  /** Called after a room's resident process exits or the room is explicitly
@@ -146,6 +149,10 @@ export interface RoomManagerOptions {
146
149
  export declare class RoomManager {
147
150
  private readonly opts;
148
151
  private agents;
152
+ /** Permission mode each live agent was spawned with. The mode is a spawn-time
153
+ * CLI argument, so room metadata alone cannot prove what a process runs. */
154
+ private agentModes;
155
+ private modelTraces;
149
156
  /** Rooms whose visible output is currently sourced from the external CLI's
150
157
  * transcript tail. While present, the resident agent's stdout mirror is
151
158
  * suppressed so the same CLI output is not pushed twice. */
@@ -280,6 +287,7 @@ export declare class RoomManager {
280
287
  behavior: "allow";
281
288
  updatedInput?: unknown;
282
289
  answer?: string;
290
+ answers?: Record<string, string>;
283
291
  } | {
284
292
  behavior: "deny";
285
293
  message: string;
@@ -289,6 +297,8 @@ export declare class RoomManager {
289
297
  open(id: string): {
290
298
  status: RoomOpenStatus;
291
299
  };
300
+ /** Stop a live agent whose spawn-time permission mode differs from `mode`. */
301
+ private retireAgentUnlessMode;
292
302
  private onAgentEvent;
293
303
  /** Post a user message: persist it, ensure agent running, feed it. */
294
304
  send(id: string, text: string, attachments?: InputAttachmentMeta[]): boolean;
@@ -2,6 +2,7 @@ import { chmodSync, closeSync, existsSync, fchmodSync, fstatSync, fsyncSync, lst
2
2
  import { readRoomHistoryPage, scanRoomHistory, } from "./room-history.js";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { createRoomModelTrace } from "./room-model-tracing.js";
5
6
  /**
6
7
  * The exact shape createRoom() generates: `room_<base36>_<base36>`. Anything
7
8
  * else — path separators, `..`, NUL, empty — is rejected before it can reach a
@@ -58,6 +59,26 @@ function readBoundedUtf8File(path, maxBytes) {
58
59
  closeSync(fd);
59
60
  }
60
61
  }
62
+ /** Room metadata names are capped at this many UTF-16 units by parseRoomMeta. */
63
+ const MAX_ROOM_NAME_LENGTH = 512;
64
+ /**
65
+ * Default room name: the cwd's last path segment. Windows drive and UNC paths
66
+ * also split on backslashes. Bounded to the metadata name limit, without
67
+ * leaving half of a surrogate pair, so the reader accepts what we write.
68
+ */
69
+ function defaultRoomName(cwd) {
70
+ const windows = /^(?:[A-Za-z]:[\\/]|\\\\)/.test(cwd);
71
+ const segment = cwd
72
+ .split(windows ? /[\\/]/ : "/")
73
+ .filter(Boolean)
74
+ .pop() ?? "room";
75
+ if (segment.length <= MAX_ROOM_NAME_LENGTH)
76
+ return segment;
77
+ const end = /[\uD800-\uDBFF]/.test(segment[MAX_ROOM_NAME_LENGTH - 1])
78
+ ? MAX_ROOM_NAME_LENGTH - 1
79
+ : MAX_ROOM_NAME_LENGTH;
80
+ return segment.slice(0, end);
81
+ }
61
82
  function parseRoomMeta(value, expectedId) {
62
83
  if (!value || typeof value !== "object" || Array.isArray(value))
63
84
  return undefined;
@@ -67,7 +88,7 @@ function parseRoomMeta(value, expectedId) {
67
88
  (expectedId !== undefined && raw.id !== expectedId) ||
68
89
  typeof raw.name !== "string" ||
69
90
  !raw.name ||
70
- raw.name.length > 512 ||
91
+ raw.name.length > MAX_ROOM_NAME_LENGTH ||
71
92
  raw.name.includes("\0") ||
72
93
  typeof raw.cwd !== "string" ||
73
94
  !raw.cwd ||
@@ -175,29 +196,41 @@ export function buildAskUserUpdatedInput(input, answersByQuestion) {
175
196
  }
176
197
  return { ...obj, answers };
177
198
  }
178
- /**
179
- * Parse an AskUserQuestion input into the first question's prompt/header/options
180
- * so the UI can render a choice card. The room UI answers one question at a time
181
- * (the first); returns undefined for a non-AskUser / malformed input (no
182
- * questions), which the caller treats as "fall back to auto-allow".
183
- */
184
- export function askUserPrompt(input) {
185
- const obj = (input ?? {});
186
- const q0 = (Array.isArray(obj.questions) ? obj.questions[0] : undefined);
187
- if (!q0 || typeof q0.question !== "string")
199
+ function parseAskUserQuestion(value) {
200
+ const q = value;
201
+ if (!q || typeof q.question !== "string")
188
202
  return undefined;
189
- const options = Array.isArray(q0.options)
190
- ? q0.options
203
+ const options = Array.isArray(q.options)
204
+ ? q.options
191
205
  .map((o) => o?.label)
192
206
  .filter((l) => typeof l === "string")
193
207
  : [];
194
208
  return {
195
- question: q0.question,
196
- header: typeof q0.header === "string" ? q0.header : undefined,
209
+ question: q.question,
210
+ header: typeof q.header === "string" ? q.header : undefined,
197
211
  options,
198
- multiSelect: q0.multiSelect === true,
212
+ multiSelect: q.multiSelect === true,
199
213
  };
200
214
  }
215
+ /**
216
+ * Parse an AskUserQuestion input into the first question's prompt/header/options
217
+ * so the UI can render a choice card. Returns undefined for a non-AskUser /
218
+ * malformed input (no questions), which the caller treats as "fall back to
219
+ * auto-allow".
220
+ */
221
+ export function askUserPrompt(input) {
222
+ const obj = (input ?? {});
223
+ return parseAskUserQuestion(Array.isArray(obj.questions) ? obj.questions[0] : undefined);
224
+ }
225
+ /** Every well-formed question of one AskUserQuestion request, in order. One
226
+ * control request carries them all and is answered exactly once, so the UI
227
+ * must collect every answer before responding. */
228
+ function askUserQuestions(input) {
229
+ const obj = (input ?? {});
230
+ if (!Array.isArray(obj.questions))
231
+ return [];
232
+ return obj.questions.flatMap((q) => parseAskUserQuestion(q) ?? []);
233
+ }
201
234
  const ROOM_ATTACHMENT_START = "<codeshell-image-attachments>";
202
235
  const ROOM_ATTACHMENT_END = "</codeshell-image-attachments>";
203
236
  function roomAttachmentSummary(attachments) {
@@ -240,6 +273,10 @@ export function stripRoomAttachmentBlock(text) {
240
273
  export class RoomManager {
241
274
  opts;
242
275
  agents = new Map();
276
+ /** Permission mode each live agent was spawned with. The mode is a spawn-time
277
+ * CLI argument, so room metadata alone cannot prove what a process runs. */
278
+ agentModes = new WeakMap();
279
+ modelTraces = new Map();
243
280
  /** Rooms whose visible output is currently sourced from the external CLI's
244
281
  * transcript tail. While present, the resident agent's stdout mirror is
245
282
  * suppressed so the same CLI output is not pushed twice. */
@@ -443,7 +480,7 @@ export class RoomManager {
443
480
  const id = `room_${this.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
444
481
  const meta = {
445
482
  id,
446
- name: input.name ?? input.cwd.split("/").filter(Boolean).pop() ?? "room",
483
+ name: input.name ?? defaultRoomName(input.cwd),
447
484
  cwd: input.cwd,
448
485
  kind: input.kind ?? "claude-code",
449
486
  permissionMode: input.permissionMode ?? "default",
@@ -762,6 +799,9 @@ export class RoomManager {
762
799
  this.writeRoomMeta(meta.id, { ...meta, permissionMode: mode });
763
800
  this.close(meta.id); // stop the old-mode process so open() respawns fresh
764
801
  }
802
+ // Metadata alone cannot prove the live process mode (a concurrent takeover
803
+ // may have rewritten it), so also compare the agent's spawn-time mode.
804
+ this.retireAgentUnlessMode(meta.id, mode);
765
805
  const { status } = this.open(meta.id);
766
806
  return { roomId: meta.id, status };
767
807
  }
@@ -828,6 +868,9 @@ export class RoomManager {
828
868
  linkedSessionMode: undefined,
829
869
  };
830
870
  this.writeRoomMeta(roomId, controllableMeta);
871
+ // Another view may have taken over and reopened this session under a
872
+ // different mode. Never report default while feeding that process.
873
+ this.retireAgentUnlessMode(roomId, controllableMeta.permissionMode);
831
874
  const { status } = this.open(roomId);
832
875
  if (status !== "running") {
833
876
  throw new Error("linked session room is unavailable");
@@ -847,8 +890,10 @@ export class RoomManager {
847
890
  catch {
848
891
  throw new Error("linked session transcript is unavailable");
849
892
  }
850
- if (!resolvedTarget ||
851
- typeof resolvedTarget.externalSessionId !== "string" ||
893
+ if (!resolvedTarget) {
894
+ throw new Error("linked session transcript was not found for the requested session");
895
+ }
896
+ if (typeof resolvedTarget.externalSessionId !== "string" ||
852
897
  typeof resolvedTarget.cwd !== "string" ||
853
898
  resolvedTarget.externalSessionId !== target.externalSessionId ||
854
899
  resolvedTarget.kind !== target.kind ||
@@ -894,8 +939,10 @@ export class RoomManager {
894
939
  agent.respondControl(requestId, decision);
895
940
  return true;
896
941
  }
942
+ // `answers` (keyed by question text) answers every question of a
943
+ // multi-question request; the legacy single `answer` maps to the first.
897
944
  const prompt = askUserPrompt(pending);
898
- const answersByQuestion = prompt ? { [prompt.question]: decision.answer ?? "" } : {};
945
+ const answersByQuestion = decision.answers ?? (prompt ? { [prompt.question]: decision.answer ?? "" } : {});
899
946
  agent.respondControl(requestId, {
900
947
  behavior: "allow",
901
948
  updatedInput: buildAskUserUpdatedInput(pending, answersByQuestion),
@@ -931,6 +978,7 @@ export class RoomManager {
931
978
  this.onAgentEvent(id, event);
932
979
  });
933
980
  this.agents.set(id, agent);
981
+ this.agentModes.set(agent, meta.permissionMode);
934
982
  try {
935
983
  agent.start();
936
984
  }
@@ -953,6 +1001,12 @@ export class RoomManager {
953
1001
  }
954
1002
  return { status: "running" };
955
1003
  }
1004
+ /** Stop a live agent whose spawn-time permission mode differs from `mode`. */
1005
+ retireAgentUnlessMode(id, mode) {
1006
+ const agent = this.agents.get(id);
1007
+ if (agent && this.agentModes.get(agent) !== mode)
1008
+ this.close(id);
1009
+ }
956
1010
  onAgentEvent(id, event) {
957
1011
  // Buffered while send() is deciding acceptance — replayed right after the
958
1012
  // user turn is appended, so a synchronous agent reply cannot precede it.
@@ -962,6 +1016,10 @@ export class RoomManager {
962
1016
  this.deferredEmits.set(id, queued);
963
1017
  return;
964
1018
  }
1019
+ const trace = this.modelTraces.get(id);
1020
+ trace?.observe(event);
1021
+ if (trace?.ended)
1022
+ this.modelTraces.delete(id);
965
1023
  if (this.transcriptFollowedRooms.has(id) &&
966
1024
  (event.type === "text" ||
967
1025
  event.type === "tool" ||
@@ -1015,8 +1073,10 @@ export class RoomManager {
1015
1073
  // `answers` record the CLI requires. Auto-allowing the unanswered input
1016
1074
  // is what made claude report "The user did not answer the questions".
1017
1075
  if (event.toolName === "AskUserQuestion") {
1018
- const askUser = askUserPrompt(event.input);
1019
- if (askUser) {
1076
+ const prompt = askUserPrompt(event.input);
1077
+ if (prompt) {
1078
+ const questions = askUserQuestions(event.input);
1079
+ const askUser = questions.length > 1 ? { ...prompt, questions } : prompt;
1020
1080
  this.pendingAskUser.set(`${id}:${event.requestId}`, event.input);
1021
1081
  this.append(id, {
1022
1082
  from: "agent",
@@ -1109,6 +1169,25 @@ export class RoomManager {
1109
1169
  // Rejected: nothing about this turn reaches history or the phone.
1110
1170
  return false;
1111
1171
  }
1172
+ if (this.opts.modelTracing) {
1173
+ try {
1174
+ let trace = this.modelTraces.get(id);
1175
+ if (!trace || trace.ended) {
1176
+ trace = createRoomModelTrace({
1177
+ storageDir: this.opts.modelTracing.storageDir,
1178
+ workspacePath: meta.cwd,
1179
+ roomId: id,
1180
+ kind: meta.kind ?? "claude",
1181
+ parameters: this.opts.modelTracing.parameters?.(meta),
1182
+ });
1183
+ this.modelTraces.set(id, trace);
1184
+ }
1185
+ trace.submit(agentText);
1186
+ }
1187
+ catch {
1188
+ // Observability cannot change an already accepted external submission.
1189
+ }
1190
+ }
1112
1191
  this.append(id, {
1113
1192
  from: "user",
1114
1193
  type: "text",
@@ -1131,6 +1210,8 @@ export class RoomManager {
1131
1210
  // throttled write.
1132
1211
  this.flushLastActive(id);
1133
1212
  const agent = this.agents.get(id);
1213
+ this.modelTraces.get(id)?.finish("cancelled");
1214
+ this.modelTraces.delete(id);
1134
1215
  try {
1135
1216
  agent?.stop();
1136
1217
  }
@@ -1147,6 +1228,9 @@ export class RoomManager {
1147
1228
  closeAll() {
1148
1229
  const ids = new Set([...this.agents.keys(), ...this.transcriptFollowedRooms]);
1149
1230
  let firstError;
1231
+ for (const trace of this.modelTraces.values())
1232
+ trace.finish("cancelled");
1233
+ this.modelTraces.clear();
1150
1234
  for (const agent of this.agents.values()) {
1151
1235
  try {
1152
1236
  agent.stop();
@@ -0,0 +1,18 @@
1
+ import { type ModelTraceStatus } from "@cjhyy/code-shell-core/internal";
2
+ import type { ResidentAgentEvent } from "./resident-agent.js";
3
+ /**
4
+ * Resident Claude accepts overlapping inputs without request IDs in its output.
5
+ * A batch preserves observed order without inventing a prompt-to-response map.
6
+ */
7
+ export declare function createRoomModelTrace(options: {
8
+ storageDir: string;
9
+ workspacePath: string;
10
+ roomId: string;
11
+ kind: string;
12
+ parameters?: Record<string, unknown>;
13
+ }): {
14
+ readonly ended: boolean;
15
+ submit(input: string): void;
16
+ observe(event: ResidentAgentEvent): void;
17
+ finish: (status: ModelTraceStatus) => void;
18
+ };
@@ -0,0 +1,196 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { recordExternalModelTrace, recordModelTraceEvent, withModelTraceContext, ModelTraceStore, } from "@cjhyy/code-shell-core/internal";
3
+ const MAX_TEXT = 128 * 1024;
4
+ const MAX_INPUTS = 128;
5
+ /**
6
+ * Resident Claude accepts overlapping inputs without request IDs in its output.
7
+ * A batch preserves observed order without inventing a prompt-to-response map.
8
+ */
9
+ export function createRoomModelTrace(options) {
10
+ const context = {
11
+ storageDir: options.storageDir,
12
+ workspacePath: options.workspacePath,
13
+ sessionId: undefined,
14
+ runId: undefined,
15
+ parentSessionId: undefined,
16
+ ephemeral: false,
17
+ source: `external-room-${options.kind}`,
18
+ purpose: "external_room_batch",
19
+ };
20
+ const startedAt = Date.now();
21
+ const traceId = randomUUID();
22
+ const store = new ModelTraceStore({ storageDir: options.storageDir });
23
+ const policyRevision = store.getConfig().policyRevision;
24
+ const contentEnabled = () => {
25
+ try {
26
+ const config = store.getConfig();
27
+ return (capture &&
28
+ config.enabled &&
29
+ config.captureContent &&
30
+ config.policyRevision === policyRevision);
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ };
36
+ const common = { ...context, traceId, provider: options.kind, model: "CLI default", startedAt };
37
+ let recordId;
38
+ let capture = false;
39
+ let pending = 0;
40
+ let batchFailed = false;
41
+ let batchCancelled = false;
42
+ let submissionCount = 0;
43
+ let ended = false;
44
+ let text = "";
45
+ let error;
46
+ const messages = [];
47
+ const tools = new Map();
48
+ const write = (status, endedAt) => {
49
+ const result = recordExternalModelTrace({
50
+ ...common,
51
+ id: recordId,
52
+ status,
53
+ endedAt,
54
+ input: !recordId || capture
55
+ ? {
56
+ messages,
57
+ parameters: {
58
+ ...options.parameters,
59
+ roomId: options.roomId,
60
+ submissionCount,
61
+ omittedInputCount: Math.max(0, submissionCount - messages.length),
62
+ outputAttribution: "accepted-room-batch-visible-output",
63
+ limitation: "Overlapping submissions have no per-request CLI output identity; internal model requests are opaque.",
64
+ },
65
+ }
66
+ : undefined,
67
+ output: capture && endedAt !== undefined ? { text, stopReason: status } : undefined,
68
+ error,
69
+ });
70
+ if (result && !recordId) {
71
+ recordId = result.id;
72
+ capture = result.inputState !== "not-recorded";
73
+ if (!capture)
74
+ messages.length = 0;
75
+ }
76
+ if (!result && !recordId)
77
+ messages.length = 0;
78
+ };
79
+ const span = (input) => withModelTraceContext(context, () => recordModelTraceEvent({
80
+ ...input,
81
+ traceId,
82
+ ...(!contentEnabled()
83
+ ? {
84
+ inputState: "not-recorded",
85
+ outputState: "not-recorded",
86
+ error: input.error ? "External room operation failed" : undefined,
87
+ }
88
+ : {}),
89
+ }));
90
+ const finish = (status) => {
91
+ if (ended)
92
+ return;
93
+ ended = true;
94
+ const endedAt = Date.now();
95
+ for (const tool of tools.values())
96
+ span({ ...tool, endedAt, status: status === "failed" ? "failed" : "cancelled" });
97
+ tools.clear();
98
+ if (recordId)
99
+ write(status, endedAt);
100
+ messages.length = 0;
101
+ text = "";
102
+ };
103
+ return {
104
+ get ended() {
105
+ return ended;
106
+ },
107
+ submit(input) {
108
+ if (ended)
109
+ return;
110
+ submissionCount++;
111
+ pending++;
112
+ if ((!recordId || contentEnabled()) && messages.length < MAX_INPUTS)
113
+ messages.push({ role: "user", content: input.slice(0, MAX_TEXT) });
114
+ write("running");
115
+ },
116
+ observe(event) {
117
+ if (ended || !recordId)
118
+ return;
119
+ switch (event.type) {
120
+ case "text":
121
+ if (contentEnabled())
122
+ text = (text + event.text).slice(0, MAX_TEXT);
123
+ else if (capture && !text.endsWith("[Content capture paused]"))
124
+ text = (text + "\n[Content capture paused]").slice(0, MAX_TEXT);
125
+ break;
126
+ case "tool": {
127
+ if (tools.size >= MAX_INPUTS)
128
+ break;
129
+ const tool = span({
130
+ kind: "tool",
131
+ name: event.tool,
132
+ startedAt: Date.now(),
133
+ status: event.id ? "running" : "observed",
134
+ ...(event.id ? {} : { endedAt: Date.now() }),
135
+ input: event.input,
136
+ });
137
+ if (tool && event.id)
138
+ tools.set(event.id, tool);
139
+ break;
140
+ }
141
+ case "tool_result": {
142
+ const tool = event.id ? tools.get(event.id) : undefined;
143
+ if (tool) {
144
+ span({
145
+ ...tool,
146
+ endedAt: Date.now(),
147
+ status: event.isError ? "failed" : "completed",
148
+ output: event.summary,
149
+ outputState: undefined,
150
+ });
151
+ tools.delete(event.id);
152
+ }
153
+ else
154
+ span({
155
+ kind: "tool",
156
+ name: "External CLI tool result (identity unavailable)",
157
+ startedAt: Date.now(),
158
+ status: event.isError ? "failed" : "completed",
159
+ output: event.summary,
160
+ });
161
+ break;
162
+ }
163
+ case "approval_request":
164
+ span({
165
+ kind: "approval",
166
+ name: event.toolName,
167
+ startedAt: Date.now(),
168
+ status: "requested",
169
+ input: event.input,
170
+ });
171
+ break;
172
+ case "error":
173
+ error = contentEnabled() ? event.error.slice(0, MAX_TEXT) : "External room CLI failed";
174
+ finish("failed");
175
+ break;
176
+ case "turn_end":
177
+ span({
178
+ kind: "lifecycle",
179
+ name: "External room CLI turn boundary",
180
+ startedAt: Date.now(),
181
+ status: event.reason,
182
+ });
183
+ batchFailed ||= /error|fail/i.test(event.reason);
184
+ batchCancelled ||= /abort|cancel|interrupt/i.test(event.reason);
185
+ pending = Math.max(0, pending - 1);
186
+ if (pending === 0)
187
+ finish(batchFailed ? "failed" : batchCancelled ? "cancelled" : "completed");
188
+ break;
189
+ case "exit":
190
+ finish(event.signal ? "cancelled" : "failed");
191
+ break;
192
+ }
193
+ },
194
+ finish,
195
+ };
196
+ }
@@ -0,0 +1,3 @@
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;