@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
@@ -10,9 +10,13 @@ export function panelExecutionProject(path) {
10
10
  }
11
11
  }
12
12
  export class PanelExecutionBusyError extends Error {
13
+ blockers;
13
14
  status = 409;
14
- constructor(message) {
15
+ constructor(message,
16
+ /** Present when executions block a change; empty while another change runs. */
17
+ blockers = []) {
15
18
  super(message);
19
+ this.blockers = blockers;
16
20
  this.name = "PanelExecutionBusyError";
17
21
  }
18
22
  }
@@ -25,7 +29,11 @@ export class PanelExecutionGate {
25
29
  sources = new Set();
26
30
  mutations = new Set();
27
31
  enter(scope) {
28
- const held = { appId: scope.appId, projectPath: panelExecutionProject(scope.projectPath) };
32
+ const held = {
33
+ appId: scope.appId,
34
+ projectPath: panelExecutionProject(scope.projectPath),
35
+ ...(scope.holder ? { holder: { ...scope.holder } } : {}),
36
+ };
29
37
  if ([...this.mutations].some((matches) => matches(held)))
30
38
  throw new PanelExecutionBusyError("面板正在更新,请完成后重试。");
31
39
  this.executions.add(held);
@@ -49,13 +57,33 @@ export class PanelExecutionGate {
49
57
  this.sources.delete(source);
50
58
  };
51
59
  }
60
+ /** Executions that a change matching `matches` would wait for, grouped by holder. */
61
+ blockers(matches) {
62
+ const grouped = new Map();
63
+ const scopes = [...this.executions, ...[...this.sources].flatMap((source) => [...source()])];
64
+ for (const scope of scopes) {
65
+ if (!matches(scope))
66
+ continue;
67
+ const blocker = {
68
+ appId: scope.appId,
69
+ projectPath: scope.projectPath,
70
+ ...(scope.holder ? { holder: { ...scope.holder } } : {}),
71
+ };
72
+ const key = JSON.stringify(blocker), existing = grouped.get(key);
73
+ if (existing)
74
+ existing.count++;
75
+ else
76
+ grouped.set(key, { ...blocker, count: 1 });
77
+ }
78
+ return [...grouped.values()];
79
+ }
52
80
  async mutate(matches, work) {
53
81
  if (this.mutations.size)
54
82
  throw new PanelExecutionBusyError("另一个面板修改正在进行,请稍后重试。");
55
83
  // No await between the snapshot and installing the admission barrier.
56
- if ([...this.executions].some(matches) ||
57
- [...this.sources].some((source) => [...source()].some(matches)))
58
- throw new PanelExecutionBusyError("面板还有正在提交、排队或运行的任务,请结束后再修改。");
84
+ const blockers = this.blockers(matches);
85
+ if (blockers.length)
86
+ throw new PanelExecutionBusyError("面板还有正在提交、排队或运行的任务,请结束后再修改。", blockers);
59
87
  this.mutations.add(matches);
60
88
  try {
61
89
  return await work();
@@ -7,6 +7,7 @@ import { type PanelAgentTaskHostOptions } from "./agent-task-host.js";
7
7
  import type { PanelProjectScopeBinding } from "./project-scope.js";
8
8
  export declare function createPanelHttp(options: PanelManagementHttpOptions & {
9
9
  dataDir: string;
10
+ traceStorageDir?: string;
10
11
  host: "hub" | "desktop";
11
12
  projectScope?: () => Promise<PanelProjectScopeBinding | undefined>;
12
13
  publicPathPrefix?: string;
@@ -98,6 +98,7 @@ export function createHubPanelAutomationHost(options) {
98
98
  releaseExecution = panelExecutionGate.enter({
99
99
  appId: job.panelSource.appId,
100
100
  projectPath: options.bindingCwd ?? cwd,
101
+ holder: { kind: "automation" },
101
102
  });
102
103
  }
103
104
  try {
@@ -9,7 +9,7 @@ function refused() {
9
9
  }
10
10
  function metadata(file) {
11
11
  try {
12
- return lstatSync(file);
12
+ return lstatSync(file, { bigint: true });
13
13
  }
14
14
  catch (error) {
15
15
  if (error.code === "ENOENT")
@@ -20,8 +20,8 @@ function metadata(file) {
20
20
  function readPointer(file) {
21
21
  const descriptor = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
22
22
  try {
23
- const info = fstatSync(descriptor);
24
- if (!info.isFile() || info.size > 8192)
23
+ const info = fstatSync(descriptor, { bigint: true });
24
+ if (!info.isFile() || info.size > 8192n)
25
25
  refused();
26
26
  const content = readFileSync(descriptor, "utf8");
27
27
  if (Buffer.byteLength(content) > 8192 || content.includes("\0"))
@@ -54,13 +54,13 @@ function sameIdentity(actual, expected) {
54
54
  }
55
55
  function directoryGuard(path) {
56
56
  const canonical = realpathSync(path);
57
- const info = lstatSync(canonical);
57
+ const info = lstatSync(canonical, { bigint: true });
58
58
  if (!info.isDirectory() || info.isSymbolicLink())
59
59
  refused();
60
60
  return () => {
61
61
  if (realpathSync(path) !== canonical)
62
62
  refused();
63
- const current = lstatSync(canonical);
63
+ const current = lstatSync(canonical, { bigint: true });
64
64
  if (!current.isDirectory() || current.isSymbolicLink() || !sameIdentity(current, info))
65
65
  refused();
66
66
  };
@@ -0,0 +1,21 @@
1
+ import { PanelModelTraceService, type PanelModelTraceOwner } from "./model-traces.js";
2
+ /** A dedicated reviewed-child descriptor; ordinary stdout/stderr never become prompts. */
3
+ export declare class PanelModelTraceChannel {
4
+ private readonly options;
5
+ private readonly decoder;
6
+ private readonly frames;
7
+ private buffer;
8
+ private readonly calls;
9
+ private tail;
10
+ private stopped;
11
+ private pending;
12
+ constructor(options: {
13
+ service: PanelModelTraceService;
14
+ owner: PanelModelTraceOwner;
15
+ isAuthorized(): Promise<boolean>;
16
+ });
17
+ write(chunk: Buffer | string): void;
18
+ finish(status: "cancelled" | "failed"): Promise<void>;
19
+ abort(status?: "cancelled" | "failed"): void;
20
+ private consume;
21
+ }
@@ -0,0 +1,110 @@
1
+ import { StringDecoder } from "node:string_decoder";
2
+ import { createNativeModelTraceFrameDecoder } from "@cjhyy/code-shell-core/internal";
3
+ import { panelModelTraceLimits, } from "./model-traces.js";
4
+ /** A dedicated reviewed-child descriptor; ordinary stdout/stderr never become prompts. */
5
+ export class PanelModelTraceChannel {
6
+ options;
7
+ decoder = new StringDecoder("utf8");
8
+ frames = createNativeModelTraceFrameDecoder();
9
+ buffer = "";
10
+ calls = new Map();
11
+ tail = Promise.resolve();
12
+ stopped = false;
13
+ pending = 0;
14
+ constructor(options) {
15
+ this.options = options;
16
+ }
17
+ write(chunk) {
18
+ if (this.stopped)
19
+ return;
20
+ this.buffer += typeof chunk === "string" ? chunk : this.decoder.write(chunk);
21
+ let newline;
22
+ while ((newline = this.buffer.indexOf("\n")) >= 0) {
23
+ const physical = this.buffer.slice(0, newline);
24
+ this.buffer = this.buffer.slice(newline + 1);
25
+ if (!physical.trim())
26
+ continue;
27
+ let line;
28
+ try {
29
+ if (Buffer.byteLength(physical) > panelModelTraceLimits.maxParamsBytes)
30
+ throw new Error("Oversized model observation");
31
+ line = this.frames.read(physical);
32
+ }
33
+ catch {
34
+ this.abort("failed");
35
+ return;
36
+ }
37
+ if (line === undefined || !line.trim())
38
+ continue;
39
+ if (++this.pending > 128) {
40
+ this.abort("failed");
41
+ return;
42
+ }
43
+ this.tail = this.tail.then(async () => {
44
+ try {
45
+ if (this.stopped || !(await this.options.isAuthorized())) {
46
+ this.abort();
47
+ return;
48
+ }
49
+ if (this.stopped)
50
+ return;
51
+ this.consume(line);
52
+ }
53
+ catch {
54
+ this.abort("failed");
55
+ }
56
+ finally {
57
+ this.pending--;
58
+ }
59
+ });
60
+ }
61
+ if (Buffer.byteLength(this.buffer) > panelModelTraceLimits.maxParamsBytes)
62
+ this.abort("failed");
63
+ }
64
+ async finish(status) {
65
+ this.buffer += this.decoder.end();
66
+ if (this.buffer.trim())
67
+ this.write("\n");
68
+ await this.tail;
69
+ this.abort(status);
70
+ }
71
+ abort(status = "cancelled") {
72
+ this.stopped = true;
73
+ this.buffer = "";
74
+ this.calls.clear();
75
+ this.frames.clear();
76
+ this.options.service.cancelOwner(this.options.owner.ownerKey, status);
77
+ }
78
+ consume(line) {
79
+ if (!line.trim())
80
+ return;
81
+ const value = JSON.parse(line);
82
+ if (!value ||
83
+ typeof value !== "object" ||
84
+ Array.isArray(value) ||
85
+ value.version !== 1 ||
86
+ value.type !== "model_trace" ||
87
+ !["start", "finish"].includes(value.phase) ||
88
+ typeof value.callId !== "string" ||
89
+ !/^[a-zA-Z0-9_-]{1,128}$/.test(value.callId))
90
+ throw new Error("Invalid model observation channel message");
91
+ const { version: _version, type: _type, phase, callId, ...params } = value;
92
+ if (Object.hasOwn(params, "id"))
93
+ throw new Error("Child cannot select a Host observation id");
94
+ if (phase === "start") {
95
+ if (this.calls.has(callId))
96
+ throw new Error("Duplicate model observation call");
97
+ const result = this.options.service.call(this.options.owner, "modelTraces.start", params);
98
+ if (!("id" in result))
99
+ throw new Error("Model observation was not started");
100
+ this.calls.set(callId, result.id);
101
+ }
102
+ else {
103
+ const id = this.calls.get(callId);
104
+ if (!id)
105
+ throw new Error("Unknown model observation call");
106
+ this.options.service.call(this.options.owner, "modelTraces.finish", { ...params, id });
107
+ this.calls.delete(callId);
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,39 @@
1
+ export declare const panelModelTraceMethods: string[];
2
+ export declare const panelModelTraceLimits: Readonly<{
3
+ maxParamsBytes: number;
4
+ maxActivePerOwner: 64;
5
+ maxActive: 2048;
6
+ maxCallsPerMinute: 240;
7
+ maxLifetimeMs: number;
8
+ }>;
9
+ /** Supplied by a verified Host binding; no field is taken from an observation. */
10
+ export interface PanelModelTraceOwner {
11
+ ownerKey: string;
12
+ appId: string;
13
+ revision: string;
14
+ workspacePath: string;
15
+ sessionId?: string;
16
+ traceId?: string;
17
+ taskId?: string;
18
+ }
19
+ /** Write-only Panel observations share the Host's policy, sanitization and history store. */
20
+ export declare class PanelModelTraceService {
21
+ private readonly options;
22
+ private readonly active;
23
+ private readonly calls;
24
+ constructor(options?: {
25
+ storageDir?: string;
26
+ now?: () => number;
27
+ });
28
+ call(owner: PanelModelTraceOwner, method: string, params: unknown): {
29
+ id: string;
30
+ recorded: boolean;
31
+ } | {
32
+ recorded: boolean;
33
+ };
34
+ cancelOwner(ownerKey: string, status?: "cancelled" | "failed"): void;
35
+ closeAll(): void;
36
+ private close;
37
+ private now;
38
+ private prune;
39
+ }
@@ -0,0 +1,222 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { isAbsolute } from "node:path";
3
+ import { recordExternalModelTrace, } from "@cjhyy/code-shell-core/internal";
4
+ import { PanelBridgeError } from "./bridge-contract.js";
5
+ export const panelModelTraceMethods = ["modelTraces.start", "modelTraces.finish"];
6
+ export const panelModelTraceLimits = Object.freeze({
7
+ maxParamsBytes: 256 * 1024,
8
+ maxActivePerOwner: 64,
9
+ maxActive: 2048,
10
+ maxCallsPerMinute: 240,
11
+ maxLifetimeMs: 24 * 60 * 60 * 1000,
12
+ });
13
+ function invalid(message = "Invalid Panel model observation") {
14
+ throw new PanelBridgeError("INVALID_ARGUMENT", message);
15
+ }
16
+ function object(value, keys) {
17
+ if (!value || typeof value !== "object" || Array.isArray(value))
18
+ invalid();
19
+ if (Object.keys(value).some((key) => !keys.includes(key)))
20
+ invalid();
21
+ return value;
22
+ }
23
+ function label(value, optional = false) {
24
+ if (optional && value === undefined)
25
+ return undefined;
26
+ if (typeof value !== "string" || !value.trim() || value.length > 200 || /[\x00-\x1f]/.test(value))
27
+ invalid();
28
+ return value;
29
+ }
30
+ function usage(value) {
31
+ if (value === undefined)
32
+ return undefined;
33
+ const input = object(value, [
34
+ "promptTokens",
35
+ "completionTokens",
36
+ "totalTokens",
37
+ "cacheReadTokens",
38
+ "cacheCreationTokens",
39
+ ]);
40
+ for (const key of ["promptTokens", "completionTokens", "totalTokens"])
41
+ if (input[key] === undefined)
42
+ invalid("Model usage requires token counts");
43
+ for (const number of Object.values(input))
44
+ if (!Number.isSafeInteger(number) || Number(number) < 0)
45
+ invalid("Invalid model usage");
46
+ return input;
47
+ }
48
+ /** Write-only Panel observations share the Host's policy, sanitization and history store. */
49
+ export class PanelModelTraceService {
50
+ options;
51
+ active = new Map();
52
+ calls = new Map();
53
+ constructor(options = {}) {
54
+ this.options = options;
55
+ }
56
+ call(owner, method, params) {
57
+ if (!owner.ownerKey ||
58
+ !/^[a-z][a-z0-9-]{0,63}$/.test(owner.appId) ||
59
+ !owner.revision ||
60
+ !isAbsolute(owner.workspacePath))
61
+ invalid("Invalid Host model observation owner");
62
+ if (!panelModelTraceMethods.includes(method))
63
+ invalid("Unsupported model observation method");
64
+ const encoded = JSON.stringify(params);
65
+ if (!encoded || Buffer.byteLength(encoded) > panelModelTraceLimits.maxParamsBytes)
66
+ throw new PanelBridgeError("PARAMS_TOO_LARGE", "Panel model observation exceeds the limit");
67
+ const now = this.now();
68
+ this.prune(now);
69
+ const calls = (this.calls.get(owner.ownerKey) ?? []).filter((time) => now - time < 60_000);
70
+ if (calls.length >= panelModelTraceLimits.maxCallsPerMinute)
71
+ throw new PanelBridgeError("RATE_LIMITED", "Panel model observation rate limit exceeded");
72
+ calls.push(now);
73
+ this.calls.set(owner.ownerKey, calls);
74
+ if (method === "modelTraces.start") {
75
+ const value = object(params, [
76
+ "provider",
77
+ "model",
78
+ "purpose",
79
+ "input",
80
+ "inputState",
81
+ "stream",
82
+ ]);
83
+ const provider = label(value.provider);
84
+ const model = label(value.model);
85
+ const purpose = label(value.purpose, true) ?? "model";
86
+ if (value.stream !== undefined && typeof value.stream !== "boolean")
87
+ invalid();
88
+ if (value.inputState !== undefined && value.inputState !== "truncated")
89
+ invalid();
90
+ const body = object(value.input, ["systemPrompt", "messages", "tools", "parameters"]);
91
+ if (body.systemPrompt !== undefined && typeof body.systemPrompt !== "string")
92
+ invalid();
93
+ for (const key of ["messages", "tools"])
94
+ if (body[key] !== undefined && !Array.isArray(body[key]))
95
+ invalid();
96
+ if (body.parameters !== undefined &&
97
+ (!body.parameters || typeof body.parameters !== "object" || Array.isArray(body.parameters)))
98
+ invalid();
99
+ if (this.active.size >= panelModelTraceLimits.maxActive ||
100
+ [...this.active.values()].filter((item) => item.ownerKey === owner.ownerKey).length >=
101
+ panelModelTraceLimits.maxActivePerOwner)
102
+ throw new PanelBridgeError("RATE_LIMITED", "Too many active Panel model observations");
103
+ const input = {
104
+ storageDir: this.options.storageDir,
105
+ workspacePath: owner.workspacePath,
106
+ ephemeral: false,
107
+ sessionId: owner.sessionId,
108
+ parentSessionId: undefined,
109
+ runId: owner.traceId,
110
+ source: `panel:${owner.appId}`,
111
+ purpose: `panel/${purpose}`,
112
+ provider,
113
+ model,
114
+ startedAt: now,
115
+ status: "running",
116
+ stream: value.stream,
117
+ coverage: "logical-input",
118
+ inputState: value.inputState,
119
+ input: {
120
+ systemPrompt: body.systemPrompt ?? "",
121
+ messages: body.messages ?? [],
122
+ tools: body.tools ?? [],
123
+ parameters: {
124
+ ...body.parameters,
125
+ codeshellPanel: {
126
+ appId: owner.appId,
127
+ revision: owner.revision,
128
+ ...(owner.taskId ? { taskId: owner.taskId } : {}),
129
+ },
130
+ },
131
+ },
132
+ };
133
+ const record = recordExternalModelTrace(input);
134
+ const id = record?.id ?? randomUUID();
135
+ // Retain only already-sanitized content. Disabled recording never resumes mid-call.
136
+ this.active.set(id, {
137
+ ownerKey: owner.ownerKey,
138
+ ownerIdentity: JSON.stringify([
139
+ owner.appId,
140
+ owner.revision,
141
+ owner.workspacePath,
142
+ owner.sessionId,
143
+ ]),
144
+ recordId: record?.id,
145
+ startedAt: now,
146
+ input: { ...input, input: undefined },
147
+ });
148
+ return { id, recorded: !!record };
149
+ }
150
+ const value = object(params, ["id", "status", "output", "outputState", "error", "usage"]);
151
+ if (typeof value.id !== "string" || !/^[a-f0-9-]{36}$/i.test(value.id))
152
+ invalid();
153
+ const observation = this.active.get(value.id);
154
+ if (!observation ||
155
+ observation.ownerKey !== owner.ownerKey ||
156
+ observation.ownerIdentity !==
157
+ JSON.stringify([owner.appId, owner.revision, owner.workspacePath, owner.sessionId]))
158
+ throw new PanelBridgeError("REVOKED", "Model observation is unavailable for this Panel owner");
159
+ if (!["completed", "failed", "cancelled"].includes(String(value.status)))
160
+ invalid();
161
+ if (value.outputState !== undefined && value.outputState !== "truncated")
162
+ invalid();
163
+ const output = value.output === undefined
164
+ ? undefined
165
+ : object(value.output, ["text", "thinking", "toolCalls", "stopReason", "data"]);
166
+ for (const key of ["text", "thinking", "stopReason"])
167
+ if (output?.[key] !== undefined && typeof output[key] !== "string")
168
+ invalid();
169
+ if (output?.toolCalls !== undefined && !Array.isArray(output.toolCalls))
170
+ invalid();
171
+ if (value.error !== undefined && typeof value.error !== "string")
172
+ invalid();
173
+ const tokens = usage(value.usage);
174
+ this.active.delete(value.id);
175
+ const record = observation.recordId
176
+ ? recordExternalModelTrace({
177
+ ...observation.input,
178
+ id: observation.recordId,
179
+ endedAt: now,
180
+ status: value.status,
181
+ output,
182
+ outputState: value.outputState,
183
+ error: value.error,
184
+ usage: tokens,
185
+ })
186
+ : null;
187
+ return { recorded: !!record };
188
+ }
189
+ cancelOwner(ownerKey, status = "cancelled") {
190
+ for (const [id, observation] of this.active)
191
+ if (observation.ownerKey === ownerKey)
192
+ this.close(id, observation, status);
193
+ this.calls.delete(ownerKey);
194
+ }
195
+ closeAll() {
196
+ for (const [id, observation] of this.active)
197
+ this.close(id, observation, "cancelled");
198
+ this.calls.clear();
199
+ }
200
+ close(id, observation, status) {
201
+ this.active.delete(id);
202
+ if (observation.recordId)
203
+ recordExternalModelTrace({
204
+ ...observation.input,
205
+ id: observation.recordId,
206
+ endedAt: this.now(),
207
+ status,
208
+ error: status === "failed" ? "Panel process exited before the model call finished" : undefined,
209
+ });
210
+ }
211
+ now() {
212
+ return (this.options.now ?? Date.now)();
213
+ }
214
+ prune(now) {
215
+ for (const [id, observation] of this.active)
216
+ if (now - observation.startedAt > panelModelTraceLimits.maxLifetimeMs)
217
+ this.close(id, observation, "cancelled");
218
+ for (const [owner, calls] of this.calls)
219
+ if (calls.every((time) => now - time >= 60_000))
220
+ this.calls.delete(owner);
221
+ }
222
+ }
@@ -1,3 +1,4 @@
1
+ import type { PanelModelTraceService, PanelModelTraceOwner } from "./model-traces.js";
1
2
  export { processLimits } from "./process-state.js";
2
3
  export interface PanelProcessApprovalScope {
3
4
  appId: string;
@@ -13,6 +14,9 @@ export interface PanelProcessOwner {
13
14
  send(event: "process.output" | "process.exit", payload: Record<string, unknown>): void;
14
15
  }
15
16
  export interface PanelAppProcessServiceOptions {
17
+ /** Dedicated descriptor only for a verified installed package entry. */
18
+ modelTraces?: PanelModelTraceService;
19
+ modelTraceOwner?(owner: PanelProcessOwner): Omit<PanelModelTraceOwner, "ownerKey"> | undefined;
16
20
  /** Host lease from before async preparation until the actual process group has exited. */
17
21
  acquireExecution?(owner: PanelProcessOwner): () => void;
18
22
  env?: NodeJS.ProcessEnv;
@@ -159,7 +163,13 @@ export declare class PanelAppProcessService {
159
163
  }>;
160
164
  private sendInput;
161
165
  revokeGuest(guestId: number): void;
162
- /** Permanently shut down all guests, including operations awaiting approval. */
163
- close(): void;
166
+ /**
167
+ * Permanently shut down all guests, including operations awaiting approval.
168
+ * Resolves once every native process group has exited or been force-killed:
169
+ * escalation runs on a Host timer, so a Host that exits earlier would leave
170
+ * a process ignoring SIGTERM running.
171
+ */
172
+ close(): Promise<void>;
173
+ private shutDown;
164
174
  private terminate;
165
175
  }
@@ -4,6 +4,7 @@ import { constants, lstatSync, realpathSync } from "node:fs";
4
4
  import { access, realpath, stat } from "node:fs/promises";
5
5
  import { basename, delimiter, extname, join } from "node:path";
6
6
  import { killProcessGroup } from "@cjhyy/code-shell-core/extension";
7
+ import { PanelModelTraceChannel } from "./model-trace-channel.js";
7
8
  import { verifyProcessEntry } from "./process-entry.js";
8
9
  import { appendProcessEvent, ProcessReceipts, processLimits, sameProcessOwner, } from "./process-state.js";
9
10
  export { processLimits } from "./process-state.js";
@@ -231,7 +232,7 @@ export class PanelAppProcessService {
231
232
  options.assertCurrent?.();
232
233
  await this.authorize(owner, epoch);
233
234
  options.assertCurrent?.();
234
- const info = await stat(resolved);
235
+ const info = await stat(resolved, { bigint: true });
235
236
  options.assertCurrent?.();
236
237
  await this.authorize(owner, epoch);
237
238
  options.assertCurrent?.();
@@ -327,7 +328,7 @@ export class PanelAppProcessService {
327
328
  }
328
329
  // Final synchronous identity check preserves the existing handle API and avoids
329
330
  // an await between validating a cwd and spawning the child into that directory.
330
- const current = lstatSync(grant.path);
331
+ const current = lstatSync(grant.path, { bigint: true });
331
332
  if (!current.isDirectory() ||
332
333
  current.isSymbolicLink() ||
333
334
  current.dev !== grant.dev ||
@@ -518,12 +519,22 @@ export class PanelAppProcessService {
518
519
  if (!this.options.allowAuthorizedProcessWithoutPrompt)
519
520
  this.approvedExecutables.add(approvalKey);
520
521
  const processId = randomUUID();
522
+ const modelTraceOwner = entry && this.options.modelTraces ? this.options.modelTraceOwner?.(owner) : undefined;
523
+ // Never inherit a caller's descriptor claim for an ordinary executable.
524
+ delete env.CODESHELL_MODEL_TRACE_FD;
525
+ if (modelTraceOwner)
526
+ env.CODESHELL_MODEL_TRACE_FD = "3";
521
527
  const child = spawn(executable.path, args, {
522
528
  cwd,
523
529
  detached: process.platform !== "win32",
524
530
  env,
525
531
  shell: false,
526
- stdio: [input.stdin === "pipe" ? "pipe" : "ignore", "pipe", "pipe"],
532
+ stdio: [
533
+ input.stdin === "pipe" ? "pipe" : "ignore",
534
+ "pipe",
535
+ "pipe",
536
+ ...(modelTraceOwner ? ["pipe"] : []),
537
+ ],
527
538
  windowsHide: true,
528
539
  });
529
540
  const record = {
@@ -607,6 +618,28 @@ export class PanelAppProcessService {
607
618
  }),
608
619
  };
609
620
  this.processes.set(processId, running);
621
+ if (modelTraceOwner && this.options.modelTraces) {
622
+ const channel = new PanelModelTraceChannel({
623
+ service: this.options.modelTraces,
624
+ owner: {
625
+ ...modelTraceOwner,
626
+ ownerKey: `process:${processId}`,
627
+ traceId: modelTraceOwner.traceId ?? processId,
628
+ },
629
+ isAuthorized: async () => {
630
+ try {
631
+ await this.authorize(owner, epoch);
632
+ return this.processes.get(processId) === running && !running.termination;
633
+ }
634
+ catch {
635
+ return false;
636
+ }
637
+ },
638
+ });
639
+ running.modelTraceChannel = channel;
640
+ child.stdio[3]?.on("data", (chunk) => channel.write(chunk));
641
+ child.stdio[3]?.on("error", () => channel.abort("failed"));
642
+ }
610
643
  let checkingInputs = false;
611
644
  const inputChecks = fileArgumentHandles.some((handle) => this.fileArguments.get(handle)?.validate)
612
645
  ? setInterval(() => {
@@ -651,6 +684,7 @@ export class PanelAppProcessService {
651
684
  });
652
685
  }
653
686
  }
687
+ await running.modelTraceChannel?.finish(record.cancelRequested ? "cancelled" : "failed");
654
688
  finishExit({ code: finalCode, signal });
655
689
  this.retiring.delete(processId);
656
690
  if (this.processes.get(processId)?.child !== child)
@@ -879,10 +913,18 @@ export class PanelAppProcessService {
879
913
  }
880
914
  }
881
915
  }
882
- /** Permanently shut down all guests, including operations awaiting approval. */
916
+ /**
917
+ * Permanently shut down all guests, including operations awaiting approval.
918
+ * Resolves once every native process group has exited or been force-killed:
919
+ * escalation runs on a Host timer, so a Host that exits earlier would leave
920
+ * a process ignoring SIGTERM running.
921
+ */
883
922
  close() {
884
- if (this.closed)
885
- return;
923
+ if (!this.closed)
924
+ this.shutDown();
925
+ return Promise.all([...this.retiring.values()].map((running) => running.termination ?? Promise.resolve())).then(() => undefined);
926
+ }
927
+ shutDown() {
886
928
  this.closed = true;
887
929
  const guests = new Set([
888
930
  ...this.guestEpochs.keys(),
@@ -901,6 +943,7 @@ export class PanelAppProcessService {
901
943
  terminate(running) {
902
944
  if (running.termination)
903
945
  return;
946
+ running.modelTraceChannel?.abort();
904
947
  running.record.cancelRequested = true;
905
948
  running.record.status = "stopping";
906
949
  const pid = running.child.pid;