@cjhyy/code-shell-server 0.9.8 → 0.9.9

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 (109) hide show
  1. package/README.md +85 -23
  2. package/dist/bin/code-shell-project-runtime.d.ts +2 -0
  3. package/dist/bin/code-shell-project-runtime.js +8 -0
  4. package/dist/desktop-web/http-api.d.ts +43 -0
  5. package/dist/desktop-web/http-api.js +376 -0
  6. package/dist/hub/approval-lease.d.ts +30 -0
  7. package/dist/hub/approval-lease.js +89 -0
  8. package/dist/hub/auth-http.d.ts +23 -0
  9. package/dist/hub/auth-http.js +259 -0
  10. package/dist/hub/auth-store.d.ts +52 -0
  11. package/dist/hub/auth-store.js +316 -0
  12. package/dist/hub/configuration.d.ts +64 -0
  13. package/dist/hub/configuration.js +555 -0
  14. package/dist/hub/files.d.ts +9 -0
  15. package/dist/hub/files.js +278 -0
  16. package/dist/hub/mcp-configuration.d.ts +51 -0
  17. package/dist/hub/mcp-configuration.js +558 -0
  18. package/dist/hub/mcp-probe.d.ts +24 -0
  19. package/dist/hub/mcp-probe.js +392 -0
  20. package/dist/hub/model-probe.d.ts +20 -0
  21. package/dist/hub/model-probe.js +193 -0
  22. package/dist/hub/outbound-transport.d.ts +36 -0
  23. package/dist/hub/outbound-transport.js +122 -0
  24. package/dist/hub/run-replay.d.ts +42 -0
  25. package/dist/hub/run-replay.js +89 -0
  26. package/dist/hub/session-management.d.ts +36 -0
  27. package/dist/hub/session-management.js +405 -0
  28. package/dist/hub/skills-management.d.ts +43 -0
  29. package/dist/hub/skills-management.js +487 -0
  30. package/dist/hub/uploads.d.ts +22 -0
  31. package/dist/hub/uploads.js +241 -0
  32. package/dist/index.d.ts +3 -0
  33. package/dist/index.desktop-web.d.ts +2 -0
  34. package/dist/index.desktop-web.js +2 -0
  35. package/dist/index.js +3 -0
  36. package/dist/index.links.d.ts +6 -0
  37. package/dist/index.links.js +6 -0
  38. package/dist/index.panels.d.ts +9 -0
  39. package/dist/index.panels.js +8 -0
  40. package/dist/index.serve.d.ts +1 -0
  41. package/dist/index.serve.js +1 -0
  42. package/dist/index.storage.d.ts +2 -0
  43. package/dist/index.storage.js +2 -0
  44. package/dist/links/catalog.d.ts +10 -0
  45. package/dist/links/catalog.js +41 -0
  46. package/dist/links/device-oauth.d.ts +52 -0
  47. package/dist/links/device-oauth.js +255 -0
  48. package/dist/links/http.d.ts +34 -0
  49. package/dist/links/http.js +144 -0
  50. package/dist/links/service.d.ts +54 -0
  51. package/dist/links/service.js +578 -0
  52. package/dist/links/types.d.ts +6 -0
  53. package/dist/links/types.js +1 -0
  54. package/dist/mobile-remote/mobile-client-event-validator.js +5 -2
  55. package/dist/mobile-remote/pending-approvals.js +2 -0
  56. package/dist/mobile-remote/remote-host-manager.d.ts +8 -0
  57. package/dist/mobile-remote/remote-host-manager.js +90 -15
  58. package/dist/mobile-remote/room-manager.js +17 -5
  59. package/dist/panels/agent-task-host.d.ts +32 -0
  60. package/dist/panels/agent-task-host.js +457 -0
  61. package/dist/panels/agent-task-models.d.ts +27 -0
  62. package/dist/panels/agent-task-models.js +51 -0
  63. package/dist/panels/agent-task-service.d.ts +105 -0
  64. package/dist/panels/agent-task-service.js +283 -0
  65. package/dist/panels/http.d.ts +34 -0
  66. package/dist/panels/http.js +37 -0
  67. package/dist/panels/hub-binding.d.ts +12 -0
  68. package/dist/panels/hub-binding.js +174 -0
  69. package/dist/panels/management-http.d.ts +29 -0
  70. package/dist/panels/management-http.js +119 -0
  71. package/dist/panels/management.d.ts +39 -0
  72. package/dist/panels/management.js +494 -0
  73. package/dist/panels/process-files.d.ts +14 -0
  74. package/dist/panels/process-files.js +299 -0
  75. package/dist/panels/process-service.d.ts +96 -0
  76. package/dist/panels/process-service.js +501 -0
  77. package/dist/panels/runtime-services.d.ts +24 -0
  78. package/dist/panels/runtime-services.js +462 -0
  79. package/dist/panels/runtime.d.ts +51 -0
  80. package/dist/panels/runtime.js +1063 -0
  81. package/dist/panels/storage-store.d.ts +9 -0
  82. package/dist/panels/storage-store.js +154 -0
  83. package/dist/panels/types.d.ts +52 -0
  84. package/dist/panels/types.js +1 -0
  85. package/dist/project-runtime/control-server.d.ts +19 -0
  86. package/dist/project-runtime/control-server.js +300 -0
  87. package/dist/project-runtime/docker-provider.d.ts +33 -0
  88. package/dist/project-runtime/docker-provider.js +487 -0
  89. package/dist/project-runtime/lock.d.ts +6 -0
  90. package/dist/project-runtime/lock.js +43 -0
  91. package/dist/project-runtime/managed-entry.d.ts +10 -0
  92. package/dist/project-runtime/managed-entry.js +113 -0
  93. package/dist/project-runtime/manager.d.ts +40 -0
  94. package/dist/project-runtime/manager.js +249 -0
  95. package/dist/project-runtime/proxy.d.ts +22 -0
  96. package/dist/project-runtime/proxy.js +706 -0
  97. package/dist/project-runtime/registry.d.ts +54 -0
  98. package/dist/project-runtime/registry.js +286 -0
  99. package/dist/project-runtime/types.d.ts +43 -0
  100. package/dist/project-runtime/types.js +1 -0
  101. package/dist/serve/cli.d.ts +5 -0
  102. package/dist/serve/cli.js +82 -23
  103. package/dist/serve/headless-server.d.ts +11 -0
  104. package/dist/serve/headless-server.js +737 -83
  105. package/dist/session-titles-store.d.ts +17 -0
  106. package/dist/session-titles-store.js +174 -0
  107. package/dist/static-files.js +1 -0
  108. package/dist/worker-bridge-core.js +17 -6
  109. package/package.json +19 -5
@@ -1,14 +1,8 @@
1
1
  // packages/server/src/serve/headless-server.ts
2
2
  //
3
- // Headless serve entry — the no-account web host (TODO「服务端部署 + Web
4
- // Client(无账号体系)」Phase 1').
5
- //
6
- // Composition, all pre-existing parts:
7
- // - AccessPasscode HTTP gate (challenge page) + WS `allows()`;
8
- // passcode + remember-cookie is the ONLY access
9
- // control — no AuthN/AuthZ, no users, by decision.
10
- // - WorkerBridgeCore spawns/drives ONE agent-server-stdio worker.
11
- // - resolveSafe path-traversal-safe static file resolution.
3
+ // Single-workspace Node host. The CLI uses Hub administrator sessions; the
4
+ // legacy passcode mode remains available to existing embedders.
5
+ // One WorkerBridgeCore owns execution and the persistent workspace sessions.
12
6
  //
13
7
  // The browser uses a deliberately small CORE-protocol projection: the host
14
8
  // answers session list/detail itself and forwards only run/approve/cancel after
@@ -20,18 +14,39 @@
20
14
  // browser re-lists sessions over the same protocol.
21
15
  import { createServer } from "node:http";
22
16
  import { readFileSync, existsSync } from "node:fs";
23
- import { dirname, extname, join, resolve } from "node:path";
24
- import { randomBytes } from "node:crypto";
17
+ import { basename, dirname, extname, join, resolve } from "node:path";
18
+ import { randomBytes, randomUUID } from "node:crypto";
25
19
  import { WebSocketServer } from "ws";
26
- import { SessionManager } from "@cjhyy/code-shell-core";
20
+ import { SessionManager, SettingsManager } from "@cjhyy/code-shell-core";
27
21
  import { AccessPasscode } from "../mobile-remote/access-passcode.js";
28
22
  import { resolveSafe } from "../mobile-remote/mobile-static.js";
29
23
  import { contentTypeFor } from "../static-files.js";
24
+ import { createHubAuth } from "../hub/auth-http.js";
25
+ import { ApprovalLeases } from "../hub/approval-lease.js";
26
+ import { HubUploads, hubJson } from "../hub/uploads.js";
27
+ import { createHubConfiguration, HubConfigurationError } from "../hub/configuration.js";
28
+ import { HubRunReplay } from "../hub/run-replay.js";
29
+ import { HubOutboundTransport } from "../hub/outbound-transport.js";
30
+ import { createHubSkills } from "../hub/skills-management.js";
31
+ import { createHubMcpConfiguration } from "../hub/mcp-configuration.js";
32
+ import { createLinkHttp } from "../links/http.js";
33
+ import { createPanelHttp } from "../panels/http.js";
34
+ import { createHubPanelBinding } from "../panels/hub-binding.js";
35
+ import { createHubSessions, hubSessionPreview, readHubSessionState, readHubTranscript, } from "../hub/session-management.js";
36
+ import { createHubFiles } from "../hub/files.js";
30
37
  import { WorkerBridgeCore, previewLine } from "../worker-bridge-core.js";
31
38
  export async function startHeadlessServer(opts) {
32
39
  const host = opts.host ?? "127.0.0.1";
33
- const log = opts.log ?? (() => { });
40
+ const log = (event, data) => {
41
+ if (!opts.log)
42
+ return;
43
+ // Transport diagnostics must not write prompts, approval answers or worker
44
+ // stderr previews to default service logs. Keep only structural metadata.
45
+ const { raw: _raw, text: _text, ...metadata } = data ?? {};
46
+ opts.log(event, opts.debugLogs ? data : metadata);
47
+ };
34
48
  const workspaceCwd = resolve(opts.cwd);
49
+ const panelBinding = opts.authMode === "hub" ? createHubPanelBinding(workspaceCwd) : undefined;
35
50
  const workerDataRoot = resolve(opts.workerDataRoot ??
36
51
  (opts.sessionRootDir ? dirname(resolve(opts.sessionRootDir)) : join(opts.dataDir, "worker")));
37
52
  const sessionRootDir = resolve(opts.sessionRootDir ?? join(workerDataRoot, "sessions"));
@@ -50,40 +65,95 @@ export async function startHeadlessServer(opts) {
50
65
  }
51
66
  const passcode = new AccessPasscode({ filePath: join(opts.dataDir, "access.json") });
52
67
  let generatedPasscode;
53
- if (opts.passcode) {
68
+ if (opts.authMode !== "hub" && opts.passcode) {
54
69
  passcode.set(opts.passcode);
55
70
  }
56
- else if (!passcode.isSet()) {
71
+ else if (opts.authMode !== "hub" && !passcode.isSet()) {
57
72
  generatedPasscode = randomBytes(6).toString("base64url");
58
73
  passcode.set(generatedPasscode);
59
74
  }
60
75
  const tabs = new Set();
76
+ // Auth revocation callbacks are registered before configuration modules are ready.
77
+ // eslint-disable-next-line prefer-const
78
+ let mcp;
79
+ // eslint-disable-next-line prefer-const
80
+ let configuration;
81
+ // eslint-disable-next-line prefer-const
82
+ let links;
83
+ // eslint-disable-next-line prefer-const
84
+ let panels;
85
+ const tabAuth = new Map();
86
+ const hubAuth = opts.authMode === "hub"
87
+ ? await createHubAuth({
88
+ dataDir: opts.dataDir,
89
+ publicOrigin: opts.publicOrigin,
90
+ onRevoke: (sessionId) => {
91
+ mcp?.cancelOwner(sessionId);
92
+ configuration?.cancelOwner(sessionId);
93
+ links?.cancelOwner(sessionId);
94
+ panels?.cancelOwner(sessionId);
95
+ for (const [ws, auth] of tabAuth) {
96
+ if (auth.sessionId === sessionId)
97
+ ws.close(4401, "session revoked");
98
+ }
99
+ },
100
+ })
101
+ : undefined;
102
+ const uploads = hubAuth ? new HubUploads(join(opts.dataDir, "uploads"), workspaceCwd) : undefined;
103
+ await uploads?.ready();
61
104
  const pendingWorkerResponses = new Map();
62
105
  const pendingResponsesByTab = new Map();
106
+ // Independent of response routing: leaving a tab does not stop its worker run.
107
+ const runningSessions = new Map();
108
+ const runOwners = new Map();
109
+ const runReplay = new HubRunReplay();
110
+ const runInputs = new Map();
111
+ let preparingRuns = 0;
112
+ let configurationChanging = false;
113
+ let configurationReloadFailed = false;
114
+ const runUploads = new Map();
115
+ const isRunning = (sessionId) => [...runningSessions.values()].includes(sessionId);
63
116
  let nextTabId = 1;
64
117
  let nextWorkerRequestId = 1;
65
- const sendToTab = (tab, line, context) => {
66
- if (tab.readyState !== tab.OPEN)
67
- return false;
68
- try {
69
- tab.send(line);
70
- return true;
71
- }
72
- catch (error) {
73
- // readyState can change between the guard and send. A dead browser must
74
- // never crash the shared worker router or its timeout reaper.
75
- log("tab.send_failed", {
76
- context,
77
- error: error instanceof Error ? error.message : String(error),
78
- });
79
- return false;
80
- }
81
- };
118
+ const outbound = new HubOutboundTransport({
119
+ tabs: () => tabs,
120
+ onDrop: (metadata) => log("tab.backpressure_drop", { ...metadata }),
121
+ });
122
+ const sendToTab = (tab, line, _context) => outbound.send(tab, line);
82
123
  const broadcast = (line) => {
83
124
  for (const tab of tabs) {
84
125
  sendToTab(tab, line, "broadcast");
85
126
  }
86
127
  };
128
+ const notify = (method, params) => {
129
+ broadcast(JSON.stringify({ jsonrpc: "2.0", method, params }));
130
+ };
131
+ const publishStream = (sessionId, event) => {
132
+ const cursor = runReplay.append(sessionId, event);
133
+ notify("agent/streamEvent", {
134
+ sessionId,
135
+ event,
136
+ hubSequence: cursor.sequence,
137
+ hubEpoch: cursor.epoch,
138
+ });
139
+ };
140
+ const leases = new ApprovalLeases((lease) => notify("serve/approvalLease", lease));
141
+ const authReaper = hubAuth
142
+ ? setInterval(() => {
143
+ leases.sweep();
144
+ void uploads?.sweep().catch(() => { });
145
+ for (const [ws, auth] of tabAuth) {
146
+ void hubAuth
147
+ .authenticate(auth.request)
148
+ .then((session) => {
149
+ if (!session)
150
+ ws.close(4401, "session expired");
151
+ })
152
+ .catch(() => ws.close(4401, "session unavailable"));
153
+ }
154
+ }, opts.authRecheckMs ?? 5_000)
155
+ : undefined;
156
+ authReaper?.unref?.();
87
157
  const deletePendingWorkerResponse = (requestId) => {
88
158
  const route = pendingWorkerResponses.get(requestId);
89
159
  if (!route)
@@ -97,7 +167,9 @@ export async function startHeadlessServer(opts) {
97
167
  };
98
168
  const clearPendingWorkerResponsesForTab = (tabId) => {
99
169
  for (const [requestId, route] of pendingWorkerResponses) {
100
- if (route.tabId === tabId)
170
+ // A submitted approval still needs its worker ACK after this tab leaves.
171
+ // In particular, an error must reopen the card on the remaining devices.
172
+ if (route.tabId === tabId && !route.approvalId)
101
173
  deletePendingWorkerResponse(requestId);
102
174
  }
103
175
  };
@@ -107,10 +179,20 @@ export async function startHeadlessServer(opts) {
107
179
  }
108
180
  pendingWorkerResponses.clear();
109
181
  pendingResponsesByTab.clear();
182
+ runningSessions.clear();
183
+ runOwners.clear();
184
+ runReplay.clear();
185
+ runInputs.clear();
186
+ for (const receipt of runUploads.values())
187
+ receipt.release();
188
+ runUploads.clear();
110
189
  };
111
190
  const pendingResponseReaper = setInterval(() => {
112
191
  const now = Date.now();
113
192
  for (const [requestId, route] of pendingWorkerResponses) {
193
+ // Runs answer only at turn end, potentially hours later.
194
+ if (hubAuth && (route.method === "agent/run" || route.approvalId))
195
+ continue;
114
196
  if (now - route.insertedAt < pendingResponseTtlMs)
115
197
  continue;
116
198
  deletePendingWorkerResponse(requestId);
@@ -132,17 +214,132 @@ export async function startHeadlessServer(opts) {
132
214
  return;
133
215
  }
134
216
  if (!message || message.id === undefined || message.method !== undefined) {
217
+ if (hubAuth && message?.method === "agent/streamEvent") {
218
+ const params = message.params;
219
+ if (typeof params?.sessionId === "string" && params.event !== undefined) {
220
+ let event = params.event;
221
+ const value = event;
222
+ if (value?.type === "session_user_message" && typeof value.clientMessageId === "string") {
223
+ const input = [...runInputs.values()].find((candidate) => candidate.sessionId === params.sessionId &&
224
+ candidate.event.clientMessageId === value.clientMessageId);
225
+ if (input) {
226
+ if (input.echoed)
227
+ return;
228
+ input.echoed = true;
229
+ event = input.event;
230
+ }
231
+ }
232
+ publishStream(params.sessionId, event);
233
+ return;
234
+ }
235
+ }
236
+ if (hubAuth && message?.method === "agent/runAccepted") {
237
+ const params = message.params;
238
+ const requestId = String(params?.requestId);
239
+ const input = runInputs.get(requestId);
240
+ if (input && !input.echoed) {
241
+ input.echoed = true;
242
+ publishStream(input.sessionId, input.event);
243
+ }
244
+ const route = pendingWorkerResponses.get(requestId);
245
+ if (route)
246
+ sendToTab(route.tab, JSON.stringify({
247
+ ...message,
248
+ params: { ...params, requestId: route.originalId },
249
+ }), "run-accepted");
250
+ return;
251
+ }
252
+ if (hubAuth && message?.method === "agent/approvalRequest") {
253
+ const approval = message.params;
254
+ if (approval &&
255
+ typeof approval.requestId === "string" &&
256
+ typeof approval.sessionId === "string") {
257
+ const request = approval.request;
258
+ if (request?.toolName === "__panel_action__") {
259
+ const ownerId = runOwners.get(approval.sessionId);
260
+ const result = ownerId && panels
261
+ ? panels.panelAction(ownerId, approval.sessionId, request.args ?? {})
262
+ : Promise.resolve({ ok: false, detail: "当前对话没有可用的面板连接。" });
263
+ void result.catch(() => ({ ok: false, detail: "面板操作未完成。" })).then((answer) => {
264
+ if (!isRunning(approval.sessionId) || runOwners.get(approval.sessionId) !== ownerId)
265
+ return;
266
+ return bridge.request("agent/approve", {
267
+ sessionId: approval.sessionId, requestId: approval.requestId,
268
+ connectionId: approval.connectionId, generation: approval.generation,
269
+ decision: { approved: true, answer: JSON.stringify(answer) },
270
+ }, { id: `panel-reply-${randomUUID()}`, consume: true, settleOnExit: true, failFast: true, timeoutMs: 5000,
271
+ meta: { origin: "host", producer: "hub-panel-action" },
272
+ });
273
+ }).catch(() => { });
274
+ return;
275
+ }
276
+ try {
277
+ leases.add(approval);
278
+ }
279
+ catch {
280
+ workerUnavailable("pending approval limit exceeded");
281
+ bridge.kill();
282
+ return;
283
+ }
284
+ }
285
+ }
286
+ else if (hubAuth && message?.method === "agent/approvalResolved") {
287
+ const requestId = message.params?.requestId;
288
+ if (requestId)
289
+ leases.resolved(requestId);
290
+ }
135
291
  broadcast(line);
136
292
  return;
137
293
  }
294
+ const receipt = runUploads.get(String(message.id));
295
+ if (receipt) {
296
+ runUploads.delete(String(message.id));
297
+ if (message.error)
298
+ receipt.release();
299
+ else
300
+ void receipt.commit().catch(() => log("upload.finalize_failed"));
301
+ }
302
+ const runSessionId = runningSessions.get(String(message.id));
303
+ runInputs.delete(String(message.id));
304
+ if (runSessionId) {
305
+ const finishedSession = runningSessions.get(String(message.id));
306
+ runningSessions.delete(String(message.id));
307
+ if (finishedSession && !isRunning(finishedSession))
308
+ runOwners.delete(finishedSession);
309
+ runReplay.finish(runSessionId, String(message.id));
310
+ if (hubAuth)
311
+ notify("serve/sessionStatus", {
312
+ sessionId: runSessionId,
313
+ running: isRunning(runSessionId),
314
+ });
315
+ }
138
316
  const route = pendingWorkerResponses.get(String(message.id));
139
317
  if (!route) {
140
318
  log("worker.response_dropped", { reason: "unknown request id", id: message.id });
141
319
  return;
142
320
  }
321
+ if (route.approvalId) {
322
+ if (message.error) {
323
+ leases.retry(route.approvalId);
324
+ notify("serve/approvalSnapshot", { approvals: leases.snapshot() });
325
+ }
326
+ else {
327
+ // The worker's successful ACK is authoritative even if an implementation
328
+ // omits the separate approvalResolved notification.
329
+ leases.resolved(route.approvalId);
330
+ notify("agent/approvalResolved", { requestId: route.approvalId });
331
+ }
332
+ }
143
333
  deletePendingWorkerResponse(String(message.id));
144
334
  sendToTab(route.tab, JSON.stringify({ ...message, id: route.originalId }), "worker-response");
145
335
  };
336
+ const workerUnavailable = (message) => {
337
+ failPendingWorkerResponses(message);
338
+ leases.clear();
339
+ if (hubAuth)
340
+ notify("serve/approvalSnapshot", { approvals: [] });
341
+ notify("serve/workerExit", { clean: false, gaveUp: true });
342
+ };
146
343
  const bridge = new WorkerBridgeCore({
147
344
  entryPath: opts.workerEntryPath,
148
345
  execPath: opts.execPath,
@@ -150,14 +347,21 @@ export async function startHeadlessServer(opts) {
150
347
  buildEnv: () => ({
151
348
  ...process.env,
152
349
  CODE_SHELL_DATA_ROOT: workerDataRoot,
350
+ CODE_SHELL_CREDENTIAL_ACCESS: "local",
153
351
  ...(opts.workerCapabilityModules
154
352
  ? { CODE_SHELL_CAPABILITY_MODULES: opts.workerCapabilityModules }
155
353
  : {}),
156
354
  }),
157
355
  log,
158
356
  onStderr: (text) => log("worker.stderr", { text: previewLine(text) }),
357
+ onSpawnFailed: () => workerUnavailable("agent worker could not start"),
358
+ onSpawnError: () => workerUnavailable("agent worker could not start"),
159
359
  onExit: (info) => {
160
360
  failPendingWorkerResponses("agent worker stopped");
361
+ leases.clear();
362
+ if (hubAuth)
363
+ notify("serve/approvalSnapshot", { approvals: [] });
364
+ configurationReloadFailed = false;
161
365
  // Synthetic serve-level notification so the UI can show "agent worker
162
366
  // stopped" without conflating it with in-protocol agent/status.
163
367
  broadcast(JSON.stringify({
@@ -168,6 +372,102 @@ export async function startHeadlessServer(opts) {
168
372
  },
169
373
  });
170
374
  bridge.subscribeLines(routeWorkerLine);
375
+ const withConfigurationMutation = async (write) => {
376
+ if (configurationChanging ||
377
+ preparingRuns > 0 ||
378
+ runningSessions.size > 0 ||
379
+ (panels?.activeTaskCount() ?? 0) > 0 ||
380
+ pendingWorkerResponses.size > 0) {
381
+ throw new HubConfigurationError(409, "请等待当前任务完成后再保存配置。");
382
+ }
383
+ configurationChanging = true;
384
+ try {
385
+ const result = await write();
386
+ // Use Desktop's fixed hot-reload operation, preserving sessions and
387
+ // background commands. This host-only RPC is never browser-controlled.
388
+ if (bridge.hasChild()) {
389
+ const outcome = await bridge.request("agent/configure", {
390
+ reloadModels: true,
391
+ reloadSettings: true,
392
+ }, {
393
+ id: `hub-configuration-${nextWorkerRequestId++}`,
394
+ timeoutMs: 5_000,
395
+ consume: true,
396
+ settleOnExit: true,
397
+ failFast: true,
398
+ meta: { origin: "serve", producer: "serve-configuration" },
399
+ });
400
+ if (outcome.status !== "result") {
401
+ configurationReloadFailed = bridge.hasChild();
402
+ throw new HubConfigurationError(503, "配置已保存但未能生效,请重新保存或重启服务。");
403
+ }
404
+ }
405
+ configurationReloadFailed = false;
406
+ notify("serve/configurationChanged", {});
407
+ return result;
408
+ }
409
+ finally {
410
+ configurationChanging = false;
411
+ }
412
+ };
413
+ const configurationOptions = {
414
+ cwd: workspaceCwd,
415
+ isAuthorized: async (req) => !!(await hubAuth?.authenticate(req)),
416
+ ownerId: async (req) => (await hubAuth?.authenticate(req))?.id,
417
+ withMutation: withConfigurationMutation,
418
+ };
419
+ configuration = hubAuth ? createHubConfiguration(configurationOptions) : undefined;
420
+ links = hubAuth
421
+ ? createLinkHttp({
422
+ ...configurationOptions,
423
+ onChanged: () => notify("serve/configurationChanged", {}),
424
+ })
425
+ : undefined;
426
+ panels = hubAuth
427
+ ? createPanelHttp({
428
+ ...configurationOptions,
429
+ ...panelBinding,
430
+ dataDir: opts.dataDir,
431
+ host: "hub",
432
+ publicPathPrefix: opts.publicPathPrefix,
433
+ agentTaskOptions: {
434
+ workerEntryPath: opts.workerEntryPath,
435
+ buildEnv: () => ({
436
+ ...process.env,
437
+ CODE_SHELL_DATA_ROOT: workerDataRoot,
438
+ CODE_SHELL_CREDENTIAL_ACCESS: "local",
439
+ ...(opts.workerCapabilityModules ? { CODE_SHELL_CAPABILITY_MODULES: opts.workerCapabilityModules } : {}),
440
+ }),
441
+ },
442
+ onChanged: () => notify("serve/configurationChanged", {}),
443
+ })
444
+ : undefined;
445
+ const skills = hubAuth
446
+ ? createHubSkills({
447
+ ...configurationOptions,
448
+ dataDir: opts.dataDir,
449
+ owner: async (req) => (await hubAuth.authenticate(req))?.id ?? null,
450
+ })
451
+ : undefined;
452
+ mcp = hubAuth
453
+ ? createHubMcpConfiguration({
454
+ ...configurationOptions,
455
+ ownerId: async (req) => (await hubAuth.authenticate(req))?.id,
456
+ })
457
+ : undefined;
458
+ const sessionManagement = hubAuth
459
+ ? createHubSessions({
460
+ cwd: workspaceCwd,
461
+ sessionRootDir,
462
+ dataDir: opts.dataDir,
463
+ isAuthorized: configurationOptions.isAuthorized,
464
+ isRunning,
465
+ onChanged: (sessionId) => notify("serve/sessionsChanged", { sessionId }),
466
+ })
467
+ : undefined;
468
+ const files = hubAuth
469
+ ? createHubFiles({ cwd: workspaceCwd, isAuthorized: configurationOptions.isAuthorized })
470
+ : undefined;
171
471
  const serveStatic = (req, res) => {
172
472
  const root = opts.staticRootDir;
173
473
  if (!root) {
@@ -212,27 +512,105 @@ export async function startHeadlessServer(opts) {
212
512
  res.end("not found");
213
513
  }
214
514
  };
215
- const server = createServer((req, res) => {
216
- // The passcode gate fronts EVERY route — same posture as the tunnel mode
217
- // of the mobile remote host. gate() renders the challenge page for HTML
218
- // navigations and sets the remember cookie on success.
515
+ const handleHttp = async (req, res) => {
516
+ const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
517
+ res.setHeader("x-content-type-options", "nosniff");
518
+ res.setHeader("referrer-policy", "no-referrer");
519
+ if (pathname === "/health" && req.method === "GET") {
520
+ hubJson(res, 200, { status: "ok", mode: hubAuth ? "hub" : "passcode" });
521
+ return;
522
+ }
523
+ if (hubAuth) {
524
+ if (await panels?.handleAssets(req, res))
525
+ return;
526
+ if (await hubAuth.handle(req, res))
527
+ return;
528
+ if (pathname.startsWith("/api/")) {
529
+ const session = await hubAuth.authenticate(req);
530
+ if (!session) {
531
+ hubJson(res, 401, { error: "login required" });
532
+ return;
533
+ }
534
+ if (!hubAuth.isOriginAllowed(req)) {
535
+ hubJson(res, 403, { error: "origin rejected" });
536
+ return;
537
+ }
538
+ if (await configuration.handle(req, res))
539
+ return;
540
+ if (await links.handle(req, res))
541
+ return;
542
+ if (await panels.handle(req, res))
543
+ return;
544
+ if (await skills.handle(req, res))
545
+ return;
546
+ if (await mcp.handle(req, res))
547
+ return;
548
+ if (await sessionManagement.handle(req, res))
549
+ return;
550
+ if (await files.handle(req, res))
551
+ return;
552
+ const upload = /^\/api\/v1\/uploads\/([^/]+)$/.exec(pathname);
553
+ if (upload && req.method === "PUT") {
554
+ await uploads.accept(upload[1], session.id, req, res, async () => !!(await hubAuth.authenticate(req)));
555
+ return;
556
+ }
557
+ hubJson(res, 404, { error: "API endpoint not found" });
558
+ return;
559
+ }
560
+ if (req.method !== "GET" && req.method !== "HEAD") {
561
+ hubJson(res, 405, { error: "method not allowed" });
562
+ return;
563
+ }
564
+ // Public static shell contains no user data and supplies the setup/login page.
565
+ serveStatic(req, res);
566
+ return;
567
+ }
219
568
  if (!passcode.gate(req, res))
220
569
  return;
221
570
  serveStatic(req, res);
571
+ };
572
+ const server = createServer((req, res) => {
573
+ void handleHttp(req, res).catch(() => hubJson(res, 500, { error: "server request failed" }));
222
574
  });
223
575
  const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
224
- server.on("upgrade", (req, socket, head) => {
576
+ const upgrade = async (req, socket, head) => {
225
577
  const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
226
- if (pathname !== "/ws" || !passcode.allows(req)) {
578
+ const session = hubAuth ? await hubAuth.authenticate(req) : undefined;
579
+ const allowed = hubAuth
580
+ ? !!session && hubAuth.isOriginAllowed(req)
581
+ : passcode.allows(req);
582
+ if (pathname !== "/ws" || !allowed) {
227
583
  socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
228
584
  socket.destroy();
229
585
  return;
230
586
  }
587
+ if (tabs.size >= 64) {
588
+ socket.write("HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n");
589
+ socket.destroy();
590
+ return;
591
+ }
231
592
  const tabId = nextTabId++;
232
593
  wss.handleUpgrade(req, socket, head, (ws) => {
233
594
  tabs.add(ws);
595
+ if (session)
596
+ tabAuth.set(ws, { sessionId: session.id, request: req });
597
+ const holderId = String(tabId);
598
+ if (hubAuth) {
599
+ sendToTab(ws, JSON.stringify({ jsonrpc: "2.0", method: "serve/hello", params: { tabId: holderId } }), "hello");
600
+ sendToTab(ws, JSON.stringify({
601
+ jsonrpc: "2.0",
602
+ method: "serve/approvalSnapshot",
603
+ params: { approvals: leases.snapshot() },
604
+ }), "approval-snapshot");
605
+ }
234
606
  log("tab.connected", { tabs: tabs.size });
235
- ws.on("message", (data) => {
607
+ const handleMessage = async (data) => {
608
+ if (ws.readyState !== ws.OPEN)
609
+ return;
610
+ if (hubAuth && !(await hubAuth.authenticate(req))) {
611
+ ws.close(4401, "session expired");
612
+ return;
613
+ }
236
614
  if (webSocketPayloadBytes(data) > 1024 * 1024) {
237
615
  log("tab.frame_dropped", { reason: "payload too large" });
238
616
  ws.close(1009, "payload too large");
@@ -253,57 +631,273 @@ export async function startHeadlessServer(opts) {
253
631
  log("tab.frame_dropped", { reason: "not jsonrpc", raw: previewLine(line) });
254
632
  return;
255
633
  }
256
- const hostReply = replyToHostSessionQuery(parsed, sessionManager, workspaceCwd);
634
+ if (hubAuth &&
635
+ (parsed.method === "serve/approval.claim" || parsed.method === "serve/approval.release")) {
636
+ const requestId = parsed.params?.requestId;
637
+ try {
638
+ if (typeof requestId !== "string")
639
+ throw new Error("requestId required");
640
+ const result = parsed.method === "serve/approval.claim"
641
+ ? leases.claim(requestId, holderId)
642
+ : (leases.release(requestId, holderId), { released: true });
643
+ sendToTab(ws, JSON.stringify({ jsonrpc: "2.0", id: parsed.id ?? null, result }), "approval-lease");
644
+ }
645
+ catch (error) {
646
+ sendToTab(ws, hostQueryError(parsed.id ?? null, -32009, error.message), "approval-lease");
647
+ }
648
+ return;
649
+ }
650
+ const hostReply = replyToHostSessionQuery(parsed, sessionManager, workspaceCwd, isRunning, hubAuth ? runReplay : undefined);
257
651
  if (hostReply) {
258
- ws.send(hostReply);
652
+ sendToTab(ws, hostReply, "host-session-query");
259
653
  return;
260
654
  }
261
- const policyReply = authorizeServeRequest(parsed, sessionManager, workspaceCwd);
655
+ const policyReply = authorizeServeRequest(parsed, sessionManager, workspaceCwd, isRunning);
262
656
  if (policyReply) {
263
- ws.send(policyReply);
657
+ sendToTab(ws, policyReply, "request-policy");
658
+ return;
659
+ }
660
+ if (hubAuth &&
661
+ (parsed.id === undefined ||
662
+ parsed.id === null ||
663
+ (typeof parsed.id !== "string" && typeof parsed.id !== "number"))) {
664
+ sendToTab(ws, hostQueryError(null, -32600, "request id required"), "request-id");
264
665
  return;
265
666
  }
266
- // This host intentionally exposes one workspace. A browser must not be
267
- // able to escape it by supplying another cwd, and a missing cwd must
268
- // not silently become the worker's global no-repo conversation.
269
- const workerMessage = parsed.method === "agent/run"
270
- ? { ...parsed, params: { ...(parsed.params ?? {}), cwd: workspaceCwd } }
271
- : parsed;
272
- if (workerMessage.id === null) {
273
- ws.send(hostQueryError(null, -32600, "JSON-RPC request id must not be null"));
667
+ if ((pendingResponsesByTab.get(tabId) ?? 0) >= 64) {
668
+ sendToTab(ws, hostQueryError(parsed.id ?? null, -32000, "too many pending agent worker requests for this tab"), "pending-limit");
274
669
  return;
275
670
  }
276
- if (workerMessage.id !== undefined) {
277
- if ((pendingResponsesByTab.get(tabId) ?? 0) >= 64) {
278
- ws.send(hostQueryError(workerMessage.id, -32000, "too many pending agent worker requests for this tab"));
671
+ let approvalId;
672
+ if (hubAuth && parsed.method === "agent/approve") {
673
+ const requestId = parsed.params?.requestId;
674
+ const approval = typeof requestId === "string" ? leases.get(requestId) : undefined;
675
+ if (!approval || approval.sessionId !== parsed.params?.sessionId) {
676
+ sendToTab(ws, hostQueryError(parsed.id ?? null, -32009, "approval is no longer pending"), "approval");
279
677
  return;
280
678
  }
281
- const workerRequestId = `serve-${tabId}-${nextWorkerRequestId++}`;
282
- pendingWorkerResponses.set(workerRequestId, {
283
- tab: ws,
284
- originalId: workerMessage.id,
285
- tabId,
286
- insertedAt: Date.now(),
287
- });
288
- pendingResponsesByTab.set(tabId, (pendingResponsesByTab.get(tabId) ?? 0) + 1);
289
- workerMessage.id = workerRequestId;
679
+ const decision = parsed.params?.decision;
680
+ if (!decision ||
681
+ typeof decision !== "object" ||
682
+ typeof decision.approved !== "boolean") {
683
+ sendToTab(ws, hostQueryError(parsed.id ?? null, -32602, "approval decision required"), "approval");
684
+ return;
685
+ }
686
+ try {
687
+ leases.submit(approval.requestId, holderId);
688
+ approvalId = approval.requestId;
689
+ // Connection/generation belong to the worker's request, never to browser input.
690
+ parsed.params = {
691
+ sessionId: approval.sessionId,
692
+ requestId: approval.requestId,
693
+ decision,
694
+ ...(approval.connectionId !== undefined
695
+ ? { connectionId: approval.connectionId }
696
+ : {}),
697
+ ...(approval.generation !== undefined ? { generation: approval.generation } : {}),
698
+ };
699
+ }
700
+ catch (error) {
701
+ sendToTab(ws, hostQueryError(parsed.id ?? null, -32009, error.message), "approval");
702
+ return;
703
+ }
704
+ }
705
+ let preparedUploads;
706
+ let uploadsTransferred = false;
707
+ const preparingRun = !!hubAuth && parsed.method === "agent/run";
708
+ if (hubAuth && (configurationChanging || configurationReloadFailed)) {
709
+ sendToTab(ws, hostQueryError(parsed.id ?? null, -32009, configurationReloadFailed
710
+ ? "请先重新保存配置或重启服务,再发送任务。"
711
+ : "配置正在保存,请稍后重新发送任务。"), "configuration-changing");
712
+ return;
290
713
  }
291
- const workerLine = JSON.stringify(workerMessage);
292
- // Spawn-on-first-frame (idempotent): the browser's first request wakes
293
- // the worker, mirroring the renderer's spawn-on-agent/run semantics.
294
- bridge.ensureWorker(opts.cwd);
295
- bridge.injectWorkerMessage(workerLine, { origin: "serve", producer: "serve-ws" });
714
+ if (preparingRun)
715
+ preparingRuns++;
716
+ try {
717
+ if (hubAuth && parsed.method === "agent/run") {
718
+ // Server-assigned ids ensure even clients that omit them have
719
+ // tracked runs, upload ownership and refreshable stream snapshots.
720
+ parsed.params = {
721
+ ...parsed.params,
722
+ sessionId: parsed.params?.sessionId ?? randomUUID(),
723
+ clientMessageId: parsed.params?.clientMessageId ?? randomUUID(),
724
+ };
725
+ if (runningSessions.size >= 64) {
726
+ sendToTab(ws, hostQueryError(parsed.id ?? null, -32000, "too many active runs"), "run-limit");
727
+ return;
728
+ }
729
+ try {
730
+ preparedUploads = await uploads.prepare(parsed.params?.uploadIds, session.id, parsed.params?.sessionId);
731
+ const { uploadIds: _uploadIds, attachments: _attachments, ...params } = parsed.params ?? {};
732
+ const attachments = preparedUploads.attachments;
733
+ parsed.params = { ...params, ...(attachments.length ? { attachments } : {}) };
734
+ }
735
+ catch (error) {
736
+ sendToTab(ws, hostQueryError(parsed.id ?? null, -32602, error.message), "attachments");
737
+ return;
738
+ }
739
+ if (ws.readyState !== ws.OPEN || !(await hubAuth.authenticate(req)))
740
+ return;
741
+ }
742
+ // Recheck after asynchronous staging/authentication; other tabs may
743
+ // have filled the runtime's capacity while this request was suspended.
744
+ if (hubAuth && parsed.method === "agent/run" && runningSessions.size >= 64) {
745
+ sendToTab(ws, hostQueryError(parsed.id ?? null, -32000, "too many active runs"), "run-limit");
746
+ return;
747
+ }
748
+ // This host intentionally exposes one workspace. A browser must not be
749
+ // able to escape it by supplying another cwd, and a missing cwd must
750
+ // not silently become the worker's global no-repo conversation.
751
+ let defaultModel;
752
+ if (hubAuth && parsed.method === "agent/run" && parsed.params?.model === undefined) {
753
+ try {
754
+ // The preparation guard remains held while reading and dispatching.
755
+ // Reloading the shared model pool alone does not change an existing
756
+ // session's selected model; this normal run parameter switches it at
757
+ // the turn boundary and also refreshes edited connection credentials.
758
+ defaultModel = new SettingsManager(workspaceCwd, "full").get().defaults.text;
759
+ }
760
+ catch {
761
+ sendToTab(ws, hostQueryError(parsed.id ?? null, -32602, "模型设置读取失败,请检查服务器配置。"), "configuration-invalid");
762
+ return;
763
+ }
764
+ }
765
+ const workerMessage = parsed.method === "agent/run"
766
+ ? {
767
+ ...parsed,
768
+ params: {
769
+ ...(parsed.params ?? {}),
770
+ ...(defaultModel ? { model: defaultModel } : {}),
771
+ cwd: workspaceCwd,
772
+ },
773
+ }
774
+ : parsed;
775
+ if (workerMessage.id === null) {
776
+ sendToTab(ws, hostQueryError(null, -32600, "JSON-RPC request id must not be null"), "request-id");
777
+ return;
778
+ }
779
+ let baselineEvents = 0;
780
+ const baselineSessionId = workerMessage.params?.sessionId;
781
+ if (hubAuth && parsed.method === "agent/run" && typeof baselineSessionId === "string") {
782
+ try {
783
+ if (readOptionalHubSessionState(sessionManager, baselineSessionId)) {
784
+ baselineEvents = readHubTranscript(sessionRootDir, baselineSessionId).length;
785
+ }
786
+ }
787
+ catch (cause) {
788
+ sendToTab(ws, hostQueryError(workerMessage.id ?? null, -32001, cause instanceof Error ? cause.message : "会话记录无法读取,请检查服务器备份。"), "transcript-unavailable");
789
+ return;
790
+ }
791
+ }
792
+ if (workerMessage.id !== undefined) {
793
+ if ((pendingResponsesByTab.get(tabId) ?? 0) >= 64) {
794
+ sendToTab(ws, hostQueryError(workerMessage.id, -32000, "too many pending agent worker requests for this tab"), "pending-limit");
795
+ return;
796
+ }
797
+ if (hubAuth && parsed.method === "agent/run") {
798
+ const runSession = workerMessage.params?.sessionId;
799
+ const ownerId = tabAuth.get(ws)?.sessionId;
800
+ const activeOwner = typeof runSession === "string" ? runOwners.get(runSession) : undefined;
801
+ if (activeOwner && activeOwner !== ownerId) {
802
+ sendToTab(ws, hostQueryError(workerMessage.id, -32009, "此对话正在另一台设备运行,请等待完成后再发送。"), "run-owner");
803
+ return;
804
+ }
805
+ }
806
+ const workerRequestId = `serve-${tabId}-${nextWorkerRequestId++}`;
807
+ pendingWorkerResponses.set(workerRequestId, {
808
+ tab: ws,
809
+ originalId: workerMessage.id,
810
+ tabId,
811
+ insertedAt: Date.now(),
812
+ method: parsed.method,
813
+ ...(approvalId ? { approvalId } : {}),
814
+ });
815
+ pendingResponsesByTab.set(tabId, (pendingResponsesByTab.get(tabId) ?? 0) + 1);
816
+ workerMessage.id = workerRequestId;
817
+ if (preparedUploads?.attachments.length) {
818
+ runUploads.set(workerRequestId, preparedUploads);
819
+ uploadsTransferred = true;
820
+ }
821
+ const runSessionId = workerMessage.params?.sessionId;
822
+ if (parsed.method === "agent/run" && typeof runSessionId === "string") {
823
+ if (hubAuth) {
824
+ runReplay.begin(runSessionId, workerRequestId, baselineEvents);
825
+ const params = workerMessage.params ?? {};
826
+ runInputs.set(workerRequestId, {
827
+ sessionId: runSessionId,
828
+ echoed: false,
829
+ event: {
830
+ type: "session_user_message",
831
+ text: typeof params.displayText === "string" ? params.displayText : params.task,
832
+ clientMessageId: params.clientMessageId,
833
+ attachments: (preparedUploads?.attachments ?? []).map((attachment) => ({
834
+ name: attachment.originalName ?? basename(attachment.path),
835
+ size: attachment.size,
836
+ mime: attachment.mime,
837
+ path: attachment.relPath ?? attachment.absPath,
838
+ })),
839
+ },
840
+ });
841
+ }
842
+ runningSessions.set(workerRequestId, runSessionId);
843
+ const ownerId = tabAuth.get(ws)?.sessionId;
844
+ if (ownerId)
845
+ runOwners.set(runSessionId, ownerId);
846
+ if (hubAuth)
847
+ notify("serve/sessionStatus", { sessionId: runSessionId, running: true });
848
+ }
849
+ }
850
+ const workerLine = JSON.stringify(workerMessage);
851
+ // Spawn-on-first-frame (idempotent): the browser's first request wakes
852
+ // the worker, mirroring the renderer's spawn-on-agent/run semantics.
853
+ bridge.ensureWorker(opts.cwd);
854
+ if (!bridge.canSend()) {
855
+ workerUnavailable("agent worker is unavailable");
856
+ return;
857
+ }
858
+ bridge.injectWorkerMessage(workerLine, { origin: "serve", producer: "serve-ws" });
859
+ }
860
+ finally {
861
+ if (preparingRun)
862
+ preparingRuns--;
863
+ if (!uploadsTransferred)
864
+ preparedUploads?.release();
865
+ }
866
+ };
867
+ let queued = 0;
868
+ let chain = Promise.resolve();
869
+ ws.on("message", (data) => {
870
+ if (++queued > 64) {
871
+ ws.close(1008, "too many queued requests");
872
+ queued--;
873
+ return;
874
+ }
875
+ chain = chain
876
+ .then(() => handleMessage(data))
877
+ .catch(() => {
878
+ sendToTab(ws, hostQueryError(null, -32000, "request failed"), "request-error");
879
+ })
880
+ .finally(() => {
881
+ queued--;
882
+ });
296
883
  });
297
884
  ws.on("close", () => {
298
885
  tabs.delete(ws);
886
+ tabAuth.delete(ws);
887
+ leases.releaseHolder(holderId);
299
888
  clearPendingWorkerResponsesForTab(tabId);
300
889
  log("tab.closed", { tabs: tabs.size });
301
890
  });
302
891
  ws.on("error", () => {
303
892
  tabs.delete(ws);
893
+ tabAuth.delete(ws);
894
+ leases.releaseHolder(holderId);
304
895
  clearPendingWorkerResponsesForTab(tabId);
305
896
  });
306
897
  });
898
+ };
899
+ server.on("upgrade", (req, socket, head) => {
900
+ void upgrade(req, socket, head).catch(() => socket.destroy());
307
901
  });
308
902
  try {
309
903
  await new Promise((resolve, reject) => {
@@ -316,6 +910,15 @@ export async function startHeadlessServer(opts) {
316
910
  }
317
911
  catch (error) {
318
912
  clearInterval(pendingResponseReaper);
913
+ if (authReaper)
914
+ clearInterval(authReaper);
915
+ await uploads?.close();
916
+ await skills?.close();
917
+ await mcp?.close();
918
+ configuration?.close();
919
+ links?.close();
920
+ panels?.close();
921
+ files?.close();
319
922
  bridge.kill();
320
923
  wss.close();
321
924
  server.close();
@@ -330,12 +933,22 @@ export async function startHeadlessServer(opts) {
330
933
  host,
331
934
  port,
332
935
  ...(generatedPasscode ? { generatedPasscode } : {}),
936
+ ...(hubAuth?.bootstrapToken ? { bootstrapToken: hubAuth.bootstrapToken } : {}),
333
937
  passcode,
334
938
  bridge,
335
939
  tabCount: () => tabs.size,
336
940
  pendingResponseCount: () => pendingWorkerResponses.size,
337
941
  close: async () => {
338
942
  clearInterval(pendingResponseReaper);
943
+ if (authReaper)
944
+ clearInterval(authReaper);
945
+ await uploads?.close();
946
+ await skills?.close();
947
+ await mcp?.close();
948
+ configuration?.close();
949
+ links?.close();
950
+ panels?.close();
951
+ files?.close();
339
952
  for (const tab of tabs) {
340
953
  try {
341
954
  tab.terminate();
@@ -369,14 +982,13 @@ function webSocketPayloadBytes(data) {
369
982
  }
370
983
  return Buffer.byteLength(String(data), "utf8");
371
984
  }
372
- function replyToHostSessionQuery(message, sessionManager, workspaceCwd) {
985
+ function replyToHostSessionQuery(message, sessionManager, workspaceCwd, isRunning = () => false, runReplay) {
373
986
  if (message.method !== "agent/query" || message.id === undefined)
374
987
  return undefined;
375
988
  const queryType = message.params?.type;
376
989
  if (queryType === "sessions") {
377
990
  const sessions = sessionManager
378
- .list()
379
- .filter((session) => resolve(session.cwd) === workspaceCwd)
991
+ .list(100, { cwd: workspaceCwd, archived: false, rootsOnly: true })
380
992
  .map((session) => ({
381
993
  sessionId: session.sessionId,
382
994
  cwd: session.cwd,
@@ -384,7 +996,9 @@ function replyToHostSessionQuery(message, sessionManager, workspaceCwd) {
384
996
  model: session.model,
385
997
  status: session.status,
386
998
  turnCount: session.turnCount,
387
- ...(session.preview ? { preview: session.preview } : {}),
999
+ lastActiveAt: session.lastActiveAt,
1000
+ ...(session.title ? { title: session.title } : {}),
1001
+ ...(session.preview ? { preview: hubSessionPreview(session.preview) } : {}),
388
1002
  }));
389
1003
  return JSON.stringify({
390
1004
  jsonrpc: "2.0",
@@ -398,31 +1012,68 @@ function replyToHostSessionQuery(message, sessionManager, workspaceCwd) {
398
1012
  if (typeof sessionId !== "string" || sessionId.length === 0) {
399
1013
  return hostQueryError(message.id, -32602, "sessionId required for session_detail");
400
1014
  }
1015
+ let missingSession = false;
401
1016
  try {
402
- const bundle = sessionManager.resume(sessionId);
403
- if (resolve(bundle.state.cwd) !== workspaceCwd) {
1017
+ const state = readOptionalHubSessionState(sessionManager, sessionId);
1018
+ missingSession = !state;
1019
+ if (!state)
1020
+ throw new Error("Session not found in this workspace");
1021
+ if (resolve(state.cwd) !== workspaceCwd) {
404
1022
  return hostQueryError(message.id, -32001, "Session not found in this workspace");
405
1023
  }
1024
+ const transcript = readHubTranscript(sessionManager.getStorageDir(), sessionId);
406
1025
  return JSON.stringify({
407
1026
  jsonrpc: "2.0",
408
1027
  id: message.id,
409
1028
  result: {
410
1029
  type: "session_detail",
411
- data: { state: bundle.state, transcript: bundle.transcript.getEvents() },
1030
+ data: {
1031
+ state,
1032
+ ...(runReplay ? runReplay.snapshot(sessionId, transcript) : { transcript }),
1033
+ running: isRunning(sessionId),
1034
+ },
412
1035
  },
413
1036
  });
414
1037
  }
415
1038
  catch (error) {
1039
+ if (runReplay?.hasEmptyBaseline(sessionId) &&
1040
+ isRunning(sessionId) &&
1041
+ (missingSession || error.status === 404)) {
1042
+ return JSON.stringify({
1043
+ jsonrpc: "2.0",
1044
+ id: message.id,
1045
+ result: {
1046
+ type: "session_detail",
1047
+ data: {
1048
+ state: { sessionId, cwd: workspaceCwd, status: "active", turnCount: 0 },
1049
+ ...runReplay.snapshot(sessionId, []),
1050
+ running: true,
1051
+ },
1052
+ },
1053
+ });
1054
+ }
416
1055
  return hostQueryError(message.id, -32001, error instanceof Error ? error.message : "Session not found");
417
1056
  }
418
1057
  }
1058
+ /** A missing session can be created; malformed or unsafe existing storage must fail closed. */
1059
+ function readOptionalHubSessionState(sessionManager, sessionId) {
1060
+ try {
1061
+ return readHubSessionState(sessionManager.getStorageDir(), sessionId);
1062
+ }
1063
+ catch (error) {
1064
+ if (error.status === 404 && !sessionManager.exists(sessionId)) {
1065
+ return undefined;
1066
+ }
1067
+ throw error;
1068
+ }
1069
+ }
419
1070
  const SERVE_ALLOWED_WORKER_METHODS = new Set(["agent/run", "agent/approve", "agent/cancel"]);
420
1071
  /**
421
1072
  * The no-account Web host deliberately exposes only the methods used by its
422
1073
  * bundled SPA. Passcode possession grants control of this workspace, not raw
423
1074
  * access to the worker's full local protocol surface.
424
1075
  */
425
- function authorizeServeRequest(message, sessionManager, workspaceCwd) {
1076
+ function authorizeServeRequest(message, sessionManager, workspaceCwd, isRunning = () => false) {
426
1077
  if (!message.method || !SERVE_ALLOWED_WORKER_METHODS.has(message.method)) {
427
1078
  return hostQueryError(message.id ?? null, -32601, "Method is not available in Web serve mode");
428
1079
  }
@@ -433,16 +1084,19 @@ function authorizeServeRequest(message, sessionManager, workspaceCwd) {
433
1084
  if (!sessionId) {
434
1085
  return hostQueryError(message.id ?? null, -32602, "sessionId is required");
435
1086
  }
1087
+ // A tracked run was already authorized for this host's forced workspace.
1088
+ // Stop must work even before Core publishes its first persistent state file.
1089
+ if (message.method === "agent/cancel" && isRunning(sessionId))
1090
+ return undefined;
436
1091
  try {
437
- const session = sessionManager.resume(sessionId).state;
438
- if (resolve(session.cwd) === workspaceCwd)
1092
+ const session = readOptionalHubSessionState(sessionManager, sessionId);
1093
+ if (session && resolve(session.cwd) === workspaceCwd)
1094
+ return undefined;
1095
+ if (!session && !sessionManager.exists(sessionId) && message.method === "agent/run")
439
1096
  return undefined;
440
1097
  }
441
1098
  catch {
442
- // A caller may choose the id for a brand-new run. Other methods must target
443
- // an already-persisted session owned by this workspace.
444
- if (message.method === "agent/run")
445
- return undefined;
1099
+ // Invalid ids and unreadable existing records must not become new sessions.
446
1100
  }
447
1101
  return hostQueryError(message.id ?? null, -32001, "Session not found in this workspace");
448
1102
  }