@cjhyy/code-shell-server 0.9.44 → 0.9.46

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 (83) 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/mobile-chat-turn.d.ts +1 -0
  50. package/dist/mobile-remote/mobile-chat-turn.js +3 -0
  51. package/dist/mobile-remote/mobile-client-event-validator.js +13 -0
  52. package/dist/mobile-remote/mobile-history.d.ts +10 -1
  53. package/dist/mobile-remote/mobile-history.js +24 -16
  54. package/dist/mobile-remote/remote-host-manager.d.ts +8 -0
  55. package/dist/mobile-remote/remote-host-manager.js +23 -8
  56. package/dist/mobile-remote/resident-agent.js +17 -4
  57. package/dist/mobile-remote/room-manager.d.ts +23 -13
  58. package/dist/mobile-remote/room-manager.js +102 -20
  59. package/dist/mobile-remote/room-model-tracing.d.ts +18 -0
  60. package/dist/mobile-remote/room-model-tracing.js +196 -0
  61. package/dist/model-trace-history.d.ts +29 -0
  62. package/dist/model-trace-history.js +198 -0
  63. package/dist/model-trace-http.d.ts +14 -0
  64. package/dist/model-trace-http.js +105 -0
  65. package/dist/panels/agent-task-host.js +1 -0
  66. package/dist/panels/directory-bookmarks.js +62 -29
  67. package/dist/panels/http.d.ts +1 -0
  68. package/dist/panels/hub-binding.js +5 -5
  69. package/dist/panels/model-trace-channel.d.ts +21 -0
  70. package/dist/panels/model-trace-channel.js +110 -0
  71. package/dist/panels/model-traces.d.ts +39 -0
  72. package/dist/panels/model-traces.js +222 -0
  73. package/dist/panels/process-service.d.ts +12 -2
  74. package/dist/panels/process-service.js +49 -6
  75. package/dist/panels/runtime.d.ts +1 -0
  76. package/dist/panels/runtime.js +54 -6
  77. package/dist/panels/tool-jobs.d.ts +2 -0
  78. package/dist/panels/tool-jobs.js +21 -1
  79. package/dist/serve/cli.js +8 -1
  80. package/dist/serve/headless-server.js +68 -14
  81. package/dist/sessions-service.js +17 -27
  82. package/dist/worker-bridge-core.js +11 -0
  83. package/package.json +8 -7
@@ -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");
@@ -894,8 +937,10 @@ export class RoomManager {
894
937
  agent.respondControl(requestId, decision);
895
938
  return true;
896
939
  }
940
+ // `answers` (keyed by question text) answers every question of a
941
+ // multi-question request; the legacy single `answer` maps to the first.
897
942
  const prompt = askUserPrompt(pending);
898
- const answersByQuestion = prompt ? { [prompt.question]: decision.answer ?? "" } : {};
943
+ const answersByQuestion = decision.answers ?? (prompt ? { [prompt.question]: decision.answer ?? "" } : {});
899
944
  agent.respondControl(requestId, {
900
945
  behavior: "allow",
901
946
  updatedInput: buildAskUserUpdatedInput(pending, answersByQuestion),
@@ -931,6 +976,7 @@ export class RoomManager {
931
976
  this.onAgentEvent(id, event);
932
977
  });
933
978
  this.agents.set(id, agent);
979
+ this.agentModes.set(agent, meta.permissionMode);
934
980
  try {
935
981
  agent.start();
936
982
  }
@@ -953,6 +999,12 @@ export class RoomManager {
953
999
  }
954
1000
  return { status: "running" };
955
1001
  }
1002
+ /** Stop a live agent whose spawn-time permission mode differs from `mode`. */
1003
+ retireAgentUnlessMode(id, mode) {
1004
+ const agent = this.agents.get(id);
1005
+ if (agent && this.agentModes.get(agent) !== mode)
1006
+ this.close(id);
1007
+ }
956
1008
  onAgentEvent(id, event) {
957
1009
  // Buffered while send() is deciding acceptance — replayed right after the
958
1010
  // user turn is appended, so a synchronous agent reply cannot precede it.
@@ -962,6 +1014,10 @@ export class RoomManager {
962
1014
  this.deferredEmits.set(id, queued);
963
1015
  return;
964
1016
  }
1017
+ const trace = this.modelTraces.get(id);
1018
+ trace?.observe(event);
1019
+ if (trace?.ended)
1020
+ this.modelTraces.delete(id);
965
1021
  if (this.transcriptFollowedRooms.has(id) &&
966
1022
  (event.type === "text" ||
967
1023
  event.type === "tool" ||
@@ -1015,8 +1071,10 @@ export class RoomManager {
1015
1071
  // `answers` record the CLI requires. Auto-allowing the unanswered input
1016
1072
  // is what made claude report "The user did not answer the questions".
1017
1073
  if (event.toolName === "AskUserQuestion") {
1018
- const askUser = askUserPrompt(event.input);
1019
- if (askUser) {
1074
+ const prompt = askUserPrompt(event.input);
1075
+ if (prompt) {
1076
+ const questions = askUserQuestions(event.input);
1077
+ const askUser = questions.length > 1 ? { ...prompt, questions } : prompt;
1020
1078
  this.pendingAskUser.set(`${id}:${event.requestId}`, event.input);
1021
1079
  this.append(id, {
1022
1080
  from: "agent",
@@ -1109,6 +1167,25 @@ export class RoomManager {
1109
1167
  // Rejected: nothing about this turn reaches history or the phone.
1110
1168
  return false;
1111
1169
  }
1170
+ if (this.opts.modelTracing) {
1171
+ try {
1172
+ let trace = this.modelTraces.get(id);
1173
+ if (!trace || trace.ended) {
1174
+ trace = createRoomModelTrace({
1175
+ storageDir: this.opts.modelTracing.storageDir,
1176
+ workspacePath: meta.cwd,
1177
+ roomId: id,
1178
+ kind: meta.kind ?? "claude",
1179
+ parameters: this.opts.modelTracing.parameters?.(meta),
1180
+ });
1181
+ this.modelTraces.set(id, trace);
1182
+ }
1183
+ trace.submit(agentText);
1184
+ }
1185
+ catch {
1186
+ // Observability cannot change an already accepted external submission.
1187
+ }
1188
+ }
1112
1189
  this.append(id, {
1113
1190
  from: "user",
1114
1191
  type: "text",
@@ -1131,6 +1208,8 @@ export class RoomManager {
1131
1208
  // throttled write.
1132
1209
  this.flushLastActive(id);
1133
1210
  const agent = this.agents.get(id);
1211
+ this.modelTraces.get(id)?.finish("cancelled");
1212
+ this.modelTraces.delete(id);
1134
1213
  try {
1135
1214
  agent?.stop();
1136
1215
  }
@@ -1147,6 +1226,9 @@ export class RoomManager {
1147
1226
  closeAll() {
1148
1227
  const ids = new Set([...this.agents.keys(), ...this.transcriptFollowedRooms]);
1149
1228
  let firstError;
1229
+ for (const trace of this.modelTraces.values())
1230
+ trace.finish("cancelled");
1231
+ this.modelTraces.clear();
1150
1232
  for (const agent of this.agents.values()) {
1151
1233
  try {
1152
1234
  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,29 @@
1
+ import { ModelTraceStore, type ModelTraceConfig, type ModelTraceConfigPatch, type ModelTraceEvent, type ModelTraceListQuery, type ModelTracePage, type ModelTraceSessionPage, type ModelTraceRecord } from "@cjhyy/code-shell-core/internal";
2
+ export type ModelTraceDetail = {
3
+ record: ModelTraceRecord;
4
+ events: ModelTraceEvent[];
5
+ };
6
+ export type ModelTraceReader = Pick<ModelTraceStore, "list" | "sessions" | "get" | "events" | "getConfig" | "setConfig">;
7
+ export declare class ModelTraceRequestError extends Error {
8
+ readonly status: number;
9
+ constructor(status: number, message: string);
10
+ }
11
+ /** Never forward renderer/browser-owned filesystem paths or unknown scan options. */
12
+ export declare function validateModelTraceQuery(input?: unknown): ModelTraceListQuery;
13
+ export declare function validateModelTraceId(input: unknown): string;
14
+ export declare function validateModelTraceConfigPatch(input: unknown): ModelTraceConfigPatch;
15
+ /** Host fixes storageDir once; no query or request can select a different store. */
16
+ export declare function createModelTraceHistory(options: {
17
+ storageDir: string;
18
+ /** Set only by the authenticated Host, never by list/get arguments. */
19
+ workspacePath?: string;
20
+ /** Additional scopes fixed and authorized by the Host, never by list/get input. */
21
+ workspaceScopes?: ModelTraceListQuery["workspaceScopes"];
22
+ store?: ModelTraceReader;
23
+ }): {
24
+ list(query?: unknown): ModelTracePage;
25
+ sessions(query?: unknown): ModelTraceSessionPage;
26
+ get(id: unknown): ModelTraceDetail | null;
27
+ getConfig(): ModelTraceConfig;
28
+ setConfig(patch: unknown): ModelTraceConfig;
29
+ };
@@ -0,0 +1,198 @@
1
+ import { ModelTraceStore, isModelTraceCallType, } from "@cjhyy/code-shell-core/internal";
2
+ import { resolve } from "node:path";
3
+ const QUERY_FIELDS = new Set([
4
+ "limit",
5
+ "cursor",
6
+ "search",
7
+ "traceId",
8
+ "sessionId",
9
+ "withoutSession",
10
+ "model",
11
+ "provider",
12
+ "purpose",
13
+ "callType",
14
+ "source",
15
+ "status",
16
+ "since",
17
+ "until",
18
+ ]);
19
+ const CONFIG_FIELDS = new Set(["enabled", "captureContent", "includeEphemeral", "retentionDays"]);
20
+ export class ModelTraceRequestError extends Error {
21
+ status;
22
+ constructor(status, message) {
23
+ super(message);
24
+ this.status = status;
25
+ }
26
+ }
27
+ function record(value, label) {
28
+ if (!value || typeof value !== "object" || Array.isArray(value))
29
+ throw new ModelTraceRequestError(400, `Invalid model trace ${label}`);
30
+ return value;
31
+ }
32
+ /** Never forward renderer/browser-owned filesystem paths or unknown scan options. */
33
+ export function validateModelTraceQuery(input) {
34
+ if (input === undefined)
35
+ return {};
36
+ const query = record(input, "query");
37
+ const result = {};
38
+ for (const [key, value] of Object.entries(query)) {
39
+ if (!QUERY_FIELDS.has(key))
40
+ throw new ModelTraceRequestError(400, `Unknown model trace query field: ${key}`);
41
+ if (value === undefined)
42
+ continue;
43
+ if (key === "callType") {
44
+ if (!isModelTraceCallType(value))
45
+ throw new ModelTraceRequestError(400, "Invalid model trace callType");
46
+ }
47
+ else if (key === "withoutSession") {
48
+ if (typeof value !== "boolean")
49
+ throw new ModelTraceRequestError(400, "Invalid model trace withoutSession");
50
+ }
51
+ else if (key === "limit") {
52
+ if (!Number.isSafeInteger(value) || value < 1 || value > 200)
53
+ throw new ModelTraceRequestError(400, "Model trace limit must be between 1 and 200");
54
+ }
55
+ else if (key === "since" || key === "until") {
56
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
57
+ throw new ModelTraceRequestError(400, `Invalid model trace ${key}`);
58
+ }
59
+ else {
60
+ const max = key === "search" || key === "cursor" ? 2048 : 512;
61
+ if (typeof value !== "string" || value.length > max || value.includes("\0"))
62
+ throw new ModelTraceRequestError(400, `Invalid model trace ${key}`);
63
+ if (key === "status" && !["running", "completed", "failed", "cancelled"].includes(value))
64
+ throw new ModelTraceRequestError(400, "Invalid model trace status");
65
+ }
66
+ result[key] = value;
67
+ }
68
+ if (typeof result.since === "number" &&
69
+ typeof result.until === "number" &&
70
+ result.since > result.until)
71
+ throw new ModelTraceRequestError(400, "Model trace time range is reversed");
72
+ return result;
73
+ }
74
+ export function validateModelTraceId(input) {
75
+ if (typeof input !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(input))
76
+ throw new ModelTraceRequestError(400, "Invalid model trace id");
77
+ return input;
78
+ }
79
+ export function validateModelTraceConfigPatch(input) {
80
+ const patch = record(input, "configuration");
81
+ for (const [key, value] of Object.entries(patch)) {
82
+ if (!CONFIG_FIELDS.has(key))
83
+ throw new ModelTraceRequestError(400, `Unknown model trace configuration field: ${key}`);
84
+ if ((key === "enabled" || key === "captureContent" || key === "includeEphemeral") &&
85
+ typeof value !== "boolean")
86
+ throw new ModelTraceRequestError(400, `Invalid model trace ${key}`);
87
+ if (key === "retentionDays" &&
88
+ (!Number.isSafeInteger(value) || value < 1 || value > 365))
89
+ throw new ModelTraceRequestError(400, "Model trace retention must be between 1 and 365 days");
90
+ }
91
+ return { ...patch };
92
+ }
93
+ /** Host fixes storageDir once; no query or request can select a different store. */
94
+ export function createModelTraceHistory(options) {
95
+ const store = options.store ?? new ModelTraceStore({ storageDir: options.storageDir });
96
+ const workspacePath = options.workspacePath === undefined ? undefined : resolve(options.workspacePath);
97
+ if (options.workspaceScopes !== undefined &&
98
+ (!Array.isArray(options.workspaceScopes) ||
99
+ options.workspaceScopes.length + (workspacePath === undefined ? 0 : 1) > 8 ||
100
+ options.workspaceScopes.some((scope) => !scope ||
101
+ typeof scope !== "object" ||
102
+ Array.isArray(scope) ||
103
+ Object.keys(scope).some((field) => !["workspacePath", "sourcePrefix"].includes(field)) ||
104
+ typeof scope.workspacePath !== "string" ||
105
+ !scope.workspacePath ||
106
+ scope.workspacePath.length > 4096 ||
107
+ scope.workspacePath.includes("\0") ||
108
+ (scope.sourcePrefix !== undefined &&
109
+ (typeof scope.sourcePrefix !== "string" ||
110
+ !scope.sourcePrefix ||
111
+ scope.sourcePrefix.length > 256 ||
112
+ scope.sourcePrefix.includes("\0"))))))
113
+ throw new ModelTraceRequestError(500, "Invalid Host model trace scopes");
114
+ const scopes = options.workspaceScopes
115
+ ? [
116
+ ...(workspacePath === undefined ? [] : [{ workspacePath }]),
117
+ ...options.workspaceScopes.map((scope) => ({
118
+ ...scope,
119
+ workspacePath: resolve(scope.workspacePath),
120
+ })),
121
+ ]
122
+ : undefined;
123
+ const matchesPath = (entry, path) => entry.workspacePath !== undefined && resolve(entry.workspacePath) === path;
124
+ const unrestricted = (entry) => scopes
125
+ ? scopes.some((scope) => scope.sourcePrefix === undefined && matchesPath(entry, scope.workspacePath))
126
+ : workspacePath === undefined || matchesPath(entry, workspacePath);
127
+ const visible = (entry) => unrestricted(entry) ||
128
+ !!scopes?.some((scope) => matchesPath(entry, scope.workspacePath) &&
129
+ typeof entry.source === "string" &&
130
+ scope.sourcePrefix !== undefined &&
131
+ entry.source.startsWith(scope.sourcePrefix));
132
+ const visibleEvents = (entry) => {
133
+ const events = store.events(entry.traceId);
134
+ const accepted = new Set(events.filter(unrestricted).map((event) => event.id));
135
+ // Events do not carry a source. A prefix-limited related scope may only
136
+ // expose the exact selected call's descendants, never another root chat's
137
+ // events merely because they share a trace identifier.
138
+ const children = new Map();
139
+ if (entry.workspacePath !== undefined)
140
+ for (const event of events)
141
+ if (event.parentId && matchesPath(event, resolve(entry.workspacePath))) {
142
+ const siblings = children.get(event.parentId) ?? [];
143
+ siblings.push(event);
144
+ children.set(event.parentId, siblings);
145
+ }
146
+ const queue = [entry.id];
147
+ const visited = new Set(queue);
148
+ for (let index = 0; index < queue.length; index++)
149
+ for (const event of children.get(queue[index]) ?? [])
150
+ if (!visited.has(event.id)) {
151
+ visited.add(event.id);
152
+ accepted.add(event.id);
153
+ queue.push(event.id);
154
+ }
155
+ return events.filter((event) => accepted.has(event.id));
156
+ };
157
+ return {
158
+ list(query) {
159
+ const validated = validateModelTraceQuery(query);
160
+ return store.list({
161
+ ...validated,
162
+ ...(scopes
163
+ ? { workspaceScopes: scopes }
164
+ : workspacePath === undefined
165
+ ? {}
166
+ : { workspacePath }),
167
+ });
168
+ },
169
+ sessions(query) {
170
+ const validated = validateModelTraceQuery(query);
171
+ try {
172
+ return store.sessions({
173
+ ...validated,
174
+ ...(scopes
175
+ ? { workspaceScopes: scopes }
176
+ : workspacePath === undefined
177
+ ? {}
178
+ : { workspacePath }),
179
+ });
180
+ }
181
+ catch (error) {
182
+ if (error instanceof Error && error.message === "Invalid model trace session cursor")
183
+ throw new ModelTraceRequestError(400, error.message);
184
+ throw error;
185
+ }
186
+ },
187
+ get(id) {
188
+ const entry = store.get(validateModelTraceId(id));
189
+ return entry && visible(entry) ? { record: entry, events: visibleEvents(entry) } : null;
190
+ },
191
+ getConfig() {
192
+ return store.getConfig();
193
+ },
194
+ setConfig(patch) {
195
+ return store.setConfig(validateModelTraceConfigPatch(patch));
196
+ },
197
+ };
198
+ }