@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
@@ -214,12 +214,21 @@ export class ResourceUploadIngest {
214
214
  }
215
215
  async cleanupExpired() {
216
216
  await this.initialize();
217
- for (const record of [...this.records.values()])
218
- if (record.expiresAt <= this.options.now()) {
219
- const key = this.key(record.scope, record.sessionId);
220
- this.finishes.get(key)?.abort();
221
- await this.locked(key, () => this.remove(record));
222
- }
217
+ for (const key of [...this.records.keys()]) {
218
+ // Another upload may have renewed while cleanup waited on an earlier
219
+ // entry. Only the current expired record can cancel a running finish.
220
+ const record = this.records.get(key);
221
+ if (!record || record.expiresAt > this.options.now())
222
+ continue;
223
+ this.finishes.get(key)?.abort();
224
+ await this.locked(key, async () => {
225
+ // A write already holding this lock may acknowledge a chunk and renew
226
+ // the lease before cleanup enters. Never delete that renewed upload.
227
+ const current = this.records.get(key);
228
+ if (current && current.expiresAt <= this.options.now())
229
+ await this.remove(current);
230
+ });
231
+ }
223
232
  }
224
233
  async begin(scope, raw) {
225
234
  await this.authorize(scope);
@@ -34,6 +34,7 @@ export interface PanelRuntimeOptions {
34
34
  bindingCwd?: string;
35
35
  projectPackages?: boolean;
36
36
  dataDir: string;
37
+ traceStorageDir?: string;
37
38
  host: "hub" | "desktop";
38
39
  /** Host authority only, never a field accepted from Panel HTTP requests. */
39
40
  projectScope?: () => Promise<PanelProjectScopeBinding | undefined>;
@@ -10,6 +10,7 @@ import { panelRuntimeApiVersion, panelProcessMethods, panelResourceMethods, pane
10
10
  import { PanelResourceService } from "./resources/service.js";
11
11
  import { servePanelResource } from "./resources/http.js";
12
12
  import { panelConnections, panelConnectionIds, materializePanelConnections, } from "./connections.js";
13
+ import { PanelModelTraceService, panelModelTraceMethods, panelModelTraceLimits, } from "./model-traces.js";
13
14
  import { PanelRuntimeServices } from "./runtime-services.js";
14
15
  import { PanelToolJobService, toolJobLimits, } from "./tool-jobs.js";
15
16
  import { createPanelToolExecutor } from "./tool-executor.js";
@@ -26,6 +27,7 @@ const MAX_FILE = 16 * 1024 * 1024;
26
27
  const GRANT_TTL = 30 * 60_000;
27
28
  const METHODS = [
28
29
  "context.get",
30
+ ...panelModelTraceMethods,
29
31
  "storage.get",
30
32
  "storage.getSnapshot",
31
33
  "storage.compareAndSet",
@@ -80,6 +82,7 @@ const MIME = {
80
82
  ".html": "text/html; charset=utf-8",
81
83
  ".js": "text/javascript; charset=utf-8",
82
84
  ".mjs": "text/javascript; charset=utf-8",
85
+ ".wasm": "application/wasm",
83
86
  ".css": "text/css; charset=utf-8",
84
87
  ".json": "application/json",
85
88
  ".md": "text/markdown; charset=utf-8",
@@ -420,6 +423,10 @@ export function createPanelRuntime(options) {
420
423
  const assets = new Map();
421
424
  const now = options.now ?? Date.now;
422
425
  const services = new PanelRuntimeServices({ dataDir: options.dataDir });
426
+ const modelTraces = new PanelModelTraceService({
427
+ storageDir: options.traceStorageDir,
428
+ now: options.now,
429
+ });
423
430
  const installed = options.listInstalled ??
424
431
  (options.projectPackages
425
432
  ? async (appId) => (await inspectProjectPanelApps(options.bindingCwd ?? options.cwd, appId)).apps
@@ -522,9 +529,31 @@ export function createPanelRuntime(options) {
522
529
  return app;
523
530
  }
524
531
  const processes = new PanelAppProcessService({
532
+ modelTraces,
533
+ modelTraceOwner: (owner) => {
534
+ const task = toolOwners.get(owner.guestId);
535
+ if (task)
536
+ return {
537
+ appId: owner.appId,
538
+ revision: owner.revision,
539
+ workspacePath: options.cwd,
540
+ traceId: task.taskId,
541
+ taskId: task.taskId,
542
+ };
543
+ const grant = byGuest.get(owner.guestId);
544
+ if (!grant || grant.revision !== owner.revision)
545
+ return undefined;
546
+ return {
547
+ appId: owner.appId,
548
+ revision: owner.revision,
549
+ workspacePath: options.cwd,
550
+ sessionId: typeof grant.context.sessionId === "string" ? grant.context.sessionId : undefined,
551
+ };
552
+ },
525
553
  acquireExecution: (owner) => panelExecutionGate.enter({
526
554
  appId: owner.appId,
527
555
  projectPath: options.bindingCwd ?? options.cwd,
556
+ holder: { kind: "process" },
528
557
  }),
529
558
  approvalScope: "guest",
530
559
  resolvePackageEntry: async (owner, name) => {
@@ -751,7 +780,7 @@ export function createPanelRuntime(options) {
751
780
  resources,
752
781
  owner: (job, send) => {
753
782
  const guestId = nextToolOwner--;
754
- toolOwners.set(guestId, job.scope);
783
+ toolOwners.set(guestId, { ...job.scope, taskId: job.id });
755
784
  return {
756
785
  guestId,
757
786
  appId: job.scope.appId,
@@ -826,6 +855,7 @@ export function createPanelRuntime(options) {
826
855
  assets.delete(grant.asset);
827
856
  byGuest.delete(grant.guestId);
828
857
  processes.revokeGuest(grant.guestId);
858
+ modelTraces.cancelOwner(`grant:${grant.id}`);
829
859
  agentTasks?.revokeInstance(grant.id);
830
860
  for (const item of grant.confirmations.values())
831
861
  item.finish(false);
@@ -912,7 +942,11 @@ export function createPanelRuntime(options) {
912
942
  };
913
943
  }
914
944
  async function onPanelAction(scope, input) {
915
- return panelExecutionGate.run({ appId: scope.appId, projectPath: options.bindingCwd ?? options.cwd }, () => onPanelActionAdmitted(scope, input));
945
+ return panelExecutionGate.run({
946
+ appId: scope.appId,
947
+ projectPath: options.bindingCwd ?? options.cwd,
948
+ holder: { kind: "panel-call" },
949
+ }, () => onPanelActionAdmitted(scope, input));
916
950
  }
917
951
  async function onPanelActionAdmitted(scope, input) {
918
952
  const grant = grants.get(scope.instanceId);
@@ -951,6 +985,7 @@ export function createPanelRuntime(options) {
951
985
  const releaseExecution = panelExecutionGate.enter({
952
986
  appId: grant.app.id,
953
987
  projectPath: options.bindingCwd ?? options.cwd,
988
+ holder: { kind: "agent-tool" },
954
989
  });
955
990
  const requestId = randomBytes(18).toString("base64url");
956
991
  const value = await new Promise((done) => {
@@ -1089,6 +1124,8 @@ export function createPanelRuntime(options) {
1089
1124
  locale: typeof input.locale === "string" ? input.locale.slice(0, 32) : "zh-CN",
1090
1125
  apiVersion: panelRuntimeApiVersion,
1091
1126
  capabilities: {
1127
+ packageAssets: { wasm: true, fetch: "package" },
1128
+ modelTraces: { writeOnly: true, coverage: "logical-input", ...panelModelTraceLimits },
1092
1129
  ...panelRuntimeCapabilities({
1093
1130
  process: app.permissions.includes("process"),
1094
1131
  cookieProcess: app.permissions.includes("process") &&
@@ -1189,7 +1226,9 @@ export function createPanelRuntime(options) {
1189
1226
  return false;
1190
1227
  if (method.startsWith("tools."))
1191
1228
  return !!app.agent?.tools.length;
1192
- return (method === "context.get" || app.permissions.includes(methodPermission(method)));
1229
+ return (method === "context.get" ||
1230
+ method.startsWith("modelTraces.") ||
1231
+ app.permissions.includes(methodPermission(method)));
1193
1232
  }),
1194
1233
  limitations: panel.compatibility.reasons,
1195
1234
  };
@@ -1276,7 +1315,11 @@ export function createPanelRuntime(options) {
1276
1315
  }
1277
1316
  }
1278
1317
  async function call(grant, input) {
1279
- return panelExecutionGate.run({ appId: grant.app.id, projectPath: options.bindingCwd ?? options.cwd }, () => callAdmitted(grant, input));
1318
+ return panelExecutionGate.run({
1319
+ appId: grant.app.id,
1320
+ projectPath: options.bindingCwd ?? options.cwd,
1321
+ holder: { kind: "panel-call" },
1322
+ }, () => callAdmitted(grant, input));
1280
1323
  }
1281
1324
  async function callAdmitted(grant, input) {
1282
1325
  if (!(await authorized(grant)))
@@ -1302,6 +1345,14 @@ export function createPanelRuntime(options) {
1302
1345
  grant.calls = history;
1303
1346
  if (method === "context.get")
1304
1347
  return grant.context;
1348
+ if (method.startsWith("modelTraces."))
1349
+ return modelTraces.call({
1350
+ ownerKey: `grant:${grant.id}`,
1351
+ appId: grant.app.id,
1352
+ revision: grant.revision,
1353
+ workspacePath: options.cwd,
1354
+ sessionId: typeof grant.context.sessionId === "string" ? grant.context.sessionId : undefined,
1355
+ }, method, params);
1305
1356
  if (method.startsWith("tasks."))
1306
1357
  return dispatchToolJobs(grant, method, params);
1307
1358
  if (method.startsWith("tools.")) {
@@ -1600,6 +1651,7 @@ export function createPanelRuntime(options) {
1600
1651
  return {
1601
1652
  effect: method,
1602
1653
  prompt: value.prompt,
1654
+ modelTraceSource: `panel:${grant.app.id}`,
1603
1655
  sessionId: grant.context.sessionId,
1604
1656
  ...(value.displayText ? { displayText: value.displayText } : {}),
1605
1657
  };
@@ -1692,6 +1744,7 @@ export function createPanelRuntime(options) {
1692
1744
  },
1693
1745
  async close() {
1694
1746
  closed = true;
1747
+ modelTraces.closeAll();
1695
1748
  for (const preparation of preparingTools)
1696
1749
  preparation.controller.abort();
1697
1750
  clearInterval(reaper);
@@ -1705,8 +1758,11 @@ export function createPanelRuntime(options) {
1705
1758
  await taskCookies?.shutdown();
1706
1759
  }
1707
1760
  finally {
1708
- processes.close();
1709
- await Promise.all([resources.shutdown(), Promise.resolve(agentTasks?.close())]);
1761
+ await Promise.all([
1762
+ processes.close(),
1763
+ resources.shutdown(),
1764
+ Promise.resolve(agentTasks?.close()),
1765
+ ]);
1710
1766
  }
1711
1767
  },
1712
1768
  async handle(request, response) {
@@ -1936,7 +1992,7 @@ export function createPanelRuntime(options) {
1936
1992
  // The embedding iframe must enable the same permission.
1937
1993
  response.setHeader("Content-Security-Policy", "default-src 'none'; script-src " +
1938
1994
  assetSource +
1939
- "; style-src " +
1995
+ " 'wasm-unsafe-eval'; style-src " +
1940
1996
  assetSource +
1941
1997
  " 'unsafe-inline'; img-src " +
1942
1998
  assetSource +
@@ -1944,7 +2000,9 @@ export function createPanelRuntime(options) {
1944
2000
  assetSource +
1945
2001
  " blob:; font-src " +
1946
2002
  assetSource +
1947
- " data:; connect-src 'none'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'; sandbox allow-scripts allow-downloads; frame-ancestors " +
2003
+ " data:; connect-src " +
2004
+ assetSource +
2005
+ "; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'; sandbox allow-scripts allow-downloads; frame-ancestors " +
1948
2006
  resourceOrigin);
1949
2007
  response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), display-capture=()");
1950
2008
  }
@@ -1,7 +1,7 @@
1
1
  import { createHmac, timingSafeEqual } from "node:crypto";
2
2
  import { chmod, lstat, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
3
3
  import { isAbsolute, join } from "node:path";
4
- import { formatNetscapeCookies, parseCookieJar, } from "@cjhyy/code-shell-core";
4
+ import { formatNetscapeCookies, normalizeCookieDomain, cookieAppliesToHost, parseCookieJar, } from "@cjhyy/code-shell-core";
5
5
  function targetUrl(raw) {
6
6
  if (typeof raw !== "string" || raw.length > 2048)
7
7
  throw new Error("Cookie selection requires an HTTPS URL");
@@ -16,16 +16,6 @@ function targetUrl(raw) {
16
16
  throw new Error("Cookie selection requires an HTTPS URL");
17
17
  return url;
18
18
  }
19
- function domain(raw) {
20
- if (typeof raw !== "string")
21
- return;
22
- const value = raw.toLowerCase().replace(/^\./, "");
23
- if (value.length > 253 ||
24
- !value.includes(".") ||
25
- !value.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)))
26
- return;
27
- return value;
28
- }
29
19
  function beneath(host, root) {
30
20
  return host === root || host.endsWith(`.${root}`);
31
21
  }
@@ -86,7 +76,7 @@ export class PanelTaskCookieService {
86
76
  ]))
87
77
  .digest("hex");
88
78
  }
89
- cookies(credential, root) {
79
+ cookies(credential, host) {
90
80
  const now = (this.options.now?.() ?? Date.now()) / 1000;
91
81
  if (typeof credential.secret !== "string" ||
92
82
  Buffer.byteLength(credential.secret) > 4 * 1024 * 1024)
@@ -106,8 +96,10 @@ export class PanelTaskCookieService {
106
96
  !cookie.path.startsWith("/") ||
107
97
  /[\x00-\x1f\x7f]/.test(cookie.path))))
108
98
  return false;
109
- const cookieDomain = domain(cookie.domain);
110
- if (!cookieDomain || !beneath(cookieDomain, root))
99
+ // Account eligibility and actual Cookie scope are different fences:
100
+ // a login captured on www.example.com can carry .example.com cookies,
101
+ // but must not export host-only cookies from a sibling login host.
102
+ if (!cookieAppliesToHost(cookie, host))
111
103
  return false;
112
104
  if (cookie.expirationDate !== undefined &&
113
105
  (typeof cookie.expirationDate !== "number" ||
@@ -120,10 +112,10 @@ export class PanelTaskCookieService {
120
112
  async snapshot(scope, url) {
121
113
  await this.options.authorize(scope);
122
114
  const result = (await this.options.credentials(scope)).flatMap((credential) => {
123
- const root = domain(credential.meta?.domain);
115
+ const root = normalizeCookieDomain(credential.meta?.domain);
124
116
  if (credential.type !== "cookie" || !root || !beneath(url.hostname.toLowerCase(), root))
125
117
  return [];
126
- const cookies = this.cookies(credential, root);
118
+ const cookies = this.cookies(credential, url.hostname);
127
119
  if (!cookies.length)
128
120
  return [];
129
121
  return [
@@ -120,6 +120,8 @@ export declare class PanelToolJobService {
120
120
  initialize(): Promise<void>;
121
121
  private load;
122
122
  private commit;
123
+ /** Make a job state visible to readers and subscribers. */
124
+ private publish;
123
125
  private lookup;
124
126
  /** Scoped existence check for transports merging read-only legacy history. */
125
127
  has(rawScope: ToolJobScope, id: string): Promise<boolean>;
@@ -179,11 +179,16 @@ export class PanelToolJobService {
179
179
  };
180
180
  }
181
181
  executionScopes() {
182
+ const held = (scope, label) => ({
183
+ appId: scope.appId,
184
+ projectPath: scope.projectPath,
185
+ holder: { kind: "tool-job", ...(label ? { label } : {}) },
186
+ });
182
187
  return [
183
- ...[...this.preparing.values()].map((item) => item.scope),
188
+ ...[...this.preparing.values()].map((item) => held(item.scope)),
184
189
  ...[...this.jobs.values()]
185
190
  .filter((job) => !TERMINAL.has(job.status) || this.active.has(job.id))
186
- .map((job) => job.scope),
191
+ .map((job) => held(job.scope, job.entry.name)),
187
192
  ];
188
193
  }
189
194
  activeCount(projectPath) {
@@ -283,6 +288,10 @@ export class PanelToolJobService {
283
288
  job.updatedAt = this.now();
284
289
  job.sequence++;
285
290
  await this.storage.write(job.id, job, toolJobLimits.maxRecordBytes);
291
+ this.publish(job);
292
+ }
293
+ /** Make a job state visible to readers and subscribers. */
294
+ publish(job) {
286
295
  this.jobs.set(job.id, job);
287
296
  try {
288
297
  this.options.onEvent?.(publicJob(job));
@@ -670,7 +679,23 @@ export class PanelToolJobService {
670
679
  next.result = result;
671
680
  next.progress = { ...next.progress, fraction: 1 };
672
681
  }
673
- await this.commit(next);
682
+ try {
683
+ await this.commit(next);
684
+ }
685
+ catch (error) {
686
+ // The executor has exited; a job that stays "running" in memory would
687
+ // count as active forever and hide the outcome. Publish an explicit
688
+ // failure for this Host session instead (keeping any result), while
689
+ // the stale disk record is recovered as interrupted by a later Host.
690
+ const outcome = next.error ? `${next.status} (${next.error.message})` : next.status;
691
+ next.status = "failed";
692
+ next.error = {
693
+ code: "TOOL_JOB_STORAGE_FAILED",
694
+ message: `Task ${outcome} but its final state could not be saved: ${error instanceof Error ? error.message : String(error)}`.slice(0, 2000),
695
+ retryable: next.recovery === "retry",
696
+ };
697
+ this.publish(next);
698
+ }
674
699
  });
675
700
  }
676
701
  async cancel(rawScope, id) {
package/dist/serve/cli.js CHANGED
@@ -10,6 +10,7 @@ import { join, resolve } from "node:path";
10
10
  import { existsSync } from "node:fs";
11
11
  import { startHeadlessServer } from "./headless-server.js";
12
12
  import { startProjectControlServer } from "../project-runtime/control-server.js";
13
+ import { configureModelTracing } from "@cjhyy/code-shell-core/internal";
13
14
  const SERVE_HELP = `Usage: code-shell-serve [options]
14
15
 
15
16
  Run the CodeShell headless Web host for a workspace.
@@ -127,7 +128,7 @@ export function resolveWorkerEntry() {
127
128
  * wrapper registered it; arena/pet stay desktop-only).
128
129
  */
129
130
  export function resolveWorkerCapabilityModules() {
130
- return `${import.meta.resolve("@cjhyy/code-shell-capability-coding")}#createCodingModule`;
131
+ return `${import.meta.resolve("@cjhyy/code-shell-capability-coding")}#createCodingModule,${import.meta.resolve("@cjhyy/code-shell-server/links")}#createLinkProviderModule`;
131
132
  }
132
133
  /** Locate the built browser app (packages/web `app` build), when present. */
133
134
  export function resolveWebAppRoot() {
@@ -150,6 +151,12 @@ export async function runServeCli(argv = process.argv.slice(2)) {
150
151
  return;
151
152
  }
152
153
  const parsed = parseServeArgs(argv);
154
+ if (parsed.runtime === "local") {
155
+ configureModelTracing({
156
+ storageDir: join(parsed.dataDir, "worker", "sessions", ".model-traces"),
157
+ source: "server",
158
+ });
159
+ }
153
160
  const staticRootDir = parsed.staticRootDir ?? resolveWebAppRoot();
154
161
  const server = parsed.runtime === "docker"
155
162
  ? await startProjectControlServer({
@@ -43,6 +43,7 @@ import { createHubFiles } from "../hub/files.js";
43
43
  import { createSessionHttpResponses } from "../session-http-responses.js";
44
44
  import { WorkerBridgeCore, previewLine } from "../worker-bridge-core.js";
45
45
  import { HostRuntime, interceptHostRequest } from "@cjhyy/code-shell-host";
46
+ import { createModelTraceHttp } from "../model-trace-http.js";
46
47
  export async function startHeadlessServer(opts) {
47
48
  const remoteLink = remoteLinkHostConfiguration(opts.remoteLink, opts.publicOrigin);
48
49
  if (remoteLink && opts.authMode !== "hub")
@@ -131,7 +132,33 @@ export async function startHeadlessServer(opts) {
131
132
  let configurationChanging = false;
132
133
  let configurationReloadFailed = false;
133
134
  const runUploads = new Map();
134
- const isRunning = (sessionId) => [...runningSessions.values()].includes(sessionId) || !!automationWorker?.ownsSession(sessionId);
135
+ // Core also starts top-level runs that never pass through a host agent/run,
136
+ // e.g. the follow-up turn of an asynchronously answered question. Count them
137
+ // from their own top-level stream boundaries so archive/export/automation
138
+ // gates see the session as busy until the run's terminal turn_complete.
139
+ const streamRuns = new Map();
140
+ const isRunning = (sessionId) => [...runningSessions.values()].includes(sessionId) ||
141
+ (streamRuns.get(sessionId) ?? 0) > 0 ||
142
+ !!automationWorker?.ownsSession(sessionId);
143
+ const observeRunBoundary = (sessionId, event) => {
144
+ if (!event || event.agentId !== undefined)
145
+ return;
146
+ const before = isRunning(sessionId);
147
+ const count = streamRuns.get(sessionId) ?? 0;
148
+ if (event.type === "session_started")
149
+ streamRuns.set(sessionId, count + 1);
150
+ else if (event.type === "turn_complete" && count > 0) {
151
+ if (count === 1)
152
+ streamRuns.delete(sessionId);
153
+ else
154
+ streamRuns.set(sessionId, count - 1);
155
+ }
156
+ else
157
+ return;
158
+ const running = isRunning(sessionId);
159
+ if (running !== before)
160
+ notify("serve/sessionStatus", { sessionId, running });
161
+ };
135
162
  let nextTabId = 1;
136
163
  let nextWorkerRequestId = 1;
137
164
  const outbound = new HubOutboundTransport({
@@ -207,6 +234,7 @@ export async function startHeadlessServer(opts) {
207
234
  pendingWorkerResponses.clear();
208
235
  pendingResponsesByTab.clear();
209
236
  runningSessions.clear();
237
+ streamRuns.clear();
210
238
  runOwners.clear();
211
239
  runReplay.clear();
212
240
  runInputs.clear();
@@ -269,6 +297,7 @@ export async function startHeadlessServer(opts) {
269
297
  let event = params.event;
270
298
  let deliveredInput;
271
299
  const value = event;
300
+ observeRunBoundary(params.sessionId, value);
272
301
  if (value?.type === "session_user_message" && typeof value.clientMessageId === "string") {
273
302
  const input = [...runInputs.values()].find((candidate) => candidate.sessionId === params.sessionId &&
274
303
  candidate.event.clientMessageId === value.clientMessageId);
@@ -421,9 +450,8 @@ export async function startHeadlessServer(opts) {
421
450
  CODE_SHELL_WORKSPACE_ACCESS: undefined,
422
451
  CODE_SHELL_DATA_ROOT: workerDataRoot,
423
452
  CODE_SHELL_CREDENTIAL_ACCESS: "local",
424
- ...(opts.workerCapabilityModules
425
- ? { CODE_SHELL_CAPABILITY_MODULES: opts.workerCapabilityModules }
426
- : {}),
453
+ CODE_SHELL_CAPABILITY_MODULES: opts.workerCapabilityModules ??
454
+ `${import.meta.resolve("@cjhyy/code-shell-server/links")}#createLinkProviderModule`,
427
455
  }),
428
456
  log,
429
457
  onStderr: (text) => log("worker.stderr", { text: previewLine(text) }),
@@ -486,6 +514,8 @@ export async function startHeadlessServer(opts) {
486
514
  };
487
515
  const configurationOptions = {
488
516
  cwd: workspaceCwd,
517
+ traceStorageDir: join(sessionRootDir, ".model-traces"),
518
+ traceSource: "server",
489
519
  isAuthorized: async (req) => !!(await hubAuth?.authenticate(req)),
490
520
  ownerId: async (req) => (await hubAuth?.authenticate(req))?.id,
491
521
  withMutation: withConfigurationMutation,
@@ -537,9 +567,8 @@ export async function startHeadlessServer(opts) {
537
567
  CODE_SHELL_WORKSPACE_ACCESS: undefined,
538
568
  CODE_SHELL_DATA_ROOT: workerDataRoot,
539
569
  CODE_SHELL_CREDENTIAL_ACCESS: "local",
540
- ...(opts.workerCapabilityModules
541
- ? { CODE_SHELL_CAPABILITY_MODULES: opts.workerCapabilityModules }
542
- : {}),
570
+ CODE_SHELL_CAPABILITY_MODULES: opts.workerCapabilityModules ??
571
+ `${import.meta.resolve("@cjhyy/code-shell-server/links")}#createLinkProviderModule`,
543
572
  }),
544
573
  },
545
574
  onChanged: () => notify("serve/configurationChanged", {}),
@@ -575,6 +604,13 @@ export async function startHeadlessServer(opts) {
575
604
  const files = hubAuth
576
605
  ? createHubFiles({ cwd: workspaceCwd, isAuthorized: configurationOptions.isAuthorized })
577
606
  : undefined;
607
+ const modelTraces = hubAuth
608
+ ? createModelTraceHttp({
609
+ storageDir: join(sessionRootDir, ".model-traces"),
610
+ workspacePath: workspaceCwd,
611
+ isAuthorized: configurationOptions.isAuthorized,
612
+ })
613
+ : undefined;
578
614
  const serveStatic = (req, res) => {
579
615
  const root = opts.staticRootDir;
580
616
  if (!root) {
@@ -681,11 +717,19 @@ export async function startHeadlessServer(opts) {
681
717
  return;
682
718
  if (await files.handle(req, res))
683
719
  return;
720
+ if (await modelTraces.handle(req, res))
721
+ return;
684
722
  const upload = /^\/api\/v1\/uploads\/([^/]+)$/.exec(pathname);
685
723
  if (upload && req.method === "PUT") {
686
724
  await uploads.accept(upload[1], session.id, req, res, async () => !!(await hubAuth.authenticate(req)));
687
725
  return;
688
726
  }
727
+ if (upload && req.method === "DELETE") {
728
+ // Removing a draft attachment releases its quota; idempotent for retries.
729
+ await uploads.discard(upload[1], session.id);
730
+ res.writeHead(204, { "cache-control": "no-store" }).end();
731
+ return;
732
+ }
689
733
  hubJson(res, 404, { error: "API endpoint not found" });
690
734
  return;
691
735
  }
@@ -828,7 +872,16 @@ export async function startHeadlessServer(opts) {
828
872
  sendToTab(ws, hostQueryError(parsed.id ?? null, -32000, "too many pending agent worker requests for this tab"), "pending-limit");
829
873
  return;
830
874
  }
875
+ // Retryable admission gates run before an approval lease is submitted:
876
+ // a submitted lease is excluded from snapshots until the worker ACKs.
877
+ if (hubAuth && (configurationChanging || configurationReloadFailed)) {
878
+ sendToTab(ws, hostQueryError(parsed.id ?? null, -32009, configurationReloadFailed
879
+ ? "请先重新保存配置或重启服务,再发送任务。"
880
+ : "配置正在保存,请稍后重新发送任务。"), "configuration-changing");
881
+ return;
882
+ }
831
883
  let approvalId;
884
+ let approvalRouted = false;
832
885
  if (hubAuth && parsed.method === "agent/approve") {
833
886
  const requestId = parsed.params?.requestId;
834
887
  const approval = typeof requestId === "string" ? leases.get(requestId) : undefined;
@@ -865,12 +918,6 @@ export async function startHeadlessServer(opts) {
865
918
  let preparedUploads;
866
919
  let uploadsTransferred = false;
867
920
  const preparingRun = !!hubAuth && parsed.method === "agent/run";
868
- if (hubAuth && (configurationChanging || configurationReloadFailed)) {
869
- sendToTab(ws, hostQueryError(parsed.id ?? null, -32009, configurationReloadFailed
870
- ? "请先重新保存配置或重启服务,再发送任务。"
871
- : "配置正在保存,请稍后重新发送任务。"), "configuration-changing");
872
- return;
873
- }
874
921
  if (preparingRun)
875
922
  preparingRuns++;
876
923
  try {
@@ -973,6 +1020,8 @@ export async function startHeadlessServer(opts) {
973
1020
  method: parsed.method,
974
1021
  ...(approvalId ? { approvalId } : {}),
975
1022
  });
1023
+ // The worker response route now owns the submitted lease.
1024
+ approvalRouted = true;
976
1025
  pendingResponsesByTab.set(tabId, (pendingResponsesByTab.get(tabId) ?? 0) + 1);
977
1026
  workerMessage.id = workerRequestId;
978
1027
  if (preparedUploads?.attachments.length) {
@@ -1023,6 +1072,11 @@ export async function startHeadlessServer(opts) {
1023
1072
  preparingRuns--;
1024
1073
  if (!uploadsTransferred)
1025
1074
  preparedUploads?.release();
1075
+ if (approvalId && !approvalRouted) {
1076
+ // Rejected before dispatch: the worker never saw this decision.
1077
+ leases.retry(approvalId);
1078
+ notify("serve/approvalSnapshot", { approvals: leases.snapshot() });
1079
+ }
1026
1080
  }
1027
1081
  };
1028
1082
  let queued = 0;
@@ -1305,7 +1359,7 @@ function replyToHostSessionQuery(message, sessionManager, workspaceCwd, isRunnin
1305
1359
  data: {
1306
1360
  state,
1307
1361
  ...(legacy ? { outputJournal: journal, legacyBaseComplete: legacy.complete } : {}),
1308
- ...(runReplay ? runReplay.snapshot(sessionId, transcript) : { transcript }),
1362
+ ...(runReplay ? runReplay.snapshot(sessionId, transcript, !!legacy) : { transcript }),
1309
1363
  running: isRunning(sessionId),
1310
1364
  },
1311
1365
  },
@@ -3,6 +3,7 @@
3
3
  * Uses core's canonical session root so CODE_SHELL_HOME matches the worker.
4
4
  */
5
5
  import { SessionManager, sessionsRoot } from "@cjhyy/code-shell-core";
6
+ import { scanSessionDirectories, readSessionListState, SessionFileError, } from "@cjhyy/code-shell-host/session-files";
6
7
  import * as fs from "node:fs/promises";
7
8
  import * as path from "node:path";
8
9
  const SAFE_ID = /^[A-Za-z0-9_.-]+$/;
@@ -175,6 +176,9 @@ export async function cleanupStaleQuickChatSessions(baseDir = sessionsRoot()) {
175
176
  }
176
177
  return [...ids, ...stagingDirs];
177
178
  }
179
+ function compareDiskSessionPosition(a, b) {
180
+ return b.mtime - a.mtime || a.id.localeCompare(b.id);
181
+ }
178
182
  function encodeDiskSessionCursor(cursor) {
179
183
  return `v1:${cursor.mtime}:${cursor.id}`;
180
184
  }
@@ -210,37 +214,18 @@ export async function listDiskSessions(opts, baseDir = sessionsRoot()) {
210
214
  parentSessionId.includes(".."))) {
211
215
  throw new Error("invalid parent session id");
212
216
  }
213
- // Async fs throughout: this is an IPC handler on the Electron main thread
214
- // (sessions:listDisk). The previous synchronous statSync/readFileSync loops
215
- // never yielded the event loop and froze the UI while enumerating sessions.
216
- let entries;
217
- try {
218
- entries = await fs.readdir(baseDir, { withFileTypes: true });
219
- }
220
- catch (e) {
221
- if (e.code === "ENOENT")
222
- return { sessions: [], nextCursor: null };
223
- throw e;
224
- }
225
- const candidates = entries.filter((e) => e.isDirectory() && SAFE_ID.test(e.name) && !isInternalEphemeralSessionId(e.name));
226
- const stats = await Promise.all(candidates.map(async (e) => {
227
- try {
228
- return { id: e.name, mtime: (await fs.stat(path.join(baseDir, e.name))).mtimeMs };
229
- }
230
- catch {
231
- return null; // skip unreadable
232
- }
233
- }));
234
- const dirs = stats.filter((d) => d !== null);
235
- dirs.sort((a, b) => b.mtime - a.mtime || a.id.localeCompare(b.id));
217
+ // Preserve the public adapter's explicit relative-root form. Host receives
218
+ // one absolute root; it never guesses a workspace for the caller.
219
+ baseDir = path.resolve(baseDir);
220
+ const dirs = (await scanSessionDirectories(baseDir)).filter(({ id }) => !isInternalEphemeralSessionId(id));
221
+ dirs.sort(compareDiskSessionPosition);
236
222
  const decodedCursor = decodeDiskSessionCursor(opts.cursor);
237
223
  if (decodedCursor === null)
238
224
  return { sessions: [], nextCursor: null };
239
225
  const start = typeof decodedCursor === "number"
240
226
  ? Math.max(0, decodedCursor)
241
227
  : (() => {
242
- const index = dirs.findIndex((entry) => entry.mtime < decodedCursor.mtime ||
243
- (entry.mtime === decodedCursor.mtime && entry.id > decodedCursor.id));
228
+ const index = dirs.findIndex((entry) => compareDiskSessionPosition(entry, decodedCursor) > 0);
244
229
  return index < 0 ? dirs.length : index;
245
230
  })();
246
231
  const sessions = [];
@@ -264,9 +249,14 @@ export async function listDiskSessions(opts, baseDir = sessionsRoot()) {
264
249
  const { id, mtime } = dirs[i];
265
250
  let state;
266
251
  try {
267
- state = JSON.parse(await fs.readFile(path.join(baseDir, id, "state.json"), "utf8"));
252
+ state = JSON.parse(await readSessionListState(baseDir, id, MAX_HISTORY_STATE_BYTES));
253
+ if (!state || typeof state !== "object" || Array.isArray(state))
254
+ continue;
268
255
  }
269
- catch {
256
+ catch (error) {
257
+ if (error instanceof SessionFileError && error.code === "too_large") {
258
+ process.emitWarning(`Session ${id}: state.json exceeds the 2 MiB catalog metadata limit; entry omitted.`, { code: "SESSION_LIST_STATE_TOO_LARGE" });
259
+ }
270
260
  continue;
271
261
  }
272
262
  if (state.ephemeral === true)