@evandu/super-pi 0.1.138 → 0.1.140

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 (72) hide show
  1. package/agents/super-pi-knowledge/index.ts +11 -13
  2. package/agents/super-pi-memory/index.ts +11 -5
  3. package/agents/super-pi-task/core.ts +418 -1
  4. package/agents/super-pi-task/index.ts +115 -0
  5. package/bin/super-pi.js +7 -11
  6. package/dist/.super-pi-check-inputs.json +2 -2
  7. package/dist/client/.super-pi-client-sources.json +7 -6
  8. package/dist/client/assets/{CodeMirrorEditor-CVImD0nh.js → CodeMirrorEditor-DvZeOLny.js} +2 -2
  9. package/dist/client/assets/{dist-CiEW7LQW.js → dist-3BuyVG3H.js} +1 -1
  10. package/dist/client/assets/{dist-faxwvvOU.js → dist-BCUfH4nP.js} +1 -1
  11. package/dist/client/assets/{dist-yONALz0V.js → dist-BIIMtU9W.js} +1 -1
  12. package/dist/client/assets/{dist-CKI1xVeE.js → dist-C-QmRwbd.js} +1 -1
  13. package/dist/client/assets/{dist-4uPxkRdw.js → dist-CTkzLBdb.js} +1 -1
  14. package/dist/client/assets/{dist-CUACXe1Y.js → dist-C_VuSpx8.js} +1 -1
  15. package/dist/client/assets/{dist-BwQM8_Mk.js → dist-CaUsvSz1.js} +1 -1
  16. package/dist/client/assets/{dist-Clf26yld.js → dist-Cb1gxS57.js} +1 -1
  17. package/dist/client/assets/{dist-YB4GJD3u.js → dist-CqDSeoUk.js} +1 -1
  18. package/dist/client/assets/{dist-DozjvPAZ.js → dist-D7yfnKNC.js} +1 -1
  19. package/dist/client/assets/{dist-Cocw5XJ_.js → dist-DA8yW4lK.js} +1 -1
  20. package/dist/client/assets/{dist-amySbADh.js → dist-DAcS4Kj8.js} +1 -1
  21. package/dist/client/assets/{dist-B75OT6A9.js → dist-DN0M4DAX.js} +1 -1
  22. package/dist/client/assets/{dist-At94VK1P.js → dist-DRgWZqFH.js} +1 -1
  23. package/dist/client/assets/{dist-BIgRwYsI.js → dist-DdfW8Gjv.js} +1 -1
  24. package/dist/client/assets/{dist-DhEa5hHR.js → dist-DlQ8PMvv.js} +1 -1
  25. package/dist/client/assets/{dist-quQ6u6Rw.js → dist-Dvca0LeR.js} +1 -1
  26. package/dist/client/assets/{dist-BqblSnWL.js → dist-HJdP1yxN.js} +1 -1
  27. package/dist/client/assets/{dist-pnGHl3nq.js → dist-gBAr9yXF.js} +1 -1
  28. package/dist/client/assets/{dist-1H_gIENV.js → dist-gyzurUDR.js} +1 -1
  29. package/dist/client/assets/{dist-DSCjkO-y.js → dist-qG53hT1b.js} +1 -1
  30. package/dist/client/assets/{dist-CNP-Q5Gs.js → dist-r4Kq1ABA.js} +1 -1
  31. package/dist/client/assets/{index-CNoiRdJC.js → index-CNbh1fL2.js} +14 -14
  32. package/dist/client/assets/index-DCSFD4VV.css +1 -0
  33. package/dist/client/index.html +2 -2
  34. package/dist/server/application/conversation-service.d.ts +13 -4
  35. package/dist/server/application/conversation-service.js +112 -61
  36. package/dist/server/application/run-service.d.ts +13 -0
  37. package/dist/server/application/run-service.js +255 -69
  38. package/dist/server/application/scheduled-task-service.js +8 -1
  39. package/dist/server/application/session-orchestrator-service.d.ts +113 -0
  40. package/dist/server/application/session-orchestrator-service.js +234 -0
  41. package/dist/server/application/workspace-service.js +16 -1
  42. package/dist/server/feishu-cli.js +15 -4
  43. package/dist/server/file-index.d.ts +30 -0
  44. package/dist/server/file-index.js +49 -4
  45. package/dist/server/files.d.ts +21 -0
  46. package/dist/server/files.js +38 -0
  47. package/dist/server/http-server.js +1580 -1402
  48. package/dist/server/index.js +23 -6
  49. package/dist/server/logging.js +56 -3
  50. package/dist/server/pi-session-pool.d.ts +2 -0
  51. package/dist/server/pi-session-pool.js +1 -0
  52. package/dist/server/pi-session.d.ts +118 -0
  53. package/dist/server/pi-session.js +132 -10
  54. package/dist/server/runtime.d.ts +48 -0
  55. package/dist/server/runtime.js +189 -86
  56. package/dist/server/server-context.d.ts +2 -0
  57. package/dist/server/session-ledger.d.ts +60 -0
  58. package/dist/server/session-ledger.js +76 -0
  59. package/dist/server/store.d.ts +44 -1
  60. package/dist/server/store.js +114 -12
  61. package/dist/server/subagent-runs.d.ts +31 -1
  62. package/dist/server/subagent-runs.js +48 -20
  63. package/dist/server/subagents.js +7 -0
  64. package/dist/server/task-tool.d.ts +18 -0
  65. package/dist/server/task-tool.js +85 -0
  66. package/dist/server/tool-runner.js +13 -4
  67. package/dist/server/types.d.ts +50 -0
  68. package/dist/server/ws-server.d.ts +86 -6
  69. package/dist/server/ws-server.js +291 -51
  70. package/package.json +16 -2
  71. package/scripts/diagnose.mjs +161 -58
  72. package/dist/client/assets/index-FxTrUapG.css +0 -1
@@ -1,7 +1,10 @@
1
1
  import { createReadStream, existsSync } from "node:fs";
2
2
  import { spawn } from "node:child_process";
3
+ import { randomUUID } from "node:crypto";
3
4
  import { readdir, rm, stat } from "node:fs/promises";
4
5
  import { createServer } from "node:http";
6
+ import { gzip } from "node:zlib";
7
+ import { promisify } from "node:util";
5
8
  import { homedir, networkInterfaces } from "node:os";
6
9
  import { basename, dirname, extname, join, relative, resolve } from "node:path";
7
10
  import { describe as describeFilesError, fileMeta, listDirectory, MAX_FILE_BYTES, readWorkspaceFile, resolveInsideRoot, toRelative, writeWorkspaceFile, } from "./files.js";
@@ -276,258 +279,173 @@ export function createHttpServer(options) {
276
279
  const { store, paths, accessToken, broadcaster, runService, catalogService, execService, toolRegistry, toolRunner } = context;
277
280
  const { uploadsDir, clientDir, rootDir } = paths;
278
281
  return createServer(async (request, response) => {
279
- // Internal tools use a narrow per-run capability, never the global management token.
280
- const requestPath = request.url?.split("?")[0] ?? "";
281
- // Tool REGISTRATION is checked after its JSON body is parsed: an agent proves its turn capability
282
- // there, while an interactive browser uses the ordinary server access token. Reading and DELETING
283
- // the registry have no such second channel, so they are NOT exempted here and fall through to the
284
- // ordinary token check below - exempting the whole path is what let any local process list and
285
- // delete a person's mounted tools on a server that had `SUPER_PI_ACCESS_TOKEN` set.
286
- //
287
- // `/api/knowledge/tool` is the same shape as `/api/memory/tool` and carries the same exemption for
288
- // the same reason: it is reached only by the Pi process of one turn, carrying that turn's
289
- // capability in a header. The MANAGEMENT half (`/api/knowledge`) is deliberately NOT exempted - it
290
- // imports and deletes documents, so it stays behind the ordinary token.
291
- //
292
- // `/api/documents/tool` is the same shape again, and the exemption is safe for the same reason:
293
- // the endpoint verifies the SESSION's capability on its own, and refuses a request whose turn is
294
- // not running. The global token is a server-wide secret; the capability is a per-conversation
295
- // one that only the Pi process of that conversation holds, so the exemption does not widen who
296
- // can reach the server - it only spares the one caller that already proves who it is.
297
- //
298
- // `/api/feishu/tool` is that shape once more. The Feishu credential is a company secret, so
299
- // the endpoint that can spend it is exactly the one an agent's own turn can reach, and the
300
- // management half (`/api/feishu/config`) stays behind the ordinary token AND the local-address
301
- // predicate.
302
- if (requestPath !== "/api/memory/tool" && requestPath !== "/api/knowledge/tool" && requestPath !== "/api/documents/tool" && requestPath !== "/api/web/tool" && requestPath !== "/api/feishu/tool" && requestPath !== "/api/todos" && requestPath !== "/api/scheduled-tasks" && requestPath !== "/api/question" && (requestPath !== "/api/tools" || request.method !== "POST") && !requestPath.startsWith("/tools/") && !isAuthorized(request, accessToken)) {
303
- response.writeHead(401, { "www-authenticate": "Bearer" }).end("Authentication required");
304
- return;
305
- }
306
- const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
307
- const remote = request.socket.remoteAddress ?? "";
308
- const json = (status, body) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); response.end(JSON.stringify(body)); };
309
- if (url.pathname === "/api/tools/export") {
310
- // NOT EXEMPT FROM THE TOKEN CHECK ABOVE, and that is deliberate: an export hands out a tool's
311
- // whole source, so it is a management read exactly like GET and DELETE, not a capability call.
312
- try {
313
- const id = url.searchParams.get("id");
314
- if (!id) {
315
- json(400, { error: "缺少工具 id。" });
316
- return;
317
- }
318
- const exported = await toolRegistry.exportBundle(id);
319
- if (!exported) {
320
- json(404, { error: "找不到这个工具。" });
321
- return;
322
- }
323
- json(200, exported);
324
- return;
325
- }
326
- catch (error) {
327
- json(400, { error: describeThrown(error) || "导出工具失败,没有拿到失败原因。" });
282
+ // Every HTTP request gets one stable id. It is returned to the browser and
283
+ // included in the server log, so a report containing a status code or a
284
+ // timeout can be joined to the exact failing request without guessing from
285
+ // timestamps. Do not echo arbitrary input into a header.
286
+ const suppliedRequestId = request.headers["x-request-id"];
287
+ const requestId = typeof suppliedRequestId === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(suppliedRequestId)
288
+ ? suppliedRequestId
289
+ : randomUUID();
290
+ response.setHeader("x-request-id", requestId);
291
+ try {
292
+ // Internal tools use a narrow per-run capability, never the global management token.
293
+ const requestPath = request.url?.split("?")[0] ?? "";
294
+ // Tool REGISTRATION is checked after its JSON body is parsed: an agent proves its turn capability
295
+ // there, while an interactive browser uses the ordinary server access token. Reading and DELETING
296
+ // the registry have no such second channel, so they are NOT exempted here and fall through to the
297
+ // ordinary token check below - exempting the whole path is what let any local process list and
298
+ // delete a person's mounted tools on a server that had `SUPER_PI_ACCESS_TOKEN` set.
299
+ //
300
+ // `/api/knowledge/tool` is the same shape as `/api/memory/tool` and carries the same exemption for
301
+ // the same reason: it is reached only by the Pi process of one turn, carrying that turn's
302
+ // capability in a header. The MANAGEMENT half (`/api/knowledge`) is deliberately NOT exempted - it
303
+ // imports and deletes documents, so it stays behind the ordinary token.
304
+ //
305
+ // `/api/documents/tool` is the same shape again, and the exemption is safe for the same reason:
306
+ // the endpoint verifies the SESSION's capability on its own, and refuses a request whose turn is
307
+ // not running. The global token is a server-wide secret; the capability is a per-conversation
308
+ // one that only the Pi process of that conversation holds, so the exemption does not widen who
309
+ // can reach the server - it only spares the one caller that already proves who it is.
310
+ //
311
+ // `/api/feishu/tool` is that shape once more. The Feishu credential is a company secret, so
312
+ // the endpoint that can spend it is exactly the one an agent's own turn can reach, and the
313
+ // management half (`/api/feishu/config`) stays behind the ordinary token AND the local-address
314
+ // predicate.
315
+ if (requestPath !== "/api/memory/tool" && requestPath !== "/api/knowledge/tool" && requestPath !== "/api/documents/tool" && requestPath !== "/api/web/tool" && requestPath !== "/api/feishu/tool" && requestPath !== "/api/todos" && requestPath !== "/api/scheduled-tasks" && requestPath !== "/api/question" && (requestPath !== "/api/tools" || request.method !== "POST") && !requestPath.startsWith("/tools/") && !isAuthorized(request, accessToken)) {
316
+ response.writeHead(401, { "www-authenticate": "Bearer" }).end("Authentication required");
328
317
  return;
329
318
  }
330
- }
331
- if (url.pathname === "/api/tools") {
332
- try {
333
- if (request.method === "GET") {
334
- // The environment state is DERIVED per request and deliberately not stored: it changes when
335
- // the user installs Python, when a tool's venv is built, and when nothing at all happens.
336
- const tools = await toolRegistry.list();
337
- json(200, { tools: await Promise.all(tools.map(async (tool) => ({ ...tool, environmentState: await toolRunner.environmentState(tool) }))) });
338
- return;
339
- }
340
- if (request.method === "DELETE") {
319
+ const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
320
+ const remote = request.socket.remoteAddress ?? "";
321
+ const json = (status, body) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); response.end(JSON.stringify(body)); };
322
+ if (url.pathname === "/api/tools/export") {
323
+ // NOT EXEMPT FROM THE TOKEN CHECK ABOVE, and that is deliberate: an export hands out a tool's
324
+ // whole source, so it is a management read exactly like GET and DELETE, not a capability call.
325
+ try {
341
326
  const id = url.searchParams.get("id");
342
327
  if (!id) {
343
328
  json(400, { error: "缺少工具 id。" });
344
329
  return;
345
330
  }
346
- const removed = await toolRegistry.remove(id);
347
- // The venv is machine-local state that belongs to that tool and nothing else, so it goes with
348
- // it. Leaving it behind would mean a re-registered tool of the same slug silently inherits a
349
- // stale environment.
350
- if (removed)
351
- await rm(toolRunner.envDir(id), { recursive: true, force: true });
352
- if (removed)
353
- broadcaster.broadcast({ type: "tools.updated", tools: await toolRegistry.list() });
354
- json(removed ? 200 : 404, { ok: removed });
355
- return;
356
- }
357
- if (request.method !== "POST") {
358
- json(405, { error: "这个接口只接受 POST 请求。" });
331
+ const exported = await toolRegistry.exportBundle(id);
332
+ if (!exported) {
333
+ json(404, { error: "找不到这个工具。" });
334
+ return;
335
+ }
336
+ json(200, exported);
359
337
  return;
360
338
  }
361
- // BYTES, NOT CHARACTERS, AND FROM THE REGISTRY'S OWN CONSTANT. The old cap counted UTF-16 code
362
- // units at 256,000, below the registry's own 512,000-byte asset limit, so JSON escaping decided
363
- // where the real ceiling was and an exported bundle could be refused on the way back in.
364
- const imported = await readRequestText(request, MAX_TOOL_BODY_BYTES);
365
- if (imported.tooLarge) {
366
- json(413, { error: "导入的内容太大,超过了大小上限。" });
339
+ catch (error) {
340
+ json(400, { error: describeThrown(error) || "导出工具失败,没有拿到失败原因。" });
367
341
  return;
368
342
  }
369
- const raw = imported.text;
370
- const body = JSON.parse(raw);
371
- if (!isAuthorized(request, accessToken)) {
372
- if (!body.conversationId) {
373
- json(401, { error: "需要访问凭据,或正在运行的会话凭据。" });
343
+ }
344
+ if (url.pathname === "/api/tools") {
345
+ try {
346
+ if (request.method === "GET") {
347
+ // The environment state is DERIVED per request and deliberately not stored: it changes when
348
+ // the user installs Python, when a tool's venv is built, and when nothing at all happens.
349
+ const tools = await toolRegistry.list();
350
+ json(200, { tools: await Promise.all(tools.map(async (tool) => ({ ...tool, environmentState: await toolRunner.environmentState(tool) }))) });
374
351
  return;
375
352
  }
376
- const verdict = runService.verifyPrimaryCapability(body.conversationId, body.capability ?? request.headers["x-super-pi-capability"]);
377
- if (!verdict.ok) {
378
- json(403, { error: verdict.message });
353
+ if (request.method === "DELETE") {
354
+ const id = url.searchParams.get("id");
355
+ if (!id) {
356
+ json(400, { error: "缺少工具 id。" });
357
+ return;
358
+ }
359
+ const removed = await toolRegistry.remove(id);
360
+ // The venv is machine-local state that belongs to that tool and nothing else, so it goes with
361
+ // it. Leaving it behind would mean a re-registered tool of the same slug silently inherits a
362
+ // stale environment.
363
+ if (removed)
364
+ await rm(toolRunner.envDir(id), { recursive: true, force: true });
365
+ if (removed)
366
+ broadcaster.broadcast({ type: "tools.updated", tools: await toolRegistry.list() });
367
+ json(removed ? 200 : 404, { ok: removed });
379
368
  return;
380
369
  }
381
- }
382
- if (!body.slug || !body.name || !Array.isArray(body.files)) {
383
- json(400, { error: "导入需要 slug、名称和文件列表。" });
384
- return;
385
- }
386
- // AN IMPORTED BUNDLE KEEPS THE EXPORTER'S FINGERPRINT. It describes where the tool was written
387
- // and verified, and overwriting it with the importer's own machine would erase the very
388
- // difference the tool list exists to show. A tool with no fingerprint gets one measured here.
389
- let environment = body.environment;
390
- if (environment && !environment.fingerprint) {
391
- const local = await toolRunner.describeLocalPython();
392
- if (local)
393
- environment = { ...environment, fingerprint: { python: local.python, platform: local.platform, packages: environment.requires } };
394
- }
395
- const tool = await toolRegistry.register({ slug: body.slug, name: body.name, description: body.description, entrypoint: body.entrypoint, script: body.script, environment, permissions: body.permissions, files: body.files });
396
- broadcaster.broadcast({ type: "tools.updated", tools: await toolRegistry.list() });
397
- json(201, tool);
398
- return;
399
- }
400
- catch (error) {
401
- json(400, { error: describeThrown(error) || "工具请求失败,没有拿到失败原因。" });
402
- return;
403
- }
404
- }
405
- if (url.pathname.startsWith("/tools/")) {
406
- let id;
407
- let resource;
408
- try {
409
- const parts = url.pathname.slice("/tools/".length).split("/");
410
- id = decodeURIComponent(parts.shift() ?? "");
411
- resource = parts.filter(Boolean).map(decodeURIComponent).join("/");
412
- }
413
- catch {
414
- response.writeHead(400).end("Malformed tool resource");
415
- return;
416
- }
417
- const listed = (await toolRegistry.list()).find((x) => x.id === id && x.enabled);
418
- if (!listed) {
419
- response.writeHead(404).end("Tool not found");
420
- return;
421
- }
422
- // A mounted tool never learns its random registry id. Its API is instead addressed relative
423
- // to its own mount (`./api/run`), and this route supplies that identity from the URL. Besides
424
- // making a generated tool usable on its first render, this binds status and cancellation to the
425
- // same tool that created a run: an opaque frame for tool A cannot inspect tool B's run ids.
426
- const toolOrigin = request.headers.origin === "null";
427
- const toolApi = (status, body) => {
428
- response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "access-control-allow-origin": "null", "vary": "Origin" });
429
- response.end(JSON.stringify(body));
430
- };
431
- if (resource === "api/run") {
432
- if (!toolOrigin) {
433
- toolApi(403, { error: "tool runtime API is only available to the sandboxed tool frame" });
434
- return;
435
- }
436
- if (request.method === "OPTIONS") {
437
- response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "POST", "access-control-allow-headers": "content-type", "access-control-max-age": "300", "vary": "Origin" }).end();
438
- return;
439
- }
440
- if (request.method !== "POST") {
441
- toolApi(405, { error: "method" });
442
- return;
443
- }
444
- const executed = await readRequestText(request, 64_000);
445
- if (executed.tooLarge) {
446
- toolApi(413, { error: "request too large" });
447
- return;
448
- }
449
- const raw = executed.text;
450
- try {
370
+ if (request.method !== "POST") {
371
+ json(405, { error: "这个接口只接受 POST 请求。" });
372
+ return;
373
+ }
374
+ // BYTES, NOT CHARACTERS, AND FROM THE REGISTRY'S OWN CONSTANT. The old cap counted UTF-16 code
375
+ // units at 256,000, below the registry's own 512,000-byte asset limit, so JSON escaping decided
376
+ // where the real ceiling was and an exported bundle could be refused on the way back in.
377
+ const imported = await readRequestText(request, MAX_TOOL_BODY_BYTES);
378
+ if (imported.tooLarge) {
379
+ json(413, { error: "导入的内容太大,超过了大小上限。" });
380
+ return;
381
+ }
382
+ const raw = imported.text;
451
383
  const body = JSON.parse(raw);
452
- const resolved = await toolRegistry.resolve(id);
453
- if (!resolved?.tool.script) {
454
- toolApi(404, { error: "python script is not registered" });
384
+ if (!isAuthorized(request, accessToken)) {
385
+ if (!body.conversationId) {
386
+ json(401, { error: "需要访问凭据,或正在运行的会话凭据。" });
387
+ return;
388
+ }
389
+ const verdict = runService.verifyPrimaryCapability(body.conversationId, body.capability ?? request.headers["x-super-pi-capability"]);
390
+ if (!verdict.ok) {
391
+ json(403, { error: verdict.message });
392
+ return;
393
+ }
394
+ }
395
+ if (!body.slug || !body.name || !Array.isArray(body.files)) {
396
+ json(400, { error: "导入需要 slug、名称和文件列表。" });
455
397
  return;
456
398
  }
457
- const run = toolRunner.start({ toolId: id, script: resolve(resolved.root, resolved.tool.script.path), cwd: resolved.root, definition: resolved.tool.script, params: body.params ?? {}, environment: resolved.tool.environment });
458
- toolApi(202, run);
459
- }
460
- catch (error) {
461
- toolApi(400, { error: describeThrown(error) || "工具运行失败,没有拿到失败原因。" });
462
- }
463
- return;
464
- }
465
- const runMatch = /^api\/runs\/([^/]+)$/.exec(resource);
466
- if (runMatch) {
467
- if (!toolOrigin) {
468
- toolApi(403, { error: "tool runtime API is only available to the sandboxed tool frame" });
399
+ // AN IMPORTED BUNDLE KEEPS THE EXPORTER'S FINGERPRINT. It describes where the tool was written
400
+ // and verified, and overwriting it with the importer's own machine would erase the very
401
+ // difference the tool list exists to show. A tool with no fingerprint gets one measured here.
402
+ let environment = body.environment;
403
+ if (environment && !environment.fingerprint) {
404
+ const local = await toolRunner.describeLocalPython();
405
+ if (local)
406
+ environment = { ...environment, fingerprint: { python: local.python, platform: local.platform, packages: environment.requires } };
407
+ }
408
+ const tool = await toolRegistry.register({ slug: body.slug, name: body.name, description: body.description, entrypoint: body.entrypoint, script: body.script, environment, permissions: body.permissions, files: body.files });
409
+ broadcaster.broadcast({ type: "tools.updated", tools: await toolRegistry.list() });
410
+ json(201, tool);
469
411
  return;
470
412
  }
471
- if (request.method === "OPTIONS") {
472
- response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "GET, DELETE", "access-control-max-age": "300", "vary": "Origin" }).end();
413
+ catch (error) {
414
+ json(400, { error: describeThrown(error) || "工具请求失败,没有拿到失败原因。" });
473
415
  return;
474
416
  }
475
- let runId;
417
+ }
418
+ if (url.pathname.startsWith("/tools/")) {
419
+ let id;
420
+ let resource;
476
421
  try {
477
- runId = decodeURIComponent(runMatch[1]);
422
+ const parts = url.pathname.slice("/tools/".length).split("/");
423
+ id = decodeURIComponent(parts.shift() ?? "");
424
+ resource = parts.filter(Boolean).map(decodeURIComponent).join("/");
478
425
  }
479
426
  catch {
480
- toolApi(400, { error: "Malformed tool resource" });
427
+ response.writeHead(400).end("Malformed tool resource");
481
428
  return;
482
429
  }
483
- if (request.method === "GET") {
484
- const run = toolRunner.get(runId, id);
485
- if (!run) {
486
- toolApi(404, { error: "run not found" });
487
- return;
488
- }
489
- toolApi(200, run);
430
+ const listed = (await toolRegistry.list()).find((x) => x.id === id && x.enabled);
431
+ if (!listed) {
432
+ response.writeHead(404).end("Tool not found");
490
433
  return;
491
434
  }
492
- if (request.method === "DELETE") {
493
- const run = toolRunner.cancel(runId, id);
494
- if (!run) {
495
- toolApi(404, { error: "run not found" });
435
+ // A mounted tool never learns its random registry id. Its API is instead addressed relative
436
+ // to its own mount (`./api/run`), and this route supplies that identity from the URL. Besides
437
+ // making a generated tool usable on its first render, this binds status and cancellation to the
438
+ // same tool that created a run: an opaque frame for tool A cannot inspect tool B's run ids.
439
+ const toolOrigin = request.headers.origin === "null";
440
+ const toolApi = (status, body) => {
441
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "access-control-allow-origin": "null", "vary": "Origin" });
442
+ response.end(JSON.stringify(body));
443
+ };
444
+ if (resource === "api/run") {
445
+ if (!toolOrigin) {
446
+ toolApi(403, { error: "tool runtime API is only available to the sandboxed tool frame" });
496
447
  return;
497
448
  }
498
- toolApi(200, run);
499
- return;
500
- }
501
- toolApi(405, { error: "method" });
502
- return;
503
- }
504
- // ---- THE MODEL, ON THE TOOL'S OWN BEHALF ---------------------------------------------------
505
- // A tool asking the model a question, over the same relative API as its Python jobs, so the frame
506
- // still never learns an id, a path or a credential. Three gates, in this order:
507
- // 1. the opaque sandbox origin - identical to every other tool API here;
508
- // 2. the tool must CARRY the grant (`permissions.llm`), which is the thing the review card showed
509
- // the person before they accepted the file. A tool that quietly started asking the model
510
- // would be spending the user's quota from inside a sandbox they were told was isolated;
511
- // 3. the run is bound to this tool id, so one frame cannot read or cancel another's answer.
512
- const llmStart = /^api\/llm$/.exec(resource);
513
- const llmRun = /^api\/llm\/([^/]+)$/.exec(resource);
514
- if (llmStart || llmRun) {
515
- if (!toolOrigin) {
516
- toolApi(403, { error: "tool runtime API is only available to the sandboxed tool frame" });
517
- return;
518
- }
519
- // GRANT FIRST, THEN RUNTIME. What a tool is allowed to ask is a fact about the tool and does not
520
- // depend on whether this machine happens to have a model, so a tool with no grant gets the same
521
- // 403 whether or not Pi is installed.
522
- if (!listed.permissions?.llm) {
523
- toolApi(403, { error: "这个工具没有调用模型的权限;注册或导入时它必须声明 permissions: { llm: true }。" });
524
- return;
525
- }
526
- if (!context.toolLlm?.available) {
527
- toolApi(503, { error: "这台机器上没有可用的 Pi 运行时,工具无法调用模型。" });
528
- return;
529
- }
530
- if (llmStart) {
531
449
  if (request.method === "OPTIONS") {
532
450
  response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "POST", "access-control-allow-headers": "content-type", "access-control-max-age": "300", "vary": "Origin" }).end();
533
451
  return;
@@ -536,1378 +454,1638 @@ export function createHttpServer(options) {
536
454
  toolApi(405, { error: "method" });
537
455
  return;
538
456
  }
539
- const asked = await readRequestText(request, 64_000);
540
- if (asked.tooLarge) {
457
+ const executed = await readRequestText(request, 64_000);
458
+ if (executed.tooLarge) {
541
459
  toolApi(413, { error: "request too large" });
542
460
  return;
543
461
  }
544
- const raw = asked.text;
462
+ const raw = executed.text;
545
463
  try {
546
464
  const body = JSON.parse(raw);
547
- toolApi(202, context.toolLlm.start({ toolId: id, prompt: body.prompt ?? "", system: body.system, timeoutMs: body.timeoutMs }));
465
+ const resolved = await toolRegistry.resolve(id);
466
+ if (!resolved?.tool.script) {
467
+ toolApi(404, { error: "python script is not registered" });
468
+ return;
469
+ }
470
+ const run = toolRunner.start({ toolId: id, script: resolve(resolved.root, resolved.tool.script.path), cwd: resolved.root, definition: resolved.tool.script, params: body.params ?? {}, environment: resolved.tool.environment });
471
+ toolApi(202, run);
548
472
  }
549
473
  catch (error) {
550
- toolApi(400, { error: describeThrown(error) || "模型调用失败,没有拿到失败原因。" });
474
+ toolApi(400, { error: describeThrown(error) || "工具运行失败,没有拿到失败原因。" });
551
475
  }
552
476
  return;
553
477
  }
554
- if (request.method === "OPTIONS") {
555
- response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "GET, DELETE", "access-control-allow-headers": "content-type", "access-control-max-age": "300", "vary": "Origin" }).end();
478
+ const runMatch = /^api\/runs\/([^/]+)$/.exec(resource);
479
+ if (runMatch) {
480
+ if (!toolOrigin) {
481
+ toolApi(403, { error: "tool runtime API is only available to the sandboxed tool frame" });
482
+ return;
483
+ }
484
+ if (request.method === "OPTIONS") {
485
+ response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "GET, DELETE", "access-control-max-age": "300", "vary": "Origin" }).end();
486
+ return;
487
+ }
488
+ let runId;
489
+ try {
490
+ runId = decodeURIComponent(runMatch[1]);
491
+ }
492
+ catch {
493
+ toolApi(400, { error: "Malformed tool resource" });
494
+ return;
495
+ }
496
+ if (request.method === "GET") {
497
+ const run = toolRunner.get(runId, id);
498
+ if (!run) {
499
+ toolApi(404, { error: "run not found" });
500
+ return;
501
+ }
502
+ toolApi(200, run);
503
+ return;
504
+ }
505
+ if (request.method === "DELETE") {
506
+ const run = toolRunner.cancel(runId, id);
507
+ if (!run) {
508
+ toolApi(404, { error: "run not found" });
509
+ return;
510
+ }
511
+ toolApi(200, run);
512
+ return;
513
+ }
514
+ toolApi(405, { error: "method" });
556
515
  return;
557
516
  }
558
- let llmId;
559
- try {
560
- llmId = decodeURIComponent(llmRun[1]);
517
+ // ---- THE MODEL, ON THE TOOL'S OWN BEHALF ---------------------------------------------------
518
+ // A tool asking the model a question, over the same relative API as its Python jobs, so the frame
519
+ // still never learns an id, a path or a credential. Three gates, in this order:
520
+ // 1. the opaque sandbox origin - identical to every other tool API here;
521
+ // 2. the tool must CARRY the grant (`permissions.llm`), which is the thing the review card showed
522
+ // the person before they accepted the file. A tool that quietly started asking the model
523
+ // would be spending the user's quota from inside a sandbox they were told was isolated;
524
+ // 3. the run is bound to this tool id, so one frame cannot read or cancel another's answer.
525
+ const llmStart = /^api\/llm$/.exec(resource);
526
+ const llmRun = /^api\/llm\/([^/]+)$/.exec(resource);
527
+ if (llmStart || llmRun) {
528
+ if (!toolOrigin) {
529
+ toolApi(403, { error: "tool runtime API is only available to the sandboxed tool frame" });
530
+ return;
531
+ }
532
+ // GRANT FIRST, THEN RUNTIME. What a tool is allowed to ask is a fact about the tool and does not
533
+ // depend on whether this machine happens to have a model, so a tool with no grant gets the same
534
+ // 403 whether or not Pi is installed.
535
+ if (!listed.permissions?.llm) {
536
+ toolApi(403, { error: "这个工具没有调用模型的权限;注册或导入时它必须声明 permissions: { llm: true }。" });
537
+ return;
538
+ }
539
+ if (!context.toolLlm?.available) {
540
+ toolApi(503, { error: "这台机器上没有可用的 Pi 运行时,工具无法调用模型。" });
541
+ return;
542
+ }
543
+ if (llmStart) {
544
+ if (request.method === "OPTIONS") {
545
+ response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "POST", "access-control-allow-headers": "content-type", "access-control-max-age": "300", "vary": "Origin" }).end();
546
+ return;
547
+ }
548
+ if (request.method !== "POST") {
549
+ toolApi(405, { error: "method" });
550
+ return;
551
+ }
552
+ const asked = await readRequestText(request, 64_000);
553
+ if (asked.tooLarge) {
554
+ toolApi(413, { error: "request too large" });
555
+ return;
556
+ }
557
+ const raw = asked.text;
558
+ try {
559
+ const body = JSON.parse(raw);
560
+ toolApi(202, context.toolLlm.start({ toolId: id, prompt: body.prompt ?? "", system: body.system, timeoutMs: body.timeoutMs }));
561
+ }
562
+ catch (error) {
563
+ toolApi(400, { error: describeThrown(error) || "模型调用失败,没有拿到失败原因。" });
564
+ }
565
+ return;
566
+ }
567
+ if (request.method === "OPTIONS") {
568
+ response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "GET, DELETE", "access-control-allow-headers": "content-type", "access-control-max-age": "300", "vary": "Origin" }).end();
569
+ return;
570
+ }
571
+ let llmId;
572
+ try {
573
+ llmId = decodeURIComponent(llmRun[1]);
574
+ }
575
+ catch {
576
+ toolApi(400, { error: "Malformed tool resource" });
577
+ return;
578
+ }
579
+ if (request.method === "GET") {
580
+ const run = context.toolLlm.get(llmId, id);
581
+ if (!run) {
582
+ toolApi(404, { error: "model call not found" });
583
+ return;
584
+ }
585
+ toolApi(200, run);
586
+ return;
587
+ }
588
+ if (request.method === "DELETE") {
589
+ const run = context.toolLlm.cancel(llmId, id);
590
+ if (!run) {
591
+ toolApi(404, { error: "model call not found" });
592
+ return;
593
+ }
594
+ toolApi(200, run);
595
+ return;
596
+ }
597
+ toolApi(405, { error: "method" });
598
+ return;
561
599
  }
562
- catch {
563
- toolApi(400, { error: "Malformed tool resource" });
600
+ if (resource === "super-pi-tool.js") {
601
+ response.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" }).end(toolBrowserSdk());
564
602
  return;
565
603
  }
566
- if (request.method === "GET") {
567
- const run = context.toolLlm.get(llmId, id);
568
- if (!run) {
569
- toolApi(404, { error: "model call not found" });
604
+ try {
605
+ const resolved = await toolRegistry.resolve(id, resource);
606
+ if (!resolved) {
607
+ response.writeHead(404).end("Tool not found");
570
608
  return;
571
609
  }
572
- toolApi(200, run);
610
+ // Mounted tools run in an opaque sandbox origin (the iframe deliberately omits
611
+ // `allow-same-origin`). In that context CSP's `'self'` is the opaque origin and
612
+ // does not authorize a request back to this server, even for a relative URL.
613
+ const policy = "default-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'";
614
+ response.writeHead(200, { "content-type": MIME_TYPES[extname(resolved.file)] ?? "application/octet-stream", "cache-control": "no-store", "content-security-policy": policy });
615
+ createReadStream(resolved.file).pipe(response);
573
616
  return;
574
617
  }
575
- if (request.method === "DELETE") {
576
- const run = context.toolLlm.cancel(llmId, id);
577
- if (!run) {
578
- toolApi(404, { error: "model call not found" });
579
- return;
580
- }
581
- toolApi(200, run);
618
+ catch {
619
+ response.writeHead(404).end("Tool file not found");
582
620
  return;
583
621
  }
584
- toolApi(405, { error: "method" });
585
- return;
586
- }
587
- if (resource === "super-pi-tool.js") {
588
- response.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" }).end(toolBrowserSdk());
589
- return;
590
622
  }
591
- try {
592
- const resolved = await toolRegistry.resolve(id, resource);
593
- if (!resolved) {
594
- response.writeHead(404).end("Tool not found");
623
+ /**
624
+ * THE TERMINAL IS NOT AN API ENDPOINT, and the routing above deliberately cannot reach one.
625
+ *
626
+ * Every command family in `CommandHandler` is answerable over plain HTTP except the terminal, which
627
+ * is WebSocket-only (`terminal.*` messages). `serverContext` therefore carries only the two
628
+ * predicates `/api/terminal` needs - is it on, and if not why - and NOT a handle on `TerminalService`.
629
+ * There is consequently no code path from an HTTP request to a spawned shell, which is the property
630
+ * worth having: the browser's own origin could not reach it even with a forged request.
631
+ */
632
+ if (url.pathname === "/api/memory/tool" || url.pathname === "/api/memory") {
633
+ if (!context.memory) {
634
+ response.writeHead(503).end();
635
+ return;
636
+ }
637
+ if ((url.pathname.endsWith("/tool") && request.method !== "POST") || !["GET", "POST"].includes(request.method ?? "")) {
638
+ response.writeHead(405).end();
639
+ return;
640
+ }
641
+ const origin = request.headers.origin;
642
+ if (origin && origin !== url.origin) {
643
+ response.writeHead(403).end("Origin not allowed");
595
644
  return;
596
645
  }
597
- // Mounted tools run in an opaque sandbox origin (the iframe deliberately omits
598
- // `allow-same-origin`). In that context CSP's `'self'` is the opaque origin and
599
- // does not authorize a request back to this server, even for a relative URL.
600
- const policy = "default-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'";
601
- response.writeHead(200, { "content-type": MIME_TYPES[extname(resolved.file)] ?? "application/octet-stream", "cache-control": "no-store", "content-security-policy": policy });
602
- createReadStream(resolved.file).pipe(response);
603
- return;
604
- }
605
- catch {
606
- response.writeHead(404).end("Tool file not found");
607
- return;
608
- }
609
- }
610
- /**
611
- * THE TERMINAL IS NOT AN API ENDPOINT, and the routing above deliberately cannot reach one.
612
- *
613
- * Every command family in `CommandHandler` is answerable over plain HTTP except the terminal, which
614
- * is WebSocket-only (`terminal.*` messages). `serverContext` therefore carries only the two
615
- * predicates `/api/terminal` needs - is it on, and if not why - and NOT a handle on `TerminalService`.
616
- * There is consequently no code path from an HTTP request to a spawned shell, which is the property
617
- * worth having: the browser's own origin could not reach it even with a forged request.
618
- */
619
- if (url.pathname === "/api/memory/tool" || url.pathname === "/api/memory") {
620
- if (!context.memory) {
621
- response.writeHead(503).end();
622
- return;
623
- }
624
- if ((url.pathname.endsWith("/tool") && request.method !== "POST") || !["GET", "POST"].includes(request.method ?? "")) {
625
- response.writeHead(405).end();
626
- return;
627
- }
628
- const origin = request.headers.origin;
629
- if (origin && origin !== url.origin) {
630
- response.writeHead(403).end("Origin not allowed");
631
- return;
632
- }
633
- try {
634
- const remembered = await readRequestText(request, 512000);
635
- if (remembered.tooLarge)
636
- throw new Error("记忆请求过大");
637
- const raw = remembered.text;
638
- const body = raw ? JSON.parse(raw) : {};
639
- const result = url.pathname.endsWith("/tool")
640
- ? await context.memory.tool(String(request.headers["x-memory-capability"] ?? ""), body)
641
- : await context.memory.manage(url.searchParams.get("workspaceId") ?? "", body);
642
- response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(result));
643
- }
644
- catch (error) {
645
- response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
646
- }
647
- return;
648
- }
649
- if (url.pathname === "/api/skills") {
650
- const workspaceId = url.searchParams.get("workspaceId") ?? "";
651
- const workspace = context.store.snapshot().workspaces.find((w) => w.id === workspaceId);
652
- try {
653
- const skills = await getAvailableSkills(workspace?.path, { memory: context.memory, workspaceId });
654
- json(200, { skills });
655
- }
656
- catch (error) {
657
- json(500, { error: error instanceof Error ? error.message : "获取技能失败" });
658
- }
659
- return;
660
- }
661
- if (url.pathname === "/api/skills/read") {
662
- let workspaceId = url.searchParams.get("workspaceId") ?? "";
663
- let name = url.searchParams.get("name") ?? url.searchParams.get("skillId") ?? "";
664
- if (request.method === "POST") {
665
646
  try {
666
- const asked = await readRequestText(request, 512000);
667
- if (asked.tooLarge)
647
+ const remembered = await readRequestText(request, 512000);
648
+ if (remembered.tooLarge)
668
649
  throw new Error("记忆请求过大");
669
- const raw = asked.text;
670
- if (raw) {
671
- const body = JSON.parse(raw);
672
- if (body.workspaceId)
673
- workspaceId = body.workspaceId;
674
- if (body.name || body.skillId)
675
- name = body.name || body.skillId;
676
- }
650
+ const raw = remembered.text;
651
+ const body = raw ? JSON.parse(raw) : {};
652
+ const result = url.pathname.endsWith("/tool")
653
+ ? await context.memory.tool(String(request.headers["x-memory-capability"] ?? ""), body)
654
+ : await context.memory.manage(url.searchParams.get("workspaceId") ?? "", body);
655
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(result));
677
656
  }
678
- catch {
679
- // ignore json parse error, fall back to url params
657
+ catch (error) {
658
+ response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
680
659
  }
681
- }
682
- if (!name) {
683
- json(400, { error: "技能名称不能为空" });
684
660
  return;
685
661
  }
686
- const workspace = context.store.snapshot().workspaces.find((w) => w.id === workspaceId);
687
- try {
688
- const result = await readSkill(name, workspace?.path, { memory: context.memory, workspaceId });
689
- if (!result) {
690
- json(404, { error: "技能不存在" });
691
- return;
662
+ if (url.pathname === "/api/skills") {
663
+ const workspaceId = url.searchParams.get("workspaceId") ?? "";
664
+ const workspace = context.store.workspace(workspaceId);
665
+ try {
666
+ const skills = await getAvailableSkills(workspace?.path, { memory: context.memory, workspaceId });
667
+ json(200, { skills });
668
+ }
669
+ catch (error) {
670
+ json(500, { error: error instanceof Error ? error.message : "获取技能失败" });
692
671
  }
693
- json(200, result);
694
- }
695
- catch (error) {
696
- json(500, { error: error instanceof Error ? error.message : "读取技能失败" });
697
- }
698
- return;
699
- }
700
- if (url.pathname === "/api/providers/fetch-models") {
701
- if (request.method !== "POST") {
702
- response.writeHead(405).end();
703
672
  return;
704
673
  }
705
- try {
706
- const asked = await readRequestText(request, 512000);
707
- if (asked.tooLarge) {
708
- json(413, { error: "请求过大" });
674
+ if (url.pathname === "/api/skills/read") {
675
+ let workspaceId = url.searchParams.get("workspaceId") ?? "";
676
+ let name = url.searchParams.get("name") ?? url.searchParams.get("skillId") ?? "";
677
+ if (request.method === "POST") {
678
+ try {
679
+ const asked = await readRequestText(request, 512000);
680
+ if (asked.tooLarge)
681
+ throw new Error("记忆请求过大");
682
+ const raw = asked.text;
683
+ if (raw) {
684
+ const body = JSON.parse(raw);
685
+ if (body.workspaceId)
686
+ workspaceId = body.workspaceId;
687
+ if (body.name || body.skillId)
688
+ name = body.name || body.skillId;
689
+ }
690
+ }
691
+ catch {
692
+ // ignore json parse error, fall back to url params
693
+ }
694
+ }
695
+ if (!name) {
696
+ json(400, { error: "技能名称不能为空" });
709
697
  return;
710
698
  }
711
- const raw = asked.text;
712
- const body = raw ? JSON.parse(raw) : {};
713
- const models = await context.providerService.fetchModels(body);
714
- json(200, { ok: true, models });
715
- }
716
- catch (error) {
717
- json(400, { ok: false, error: error instanceof Error ? error.message : String(error) });
718
- }
719
- return;
720
- }
721
- if (url.pathname === "/api/knowledge/tool" || url.pathname === "/api/knowledge") {
722
- if (!context.knowledge) {
723
- response.writeHead(503).end();
724
- return;
725
- }
726
- if ((url.pathname.endsWith("/tool") && request.method !== "POST") || !["GET", "POST"].includes(request.method ?? "")) {
727
- response.writeHead(405).end();
728
- return;
729
- }
730
- const origin = request.headers.origin;
731
- if (origin && origin !== url.origin) {
732
- response.writeHead(403).end("Origin not allowed");
699
+ const workspace = context.store.workspace(workspaceId);
700
+ try {
701
+ const result = await readSkill(name, workspace?.path, { memory: context.memory, workspaceId });
702
+ if (!result) {
703
+ json(404, { error: "技能不存在" });
704
+ return;
705
+ }
706
+ json(200, result);
707
+ }
708
+ catch (error) {
709
+ json(500, { error: error instanceof Error ? error.message : "读取技能失败" });
710
+ }
733
711
  return;
734
712
  }
735
- try {
736
- let raw = "";
737
- let totalBytes = 0;
738
- // A pasted document rides in the body, so the cap is the document cap - past it the request is
739
- // refused outright rather than truncated into a document that silently lost its tail.
740
- const known = await readRequestText(request, MAX_KNOWLEDGE_REQUEST_BYTES);
741
- if (known.tooLarge) {
742
- response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "知识库请求过大" }));
713
+ if (url.pathname === "/api/providers/fetch-models") {
714
+ if (request.method !== "POST") {
715
+ response.writeHead(405).end();
743
716
  return;
744
717
  }
745
- raw = known.text;
746
- const body = raw ? JSON.parse(raw) : {};
747
- const result = url.pathname.endsWith("/tool")
748
- ? await context.knowledge.tool(String(request.headers["x-knowledge-capability"] ?? ""), body, abortedAs(request))
749
- : await context.knowledge.manage(url.searchParams.get("workspaceId") ?? "", body);
750
- response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(result));
751
- }
752
- catch (error) {
753
- response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
754
- }
755
- return;
756
- }
757
- if (url.pathname === "/api/documents/tool") {
758
- if (!context.documentService) {
759
- response.writeHead(503).end();
760
- return;
761
- }
762
- if (request.method !== "POST") {
763
- response.writeHead(405).end();
764
- return;
765
- }
766
- const origin = request.headers.origin;
767
- if (origin && origin !== url.origin) {
768
- response.writeHead(403).end("Origin not allowed");
718
+ try {
719
+ const asked = await readRequestText(request, 512000);
720
+ if (asked.tooLarge) {
721
+ json(413, { error: "请求过大" });
722
+ return;
723
+ }
724
+ const raw = asked.text;
725
+ const body = raw ? JSON.parse(raw) : {};
726
+ const models = await context.providerService.fetchModels(body);
727
+ json(200, { ok: true, models });
728
+ }
729
+ catch (error) {
730
+ json(400, { ok: false, error: error instanceof Error ? error.message : String(error) });
731
+ }
769
732
  return;
770
733
  }
771
- try {
772
- let raw = "";
773
- let totalBytes = 0;
774
- // A DOCUMENT'S TEXT RIDES IN THE REQUEST BODY, so the cap is the document cap: a request
775
- // past it is refused outright rather than truncated into a document that silently lost its
776
- // tail.
777
- const documented = await readRequestText(request, MAX_DOCUMENT_REQUEST_BYTES);
778
- if (documented.tooLarge) {
779
- response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "文档请求过大" }));
734
+ if (url.pathname === "/api/knowledge/tool" || url.pathname === "/api/knowledge") {
735
+ if (!context.knowledge) {
736
+ response.writeHead(503).end();
780
737
  return;
781
738
  }
782
- raw = documented.text;
783
- const body = raw ? JSON.parse(raw) : {};
784
- // The capability says WHICH CONVERSATION is asking, and the workspace is derived from it
785
- // rather than taken from the request: a request that named its own workspace would be a
786
- // request that could read another workspace.
787
- const conversationId = String(request.headers["x-documents-conversation"] ?? "");
788
- const verdict = verifyCapability(conversationId, request.headers["x-documents-capability"], { running: context.runService.isRunning(conversationId) });
789
- if (!verdict.ok) {
790
- response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: verdict.message }));
739
+ if ((url.pathname.endsWith("/tool") && request.method !== "POST") || !["GET", "POST"].includes(request.method ?? "")) {
740
+ response.writeHead(405).end();
791
741
  return;
792
742
  }
793
- const result = body.action === "write"
794
- ? await context.documentService.write(conversationId, String(body.path ?? ""), String(body.content ?? ""), typeof body.title === "string" ? body.title : undefined, typeof body.baseEtag === "string" ? body.baseEtag : undefined)
795
- : await context.documentService.read(conversationId, String(body.path ?? ""));
796
- response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(result));
797
- }
798
- catch (error) {
799
- response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
800
- }
801
- return;
802
- }
803
- if (url.pathname === "/api/web/tool") {
804
- if (!context.web) {
805
- response.writeHead(503).end();
806
- return;
807
- }
808
- if (request.method !== "POST") {
809
- response.writeHead(405).end();
810
- return;
811
- }
812
- const origin = request.headers.origin;
813
- if (origin && origin !== url.origin) {
814
- response.writeHead(403).end("Origin not allowed");
815
- return;
816
- }
817
- try {
818
- let raw = "", totalBytes = 0;
819
- const asked = await readRequestText(request, MAX_WEB_REQUEST_BYTES);
820
- if (asked.tooLarge) {
821
- response.writeHead(413).end();
743
+ const origin = request.headers.origin;
744
+ if (origin && origin !== url.origin) {
745
+ response.writeHead(403).end("Origin not allowed");
822
746
  return;
823
747
  }
824
- raw = asked.text;
825
- const conversationId = String(request.headers["x-web-conversation"] ?? "");
826
- const verdict = verifyCapability(conversationId, request.headers["x-web-capability"], { running: context.runService.isRunning(conversationId) });
827
- if (!verdict.ok) {
828
- response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: verdict.message }));
829
- return;
748
+ try {
749
+ let raw = "";
750
+ let totalBytes = 0;
751
+ // A pasted document rides in the body, so the cap is the document cap - past it the request is
752
+ // refused outright rather than truncated into a document that silently lost its tail.
753
+ const known = await readRequestText(request, MAX_KNOWLEDGE_REQUEST_BYTES);
754
+ if (known.tooLarge) {
755
+ response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "知识库请求过大" }));
756
+ return;
757
+ }
758
+ raw = known.text;
759
+ const body = raw ? JSON.parse(raw) : {};
760
+ const result = url.pathname.endsWith("/tool")
761
+ ? await context.knowledge.tool(String(request.headers["x-knowledge-capability"] ?? ""), body, abortedAs(request))
762
+ : await context.knowledge.manage(url.searchParams.get("workspaceId") ?? "", body);
763
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(result));
764
+ }
765
+ catch (error) {
766
+ response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
830
767
  }
831
- const body = raw ? JSON.parse(raw) : {};
832
- const result = body.action === "search"
833
- ? await context.web.search(String(body.query ?? ""), Number(body.limit ?? 5), abortedAs(request))
834
- : body.action === "fetch"
835
- ? await context.web.fetch(String(body.url ?? ""), abortedAs(request))
836
- : await Promise.reject(new Error("未知网页工具操作"));
837
- json(200, result);
838
- }
839
- catch (error) {
840
- json(400, { error: error instanceof Error ? error.message : String(error) });
841
- }
842
- return;
843
- }
844
- if (url.pathname === "/api/feishu/tool") {
845
- if (!context.feishu) {
846
- response.writeHead(503).end();
847
- return;
848
- }
849
- if (request.method !== "POST") {
850
- response.writeHead(405).end();
851
- return;
852
- }
853
- const origin = request.headers.origin;
854
- if (origin && origin !== url.origin) {
855
- response.writeHead(403).end("Origin not allowed");
856
768
  return;
857
769
  }
858
- try {
859
- let raw = "", totalBytes = 0;
860
- // The Markdown of a document rides in the request body, so the cap is the document cap.
861
- const asked = await readRequestText(request, MAX_FEISHU_REQUEST_BYTES);
862
- if (asked.tooLarge) {
863
- response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "飞书请求过大" }));
770
+ if (url.pathname === "/api/documents/tool") {
771
+ if (!context.documentService) {
772
+ response.writeHead(503).end();
864
773
  return;
865
774
  }
866
- raw = asked.text;
867
- const conversationId = String(request.headers["x-feishu-conversation"] ?? "");
868
- // THE SAME PROOF THE DOCUMENT TOOLS USE: the request must carry the session's own
869
- // capability, and the session must actually be running a turn. The Feishu credential is a
870
- // company secret, so this endpoint is the only way to spend it.
871
- const verdict = verifyCapability(conversationId, request.headers["x-feishu-capability"], { running: context.runService.isRunning(conversationId) });
872
- if (!verdict.ok) {
873
- response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: verdict.message }));
775
+ if (request.method !== "POST") {
776
+ response.writeHead(405).end();
874
777
  return;
875
778
  }
876
- const body = raw ? JSON.parse(raw) : {};
877
- json(200, await context.feishu.tool(body));
878
- }
879
- catch (error) {
880
- json(400, { error: error instanceof Error ? error.message : String(error) });
881
- }
882
- return;
883
- }
884
- /**
885
- * THE FEISHU CONNECTION, as the settings panel sees it.
886
- *
887
- * A GET answers with what the official CLI can do right now: whether it is installed, whether
888
- * it has an application, and whether a person has authorized it. It never answers with a
889
- * credential - the CLI keeps those in the OS keychain, and this server never sees them.
890
- *
891
- * The address predicate is on the POSTs and not on the GET: connecting is a change to the
892
- * machine, and the project's rule is that changes like this come from the local network.
893
- * Reading whether the CLI is connected leaks nothing beyond what the panel already shows.
894
- */
895
- if (url.pathname === "/api/feishu/status") {
896
- if (!context.feishu) {
897
- response.writeHead(503).end();
898
- return;
899
- }
900
- if (request.method !== "GET") {
901
- json(405, { error: "这个接口只接受 GET 请求。" });
902
- return;
903
- }
904
- json(200, await context.feishu.status());
905
- return;
906
- }
907
- /**
908
- * STARTS THE CONNECT FLOW, and finishes it.
909
- *
910
- * The first POST asks the CLI for a device code (or for it to register an application), and
911
- * answers with the URL the person has to open. The second one hands the device code back to
912
- * the CLI once they have authorized it, which is where the token is written - into the keychain,
913
- * not into this server.
914
- *
915
- * The CLI's own documentation describes exactly this two-step shape for agents, and the two
916
- * calls are separate endpoints because the person has to go and do something in between.
917
- */
918
- if (url.pathname === "/api/feishu/connect" || url.pathname === "/api/feishu/connect/complete") {
919
- if (!context.feishu) {
920
- response.writeHead(503).end();
921
- return;
922
- }
923
- if (request.method !== "POST") {
924
- json(405, { error: "这个接口只接受 POST 请求。" });
925
- return;
926
- }
927
- if (!isLocalOrPrivateAddress(remote)) {
928
- json(403, { error: "连接飞书账号只允许来自本机或局域网的请求。" });
929
- return;
930
- }
931
- try {
932
- const result = url.pathname.endsWith("/complete")
933
- ? await context.feishu.completeConnect()
934
- : await context.feishu.connect();
935
- json(result.ok ? 200 : 400, result);
936
- }
937
- catch (error) {
938
- json(400, { error: error instanceof Error ? error.message : String(error) });
939
- }
940
- return;
941
- }
942
- /**
943
- * CHECKS THE CONNECTION, and says why it does not work when it does not.
944
- *
945
- * The status already verifies the credential against Feishu, so this is the same answer shaped
946
- * for a button - the difference is that a failure comes back as a MESSAGE AND A HINT rather
947
- * than as a state the panel has to interpret.
948
- */
949
- if (url.pathname === "/api/feishu/verify" && request.method === "POST") {
950
- if (!context.feishu) {
951
- response.writeHead(503).end();
952
- return;
953
- }
954
- if (!isLocalOrPrivateAddress(remote)) {
955
- json(403, { error: "测试飞书连接只允许来自本机或局域网的请求。" });
956
- return;
957
- }
958
- const result = await context.feishu.verify();
959
- json(result.ok ? 200 : 400, result);
960
- return;
961
- }
962
- if (url.pathname === "/api/shutdown") {
963
- if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
964
- response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Shutdown requires a loopback or private network POST");
965
- return;
966
- }
967
- response.writeHead(202, { "content-type": "application/json; charset=utf-8" })
968
- .end(JSON.stringify({ status: "shutting_down", message: "Server is stopping" }));
969
- setTimeout(() => { void onShutdown("api-shutdown"); }, 50);
970
- return;
971
- }
972
- if (url.pathname === "/api/update" && request.method === "POST") {
973
- if (!isLocalOrPrivateAddress(remote)) {
974
- response.writeHead(403).end("Update requires a loopback or private network POST");
975
- return;
976
- }
977
- response.writeHead(202, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ status: "updating" }));
978
- // Use npm directly instead of `pi update` to avoid Windows detached+shell exit timing issues.
979
- // npm's exit code correctly reflects whether the update succeeded.
980
- const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
981
- const update = packageUpdateArgs(rootDir);
982
- const child = spawn(npmCmd, update.args, { stdio: "ignore", shell: process.platform === "win32", env: process.env });
983
- child.on("close", (code) => {
984
- if (code === 0 || code === null) {
985
- console.log("Super Pi: npm install completed, triggering restart.");
986
- const restart = spawn(process.execPath, [join(rootDir, "scripts", "restart-server.mjs")], { cwd: rootDir, detached: true, stdio: "ignore", env: process.env });
987
- restart.on("error", (restartError) => console.error(`Super Pi: restart after update failed: ${restartError instanceof Error ? restartError.message : String(restartError)}`));
988
- restart.unref();
989
- }
990
- else {
991
- console.error(`Super Pi: npm install failed with code ${code}`);
779
+ const origin = request.headers.origin;
780
+ if (origin && origin !== url.origin) {
781
+ response.writeHead(403).end("Origin not allowed");
782
+ return;
783
+ }
784
+ try {
785
+ let raw = "";
786
+ let totalBytes = 0;
787
+ // A DOCUMENT'S TEXT RIDES IN THE REQUEST BODY, so the cap is the document cap: a request
788
+ // past it is refused outright rather than truncated into a document that silently lost its
789
+ // tail.
790
+ const documented = await readRequestText(request, MAX_DOCUMENT_REQUEST_BYTES);
791
+ if (documented.tooLarge) {
792
+ response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "文档请求过大" }));
793
+ return;
794
+ }
795
+ raw = documented.text;
796
+ const body = raw ? JSON.parse(raw) : {};
797
+ // The capability says WHICH CONVERSATION is asking, and the workspace is derived from it
798
+ // rather than taken from the request: a request that named its own workspace would be a
799
+ // request that could read another workspace.
800
+ const conversationId = String(request.headers["x-documents-conversation"] ?? "");
801
+ const verdict = verifyCapability(conversationId, request.headers["x-documents-capability"], { running: context.runService.isRunning(conversationId) });
802
+ if (!verdict.ok) {
803
+ response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: verdict.message }));
804
+ return;
805
+ }
806
+ const result = body.action === "write"
807
+ ? await context.documentService.write(conversationId, String(body.path ?? ""), String(body.content ?? ""), typeof body.title === "string" ? body.title : undefined, typeof body.baseEtag === "string" ? body.baseEtag : undefined)
808
+ : await context.documentService.read(conversationId, String(body.path ?? ""));
809
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(result));
810
+ }
811
+ catch (error) {
812
+ response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
992
813
  }
993
- });
994
- child.on("error", (error) => { console.warn(`Super Pi: update spawn failed: ${error instanceof Error ? error.message : "unknown error"}`); });
995
- child.unref();
996
- return;
997
- }
998
- if (url.pathname === "/api/update-check" && request.method === "GET") {
999
- if (!isLocalOrPrivateAddress(remote)) {
1000
- response.writeHead(403).end("Update check requires a loopback or private network request");
1001
814
  return;
1002
815
  }
1003
- try {
1004
- const current = checkDiskVersion(rootDir);
1005
- // Query the npm registry for the latest @evandu/super-pi version.
1006
- const remoteVersion = await fetch("https://registry.npmjs.org/@evandu/super-pi/latest").then(async (result) => {
1007
- if (!result.ok)
1008
- throw new Error(`npm registry HTTP ${result.status}`);
1009
- return result.json();
1010
- });
1011
- const latestVersion = typeof remoteVersion.version === "string" ? remoteVersion.version : undefined;
1012
- response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ ...current, latestVersion, updateAvailable: Boolean(latestVersion && latestVersion !== current.diskVersion) }));
1013
- }
1014
- catch (error) {
1015
- response.writeHead(502, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
1016
- }
1017
- return;
1018
- }
1019
- if (url.pathname === "/api/health") {
1020
- const version = checkDiskVersion(rootDir);
1021
- response.writeHead(200, { "content-type": "application/json; charset=utf-8" })
1022
- .end(JSON.stringify({ status: "ok", service: "super-pi", ...version }));
1023
- return;
1024
- }
1025
- /**
1026
- * WHETHER THE TERMINAL EXISTS, as a plain GET rather than part of the `state` snapshot.
1027
- *
1028
- * It is deployment configuration, not application state: it does not change while the server runs,
1029
- * it is identical for every workspace, and putting it on `state` would drag it into the persistence
1030
- * layer and into every existing snapshot schema for no benefit. A `GET` also means the right rail
1031
- * can decide whether to RENDER the tab at all - and not rendering it is the point, because a tab
1032
- * that is present and refuses is a tab that invites a user to keep clicking a closed door.
1033
- *
1034
- * No address check: this leaks nothing beyond "is this build configured for a shell", which is
1035
- * already printed in the server's own startup banner to whoever can see the console.
1036
- */
1037
- if (url.pathname === "/api/terminal") {
1038
- const enabled = context.terminalEnabled?.() ?? false;
1039
- response.writeHead(200, { "content-type": "application/json; charset=utf-8" })
1040
- .end(JSON.stringify({ enabled, reason: enabled ? "" : (context.terminalDisabledReason?.() ?? "终端未启用。") }));
1041
- return;
1042
- }
1043
- if (url.pathname === "/api/terminal-shell") {
1044
- const { defaultShell, shellSource, terminalShellCandidates } = await import("./terminal.js");
1045
- const configured = context.store.snapshot().terminalShell;
1046
- const shell = defaultShell(process.env, configured);
1047
- response.writeHead(200, { "content-type": "application/json; charset=utf-8" })
1048
- .end(JSON.stringify({ shell, source: shellSource(process.env, configured), candidates: terminalShellCandidates() }));
1049
- return;
1050
- }
1051
- if (url.pathname === "/api/git") {
1052
- const json = (status, body) => response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1053
- try {
1054
- const workspaceId = url.searchParams.get("workspaceId");
1055
- const workspace = context.store.snapshot().workspaces.find((item) => item.id === workspaceId);
1056
- if (!workspace) {
1057
- json(404, { error: `工作区 ${workspaceId ?? "(missing)"} 不存在`, code: "no_workspace" });
816
+ if (url.pathname === "/api/web/tool") {
817
+ if (!context.web) {
818
+ response.writeHead(503).end();
1058
819
  return;
1059
820
  }
1060
- const op = url.searchParams.get("op") ?? "status";
1061
- // Blame is a read with a GET shape: every other verb is refused HERE, before the POST branch
1062
- // could ever route the op into gitMutate and before any body is read.
1063
- if (op === "blame" && request.method !== "GET") {
1064
- json(405, { error: "Git 请求方法不支持", code: "method" });
821
+ if (request.method !== "POST") {
822
+ response.writeHead(405).end();
1065
823
  return;
1066
824
  }
1067
- if (request.method === "POST") {
1068
- // A GIT WRITE IS A WRITE, AND IT GETS THE SAME GATE AS EVERY OTHER WRITE. `gitMutate` can
1069
- // stage, DISCARD (`git restore -- <path>`, which throws away uncommitted work), commit,
1070
- // pull and PUSH - so an endpoint that skips the address predicate hands a public caller the
1071
- // keys to somebody's repository. The server listens on 0.0.0.0 by default and the global
1072
- // access token is OPTIONAL (`isAuthorized` passes everyone when there is none), so this
1073
- // predicate IS the boundary for every other write endpoint in this file (ledger TEST-042).
1074
- if (!isLocalOrPrivateAddress(remote)) {
1075
- json(403, { error: "Git 写操作只允许来自本机或局域网的请求。" });
825
+ const origin = request.headers.origin;
826
+ if (origin && origin !== url.origin) {
827
+ response.writeHead(403).end("Origin not allowed");
828
+ return;
829
+ }
830
+ try {
831
+ let raw = "", totalBytes = 0;
832
+ const asked = await readRequestText(request, MAX_WEB_REQUEST_BYTES);
833
+ if (asked.tooLarge) {
834
+ response.writeHead(413).end();
1076
835
  return;
1077
836
  }
1078
- const asked = await readRequestText(request, 512000);
1079
- if (asked.tooLarge) {
1080
- json(413, { error: "请求过大" });
837
+ raw = asked.text;
838
+ const conversationId = String(request.headers["x-web-conversation"] ?? "");
839
+ const verdict = verifyCapability(conversationId, request.headers["x-web-capability"], { running: context.runService.isRunning(conversationId) });
840
+ if (!verdict.ok) {
841
+ response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: verdict.message }));
1081
842
  return;
1082
843
  }
1083
- const raw = asked.text;
1084
844
  const body = raw ? JSON.parse(raw) : {};
1085
- json(200, await gitMutate(workspace.path, op, body.path, body.message));
845
+ const result = body.action === "search"
846
+ ? await context.web.search(String(body.query ?? ""), Number(body.limit ?? 5), abortedAs(request))
847
+ : body.action === "fetch"
848
+ ? await context.web.fetch(String(body.url ?? ""), abortedAs(request))
849
+ : await Promise.reject(new Error("未知网页工具操作"));
850
+ json(200, result);
851
+ }
852
+ catch (error) {
853
+ json(400, { error: error instanceof Error ? error.message : String(error) });
854
+ }
855
+ return;
856
+ }
857
+ if (url.pathname === "/api/feishu/tool") {
858
+ if (!context.feishu) {
859
+ response.writeHead(503).end();
1086
860
  return;
1087
861
  }
1088
- if (request.method !== "GET") {
1089
- json(405, { error: "Git 请求方法不支持", code: "method" });
862
+ if (request.method !== "POST") {
863
+ response.writeHead(405).end();
1090
864
  return;
1091
865
  }
1092
- if (op === "status")
1093
- json(200, await gitStatus(workspace.path));
1094
- else if (op === "log")
1095
- json(200, await gitLog(workspace.path, Number(url.searchParams.get("limit") ?? 20)));
1096
- else if (op === "diff") {
1097
- const path = url.searchParams.get("path");
1098
- if (!path) {
1099
- json(400, { error: "diff 需要 path", code: "bad_path" });
866
+ const origin = request.headers.origin;
867
+ if (origin && origin !== url.origin) {
868
+ response.writeHead(403).end("Origin not allowed");
869
+ return;
870
+ }
871
+ try {
872
+ let raw = "", totalBytes = 0;
873
+ // The Markdown of a document rides in the request body, so the cap is the document cap.
874
+ const asked = await readRequestText(request, MAX_FEISHU_REQUEST_BYTES);
875
+ if (asked.tooLarge) {
876
+ response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "飞书请求过大" }));
1100
877
  return;
1101
878
  }
1102
- json(200, await gitDiff(workspace.path, path, url.searchParams.get("staged") === "1"));
1103
- }
1104
- else if (op === "blame") {
1105
- const path = url.searchParams.get("path");
1106
- if (!path) {
1107
- json(400, { error: "blame 需要 path", code: "bad_path" });
879
+ raw = asked.text;
880
+ const conversationId = String(request.headers["x-feishu-conversation"] ?? "");
881
+ // THE SAME PROOF THE DOCUMENT TOOLS USE: the request must carry the session's own
882
+ // capability, and the session must actually be running a turn. The Feishu credential is a
883
+ // company secret, so this endpoint is the only way to spend it.
884
+ const verdict = verifyCapability(conversationId, request.headers["x-feishu-capability"], { running: context.runService.isRunning(conversationId) });
885
+ if (!verdict.ok) {
886
+ response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: verdict.message }));
1108
887
  return;
1109
888
  }
1110
- const startLine = url.searchParams.get("startLine");
1111
- const endLine = url.searchParams.get("endLine");
1112
- json(200, await gitBlame(workspace.path, path, {
1113
- revision: url.searchParams.get("revision") ?? undefined,
1114
- startLine: startLine === null ? undefined : Number(startLine),
1115
- endLine: endLine === null ? undefined : Number(endLine),
1116
- }));
889
+ const body = raw ? JSON.parse(raw) : {};
890
+ json(200, await context.feishu.tool(body));
891
+ }
892
+ catch (error) {
893
+ json(400, { error: error instanceof Error ? error.message : String(error) });
1117
894
  }
1118
- else
1119
- json(400, { error: "未知 Git 操作", code: "bad_op" });
1120
- }
1121
- catch (error) {
1122
- const message = error instanceof Error ? error.message : "Git 读取失败";
1123
- const code = error?.code;
1124
- if (code === 128 || code === "not_a_repo" || /not a git repository/i.test(message))
1125
- json(404, { error: "此工作区不是 git 仓库", code: "not_a_repo" });
1126
- else if (code === "path")
1127
- json(400, { error: message, code: "bad_path" });
1128
- else if (code === "not_found")
1129
- json(404, { error: message, code: "not_found" });
1130
- else if (code === "bad_range" || code === "bad_revision")
1131
- json(400, { error: message, code });
1132
- else
1133
- json(400, { error: message, code: "git_error" });
1134
- }
1135
- return;
1136
- }
1137
- if (url.pathname === "/api/subagent/dispatch") {
1138
- if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
1139
- response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Sub-agent dispatch requires a loopback or private network POST");
1140
895
  return;
1141
896
  }
1142
- let raw = "";
1143
- let totalBytes = 0;
1144
- const reader = textBodyReader();
1145
- for await (const chunk of request) {
1146
- const piece = chunk;
1147
- totalBytes += piece.length;
1148
- if (totalBytes > MAX_FILE_BYTES * 2) {
1149
- response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
1150
- request.destroy();
897
+ /**
898
+ * THE FEISHU CONNECTION, as the settings panel sees it.
899
+ *
900
+ * A GET answers with what the official CLI can do right now: whether it is installed, whether
901
+ * it has an application, and whether a person has authorized it. It never answers with a
902
+ * credential - the CLI keeps those in the OS keychain, and this server never sees them.
903
+ *
904
+ * The address predicate is on the POSTs and not on the GET: connecting is a change to the
905
+ * machine, and the project's rule is that changes like this come from the local network.
906
+ * Reading whether the CLI is connected leaks nothing beyond what the panel already shows.
907
+ */
908
+ if (url.pathname === "/api/feishu/status") {
909
+ if (!context.feishu) {
910
+ response.writeHead(503).end();
1151
911
  return;
1152
912
  }
1153
- raw = reader.push(piece);
913
+ if (request.method !== "GET") {
914
+ json(405, { error: "这个接口只接受 GET 请求。" });
915
+ return;
916
+ }
917
+ json(200, await context.feishu.status());
918
+ return;
1154
919
  }
1155
- raw = reader.finish();
1156
- const json = (status, body) => {
1157
- response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1158
- };
1159
- try {
1160
- const body = JSON.parse(raw);
1161
- const result = await runService.dispatchTask(body);
1162
- json(result.status, result.body);
920
+ /**
921
+ * STARTS THE CONNECT FLOW, and finishes it.
922
+ *
923
+ * The first POST asks the CLI for a device code (or for it to register an application), and
924
+ * answers with the URL the person has to open. The second one hands the device code back to
925
+ * the CLI once they have authorized it, which is where the token is written - into the keychain,
926
+ * not into this server.
927
+ *
928
+ * The CLI's own documentation describes exactly this two-step shape for agents, and the two
929
+ * calls are separate endpoints because the person has to go and do something in between.
930
+ */
931
+ if (url.pathname === "/api/feishu/connect" || url.pathname === "/api/feishu/connect/complete") {
932
+ if (!context.feishu) {
933
+ response.writeHead(503).end();
934
+ return;
935
+ }
936
+ if (request.method !== "POST") {
937
+ json(405, { error: "这个接口只接受 POST 请求。" });
938
+ return;
939
+ }
940
+ if (!isLocalOrPrivateAddress(remote)) {
941
+ json(403, { error: "连接飞书账号只允许来自本机或局域网的请求。" });
942
+ return;
943
+ }
944
+ try {
945
+ const result = url.pathname.endsWith("/complete")
946
+ ? await context.feishu.completeConnect()
947
+ : await context.feishu.connect();
948
+ json(result.ok ? 200 : 400, result);
949
+ }
950
+ catch (error) {
951
+ json(400, { error: error instanceof Error ? error.message : String(error) });
952
+ }
953
+ return;
1163
954
  }
1164
- catch (error) {
1165
- json(400, { error: error instanceof Error ? error.message : "task dispatch failed" });
955
+ /**
956
+ * CHECKS THE CONNECTION, and says why it does not work when it does not.
957
+ *
958
+ * The status already verifies the credential against Feishu, so this is the same answer shaped
959
+ * for a button - the difference is that a failure comes back as a MESSAGE AND A HINT rather
960
+ * than as a state the panel has to interpret.
961
+ */
962
+ if (url.pathname === "/api/feishu/verify" && request.method === "POST") {
963
+ if (!context.feishu) {
964
+ response.writeHead(503).end();
965
+ return;
966
+ }
967
+ if (!isLocalOrPrivateAddress(remote)) {
968
+ json(403, { error: "测试飞书连接只允许来自本机或局域网的请求。" });
969
+ return;
970
+ }
971
+ const result = await context.feishu.verify();
972
+ json(result.ok ? 200 : 400, result);
973
+ return;
1166
974
  }
1167
- return;
1168
- }
1169
- /**
1170
- * Lightweight background command execution, independent from subagent dispatch.
1171
- * POST with { cmd, cwd, conversationId, capability } -> 202 { sessionId, status: "running" }
1172
- * Result is delivered via WebSocket exec.exit event and injected into the conversation.
1173
- */
1174
- if (url.pathname === "/api/exec") {
1175
- if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
1176
- response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Exec requires a loopback or private network POST");
975
+ if (url.pathname === "/api/shutdown") {
976
+ if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
977
+ response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Shutdown requires a loopback or private network POST");
978
+ return;
979
+ }
980
+ response.writeHead(202, { "content-type": "application/json; charset=utf-8" })
981
+ .end(JSON.stringify({ status: "shutting_down", message: "Server is stopping" }));
982
+ setTimeout(() => { void onShutdown("api-shutdown"); }, 50);
1177
983
  return;
1178
984
  }
1179
- if (!execService) {
1180
- response.writeHead(503, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "后台命令服务没有启用。", code: "unavailable" }));
985
+ if (url.pathname === "/api/update" && request.method === "POST") {
986
+ if (!isLocalOrPrivateAddress(remote)) {
987
+ response.writeHead(403).end("Update requires a loopback or private network POST");
988
+ return;
989
+ }
990
+ response.writeHead(202, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ status: "updating" }));
991
+ // Use npm directly instead of `pi update` to avoid Windows detached+shell exit timing issues.
992
+ // npm's exit code correctly reflects whether the update succeeded.
993
+ const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
994
+ const update = packageUpdateArgs(rootDir);
995
+ const child = spawn(npmCmd, update.args, { stdio: "ignore", shell: process.platform === "win32", env: process.env });
996
+ child.on("close", (code) => {
997
+ if (code === 0 || code === null) {
998
+ console.log("Super Pi: npm install completed, triggering restart.");
999
+ const restart = spawn(process.execPath, [join(rootDir, "scripts", "restart-server.mjs")], { cwd: rootDir, detached: true, stdio: "ignore", env: process.env });
1000
+ restart.on("error", (restartError) => console.error(`Super Pi: restart after update failed: ${restartError instanceof Error ? restartError.message : String(restartError)}`));
1001
+ restart.unref();
1002
+ }
1003
+ else {
1004
+ console.error(`Super Pi: npm install failed with code ${code}`);
1005
+ }
1006
+ });
1007
+ child.on("error", (error) => { console.warn(`Super Pi: update spawn failed: ${error instanceof Error ? error.message : "unknown error"}`); });
1008
+ child.unref();
1181
1009
  return;
1182
1010
  }
1183
- let raw = "";
1184
- let totalBytes = 0;
1185
- const reader = textBodyReader();
1186
- for await (const chunk of request) {
1187
- const piece = chunk;
1188
- totalBytes += piece.length;
1189
- if (totalBytes > MAX_FILE_BYTES * 2) {
1190
- response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
1191
- request.destroy();
1011
+ if (url.pathname === "/api/update-check" && request.method === "GET") {
1012
+ if (!isLocalOrPrivateAddress(remote)) {
1013
+ response.writeHead(403).end("Update check requires a loopback or private network request");
1192
1014
  return;
1193
1015
  }
1194
- raw = reader.push(piece);
1195
- }
1196
- raw = reader.finish();
1197
- const json = (status, body) => {
1198
- response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1199
- };
1200
- let body;
1201
- try {
1202
- body = JSON.parse(raw);
1016
+ try {
1017
+ const current = checkDiskVersion(rootDir);
1018
+ // Query the npm registry for the latest @evandu/super-pi version.
1019
+ const remoteVersion = await fetch("https://registry.npmjs.org/@evandu/super-pi/latest").then(async (result) => {
1020
+ if (!result.ok)
1021
+ throw new Error(`npm registry HTTP ${result.status}`);
1022
+ return result.json();
1023
+ });
1024
+ const latestVersion = typeof remoteVersion.version === "string" ? remoteVersion.version : undefined;
1025
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ ...current, latestVersion, updateAvailable: Boolean(latestVersion && latestVersion !== current.diskVersion) }));
1026
+ }
1027
+ catch (error) {
1028
+ response.writeHead(502, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
1029
+ }
1030
+ return;
1203
1031
  }
1204
- catch {
1205
- json(400, { error: "请求体不是合法的 JSON。" });
1032
+ if (url.pathname === "/api/health") {
1033
+ const version = checkDiskVersion(rootDir);
1034
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8" })
1035
+ .end(JSON.stringify({ status: "ok", service: "super-pi", ...version }));
1206
1036
  return;
1207
1037
  }
1208
- const { cmd, description, cwd, conversationId, capability, toolCallId } = body;
1209
- if (!cmd || !cwd || !conversationId) {
1210
- json(400, { error: "需要 cmd、cwd 和 conversationId。" });
1038
+ /**
1039
+ * WHETHER THE TERMINAL EXISTS, as a plain GET rather than part of the `state` snapshot.
1040
+ *
1041
+ * It is deployment configuration, not application state: it does not change while the server runs,
1042
+ * it is identical for every workspace, and putting it on `state` would drag it into the persistence
1043
+ * layer and into every existing snapshot schema for no benefit. A `GET` also means the right rail
1044
+ * can decide whether to RENDER the tab at all - and not rendering it is the point, because a tab
1045
+ * that is present and refuses is a tab that invites a user to keep clicking a closed door.
1046
+ *
1047
+ * No address check: this leaks nothing beyond "is this build configured for a shell", which is
1048
+ * already printed in the server's own startup banner to whoever can see the console.
1049
+ */
1050
+ if (url.pathname === "/api/terminal") {
1051
+ const enabled = context.terminalEnabled?.() ?? false;
1052
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8" })
1053
+ .end(JSON.stringify({ enabled, reason: enabled ? "" : (context.terminalDisabledReason?.() ?? "终端未启用。") }));
1211
1054
  return;
1212
1055
  }
1213
- // Verify both the credential and RunService's authoritative active-turn state.
1214
- const result = runService.verifyPrimaryCapability(conversationId, capability);
1215
- if (!result.ok) {
1216
- json(403, { error: result.message });
1056
+ if (url.pathname === "/api/terminal-shell") {
1057
+ const { defaultShell, shellSource, terminalShellCandidates } = await import("./terminal.js");
1058
+ const configured = context.store.snapshot().terminalShell;
1059
+ const shell = defaultShell(process.env, configured);
1060
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8" })
1061
+ .end(JSON.stringify({ shell, source: shellSource(process.env, configured), candidates: terminalShellCandidates() }));
1217
1062
  return;
1218
1063
  }
1219
- // The `cwd` here is the directory the Pi child was launched in - which is the conversation's
1220
- // workspace, already authenticated by the per-conversation capability check above. Validating it
1221
- // against the workspace list looked cheap, but Windows path normalization (8.3 short names, UNC
1222
- // prefixes, drive-letter case, separator styles) routinely produces the same path in two different
1223
- // strings and a strict equality check refused valid calls; a relaxed comparison is no longer
1224
- // providing a security boundary that the capability check is not already providing. The trust
1225
- // decision is the one above; this stays as a no-op.
1226
- void cwd;
1227
- // Set up the background task record BEFORE spawning, so the UI shows it immediately
1228
- const { randomUUID } = await import("node:crypto");
1229
- const sessionId = `exec_${randomUUID()}`;
1230
- // The same anchor a sub-agent card carries, for the same reason: a background command asked for
1231
- // inside a turn belongs at that call. Resolved AFTER the capability check above, because a position
1232
- // is worth reading only from a caller already inside the turn.
1233
- const origin = await runService.waitForToolCall(conversationId, toolCallId);
1234
- await store.setBackgroundTask({
1235
- id: sessionId,
1236
- conversationId,
1237
- title: description?.trim() || (cmd.length > 60 ? cmd.slice(0, 60) + "…" : cmd),
1238
- agentId: "exec",
1239
- status: "running",
1240
- startedAt: new Date().toISOString(),
1241
- resultBlockId: sessionId,
1242
- ...(origin ? origin : {}),
1243
- });
1244
- if (origin) {
1245
- const toolCalls = await store.updateToolCall(conversationId, origin.originMessageId, origin.originCallRow, { backgroundTask: { id: sessionId, status: "running" } });
1246
- broadcaster.broadcast({ type: "message.tools", conversationId, messageId: origin.originMessageId, toolCalls });
1064
+ if (url.pathname === "/api/git") {
1065
+ const json = (status, body) => response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1066
+ try {
1067
+ const workspaceId = url.searchParams.get("workspaceId");
1068
+ const workspace = workspaceId ? context.store.workspace(workspaceId) : undefined;
1069
+ if (!workspace) {
1070
+ json(404, { error: `工作区 ${workspaceId ?? "(missing)"} 不存在`, code: "no_workspace" });
1071
+ return;
1072
+ }
1073
+ const op = url.searchParams.get("op") ?? "status";
1074
+ // Blame is a read with a GET shape: every other verb is refused HERE, before the POST branch
1075
+ // could ever route the op into gitMutate and before any body is read.
1076
+ if (op === "blame" && request.method !== "GET") {
1077
+ json(405, { error: "Git 请求方法不支持", code: "method" });
1078
+ return;
1079
+ }
1080
+ if (request.method === "POST") {
1081
+ // A GIT WRITE IS A WRITE, AND IT GETS THE SAME GATE AS EVERY OTHER WRITE. `gitMutate` can
1082
+ // stage, DISCARD (`git restore -- <path>`, which throws away uncommitted work), commit,
1083
+ // pull and PUSH - so an endpoint that skips the address predicate hands a public caller the
1084
+ // keys to somebody's repository. The server listens on 0.0.0.0 by default and the global
1085
+ // access token is OPTIONAL (`isAuthorized` passes everyone when there is none), so this
1086
+ // predicate IS the boundary for every other write endpoint in this file (ledger TEST-042).
1087
+ if (!isLocalOrPrivateAddress(remote)) {
1088
+ json(403, { error: "Git 写操作只允许来自本机或局域网的请求。" });
1089
+ return;
1090
+ }
1091
+ const asked = await readRequestText(request, 512000);
1092
+ if (asked.tooLarge) {
1093
+ json(413, { error: "请求过大" });
1094
+ return;
1095
+ }
1096
+ const raw = asked.text;
1097
+ const body = raw ? JSON.parse(raw) : {};
1098
+ json(200, await gitMutate(workspace.path, op, body.path, body.message));
1099
+ return;
1100
+ }
1101
+ if (request.method !== "GET") {
1102
+ json(405, { error: "Git 请求方法不支持", code: "method" });
1103
+ return;
1104
+ }
1105
+ if (op === "status")
1106
+ json(200, await gitStatus(workspace.path));
1107
+ else if (op === "log")
1108
+ json(200, await gitLog(workspace.path, Number(url.searchParams.get("limit") ?? 20)));
1109
+ else if (op === "diff") {
1110
+ const path = url.searchParams.get("path");
1111
+ if (!path) {
1112
+ json(400, { error: "diff 需要 path", code: "bad_path" });
1113
+ return;
1114
+ }
1115
+ json(200, await gitDiff(workspace.path, path, url.searchParams.get("staged") === "1"));
1116
+ }
1117
+ else if (op === "blame") {
1118
+ const path = url.searchParams.get("path");
1119
+ if (!path) {
1120
+ json(400, { error: "blame 需要 path", code: "bad_path" });
1121
+ return;
1122
+ }
1123
+ const startLine = url.searchParams.get("startLine");
1124
+ const endLine = url.searchParams.get("endLine");
1125
+ json(200, await gitBlame(workspace.path, path, {
1126
+ revision: url.searchParams.get("revision") ?? undefined,
1127
+ startLine: startLine === null ? undefined : Number(startLine),
1128
+ endLine: endLine === null ? undefined : Number(endLine),
1129
+ }));
1130
+ }
1131
+ else
1132
+ json(400, { error: "未知 Git 操作", code: "bad_op" });
1133
+ }
1134
+ catch (error) {
1135
+ const message = error instanceof Error ? error.message : "Git 读取失败";
1136
+ const code = error?.code;
1137
+ if (code === 128 || code === "not_a_repo" || /not a git repository/i.test(message))
1138
+ json(404, { error: "此工作区不是 git 仓库", code: "not_a_repo" });
1139
+ else if (code === "path")
1140
+ json(400, { error: message, code: "bad_path" });
1141
+ else if (code === "not_found")
1142
+ json(404, { error: message, code: "not_found" });
1143
+ else if (code === "bad_range" || code === "bad_revision")
1144
+ json(400, { error: message, code });
1145
+ else
1146
+ json(400, { error: message, code: "git_error" });
1147
+ }
1148
+ return;
1247
1149
  }
1248
- broadcaster.broadcastState();
1249
- try {
1250
- execService.spawn(sessionId, cmd, cwd);
1251
- json(202, { sessionId, status: "running" });
1150
+ if (url.pathname === "/api/subagent/dispatch") {
1151
+ if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
1152
+ response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Sub-agent dispatch requires a loopback or private network POST");
1153
+ return;
1154
+ }
1155
+ let raw = "";
1156
+ let totalBytes = 0;
1157
+ const reader = textBodyReader();
1158
+ for await (const chunk of request) {
1159
+ const piece = chunk;
1160
+ totalBytes += piece.length;
1161
+ if (totalBytes > MAX_FILE_BYTES * 2) {
1162
+ response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
1163
+ request.destroy();
1164
+ return;
1165
+ }
1166
+ raw = reader.push(piece);
1167
+ }
1168
+ raw = reader.finish();
1169
+ const json = (status, body) => {
1170
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1171
+ };
1172
+ try {
1173
+ const body = JSON.parse(raw);
1174
+ const result = await runService.dispatchTask(body);
1175
+ json(result.status, result.body);
1176
+ }
1177
+ catch (error) {
1178
+ json(400, { error: error instanceof Error ? error.message : "task dispatch failed" });
1179
+ }
1180
+ return;
1252
1181
  }
1253
- catch (error) {
1182
+ /**
1183
+ * Lightweight background command execution, independent from subagent dispatch.
1184
+ * POST with { cmd, cwd, conversationId, capability } -> 202 { sessionId, status: "running" }
1185
+ * Result is delivered via WebSocket exec.exit event and injected into the conversation.
1186
+ */
1187
+ if (url.pathname === "/api/exec") {
1188
+ if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
1189
+ response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Exec requires a loopback or private network POST");
1190
+ return;
1191
+ }
1192
+ if (!execService) {
1193
+ response.writeHead(503, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "后台命令服务没有启用。", code: "unavailable" }));
1194
+ return;
1195
+ }
1196
+ let raw = "";
1197
+ let totalBytes = 0;
1198
+ const reader = textBodyReader();
1199
+ for await (const chunk of request) {
1200
+ const piece = chunk;
1201
+ totalBytes += piece.length;
1202
+ if (totalBytes > MAX_FILE_BYTES * 2) {
1203
+ response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
1204
+ request.destroy();
1205
+ return;
1206
+ }
1207
+ raw = reader.push(piece);
1208
+ }
1209
+ raw = reader.finish();
1210
+ const json = (status, body) => {
1211
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1212
+ };
1213
+ let body;
1214
+ try {
1215
+ body = JSON.parse(raw);
1216
+ }
1217
+ catch {
1218
+ json(400, { error: "请求体不是合法的 JSON。" });
1219
+ return;
1220
+ }
1221
+ const { cmd, description, cwd, conversationId, capability, toolCallId } = body;
1222
+ if (!cmd || !cwd || !conversationId) {
1223
+ json(400, { error: "需要 cmd、cwd 和 conversationId。" });
1224
+ return;
1225
+ }
1226
+ // Verify both the credential and RunService's authoritative active-turn state.
1227
+ const result = runService.verifyPrimaryCapability(conversationId, capability);
1228
+ if (!result.ok) {
1229
+ json(403, { error: result.message });
1230
+ return;
1231
+ }
1232
+ // The `cwd` here is the directory the Pi child was launched in - which is the conversation's
1233
+ // workspace, already authenticated by the per-conversation capability check above. Validating it
1234
+ // against the workspace list looked cheap, but Windows path normalization (8.3 short names, UNC
1235
+ // prefixes, drive-letter case, separator styles) routinely produces the same path in two different
1236
+ // strings and a strict equality check refused valid calls; a relaxed comparison is no longer
1237
+ // providing a security boundary that the capability check is not already providing. The trust
1238
+ // decision is the one above; this stays as a no-op.
1239
+ void cwd;
1240
+ // Set up the background task record BEFORE spawning, so the UI shows it immediately
1241
+ const { randomUUID } = await import("node:crypto");
1242
+ const sessionId = `exec_${randomUUID()}`;
1243
+ // The same anchor a sub-agent card carries, for the same reason: a background command asked for
1244
+ // inside a turn belongs at that call. Resolved AFTER the capability check above, because a position
1245
+ // is worth reading only from a caller already inside the turn.
1246
+ const origin = await runService.waitForToolCall(conversationId, toolCallId);
1247
+ await store.setBackgroundTask({
1248
+ id: sessionId,
1249
+ conversationId,
1250
+ title: description?.trim() || (cmd.length > 60 ? cmd.slice(0, 60) + "…" : cmd),
1251
+ agentId: "exec",
1252
+ status: "running",
1253
+ startedAt: new Date().toISOString(),
1254
+ resultBlockId: sessionId,
1255
+ ...(origin ? origin : {}),
1256
+ });
1254
1257
  if (origin) {
1255
- const toolCalls = await store.updateToolCall(conversationId, origin.originMessageId, origin.originCallRow, { backgroundTask: { id: sessionId, status: "failed", output: error instanceof Error ? error.message : "spawn failed" } });
1258
+ const toolCalls = await store.updateToolCall(conversationId, origin.originMessageId, origin.originCallRow, { backgroundTask: { id: sessionId, status: "running" } });
1256
1259
  broadcaster.broadcast({ type: "message.tools", conversationId, messageId: origin.originMessageId, toolCalls });
1257
1260
  }
1258
- await store.removeBackgroundTask(sessionId);
1259
1261
  broadcaster.broadcastState();
1260
- json(500, { error: error instanceof Error ? error.message : "spawn failed" });
1261
- }
1262
- return;
1263
- }
1264
- if (url.pathname === "/api/workflow/run") {
1265
- if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
1266
- response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Workflow dispatch requires a loopback or private network POST");
1262
+ try {
1263
+ execService.spawn(sessionId, cmd, cwd);
1264
+ json(202, { sessionId, status: "running" });
1265
+ }
1266
+ catch (error) {
1267
+ if (origin) {
1268
+ const toolCalls = await store.updateToolCall(conversationId, origin.originMessageId, origin.originCallRow, { backgroundTask: { id: sessionId, status: "failed", output: error instanceof Error ? error.message : "spawn failed" } });
1269
+ broadcaster.broadcast({ type: "message.tools", conversationId, messageId: origin.originMessageId, toolCalls });
1270
+ }
1271
+ await store.removeBackgroundTask(sessionId);
1272
+ broadcaster.broadcastState();
1273
+ json(500, { error: error instanceof Error ? error.message : "spawn failed" });
1274
+ }
1267
1275
  return;
1268
1276
  }
1269
- let raw = "";
1270
- let totalBytes = 0;
1271
- const reader = textBodyReader();
1272
- for await (const chunk of request) {
1273
- const piece = chunk;
1274
- totalBytes += piece.length;
1275
- if (totalBytes > MAX_FILE_BYTES * 2) {
1276
- response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
1277
- request.destroy();
1277
+ if (url.pathname === "/api/workflow/run") {
1278
+ if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
1279
+ response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Workflow dispatch requires a loopback or private network POST");
1278
1280
  return;
1279
1281
  }
1280
- raw = reader.push(piece);
1281
- }
1282
- raw = reader.finish();
1283
- const json = (status, body) => {
1284
- response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1285
- };
1286
- try {
1287
- const result = await runService.runWorkflow(JSON.parse(raw));
1288
- json(result.status, result.body);
1289
- }
1290
- catch (error) {
1291
- json(400, { error: error instanceof Error ? error.message : "workflow dispatch failed" });
1292
- }
1293
- return;
1294
- }
1295
- if (url.pathname === "/api/todos") {
1296
- if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
1297
- response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Todo writes require a loopback or private network POST");
1282
+ let raw = "";
1283
+ let totalBytes = 0;
1284
+ const reader = textBodyReader();
1285
+ for await (const chunk of request) {
1286
+ const piece = chunk;
1287
+ totalBytes += piece.length;
1288
+ if (totalBytes > MAX_FILE_BYTES * 2) {
1289
+ response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
1290
+ request.destroy();
1291
+ return;
1292
+ }
1293
+ raw = reader.push(piece);
1294
+ }
1295
+ raw = reader.finish();
1296
+ const json = (status, body) => {
1297
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1298
+ };
1299
+ try {
1300
+ const result = await runService.runWorkflow(JSON.parse(raw));
1301
+ json(result.status, result.body);
1302
+ }
1303
+ catch (error) {
1304
+ json(400, { error: error instanceof Error ? error.message : "workflow dispatch failed" });
1305
+ }
1298
1306
  return;
1299
1307
  }
1300
- let raw = "";
1301
- let totalBytes = 0;
1302
- const reader = textBodyReader();
1303
- for await (const chunk of request) {
1304
- const piece = chunk;
1305
- totalBytes += piece.length;
1306
- if (totalBytes > MAX_FILE_BYTES * 2) {
1307
- response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
1308
- request.destroy();
1308
+ if (url.pathname === "/api/todos") {
1309
+ if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
1310
+ response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Todo writes require a loopback or private network POST");
1309
1311
  return;
1310
1312
  }
1311
- raw = reader.push(piece);
1313
+ let raw = "";
1314
+ let totalBytes = 0;
1315
+ const reader = textBodyReader();
1316
+ for await (const chunk of request) {
1317
+ const piece = chunk;
1318
+ totalBytes += piece.length;
1319
+ if (totalBytes > MAX_FILE_BYTES * 2) {
1320
+ response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
1321
+ request.destroy();
1322
+ return;
1323
+ }
1324
+ raw = reader.push(piece);
1325
+ }
1326
+ raw = reader.finish();
1327
+ const json = (status, body) => {
1328
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1329
+ };
1330
+ try {
1331
+ const body = JSON.parse(raw);
1332
+ const conversationId = body.conversationId?.trim();
1333
+ if (!conversationId)
1334
+ throw new Error("todowrite requires conversationId");
1335
+ const verdict = runService.verifyPrimaryCapability(conversationId, body.capability);
1336
+ if (!verdict.ok) {
1337
+ json(403, { error: verdict.message });
1338
+ return;
1339
+ }
1340
+ const { todos, dropped } = await store.setTodos(conversationId, body.todos);
1341
+ broadcaster.broadcastState();
1342
+ json(200, { todos, dropped, conversationId });
1343
+ }
1344
+ catch (error) {
1345
+ json(400, { error: error instanceof Error ? error.message : "todo write failed" });
1346
+ }
1347
+ return;
1312
1348
  }
1313
- raw = reader.finish();
1314
- const json = (status, body) => {
1315
- response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1316
- };
1317
- try {
1318
- const body = JSON.parse(raw);
1319
- const conversationId = body.conversationId?.trim();
1320
- if (!conversationId)
1321
- throw new Error("todowrite requires conversationId");
1322
- const verdict = runService.verifyPrimaryCapability(conversationId, body.capability);
1323
- if (!verdict.ok) {
1324
- json(403, { error: verdict.message });
1349
+ if (url.pathname === "/api/scheduled-tasks") {
1350
+ // THE AGENT'S DOOR TO THE SCHEDULED TASKS, and the same shape as `/api/todos`: exempt from the
1351
+ // global token because the caller proves itself with a per-conversation capability instead, and
1352
+ // restricted to loopback or a private network for the same reason `/api/todos` is.
1353
+ if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
1354
+ response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Scheduled task writes require a loopback or private network POST");
1325
1355
  return;
1326
1356
  }
1327
- const { todos, dropped } = await store.setTodos(conversationId, body.todos);
1328
- broadcaster.broadcastState();
1329
- json(200, { todos, dropped, conversationId });
1330
- }
1331
- catch (error) {
1332
- json(400, { error: error instanceof Error ? error.message : "todo write failed" });
1333
- }
1334
- return;
1335
- }
1336
- if (url.pathname === "/api/scheduled-tasks") {
1337
- // THE AGENT'S DOOR TO THE SCHEDULED TASKS, and the same shape as `/api/todos`: exempt from the
1338
- // global token because the caller proves itself with a per-conversation capability instead, and
1339
- // restricted to loopback or a private network for the same reason `/api/todos` is.
1340
- if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
1341
- response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Scheduled task writes require a loopback or private network POST");
1357
+ let raw = "";
1358
+ let totalBytes = 0;
1359
+ const reader = textBodyReader();
1360
+ for await (const chunk of request) {
1361
+ const piece = chunk;
1362
+ totalBytes += piece.length;
1363
+ if (totalBytes > MAX_FILE_BYTES * 2) {
1364
+ response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
1365
+ request.destroy();
1366
+ return;
1367
+ }
1368
+ raw = reader.push(piece);
1369
+ }
1370
+ raw = reader.finish();
1371
+ const json = (status, body) => {
1372
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1373
+ };
1374
+ try {
1375
+ const body = JSON.parse(raw);
1376
+ const conversationId = body.conversationId?.trim();
1377
+ if (!conversationId)
1378
+ throw new Error("scheduled_task requires conversationId");
1379
+ const verdict = runService.verifyPrimaryCapability(conversationId, body.capability);
1380
+ if (!verdict.ok) {
1381
+ json(403, { error: verdict.message });
1382
+ return;
1383
+ }
1384
+ const service = context.scheduledTaskService;
1385
+ if (!service) {
1386
+ json(503, { error: "定时任务功能在这个实例上没有启用。" });
1387
+ return;
1388
+ }
1389
+ // THE WORKSPACE IS THE CALLER'S, not the agent's choice. The conversation the turn belongs to
1390
+ // decides where the task runs and what its defaults are; a task could otherwise be filed under
1391
+ // a workspace its own author cannot see.
1392
+ const workspaceId = conversationWorkspace(store.snapshot(), conversationId).id;
1393
+ const action = (body.action ?? "create").trim();
1394
+ if (action === "list") {
1395
+ json(200, { tasks: service.listForWorkspace(workspaceId) });
1396
+ return;
1397
+ }
1398
+ if (action === "create") {
1399
+ const task = await service.create({
1400
+ name: body.name,
1401
+ prompt: body.prompt,
1402
+ schedule: body.schedule,
1403
+ workspaceId,
1404
+ model: body.model ? { provider: body.model.provider, modelId: body.model.modelId, label: body.model.label ?? body.model.modelId } : null,
1405
+ agentId: body.agentId,
1406
+ }, "agent");
1407
+ json(200, { task, describe: describeSchedule(task.schedule) });
1408
+ return;
1409
+ }
1410
+ if (action === "update") {
1411
+ const id = body.id?.trim();
1412
+ if (!id)
1413
+ throw new Error("scheduled_task update requires an id");
1414
+ // THE TASK MUST BE THE CALLER'S TO CHANGE. A task runs in a workspace: it opens its
1415
+ // conversation there, uses that workspace's defaults and its tools. The caller cannot even
1416
+ // LIST another workspace's tasks (`list` is scoped), so it must not be able to change or
1417
+ // delete them either - and the refusal must not say "it exists, over there".
1418
+ service.requireForWorkspace(id, workspaceId);
1419
+ const task = await service.update(id, {
1420
+ name: body.name,
1421
+ prompt: body.prompt,
1422
+ schedule: body.schedule,
1423
+ // "NOT MENTIONED" AND "CLEARED" ARE DIFFERENT REQUESTS. The extension only sends the
1424
+ // fields the model actually wrote, so a model that changes the prompt and says nothing
1425
+ // about the model leaves the key ABSENT - and the service reads an absent model as
1426
+ // "keep what is there". Mapping absence to null made "not mentioned" mean "delete", so a
1427
+ // model the user had picked for this task was silently dropped and the task fell back to
1428
+ // the default model (measured by scripts/scheduled-task-scope.test.mjs, ledger
1429
+ // TEST-025). Every other field here already has this asymmetry right: name, prompt,
1430
+ // schedule and agentId are all "absent means unchanged".
1431
+ model: body.model === undefined
1432
+ ? undefined
1433
+ : (body.model ? { provider: body.model.provider, modelId: body.model.modelId, label: body.model.label ?? body.model.modelId } : null),
1434
+ agentId: body.agentId,
1435
+ });
1436
+ json(200, { task, describe: describeSchedule(task.schedule) });
1437
+ return;
1438
+ }
1439
+ if (action === "delete") {
1440
+ const id = body.id?.trim();
1441
+ if (!id)
1442
+ throw new Error("scheduled_task delete requires an id");
1443
+ service.requireForWorkspace(id, workspaceId);
1444
+ await service.remove(id);
1445
+ json(200, { deleted: id });
1446
+ return;
1447
+ }
1448
+ json(400, { error: "action 只能是 create、list、update 或 delete。" });
1449
+ }
1450
+ catch (error) {
1451
+ json(400, { error: error instanceof Error ? error.message : "scheduled task request failed" });
1452
+ }
1342
1453
  return;
1343
1454
  }
1344
- let raw = "";
1345
- let totalBytes = 0;
1346
- const reader = textBodyReader();
1347
- for await (const chunk of request) {
1348
- const piece = chunk;
1349
- totalBytes += piece.length;
1350
- if (totalBytes > MAX_FILE_BYTES * 2) {
1455
+ if (url.pathname === "/api/orchestrator") {
1456
+ if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
1457
+ response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Orchestrator requires a loopback or private network POST");
1458
+ return;
1459
+ }
1460
+ const asked = await readRequestText(request, MAX_FILE_BYTES * 2);
1461
+ if (asked.tooLarge) {
1351
1462
  response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
1352
- request.destroy();
1353
1463
  return;
1354
1464
  }
1355
- raw = reader.push(piece);
1465
+ const json = (status, body) => {
1466
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1467
+ };
1468
+ try {
1469
+ const body = JSON.parse(asked.text);
1470
+ const service = context.sessionOrchestratorService;
1471
+ if (!service) {
1472
+ json(503, { error: "编排服务未就绪" });
1473
+ return;
1474
+ }
1475
+ if (body.capability && body.parentConversationId) {
1476
+ const verdict = runService.verifyPrimaryCapability(body.parentConversationId, body.capability);
1477
+ if (!verdict.ok) {
1478
+ json(403, { error: verdict.message });
1479
+ return;
1480
+ }
1481
+ }
1482
+ else if (context.accessToken && !isAuthorized(request, context.accessToken)) {
1483
+ json(401, { error: "未授权" });
1484
+ return;
1485
+ }
1486
+ const action = (body.action || "").trim();
1487
+ if (action === "spawn") {
1488
+ if (!body.task)
1489
+ throw new Error("spawn requires task");
1490
+ const result = await service.spawn({
1491
+ task: body.task,
1492
+ parentConversationId: body.parentConversationId,
1493
+ workspaceId: body.workspaceId,
1494
+ title: body.title,
1495
+ agentId: body.agentId,
1496
+ model: body.model,
1497
+ thinkingLevel: body.thinkingLevel,
1498
+ initialPrompt: body.initialPrompt,
1499
+ wait: body.wait,
1500
+ });
1501
+ json(200, { ok: true, ...result });
1502
+ return;
1503
+ }
1504
+ if (action === "dispatch") {
1505
+ if (!body.conversationId)
1506
+ throw new Error("dispatch requires conversationId");
1507
+ if (!body.content)
1508
+ throw new Error("dispatch requires content");
1509
+ const result = await service.dispatch(body.conversationId, body.content, { wait: body.wait });
1510
+ json(200, { ok: true, ...result });
1511
+ return;
1512
+ }
1513
+ if (action === "status") {
1514
+ if (!body.conversationId)
1515
+ throw new Error("status requires conversationId");
1516
+ const summary = await service.status(body.conversationId);
1517
+ json(200, { ok: true, summary });
1518
+ return;
1519
+ }
1520
+ if (action === "list") {
1521
+ const sessions = await service.list(body.parentConversationId);
1522
+ json(200, { ok: true, sessions });
1523
+ return;
1524
+ }
1525
+ if (action === "stop") {
1526
+ if (!body.conversationId)
1527
+ throw new Error("stop requires conversationId");
1528
+ const result = await service.stop(body.conversationId, { mode: body.mode, messageId: body.messageId });
1529
+ json(200, { ok: true, ...result });
1530
+ return;
1531
+ }
1532
+ if (action === "resume") {
1533
+ if (!body.conversationId)
1534
+ throw new Error("resume requires conversationId");
1535
+ const result = await service.resume(body.conversationId, body.prompt, { wait: body.wait });
1536
+ json(200, { ok: true, ...result });
1537
+ return;
1538
+ }
1539
+ json(400, { error: `不支持的编排操作:${action}` });
1540
+ }
1541
+ catch (error) {
1542
+ json(400, { error: error instanceof Error ? error.message : "orchestrator request failed" });
1543
+ }
1544
+ return;
1356
1545
  }
1357
- raw = reader.finish();
1358
- const json = (status, body) => {
1359
- response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1360
- };
1361
- try {
1362
- const body = JSON.parse(raw);
1363
- const conversationId = body.conversationId?.trim();
1364
- if (!conversationId)
1365
- throw new Error("scheduled_task requires conversationId");
1366
- const verdict = runService.verifyPrimaryCapability(conversationId, body.capability);
1367
- if (!verdict.ok) {
1368
- json(403, { error: verdict.message });
1546
+ if (url.pathname === "/api/orchestrator/status" && request.method === "GET") {
1547
+ if (!isLocalOrPrivateAddress(remote)) {
1548
+ response.writeHead(403).end();
1369
1549
  return;
1370
1550
  }
1371
- const service = context.scheduledTaskService;
1551
+ const service = context.sessionOrchestratorService;
1372
1552
  if (!service) {
1373
- json(503, { error: "定时任务功能在这个实例上没有启用。" });
1374
- return;
1375
- }
1376
- // THE WORKSPACE IS THE CALLER'S, not the agent's choice. The conversation the turn belongs to
1377
- // decides where the task runs and what its defaults are; a task could otherwise be filed under
1378
- // a workspace its own author cannot see.
1379
- const workspaceId = conversationWorkspace(store.snapshot(), conversationId).id;
1380
- const action = (body.action ?? "create").trim();
1381
- if (action === "list") {
1382
- json(200, { tasks: service.listForWorkspace(workspaceId) });
1553
+ response.writeHead(503, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "编排服务未就绪" }));
1383
1554
  return;
1384
1555
  }
1385
- if (action === "create") {
1386
- const task = await service.create({
1387
- name: body.name,
1388
- prompt: body.prompt,
1389
- schedule: body.schedule,
1390
- workspaceId,
1391
- model: body.model ? { provider: body.model.provider, modelId: body.model.modelId, label: body.model.label ?? body.model.modelId } : null,
1392
- agentId: body.agentId,
1393
- }, "agent");
1394
- json(200, { task, describe: describeSchedule(task.schedule) });
1556
+ const conversationId = url.searchParams.get("conversationId");
1557
+ if (!conversationId) {
1558
+ response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "缺少 conversationId" }));
1395
1559
  return;
1396
1560
  }
1397
- if (action === "update") {
1398
- const id = body.id?.trim();
1399
- if (!id)
1400
- throw new Error("scheduled_task update requires an id");
1401
- // THE TASK MUST BE THE CALLER'S TO CHANGE. A task runs in a workspace: it opens its
1402
- // conversation there, uses that workspace's defaults and its tools. The caller cannot even
1403
- // LIST another workspace's tasks (`list` is scoped), so it must not be able to change or
1404
- // delete them either - and the refusal must not say "it exists, over there".
1405
- service.requireForWorkspace(id, workspaceId);
1406
- const task = await service.update(id, {
1407
- name: body.name,
1408
- prompt: body.prompt,
1409
- schedule: body.schedule,
1410
- // "NOT MENTIONED" AND "CLEARED" ARE DIFFERENT REQUESTS. The extension only sends the
1411
- // fields the model actually wrote, so a model that changes the prompt and says nothing
1412
- // about the model leaves the key ABSENT - and the service reads an absent model as
1413
- // "keep what is there". Mapping absence to null made "not mentioned" mean "delete", so a
1414
- // model the user had picked for this task was silently dropped and the task fell back to
1415
- // the default model (measured by scripts/scheduled-task-scope.test.mjs, ledger
1416
- // TEST-025). Every other field here already has this asymmetry right: name, prompt,
1417
- // schedule and agentId are all "absent means unchanged".
1418
- model: body.model === undefined
1419
- ? undefined
1420
- : (body.model ? { provider: body.model.provider, modelId: body.model.modelId, label: body.model.label ?? body.model.modelId } : null),
1421
- agentId: body.agentId,
1422
- });
1423
- json(200, { task, describe: describeSchedule(task.schedule) });
1424
- return;
1561
+ try {
1562
+ const summary = await service.status(conversationId);
1563
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ ok: true, summary }));
1425
1564
  }
1426
- if (action === "delete") {
1427
- const id = body.id?.trim();
1428
- if (!id)
1429
- throw new Error("scheduled_task delete requires an id");
1430
- service.requireForWorkspace(id, workspaceId);
1431
- await service.remove(id);
1432
- json(200, { deleted: id });
1433
- return;
1565
+ catch (error) {
1566
+ response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: error instanceof Error ? error.message : "status failed" }));
1434
1567
  }
1435
- json(400, { error: "action 只能是 create、list、update 或 delete。" });
1436
- }
1437
- catch (error) {
1438
- json(400, { error: error instanceof Error ? error.message : "scheduled task request failed" });
1439
- }
1440
- return;
1441
- }
1442
- if (url.pathname === "/api/question") {
1443
- if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
1444
- response.writeHead(403).end();
1445
1568
  return;
1446
1569
  }
1447
- // READ WITH A CEILING. This endpoint is exempt from the global token (the caller proves itself
1448
- // with a per-conversation capability instead), which means anyone on the LAN can REACH it - and
1449
- // this loop used to read the whole body into one string before checking anything, so an oversized
1450
- // body cost its full size in memory before the 403 said no. A question is a few hundred bytes;
1451
- // 64KB is orders of magnitude above any honest one and far below what a hostile body could use,
1452
- // and the shape is the same ceiling the `/api/tools` endpoints already enforce.
1453
- const asked = await readRequestText(request, 64_000);
1454
- if (asked.tooLarge) {
1455
- response.writeHead(413, { "content-type": "application/json" }).end(JSON.stringify({ error: "question request too large" }));
1456
- return;
1457
- }
1458
- const raw = asked.text;
1459
- try {
1460
- const body = JSON.parse(raw);
1461
- if (!body.conversationId || !body.question || !Array.isArray(body.options))
1462
- throw new Error("invalid question");
1463
- const verdict = runService.verifyPrimaryCapability(body.conversationId, body.capability);
1464
- if (!verdict.ok) {
1465
- response.writeHead(403, { "content-type": "application/json" }).end(JSON.stringify({ error: verdict.message }));
1570
+ if (url.pathname === "/api/orchestrator/list" && request.method === "GET") {
1571
+ if (!isLocalOrPrivateAddress(remote)) {
1572
+ response.writeHead(403).end();
1466
1573
  return;
1467
1574
  }
1468
- const result = await runService.askQuestion(body.conversationId);
1469
- response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(result));
1470
- }
1471
- catch (error) {
1472
- response.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: describeThrown(error) || "问答失败,没有拿到失败原因。" }));
1473
- }
1474
- return;
1475
- }
1476
- if (url.pathname === "/api/files/diff") {
1477
- const json = (status, body) => {
1478
- response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1479
- };
1480
- try {
1481
- const workspaceId = url.searchParams.get("workspaceId")?.trim();
1482
- const workspace = store.snapshot().workspaces.find((item) => item.id === workspaceId);
1483
- if (!workspaceId || !workspace) {
1484
- json(404, { error: `找不到工作区「${workspaceId ?? "未指定"}」——它可能已经被删除。`, code: "no_workspace" });
1575
+ const service = context.sessionOrchestratorService;
1576
+ if (!service) {
1577
+ response.writeHead(503, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "编排服务未就绪" }));
1485
1578
  return;
1486
1579
  }
1487
- const path = url.searchParams.get("path")?.trim() ?? "";
1488
- if (!path) {
1489
- json(400, { error: "需要 path 参数", code: "bad_request" });
1490
- return;
1580
+ const parentId = url.searchParams.get("parentId") || undefined;
1581
+ try {
1582
+ const sessions = await service.list(parentId);
1583
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ ok: true, sessions }));
1491
1584
  }
1492
- const requested = url.searchParams.get("base")?.trim();
1493
- const preferred = requested === "git" || requested === "agent" ? requested : "auto";
1494
- // The baseline is chosen by the server, but the client can name one - and when it names one, that
1495
- // is the one it gets. A silent fall-back would show the user a comparison they did not ask for.
1496
- const diff = await computeFileDiff({
1497
- root: workspace.path,
1498
- path,
1499
- preferred,
1500
- tracker: context.fileChanges,
1501
- workspaceId,
1502
- });
1503
- json(200, diff);
1504
- }
1505
- catch (error) {
1506
- const described = describeFilesError(error);
1507
- json(described.status, described.body);
1585
+ catch (error) {
1586
+ response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: error instanceof Error ? error.message : "list failed" }));
1587
+ }
1588
+ return;
1508
1589
  }
1509
- return;
1510
- }
1511
- if (url.pathname === "/api/files/search") {
1512
- const json = (status, body) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body)); };
1513
- try {
1514
- if (request.method !== "GET") {
1515
- json(405, { error: "内容搜索需要 GET 请求。", code: "method" });
1590
+ if (url.pathname === "/api/question") {
1591
+ if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
1592
+ response.writeHead(403).end();
1516
1593
  return;
1517
1594
  }
1518
- const workspaceId = url.searchParams.get("workspaceId")?.trim();
1519
- const query = url.searchParams.get("q")?.trim() ?? "";
1520
- const workspace = store.snapshot().workspaces.find((item) => item.id === workspaceId);
1521
- if (!workspaceId || !workspace) {
1522
- json(404, { error: "找不到这个工作区——它可能已经被删除。", code: "no_workspace" });
1595
+ // READ WITH A CEILING. This endpoint is exempt from the global token (the caller proves itself
1596
+ // with a per-conversation capability instead), which means anyone on the LAN can REACH it - and
1597
+ // this loop used to read the whole body into one string before checking anything, so an oversized
1598
+ // body cost its full size in memory before the 403 said no. A question is a few hundred bytes;
1599
+ // 64KB is orders of magnitude above any honest one and far below what a hostile body could use,
1600
+ // and the shape is the same ceiling the `/api/tools` endpoints already enforce.
1601
+ const asked = await readRequestText(request, 64_000);
1602
+ if (asked.tooLarge) {
1603
+ response.writeHead(413, { "content-type": "application/json" }).end(JSON.stringify({ error: "question request too large" }));
1523
1604
  return;
1524
1605
  }
1525
- await resolveInsideRoot(workspace.path, "");
1526
- if (query.length > 256 || /[\r\n]/.test(query)) {
1527
- json(400, { error: "搜索词只能是一行,且不超过 256 个字符。", code: "bad_query" });
1528
- return;
1606
+ const raw = asked.text;
1607
+ try {
1608
+ const body = JSON.parse(raw);
1609
+ if (!body.conversationId || !body.question || !Array.isArray(body.options))
1610
+ throw new Error("invalid question");
1611
+ const verdict = runService.verifyPrimaryCapability(body.conversationId, body.capability);
1612
+ if (!verdict.ok) {
1613
+ response.writeHead(403, { "content-type": "application/json" }).end(JSON.stringify({ error: verdict.message }));
1614
+ return;
1615
+ }
1616
+ const result = await runService.askQuestion(body.conversationId);
1617
+ response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(result));
1529
1618
  }
1530
- if (!query) {
1531
- json(200, { mode: "content", query, hits: [], truncated: false, skipped: [], complete: true, reasons: [], maxResults: MAX_CONTENT_RESULTS, maxFileBytes: MAX_CONTENT_FILE_BYTES, maxDepth: MAX_CONTENT_DEPTH });
1532
- return;
1619
+ catch (error) {
1620
+ response.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: describeThrown(error) || "问答失败,没有拿到失败原因。" }));
1533
1621
  }
1534
- const indexes = context.fileIndexes ?? new Map();
1535
- if (!context.fileIndexes)
1536
- context.fileIndexes = indexes;
1537
- let index = indexes.get(workspaceId);
1538
- if (!index) {
1539
- index = new FileIndex(workspaceId, (event) => broadcaster.broadcast(event));
1540
- indexes.set(workspaceId, index);
1541
- }
1542
- index.start(workspace.path);
1543
- const deadline = Date.now() + 4500;
1544
- while (!index.builtAt && Date.now() < deadline)
1545
- await new Promise((resolve) => setTimeout(resolve, 25));
1546
- const snapshot = index.snapshot();
1547
- const result = await searchByContent(workspace.path, [...index.entries.values()], query, snapshot);
1548
- json(200, { mode: "content", query, ...result, maxResults: MAX_CONTENT_RESULTS, maxFileBytes: MAX_CONTENT_FILE_BYTES, maxDepth: MAX_CONTENT_DEPTH });
1549
- }
1550
- catch (error) {
1551
- const described = describeFilesError(error);
1552
- json(described.status, described.body);
1622
+ return;
1553
1623
  }
1554
- return;
1555
- }
1556
- if (url.pathname === "/api/files/index") {
1557
- const json = (status, body) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body)); };
1558
- try {
1559
- if (request.method !== "GET") {
1560
- json(405, { error: "文件索引需要 GET 请求。", code: "method" });
1561
- return;
1624
+ if (url.pathname === "/api/files/diff") {
1625
+ const json = (status, body) => {
1626
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1627
+ };
1628
+ try {
1629
+ const workspaceId = url.searchParams.get("workspaceId")?.trim();
1630
+ const workspace = workspaceId ? store.workspace(workspaceId) : undefined;
1631
+ if (!workspaceId || !workspace) {
1632
+ json(404, { error: `找不到工作区「${workspaceId ?? "未指定"}」——它可能已经被删除。`, code: "no_workspace" });
1633
+ return;
1634
+ }
1635
+ const path = url.searchParams.get("path")?.trim() ?? "";
1636
+ if (!path) {
1637
+ json(400, { error: "需要 path 参数", code: "bad_request" });
1638
+ return;
1639
+ }
1640
+ const requested = url.searchParams.get("base")?.trim();
1641
+ const preferred = requested === "git" || requested === "agent" ? requested : "auto";
1642
+ // The baseline is chosen by the server, but the client can name one - and when it names one, that
1643
+ // is the one it gets. A silent fall-back would show the user a comparison they did not ask for.
1644
+ const diff = await computeFileDiff({
1645
+ root: workspace.path,
1646
+ path,
1647
+ preferred,
1648
+ tracker: context.fileChanges,
1649
+ workspaceId,
1650
+ });
1651
+ json(200, diff);
1562
1652
  }
1563
- const workspaceId = url.searchParams.get("workspaceId")?.trim();
1564
- const workspace = store.snapshot().workspaces.find((item) => item.id === workspaceId);
1565
- if (!workspaceId || !workspace) {
1566
- json(404, { error: `找不到工作区「${workspaceId ?? "未指定"}」——它可能已经被删除。`, code: "no_workspace" });
1567
- return;
1653
+ catch (error) {
1654
+ const described = describeFilesError(error);
1655
+ json(described.status, described.body);
1568
1656
  }
1569
- const indexes = context.fileIndexes ?? new Map();
1570
- if (!context.fileIndexes)
1571
- context.fileIndexes = indexes;
1572
- let index = indexes.get(workspaceId);
1573
- if (!index) {
1574
- index = new FileIndex(workspaceId, (event) => broadcaster.broadcast(event));
1575
- indexes.set(workspaceId, index);
1576
- }
1577
- index.start(workspace.path);
1578
- // The NAME index is started here too, and this is the whole reason the panel can promise a
1579
- // complete search: the client fetches this endpoint the moment a workspace opens, so the
1580
- // background scan is already running before anybody types. Without this the index would
1581
- // start on the first keystroke and the first search would be answered from a half-built map.
1582
- nameIndexFor(context, workspaceId, workspace.path);
1583
- const sinceRaw = url.searchParams.get("since");
1584
- const since = sinceRaw === null ? undefined : Number(sinceRaw);
1585
- json(200, index.snapshot(Number.isSafeInteger(since) && since >= 0 ? since : undefined));
1586
- }
1587
- catch (error) {
1588
- const described = describeFilesError(error);
1589
- json(described.status, described.body);
1657
+ return;
1590
1658
  }
1591
- return;
1592
- }
1593
- if (url.pathname === "/api/files" || url.pathname === "/api/file") {
1594
- const json = (status, body) => {
1595
- response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1596
- };
1597
- try {
1598
- const workspaceId = url.searchParams.get("workspaceId")?.trim();
1599
- const workspace = store.snapshot().workspaces.find((item) => item.id === workspaceId);
1600
- if (!workspaceId || !workspace) {
1601
- json(404, { error: `找不到工作区「${workspaceId ?? "未指定"}」——它可能已经被删除。`, code: "no_workspace" });
1602
- return;
1603
- }
1604
- const root = workspace.path;
1605
- if (url.pathname === "/api/files") {
1659
+ if (url.pathname === "/api/files/search") {
1660
+ const json = (status, body) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body)); };
1661
+ try {
1606
1662
  if (request.method !== "GET") {
1607
- json(405, { error: "目录列表需要 GET 请求。", code: "method" });
1608
- return;
1609
- }
1610
- const query = url.searchParams.get("q");
1611
- if (query !== null && query.trim() !== "") {
1612
- // THE SEARCH IS AN INDEX QUERY, not a walk.
1613
- //
1614
- // The old path re-walked the entire tree on every keystroke and then reported its own
1615
- // limits ("已跳过 N 个目录", "结果不完整"). The index answers from memory - no filesystem
1616
- // access at all - and covers every directory in the workspace, so there is nothing to
1617
- // report a limit about. `truncated` and `skipped` stay in the response so the protocol
1618
- // shape does not change under the client, but they are now constants that say the
1619
- // opposite of what they used to.
1620
- const index = nameIndexFor(context, workspaceId, root);
1621
- // THE WORKSPACE MUST EXIST, AND THE INDEX IS WHAT KNOWS.
1622
- //
1623
- // The background scan records a root it could not read rather than throwing it - a scan
1624
- // has no caller to receive a rejection, and an unhandled one terminates the process. So
1625
- // the route asks the index what happened and turns the answer into the status it
1626
- // deserves. Without this a workspace whose directory is gone would answer 200 with an
1627
- // empty list, which is byte-for-byte the response for "your search matched nothing" -
1628
- // the exact confusion this endpoint is supposed to avoid.
1629
- await index.waitUntilIndexed(4000);
1630
- index.throwIfBroken();
1631
- // WAIT FOR THE INDEX, briefly. A search answered from a half-built map would say "no
1632
- // matches" for files that are on disk - which is precisely the "搜不出他想搜的东西"
1633
- // failure this work exists to remove. Past the deadline the answer is still returned, with
1634
- // `indexed: false` so the UI can say the index is still building rather than pretending
1635
- // the tree is empty.
1636
- const result = index.search(query);
1637
- json(200, {
1638
- mode: "search",
1639
- query: query.trim(),
1640
- hits: result.hits.map((hit) => ({ path: hit.path, name: hit.name, directory: hit.directory })),
1641
- total: result.total,
1642
- truncated: false,
1643
- skipped: [],
1644
- indexed: result.indexed,
1645
- progress: result.progress,
1646
- incomplete: result.incomplete,
1647
- });
1663
+ json(405, { error: "内容搜索需要 GET 请求。", code: "method" });
1648
1664
  return;
1649
1665
  }
1650
- const listing = await listDirectory(root, url.searchParams.get("path"));
1651
- json(200, { mode: "list", ...listing, maxEntries: 2000 });
1652
- return;
1666
+ const workspaceId = url.searchParams.get("workspaceId")?.trim();
1667
+ const query = url.searchParams.get("q")?.trim() ?? "";
1668
+ const workspace = workspaceId ? store.workspace(workspaceId) : undefined;
1669
+ if (!workspaceId || !workspace) {
1670
+ json(404, { error: "找不到这个工作区——它可能已经被删除。", code: "no_workspace" });
1671
+ return;
1672
+ }
1673
+ await resolveInsideRoot(workspace.path, "");
1674
+ if (query.length > 256 || /[\r\n]/.test(query)) {
1675
+ json(400, { error: "搜索词只能是一行,且不超过 256 个字符。", code: "bad_query" });
1676
+ return;
1677
+ }
1678
+ if (!query) {
1679
+ json(200, { mode: "content", query, hits: [], truncated: false, skipped: [], complete: true, reasons: [], maxResults: MAX_CONTENT_RESULTS, maxFileBytes: MAX_CONTENT_FILE_BYTES, maxDepth: MAX_CONTENT_DEPTH });
1680
+ return;
1681
+ }
1682
+ const indexes = context.fileIndexes ?? new Map();
1683
+ if (!context.fileIndexes)
1684
+ context.fileIndexes = indexes;
1685
+ let index = indexes.get(workspaceId);
1686
+ if (!index) {
1687
+ index = new FileIndex(workspaceId, (event) => broadcaster.broadcast(event));
1688
+ indexes.set(workspaceId, index);
1689
+ }
1690
+ index.start(workspace.path);
1691
+ const deadline = Date.now() + 4500;
1692
+ while (!index.builtAt && Date.now() < deadline)
1693
+ await new Promise((resolve) => setTimeout(resolve, 25));
1694
+ const snapshot = index.snapshot();
1695
+ const result = await searchByContent(workspace.path, [...index.entries.values()], query, snapshot);
1696
+ json(200, { mode: "content", query, ...result, maxResults: MAX_CONTENT_RESULTS, maxFileBytes: MAX_CONTENT_FILE_BYTES, maxDepth: MAX_CONTENT_DEPTH });
1653
1697
  }
1654
- if (request.method === "GET") {
1655
- const rel = url.searchParams.get("path") ?? "";
1656
- if (url.searchParams.get("meta") === "1") {
1657
- const resolved = await resolveInsideRoot(root, rel);
1658
- const meta = await fileMeta(resolved);
1659
- const path = toRelative(root, resolved);
1660
- context.fileIndexes?.get(workspaceId)?.recordFile(path, meta);
1661
- json(200, { path, ...meta });
1698
+ catch (error) {
1699
+ const described = describeFilesError(error);
1700
+ json(described.status, described.body);
1701
+ }
1702
+ return;
1703
+ }
1704
+ if (url.pathname === "/api/files/index") {
1705
+ const json = (status, body) => {
1706
+ const raw = Buffer.from(JSON.stringify(body), "utf8");
1707
+ // A workspace index is intentionally a JSON snapshot for quick-open, but it can be several MB.
1708
+ // Compress only when the browser advertises support; this keeps local callers and old clients
1709
+ // compatible while cutting remote transfer time by an order of magnitude on repetitive paths.
1710
+ const encodings = (request.headers["accept-encoding"] ?? "").split(",").map((part) => part.trim().split(";").map((value) => value.trim()));
1711
+ const gzipEntry = encodings.find(([name]) => name.toLowerCase() === "gzip") ?? encodings.find(([name]) => name === "*");
1712
+ const quality = gzipEntry?.find((part) => /^q=/i.test(part));
1713
+ if (gzipEntry && (!quality || Number(quality.slice(2)) > 0) && raw.length >= 1024) {
1714
+ void promisify(gzip)(raw, { level: 6 }).then((encoded) => {
1715
+ if (!response.destroyed)
1716
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8", "content-encoding": "gzip", "vary": "Accept-Encoding", "content-length": encoded.length }).end(encoded);
1717
+ }).catch((error) => { console.warn("Super Pi: file index compression failed", error); if (!response.destroyed)
1718
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8", "vary": "Accept-Encoding", "content-length": raw.length }).end(raw); });
1662
1719
  return;
1663
1720
  }
1664
- const file = await readWorkspaceFile(root, rel);
1665
- context.fileIndexes?.get(workspaceId)?.recordFile(file.path, file.meta);
1666
- json(200, { path: file.path, content: file.content, eol: file.eol, bom: file.bom, meta: file.meta, maxBytes: MAX_FILE_BYTES });
1667
- return;
1721
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8", "vary": "Accept-Encoding", "content-length": raw.length }).end(raw);
1722
+ };
1723
+ try {
1724
+ if (request.method !== "GET") {
1725
+ json(405, { error: "文件索引需要 GET 请求。", code: "method" });
1726
+ return;
1727
+ }
1728
+ const workspaceId = url.searchParams.get("workspaceId")?.trim();
1729
+ const workspace = workspaceId ? store.workspace(workspaceId) : undefined;
1730
+ if (!workspaceId || !workspace) {
1731
+ json(404, { error: `找不到工作区「${workspaceId ?? "未指定"}」——它可能已经被删除。`, code: "no_workspace" });
1732
+ return;
1733
+ }
1734
+ const indexes = context.fileIndexes ?? new Map();
1735
+ if (!context.fileIndexes)
1736
+ context.fileIndexes = indexes;
1737
+ let index = indexes.get(workspaceId);
1738
+ if (!index) {
1739
+ index = new FileIndex(workspaceId, (event) => broadcaster.broadcast(event));
1740
+ indexes.set(workspaceId, index);
1741
+ }
1742
+ index.start(workspace.path);
1743
+ // Quick Open requests the full index on demand. Name search can share its background scan;
1744
+ // merely selecting a session no longer starts a multi-MB download.
1745
+ nameIndexFor(context, workspaceId, workspace.path);
1746
+ const sinceRaw = url.searchParams.get("since");
1747
+ const since = sinceRaw === null ? undefined : Number(sinceRaw);
1748
+ json(200, index.snapshot(Number.isSafeInteger(since) && since >= 0 ? since : undefined));
1749
+ }
1750
+ catch (error) {
1751
+ const described = describeFilesError(error);
1752
+ json(described.status, described.body);
1668
1753
  }
1669
- if (request.method === "PUT") {
1670
- const remoteAddr = request.socket.remoteAddress ?? "";
1671
- if (!isLocalOrPrivateAddress(remoteAddr)) {
1672
- json(403, { error: "保存文件需要从本机或局域网发起。", code: "not_loopback" });
1754
+ return;
1755
+ }
1756
+ if (url.pathname === "/api/files" || url.pathname === "/api/file") {
1757
+ const json = (status, body) => {
1758
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1759
+ };
1760
+ try {
1761
+ const workspaceId = url.searchParams.get("workspaceId")?.trim();
1762
+ const workspace = workspaceId ? store.workspace(workspaceId) : undefined;
1763
+ if (!workspaceId || !workspace) {
1764
+ json(404, { error: `找不到工作区「${workspaceId ?? "未指定"}」——它可能已经被删除。`, code: "no_workspace" });
1673
1765
  return;
1674
1766
  }
1675
- let raw = "";
1676
- let totalBytes = 0;
1677
- const reader = textBodyReader();
1678
- for await (const chunk of request) {
1679
- const piece = chunk;
1680
- totalBytes += piece.length;
1681
- if (totalBytes > MAX_FILE_BYTES * 2) {
1682
- json(413, { error: "请求体过大", code: "too_large" });
1683
- request.destroy();
1767
+ const root = workspace.path;
1768
+ if (url.pathname === "/api/files") {
1769
+ if (request.method !== "GET") {
1770
+ json(405, { error: "目录列表需要 GET 请求。", code: "method" });
1771
+ return;
1772
+ }
1773
+ const query = url.searchParams.get("q");
1774
+ if (query !== null && query.trim() !== "") {
1775
+ // THE SEARCH IS AN INDEX QUERY, not a walk.
1776
+ //
1777
+ // The old path re-walked the entire tree on every keystroke and then reported its own
1778
+ // limits ("已跳过 N 个目录", "结果不完整"). The index answers from memory - no filesystem
1779
+ // access at all - and covers every directory in the workspace, so there is nothing to
1780
+ // report a limit about. `truncated` and `skipped` stay in the response so the protocol
1781
+ // shape does not change under the client, but they are now constants that say the
1782
+ // opposite of what they used to.
1783
+ const index = nameIndexFor(context, workspaceId, root);
1784
+ // THE WORKSPACE MUST EXIST, AND THE INDEX IS WHAT KNOWS.
1785
+ //
1786
+ // The background scan records a root it could not read rather than throwing it - a scan
1787
+ // has no caller to receive a rejection, and an unhandled one terminates the process. So
1788
+ // the route asks the index what happened and turns the answer into the status it
1789
+ // deserves. Without this a workspace whose directory is gone would answer 200 with an
1790
+ // empty list, which is byte-for-byte the response for "your search matched nothing" -
1791
+ // the exact confusion this endpoint is supposed to avoid.
1792
+ await index.waitUntilIndexed(4000);
1793
+ index.throwIfBroken();
1794
+ // WAIT FOR THE INDEX, briefly. A search answered from a half-built map would say "no
1795
+ // matches" for files that are on disk - which is precisely the "搜不出他想搜的东西"
1796
+ // failure this work exists to remove. Past the deadline the answer is still returned, with
1797
+ // `indexed: false` so the UI can say the index is still building rather than pretending
1798
+ // the tree is empty.
1799
+ const result = index.search(query);
1800
+ json(200, {
1801
+ mode: "search",
1802
+ query: query.trim(),
1803
+ hits: result.hits.map((hit) => ({ path: hit.path, name: hit.name, directory: hit.directory })),
1804
+ total: result.total,
1805
+ truncated: false,
1806
+ skipped: [],
1807
+ indexed: result.indexed,
1808
+ progress: result.progress,
1809
+ incomplete: result.incomplete,
1810
+ });
1684
1811
  return;
1685
1812
  }
1686
- raw = reader.push(piece);
1813
+ const listing = await listDirectory(root, url.searchParams.get("path"));
1814
+ json(200, { mode: "list", ...listing, maxEntries: 2000 });
1815
+ return;
1687
1816
  }
1688
- raw = reader.finish();
1689
- const declared = Number(request.headers["content-length"] ?? "0");
1690
- if (Number.isFinite(declared) && declared > MAX_FILE_BYTES * 2) {
1691
- json(413, { error: "请求体过大", code: "too_large" });
1817
+ if (request.method === "GET") {
1818
+ const rel = url.searchParams.get("path") ?? "";
1819
+ if (url.searchParams.get("meta") === "1") {
1820
+ const resolved = await resolveInsideRoot(root, rel);
1821
+ const meta = await fileMeta(resolved);
1822
+ const path = toRelative(root, resolved);
1823
+ context.fileIndexes?.get(workspaceId)?.recordFile(path, meta);
1824
+ json(200, { path, ...meta });
1825
+ return;
1826
+ }
1827
+ const file = await readWorkspaceFile(root, rel);
1828
+ context.fileIndexes?.get(workspaceId)?.recordFile(file.path, file.meta);
1829
+ json(200, { path: file.path, content: file.content, eol: file.eol, bom: file.bom, meta: file.meta, maxBytes: MAX_FILE_BYTES });
1692
1830
  return;
1693
1831
  }
1694
- const parsed = JSON.parse(raw);
1695
- if (typeof parsed.path !== "string" || typeof parsed.content !== "string") {
1696
- json(400, { error: "写入需要 path 与 content 两个字符串字段", code: "bad_request" });
1832
+ if (request.method === "PUT") {
1833
+ const remoteAddr = request.socket.remoteAddress ?? "";
1834
+ if (!isLocalOrPrivateAddress(remoteAddr)) {
1835
+ json(403, { error: "保存文件需要从本机或局域网发起。", code: "not_loopback" });
1836
+ return;
1837
+ }
1838
+ let raw = "";
1839
+ let totalBytes = 0;
1840
+ const reader = textBodyReader();
1841
+ for await (const chunk of request) {
1842
+ const piece = chunk;
1843
+ totalBytes += piece.length;
1844
+ if (totalBytes > MAX_FILE_BYTES * 2) {
1845
+ json(413, { error: "请求体过大", code: "too_large" });
1846
+ request.destroy();
1847
+ return;
1848
+ }
1849
+ raw = reader.push(piece);
1850
+ }
1851
+ raw = reader.finish();
1852
+ const declared = Number(request.headers["content-length"] ?? "0");
1853
+ if (Number.isFinite(declared) && declared > MAX_FILE_BYTES * 2) {
1854
+ json(413, { error: "请求体过大", code: "too_large" });
1855
+ return;
1856
+ }
1857
+ const parsed = JSON.parse(raw);
1858
+ if (typeof parsed.path !== "string" || typeof parsed.content !== "string") {
1859
+ json(400, { error: "写入需要 path 与 content 两个字符串字段", code: "bad_request" });
1860
+ return;
1861
+ }
1862
+ const outcome = await writeWorkspaceFile(root, parsed.path, parsed.content, parsed.baseEtag);
1863
+ context.fileIndexes?.get(workspaceId)?.recordFile(outcome.path, outcome.meta);
1864
+ // The name index learns about the save too. A file the user just saved must be findable
1865
+ // by name immediately, and the index is the thing the search reads - so this is where the
1866
+ // index stays true without anybody re-scanning the tree.
1867
+ context.nameIndexes?.get(workspaceId)?.upsert(outcome.path);
1868
+ json(200, outcome);
1697
1869
  return;
1698
1870
  }
1699
- const outcome = await writeWorkspaceFile(root, parsed.path, parsed.content, parsed.baseEtag);
1700
- context.fileIndexes?.get(workspaceId)?.recordFile(outcome.path, outcome.meta);
1701
- // The name index learns about the save too. A file the user just saved must be findable
1702
- // by name immediately, and the index is the thing the search reads - so this is where the
1703
- // index stays true without anybody re-scanning the tree.
1704
- context.nameIndexes?.get(workspaceId)?.upsert(outcome.path);
1705
- json(200, outcome);
1706
- return;
1871
+ json(405, { error: `/api/file 不支持 ${request.method} 请求。`, code: "method" });
1707
1872
  }
1708
- json(405, { error: `/api/file 不支持 ${request.method} 请求。`, code: "method" });
1709
- }
1710
- catch (error) {
1711
- const described = describeFilesError(error);
1712
- json(described.status, described.body);
1873
+ catch (error) {
1874
+ const described = describeFilesError(error);
1875
+ json(described.status, described.body);
1876
+ }
1877
+ return;
1713
1878
  }
1714
- return;
1715
- }
1716
- if (url.pathname === "/api/fs/dirs") {
1717
- const fail = (status, message) => {
1718
- response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: message }));
1719
- };
1720
- try {
1721
- const rawPath = url.searchParams.get("path")?.trim();
1722
- const defaultDir = homedir() || rootDir;
1723
- const expandTilde = (filepath) => {
1724
- if (filepath === "~")
1725
- return homedir();
1726
- if (filepath.startsWith("~/") || filepath.startsWith("~\\")) {
1727
- return join(homedir(), filepath.slice(2));
1728
- }
1729
- return filepath;
1879
+ if (url.pathname === "/api/fs/dirs") {
1880
+ const fail = (status, message) => {
1881
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: message }));
1730
1882
  };
1731
- const target = resolve(rawPath ? expandTilde(rawPath) : defaultDir);
1732
- const info = await stat(target).catch((error) => {
1733
- if (error?.code === "ENOENT" || error?.code === "ENOTDIR")
1734
- return undefined;
1735
- throw error;
1736
- });
1737
- if (!info) {
1738
- fail(404, `目录不存在:${target}`);
1739
- return;
1883
+ try {
1884
+ const rawPath = url.searchParams.get("path")?.trim();
1885
+ const defaultDir = homedir() || rootDir;
1886
+ const expandTilde = (filepath) => {
1887
+ if (filepath === "~")
1888
+ return homedir();
1889
+ if (filepath.startsWith("~/") || filepath.startsWith("~\\")) {
1890
+ return join(homedir(), filepath.slice(2));
1891
+ }
1892
+ return filepath;
1893
+ };
1894
+ const target = resolve(rawPath ? expandTilde(rawPath) : defaultDir);
1895
+ const info = await stat(target).catch((error) => {
1896
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR")
1897
+ return undefined;
1898
+ throw error;
1899
+ });
1900
+ if (!info) {
1901
+ fail(404, `目录不存在:${target}`);
1902
+ return;
1903
+ }
1904
+ if (!info.isDirectory()) {
1905
+ fail(400, `该路径不是目录:${target}`);
1906
+ return;
1907
+ }
1908
+ const dirents = await readdir(target, { withFileTypes: true });
1909
+ const entries = dirents
1910
+ .filter((item) => item.isDirectory())
1911
+ .map((item) => ({ name: item.name, path: join(target, item.name) }))
1912
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
1913
+ const truncated = entries.length > 2000;
1914
+ const parent = dirname(target);
1915
+ const getAvailableDrives = () => {
1916
+ if (process.platform !== "win32")
1917
+ return [];
1918
+ const drives = [];
1919
+ for (let i = 65; i <= 90; i++) {
1920
+ const drive = String.fromCharCode(i) + ":\\";
1921
+ try {
1922
+ if (existsSync(drive))
1923
+ drives.push(drive);
1924
+ }
1925
+ catch { }
1926
+ }
1927
+ return drives;
1928
+ };
1929
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({
1930
+ path: target,
1931
+ parent: parent === target ? null : parent,
1932
+ suggestedName: basename(target) || target,
1933
+ entries: truncated ? entries.slice(0, 2000) : entries,
1934
+ truncated,
1935
+ drives: getAvailableDrives(),
1936
+ homeDir: homedir(),
1937
+ }));
1740
1938
  }
1741
- if (!info.isDirectory()) {
1742
- fail(400, `该路径不是目录:${target}`);
1939
+ catch (error) {
1940
+ const code = error.code;
1941
+ if (code === "EACCES" || code === "EPERM")
1942
+ fail(403, `没有访问权限:${url.searchParams.get("path") ?? rootDir}`);
1943
+ else
1944
+ fail(500, `读取目录失败:${error instanceof Error ? error.message : "未知错误"}`);
1945
+ }
1946
+ return;
1947
+ }
1948
+ if (url.pathname === "/api/upload") {
1949
+ if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
1950
+ response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "上传需要从本机或局域网发起。" }));
1743
1951
  return;
1744
1952
  }
1745
- const dirents = await readdir(target, { withFileTypes: true });
1746
- const entries = dirents
1747
- .filter((item) => item.isDirectory())
1748
- .map((item) => ({ name: item.name, path: join(target, item.name) }))
1749
- .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
1750
- const truncated = entries.length > 2000;
1751
- const parent = dirname(target);
1752
- const getAvailableDrives = () => {
1753
- if (process.platform !== "win32")
1754
- return [];
1755
- const drives = [];
1756
- for (let i = 65; i <= 90; i++) {
1757
- const drive = String.fromCharCode(i) + ":\\";
1758
- try {
1759
- if (existsSync(drive))
1760
- drives.push(drive);
1953
+ const json = (status, body) => {
1954
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1955
+ };
1956
+ try {
1957
+ let totalBytes = 0;
1958
+ const chunks = [];
1959
+ for await (const chunk of request) {
1960
+ totalBytes += chunk.length;
1961
+ if (totalBytes > MAX_UPLOAD_BODY_BYTES) {
1962
+ json(413, { error: "图片超过了大小上限。" });
1963
+ request.destroy();
1964
+ return;
1761
1965
  }
1762
- catch { }
1966
+ chunks.push(chunk);
1763
1967
  }
1764
- return drives;
1765
- };
1766
- response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({
1767
- path: target,
1768
- parent: parent === target ? null : parent,
1769
- suggestedName: basename(target) || target,
1770
- entries: truncated ? entries.slice(0, 2000) : entries,
1771
- truncated,
1772
- drives: getAvailableDrives(),
1773
- homeDir: homedir(),
1774
- }));
1775
- }
1776
- catch (error) {
1777
- const code = error.code;
1778
- if (code === "EACCES" || code === "EPERM")
1779
- fail(403, `没有访问权限:${url.searchParams.get("path") ?? rootDir}`);
1780
- else
1781
- fail(500, `读取目录失败:${error instanceof Error ? error.message : "未知错误"}`);
1782
- }
1783
- return;
1784
- }
1785
- if (url.pathname === "/api/upload") {
1786
- if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
1787
- response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "上传需要从本机或局域网发起。" }));
1788
- return;
1789
- }
1790
- const json = (status, body) => {
1791
- response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1792
- };
1793
- try {
1794
- let totalBytes = 0;
1795
- const chunks = [];
1796
- for await (const chunk of request) {
1797
- totalBytes += chunk.length;
1798
- if (totalBytes > MAX_UPLOAD_BODY_BYTES) {
1799
- json(413, { error: "图片超过了大小上限。" });
1800
- request.destroy();
1968
+ const raw = Buffer.concat(chunks).toString("utf8");
1969
+ const body = JSON.parse(raw);
1970
+ const conversationId = body.conversationId?.trim();
1971
+ if (!conversationId) {
1972
+ json(400, { error: "缺少会话 ID。" });
1801
1973
  return;
1802
1974
  }
1803
- chunks.push(chunk);
1804
- }
1805
- const raw = Buffer.concat(chunks).toString("utf8");
1806
- const body = JSON.parse(raw);
1807
- const conversationId = body.conversationId?.trim();
1808
- if (!conversationId) {
1809
- json(400, { error: "缺少会话 ID。" });
1810
- return;
1975
+ const conversation = store.snapshot().conversations.find((c) => c.id === conversationId);
1976
+ if (!conversation) {
1977
+ json(404, { error: "找不到这个会话——它可能已经被删除。" });
1978
+ return;
1979
+ }
1980
+ if (!body.mimeType || typeof body.mimeType !== "string") {
1981
+ json(400, { error: "缺少图片类型。" });
1982
+ return;
1983
+ }
1984
+ if (!body.data || typeof body.data !== "string") {
1985
+ json(400, { error: "缺少图片数据。" });
1986
+ return;
1987
+ }
1988
+ const catalog = await catalogService.getCatalog(conversation.model);
1989
+ const currentModel = catalog.models.find((m) => m.provider === conversation.model?.provider && m.modelId === conversation.model?.modelId)
1990
+ ?? catalog.models.find((m) => m.provider === catalog.defaultModel?.provider && m.modelId === catalog.defaultModel?.modelId);
1991
+ if (currentModel && !currentModel.imageInput) {
1992
+ json(400, { error: `当前模型「${currentModel.label || currentModel.modelId}」不支持图片输入` });
1993
+ return;
1994
+ }
1995
+ const record = await saveUpload(uploadsDir, conversationId, body.name, body.mimeType, body.data);
1996
+ json(200, record);
1811
1997
  }
1812
- const conversation = store.snapshot().conversations.find((c) => c.id === conversationId);
1813
- if (!conversation) {
1814
- json(404, { error: "找不到这个会话——它可能已经被删除。" });
1815
- return;
1998
+ catch (error) {
1999
+ json(400, { error: error instanceof Error ? error.message : "Upload failed" });
1816
2000
  }
1817
- if (!body.mimeType || typeof body.mimeType !== "string") {
1818
- json(400, { error: "缺少图片类型。" });
2001
+ return;
2002
+ }
2003
+ if (url.pathname.startsWith("/api/upload/")) {
2004
+ if (request.method !== "GET" || !isLocalOrPrivateAddress(remote)) {
2005
+ response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "读取上传的图片需要从本机或局域网发起。" }));
1819
2006
  return;
1820
2007
  }
1821
- if (!body.data || typeof body.data !== "string") {
1822
- json(400, { error: "缺少图片数据。" });
2008
+ const pathParts = url.pathname.slice("/api/upload/".length).split("/").filter(Boolean);
2009
+ if (!pathParts.length || pathParts.length > 2) {
2010
+ response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "上传路径不合法。" }));
1823
2011
  return;
1824
2012
  }
1825
- const catalog = await catalogService.getCatalog(conversation.model);
1826
- const currentModel = catalog.models.find((m) => m.provider === conversation.model?.provider && m.modelId === conversation.model?.modelId)
1827
- ?? catalog.models.find((m) => m.provider === catalog.defaultModel?.provider && m.modelId === catalog.defaultModel?.modelId);
1828
- if (currentModel && !currentModel.imageInput) {
1829
- json(400, { error: `当前模型「${currentModel.label || currentModel.modelId}」不支持图片输入` });
2013
+ const convId = pathParts.length === 2 ? pathParts[0] : undefined;
2014
+ const fileOrId = pathParts.length === 2 ? pathParts[1] : pathParts[0];
2015
+ const found = await findUploadFile(uploadsDir, convId, fileOrId);
2016
+ if (!found) {
2017
+ response.writeHead(404, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "Image not found" }));
1830
2018
  return;
1831
2019
  }
1832
- const record = await saveUpload(uploadsDir, conversationId, body.name, body.mimeType, body.data);
1833
- json(200, record);
2020
+ response.writeHead(200, {
2021
+ "content-type": found.mimeType,
2022
+ "x-content-type-options": "nosniff",
2023
+ "cache-control": "public, max-age=31536000, immutable",
2024
+ "content-length": found.size,
2025
+ });
2026
+ createReadStream(found.filePath).on("error", () => {
2027
+ if (!response.headersSent) {
2028
+ response.writeHead(500, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "Failed to read upload file" }));
2029
+ return;
2030
+ }
2031
+ response.destroy();
2032
+ }).pipe(response);
2033
+ return;
1834
2034
  }
1835
- catch (error) {
1836
- json(400, { error: error instanceof Error ? error.message : "Upload failed" });
2035
+ // Static client asset handling
2036
+ const requested = url.pathname === "/" ? "index.html" : url.pathname.slice(1);
2037
+ const requestedPath = resolve(clientDir, requested);
2038
+ const safe = !relative(clientDir, requestedPath).startsWith("..") && !relative(clientDir, requestedPath).includes(":");
2039
+ if (!safe) {
2040
+ response.writeHead(403).end();
2041
+ return;
1837
2042
  }
1838
- return;
1839
- }
1840
- if (url.pathname.startsWith("/api/upload/")) {
1841
- if (request.method !== "GET" || !isLocalOrPrivateAddress(remote)) {
1842
- response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "读取上传的图片需要从本机或局域网发起。" }));
2043
+ // Only extensionless URLs are application-navigation requests. A missing hashed
2044
+ // asset must stay a real 404: returning index.html with text/html for /assets/*.js
2045
+ // makes stale browser documents look like successful script loads and leaves the
2046
+ // browser with an invalid application state after a refresh.
2047
+ const requestedExtension = extname(requestedPath);
2048
+ const candidate = existsSync(requestedPath)
2049
+ ? requestedPath
2050
+ : requestedExtension
2051
+ ? undefined
2052
+ : join(clientDir, "index.html");
2053
+ if (!candidate) {
2054
+ response.writeHead(404, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }).end("Not found");
1843
2055
  return;
1844
2056
  }
1845
- const pathParts = url.pathname.slice("/api/upload/".length).split("/").filter(Boolean);
1846
- if (!pathParts.length || pathParts.length > 2) {
1847
- response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "上传路径不合法。" }));
2057
+ if (!existsSync(candidate)) {
2058
+ response.writeHead(503, { "content-type": "text/plain; charset=utf-8" }).end("Super Pi client has not been built. Run npm run build.");
1848
2059
  return;
1849
2060
  }
1850
- const convId = pathParts.length === 2 ? pathParts[0] : undefined;
1851
- const fileOrId = pathParts.length === 2 ? pathParts[1] : pathParts[0];
1852
- const found = await findUploadFile(uploadsDir, convId, fileOrId);
1853
- if (!found) {
1854
- response.writeHead(404, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "Image not found" }));
2061
+ const candidateInfo = await stat(candidate).catch(() => undefined);
2062
+ if (!candidateInfo?.isFile()) {
2063
+ response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("Not found");
1855
2064
  return;
1856
2065
  }
1857
2066
  response.writeHead(200, {
1858
- "content-type": found.mimeType,
2067
+ "content-type": MIME_TYPES[extname(candidate)] ?? "application/octet-stream",
1859
2068
  "x-content-type-options": "nosniff",
1860
- "cache-control": "public, max-age=31536000, immutable",
1861
- "content-length": found.size,
2069
+ "cache-control": candidate === join(clientDir, "index.html") ? "no-store" : "public, max-age=31536000, immutable",
1862
2070
  });
1863
- createReadStream(found.filePath).on("error", () => {
1864
- if (!response.headersSent) {
1865
- response.writeHead(500, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "Failed to read upload file" }));
1866
- return;
1867
- }
1868
- response.destroy();
1869
- }).pipe(response);
1870
- return;
2071
+ const stream = createReadStream(candidate);
2072
+ stream.on("error", () => { if (!response.headersSent)
2073
+ response.writeHead(404); response.end(); });
2074
+ stream.pipe(response);
1871
2075
  }
1872
- // Static client asset handling
1873
- const requested = url.pathname === "/" ? "index.html" : url.pathname.slice(1);
1874
- const requestedPath = resolve(clientDir, requested);
1875
- const safe = !relative(clientDir, requestedPath).startsWith("..") && !relative(clientDir, requestedPath).includes(":");
1876
- if (!safe) {
1877
- response.writeHead(403).end();
1878
- return;
1879
- }
1880
- // Only extensionless URLs are application-navigation requests. A missing hashed
1881
- // asset must stay a real 404: returning index.html with text/html for /assets/*.js
1882
- // makes stale browser documents look like successful script loads and leaves the
1883
- // browser with an invalid application state after a refresh.
1884
- const requestedExtension = extname(requestedPath);
1885
- const candidate = existsSync(requestedPath)
1886
- ? requestedPath
1887
- : requestedExtension
1888
- ? undefined
1889
- : join(clientDir, "index.html");
1890
- if (!candidate) {
1891
- response.writeHead(404, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }).end("Not found");
1892
- return;
1893
- }
1894
- if (!existsSync(candidate)) {
1895
- response.writeHead(503, { "content-type": "text/plain; charset=utf-8" }).end("Super Pi client has not been built. Run npm run build.");
1896
- return;
1897
- }
1898
- const candidateInfo = await stat(candidate).catch(() => undefined);
1899
- if (!candidateInfo?.isFile()) {
1900
- response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("Not found");
1901
- return;
2076
+ catch (error) {
2077
+ // This is the last safety net for an async route. Before this boundary,
2078
+ // an unexpected throw became an unhandled rejection and left clients with
2079
+ // a socket that simply hung. Preserve the real reason in the detached
2080
+ // server log and give the caller a bounded, correlatable response.
2081
+ console.error(`Super Pi HTTP request failed requestId=${requestId} method=${request.method ?? "?"} url=${request.url ?? "?"}:`, describeThrown(error));
2082
+ if (response.headersSent) {
2083
+ response.destroy();
2084
+ }
2085
+ else {
2086
+ response.writeHead(500, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
2087
+ response.end(JSON.stringify({ error: "服务端处理请求时发生异常。", requestId }));
2088
+ }
1902
2089
  }
1903
- response.writeHead(200, {
1904
- "content-type": MIME_TYPES[extname(candidate)] ?? "application/octet-stream",
1905
- "x-content-type-options": "nosniff",
1906
- "cache-control": candidate === join(clientDir, "index.html") ? "no-store" : "public, max-age=31536000, immutable",
1907
- });
1908
- const stream = createReadStream(candidate);
1909
- stream.on("error", () => { if (!response.headersSent)
1910
- response.writeHead(404); response.end(); });
1911
- stream.pipe(response);
1912
2090
  });
1913
2091
  }