@cjhyy/code-shell-server 0.9.43 → 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.
- package/dist/attachment-service.d.ts +2 -0
- package/dist/attachment-service.js +40 -27
- package/dist/bin/code-shell-link-cleanup.d.ts +2 -0
- package/dist/bin/code-shell-link-cleanup.js +3 -0
- package/dist/desktop-web/http-api.d.ts +3 -0
- package/dist/desktop-web/http-api.js +16 -1
- package/dist/hub/configuration.d.ts +6 -3
- package/dist/hub/configuration.js +19 -6
- package/dist/hub/mcp-probe.d.ts +4 -24
- package/dist/hub/mcp-probe.js +18 -389
- package/dist/hub/model-probe.d.ts +2 -20
- package/dist/hub/model-probe.js +2 -193
- package/dist/hub/run-replay.d.ts +7 -1
- package/dist/hub/run-replay.js +10 -2
- package/dist/hub/session-management.js +18 -53
- package/dist/hub/skills-management.js +6 -8
- package/dist/hub/uploads.d.ts +10 -0
- package/dist/hub/uploads.js +107 -15
- package/dist/index.desktop-web.d.ts +1 -0
- package/dist/index.desktop-web.js +1 -0
- package/dist/index.links.d.ts +3 -0
- package/dist/index.links.js +2 -0
- package/dist/index.panels.d.ts +1 -0
- package/dist/index.panels.js +1 -0
- package/dist/index.serve.d.ts +1 -0
- package/dist/index.serve.js +1 -0
- package/dist/index.storage.d.ts +1 -0
- package/dist/index.storage.js +1 -0
- package/dist/links/catalog.js +2 -0
- package/dist/links/diagnostics.d.ts +60 -0
- package/dist/links/diagnostics.js +184 -0
- package/dist/links/http.d.ts +10 -8
- package/dist/links/http.js +144 -125
- package/dist/links/legacy-cleanup-cli.d.ts +3 -0
- package/dist/links/legacy-cleanup-cli.js +64 -0
- package/dist/links/providers/defaults.d.ts +5 -0
- package/dist/links/providers/defaults.js +782 -0
- package/dist/links/providers/index.d.ts +7 -0
- package/dist/links/providers/index.js +22 -0
- package/dist/links/remote-catalog.d.ts +10 -3
- package/dist/links/remote-catalog.js +76 -29
- package/dist/links/runtime/operation.d.ts +9 -0
- package/dist/links/runtime/operation.js +16 -0
- package/dist/links/service.d.ts +18 -2
- package/dist/links/service.js +593 -62
- package/dist/links/types.d.ts +21 -1
- package/dist/mobile-remote/codex-parse.d.ts +3 -1
- package/dist/mobile-remote/codex-parse.js +45 -11
- package/dist/mobile-remote/mobile-chat-turn.d.ts +1 -0
- package/dist/mobile-remote/mobile-chat-turn.js +3 -0
- package/dist/mobile-remote/mobile-client-event-validator.js +13 -0
- package/dist/mobile-remote/mobile-history.d.ts +10 -1
- package/dist/mobile-remote/mobile-history.js +24 -16
- package/dist/mobile-remote/remote-host-manager.d.ts +8 -0
- package/dist/mobile-remote/remote-host-manager.js +23 -8
- package/dist/mobile-remote/resident-agent.js +17 -4
- package/dist/mobile-remote/room-manager.d.ts +23 -13
- package/dist/mobile-remote/room-manager.js +102 -20
- package/dist/mobile-remote/room-model-tracing.d.ts +18 -0
- package/dist/mobile-remote/room-model-tracing.js +196 -0
- package/dist/model-trace-history.d.ts +29 -0
- package/dist/model-trace-history.js +198 -0
- package/dist/model-trace-http.d.ts +14 -0
- package/dist/model-trace-http.js +105 -0
- package/dist/panels/agent-task-host.js +1 -0
- package/dist/panels/directory-bookmarks.js +62 -29
- package/dist/panels/http.d.ts +1 -0
- package/dist/panels/hub-binding.js +5 -5
- package/dist/panels/model-trace-channel.d.ts +21 -0
- package/dist/panels/model-trace-channel.js +110 -0
- package/dist/panels/model-traces.d.ts +39 -0
- package/dist/panels/model-traces.js +222 -0
- package/dist/panels/process-service.d.ts +12 -2
- package/dist/panels/process-service.js +49 -6
- package/dist/panels/runtime.d.ts +1 -0
- package/dist/panels/runtime.js +54 -6
- package/dist/panels/tool-jobs.d.ts +2 -0
- package/dist/panels/tool-jobs.js +21 -1
- package/dist/serve/cli.js +8 -1
- package/dist/serve/headless-server.js +68 -14
- package/dist/sessions-service.js +17 -27
- package/dist/worker-bridge-core.js +11 -0
- 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 >
|
|
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
|
-
|
|
180
|
-
|
|
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(
|
|
190
|
-
?
|
|
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:
|
|
196
|
-
header: typeof
|
|
209
|
+
question: q.question,
|
|
210
|
+
header: typeof q.header === "string" ? q.header : undefined,
|
|
197
211
|
options,
|
|
198
|
-
multiSelect:
|
|
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
|
|
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
|
|
1019
|
-
if (
|
|
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
|
+
}
|