@evandu/super-pi 0.1.145 → 0.1.146

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 (75) hide show
  1. package/agents/super-pi-task/core.ts +15 -2
  2. package/agents/super-pi-task/index.ts +3 -0
  3. package/dist/.super-pi-check-inputs.json +2 -2
  4. package/dist/client/.super-pi-client-sources.json +9 -9
  5. package/dist/client/assets/{CodeMirrorEditor-A9KvbIkm.js → CodeMirrorEditor-CEwBCVM6.js} +2 -2
  6. package/dist/client/assets/{dist-Br-AuQUG.js → dist-76ArZ5M4.js} +1 -1
  7. package/dist/client/assets/{dist-DSHM-PAs.js → dist-9F1wL-T5.js} +1 -1
  8. package/dist/client/assets/{dist-UJ46aPS3.js → dist-B0RXZeXQ.js} +1 -1
  9. package/dist/client/assets/{dist-BAiV8I_O.js → dist-BCNaDgNW.js} +1 -1
  10. package/dist/client/assets/{dist-4JbFTwg2.js → dist-BXBSIuTD.js} +1 -1
  11. package/dist/client/assets/{dist-UPJPMjWv.js → dist-Bh8Q7Y_n.js} +1 -1
  12. package/dist/client/assets/{dist-CIgjifH-.js → dist-BoyICDlL.js} +1 -1
  13. package/dist/client/assets/{dist-2boAf_pN.js → dist-BuR5Iagd.js} +1 -1
  14. package/dist/client/assets/{dist-B1p4YF9C.js → dist-Bx6ZWYUW.js} +1 -1
  15. package/dist/client/assets/{dist-BTVbZigv.js → dist-CAOcjKAQ.js} +1 -1
  16. package/dist/client/assets/{dist-CjszAoXk.js → dist-CHt8PX8R.js} +1 -1
  17. package/dist/client/assets/{dist-bIDce5j-.js → dist-CSCUT1PQ.js} +1 -1
  18. package/dist/client/assets/{dist-Ds5gfeAi.js → dist-CUB_Chdh.js} +1 -1
  19. package/dist/client/assets/{dist-D-o7iQ8c.js → dist-CbVf2Cjn.js} +1 -1
  20. package/dist/client/assets/{dist-B9DE7sc5.js → dist-DBUqwiWG.js} +1 -1
  21. package/dist/client/assets/{dist-DM4Olwfx.js → dist-DC1IocrS.js} +1 -1
  22. package/dist/client/assets/{dist-CK2KFFtT.js → dist-DFMtP3wG.js} +1 -1
  23. package/dist/client/assets/{dist-BYOpvW02.js → dist-DN0GlnnZ.js} +1 -1
  24. package/dist/client/assets/{dist-CQFASxyb.js → dist-DftD4ibj.js} +1 -1
  25. package/dist/client/assets/{dist-BlxwDnWr.js → dist-Do52MRwm.js} +1 -1
  26. package/dist/client/assets/{dist-CQV5kOCE.js → dist-JvQIIdqB.js} +1 -1
  27. package/dist/client/assets/{dist-CRnm1rnO.js → dist-oLq6HXLG.js} +1 -1
  28. package/dist/client/assets/{index-CiIc734l.css → index-D-OxlUxN.css} +1 -1
  29. package/dist/client/assets/index-E7yaLa2C.js +102 -0
  30. package/dist/client/index.html +2 -2
  31. package/dist/server/agents.d.ts +1 -1
  32. package/dist/server/agents.js +3 -1
  33. package/dist/server/application/conversation-service.d.ts +1 -1
  34. package/dist/server/application/conversation-service.js +6 -4
  35. package/dist/server/application/run-service.d.ts +16 -2
  36. package/dist/server/application/run-service.js +121 -14
  37. package/dist/server/application/scheduled-task-service.d.ts +14 -1
  38. package/dist/server/application/scheduled-task-service.js +27 -2
  39. package/dist/server/application/session-orchestrator-service.d.ts +11 -2
  40. package/dist/server/application/session-orchestrator-service.js +103 -27
  41. package/dist/server/command-handler.js +1 -1
  42. package/dist/server/conversation-usage.d.ts +19 -0
  43. package/dist/server/conversation-usage.js +40 -0
  44. package/dist/server/file-diff.d.ts +3 -0
  45. package/dist/server/file-diff.js +4 -4
  46. package/dist/server/file-index.d.ts +4 -2
  47. package/dist/server/file-index.js +14 -9
  48. package/dist/server/file-name-index.d.ts +5 -2
  49. package/dist/server/file-name-index.js +22 -5
  50. package/dist/server/git.d.ts +3 -3
  51. package/dist/server/git.js +10 -9
  52. package/dist/server/http-server.js +195 -33
  53. package/dist/server/model-context-window.d.ts +46 -0
  54. package/dist/server/model-context-window.js +121 -0
  55. package/dist/server/pi-session.d.ts +6 -0
  56. package/dist/server/pi-session.js +15 -1
  57. package/dist/server/runtime.d.ts +3 -0
  58. package/dist/server/runtime.js +21 -6
  59. package/dist/server/session-cwd.d.ts +17 -0
  60. package/dist/server/session-cwd.js +43 -0
  61. package/dist/server/session-ledger.d.ts +7 -0
  62. package/dist/server/store.d.ts +22 -0
  63. package/dist/server/store.js +52 -0
  64. package/dist/server/task-tool.d.ts +2 -1
  65. package/dist/server/task-tool.js +3 -1
  66. package/dist/server/terminal.d.ts +1 -0
  67. package/dist/server/terminal.js +5 -3
  68. package/dist/server/turn-failure.js +16 -1
  69. package/dist/server/types.d.ts +18 -0
  70. package/dist/server/version.d.ts +25 -2
  71. package/dist/server/version.js +98 -26
  72. package/dist/server/ws-server.d.ts +1 -1
  73. package/dist/server/ws-server.js +7 -5
  74. package/package.json +20 -3
  75. package/dist/client/assets/index-BGsP8pt_.js +0 -100
@@ -23,7 +23,8 @@ import { MAX_WEB_REQUEST_BYTES } from "./web.js";
23
23
  import { MAX_TOOL_BODY_BYTES } from "./tool-registry.js";
24
24
  import { packageUpdateArgs } from "./update.js";
25
25
  import { FileIndex } from "./file-index.js";
26
- import { ensureNameIndex } from "./file-name-index.js";
26
+ import { ensureNameIndex, recordWorkspacePath } from "./file-name-index.js";
27
+ import { resolveSessionCwd, sessionScopeKey, workspacePathFromSession } from "./session-cwd.js";
27
28
  import { MAX_CONTENT_DEPTH, MAX_CONTENT_FILE_BYTES, MAX_CONTENT_RESULTS, searchByContent } from "./content-search.js";
28
29
  import { getAvailableSkills, readSkill } from "./skills.js";
29
30
  /**
@@ -34,11 +35,11 @@ import { getAvailableSkills, readSkill } from "./skills.js";
34
35
  * known so far rather than refused. The scan is the server's cost, not the user's - the old
35
36
  * implementation made the user pay it on every keystroke.
36
37
  */
37
- function nameIndexFor(context, workspaceId, root) {
38
+ function nameIndexFor(context, workspaceId, root, cwd = "") {
38
39
  const indexes = context.nameIndexes ?? new Map();
39
40
  if (!context.nameIndexes)
40
41
  context.nameIndexes = indexes;
41
- return ensureNameIndex(indexes, workspaceId, root);
42
+ return ensureNameIndex(indexes, workspaceId, root, cwd);
42
43
  }
43
44
  const MIME_TYPES = {
44
45
  ".css": "text/css; charset=utf-8",
@@ -312,7 +313,7 @@ export function createHttpServer(options) {
312
313
  // the endpoint that can spend it is exactly the one an agent's own turn can reach, and the
313
314
  // management half (`/api/feishu/config`) stays behind the ordinary token AND the local-address
314
315
  // 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
+ if (requestPath !== "/api/conversation/usage" && 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
317
  response.writeHead(401, { "www-authenticate": "Bearer" }).end("Authentication required");
317
318
  return;
318
319
  }
@@ -1070,6 +1071,8 @@ export function createHttpServer(options) {
1070
1071
  json(404, { error: `工作区 ${workspaceId ?? "(missing)"} 不存在`, code: "no_workspace" });
1071
1072
  return;
1072
1073
  }
1074
+ const sessionRoot = await resolveSessionCwd(workspace.path, url.searchParams.get("cwd"));
1075
+ const cwd = relative(resolve(workspace.path), sessionRoot).replaceAll("\\", "/");
1073
1076
  const op = url.searchParams.get("op") ?? "status";
1074
1077
  // Blame is a read with a GET shape: every other verb is refused HERE, before the POST branch
1075
1078
  // could ever route the op into gitMutate and before any body is read.
@@ -1095,7 +1098,8 @@ export function createHttpServer(options) {
1095
1098
  }
1096
1099
  const raw = asked.text;
1097
1100
  const body = raw ? JSON.parse(raw) : {};
1098
- json(200, await gitMutate(workspace.path, op, body.path, body.message));
1101
+ const path = body.path ? await workspacePathFromSession(workspace.path, sessionRoot, body.path) : undefined;
1102
+ json(200, await gitMutate(workspace.path, op, path, body.message, cwd));
1099
1103
  return;
1100
1104
  }
1101
1105
  if (request.method !== "GET") {
@@ -1103,16 +1107,16 @@ export function createHttpServer(options) {
1103
1107
  return;
1104
1108
  }
1105
1109
  if (op === "status")
1106
- json(200, await gitStatus(workspace.path));
1110
+ json(200, await gitStatus(workspace.path, cwd));
1107
1111
  else if (op === "log")
1108
- json(200, await gitLog(workspace.path, Number(url.searchParams.get("limit") ?? 20)));
1112
+ json(200, await gitLog(workspace.path, Number(url.searchParams.get("limit") ?? 20), cwd));
1109
1113
  else if (op === "diff") {
1110
1114
  const path = url.searchParams.get("path");
1111
1115
  if (!path) {
1112
1116
  json(400, { error: "diff 需要 path", code: "bad_path" });
1113
1117
  return;
1114
1118
  }
1115
- json(200, await gitDiff(workspace.path, path, url.searchParams.get("staged") === "1"));
1119
+ json(200, await gitDiff(workspace.path, await workspacePathFromSession(workspace.path, sessionRoot, path), url.searchParams.get("staged") === "1"));
1116
1120
  }
1117
1121
  else if (op === "blame") {
1118
1122
  const path = url.searchParams.get("path");
@@ -1122,7 +1126,7 @@ export function createHttpServer(options) {
1122
1126
  }
1123
1127
  const startLine = url.searchParams.get("startLine");
1124
1128
  const endLine = url.searchParams.get("endLine");
1125
- json(200, await gitBlame(workspace.path, path, {
1129
+ json(200, await gitBlame(workspace.path, await workspacePathFromSession(workspace.path, sessionRoot, path), {
1126
1130
  revision: url.searchParams.get("revision") ?? undefined,
1127
1131
  startLine: startLine === null ? undefined : Number(startLine),
1128
1132
  endLine: endLine === null ? undefined : Number(endLine),
@@ -1136,7 +1140,7 @@ export function createHttpServer(options) {
1136
1140
  const code = error?.code;
1137
1141
  if (code === 128 || code === "not_a_repo" || /not a git repository/i.test(message))
1138
1142
  json(404, { error: "此工作区不是 git 仓库", code: "not_a_repo" });
1139
- else if (code === "path")
1143
+ else if (code === "path" || code === "outside_workspace")
1140
1144
  json(400, { error: message, code: "bad_path" });
1141
1145
  else if (code === "not_found")
1142
1146
  json(404, { error: message, code: "not_found" });
@@ -1480,27 +1484,70 @@ export function createHttpServer(options) {
1480
1484
  }
1481
1485
  const action = (body.action || "").trim();
1482
1486
  if (action === "spawn") {
1487
+ // Capability auth above proves who is calling. The target workspace is a separate, explicit
1488
+ // choice: a parentConversationId records a tree edge and never selects that target.
1489
+ const requestedWorkspaceId = body.workspaceId?.trim();
1490
+ if (!requestedWorkspaceId) {
1491
+ json(400, { error: "创建会话必须显式指定 workspaceId;父会话只记录树形关系,不能代替目标工作区。" });
1492
+ return;
1493
+ }
1494
+ const store = context.store;
1495
+ const callerWorkspaceId = store && callerConversationId
1496
+ ? store.conversation(callerConversationId)?.workspaceId
1497
+ : undefined;
1498
+ if (requestedWorkspaceId !== callerWorkspaceId) {
1499
+ json(403, {
1500
+ error: `调用方会话 ${callerConversationId} 所在工作区为 ${callerWorkspaceId ?? "未知"},请求的目标工作区 ${requestedWorkspaceId} 不属于调用方,拒绝在该工作区创建会话。请传入调用方自己的工作区。`,
1501
+ });
1502
+ return;
1503
+ }
1483
1504
  if (!body.task)
1484
1505
  throw new Error("spawn requires task");
1485
1506
  const result = await service.spawn({
1486
1507
  task: body.task,
1487
1508
  parentConversationId: body.parentConversationId,
1488
- workspaceId: body.workspaceId,
1509
+ workspaceId: requestedWorkspaceId,
1489
1510
  title: body.title,
1490
1511
  agentId: body.agentId,
1491
1512
  model: body.model,
1492
1513
  thinkingLevel: body.thinkingLevel,
1493
1514
  initialPrompt: body.initialPrompt,
1494
1515
  wait: body.wait,
1516
+ cwd: body.cwd,
1495
1517
  });
1496
1518
  json(200, { ok: true, ...result });
1497
1519
  return;
1498
1520
  }
1521
+ const verifyTargetScope = (targetConversationId) => {
1522
+ const store = context.store;
1523
+ const callerWorkspaceId = store && callerConversationId
1524
+ ? store.conversation(callerConversationId)?.workspaceId
1525
+ : undefined;
1526
+ if (!callerWorkspaceId) {
1527
+ return {
1528
+ ok: false,
1529
+ error: `调用方会话 ${callerConversationId} 不存在或未关联有效工作区,拒绝编排操作。`,
1530
+ };
1531
+ }
1532
+ const target = store?.conversation(targetConversationId);
1533
+ if (target && target.workspaceId !== callerWorkspaceId) {
1534
+ return {
1535
+ ok: false,
1536
+ error: `调用方会话 ${callerConversationId} 所在工作区为 ${callerWorkspaceId},目标会话 ${targetConversationId} 所在工作区为 ${target.workspaceId},拒绝跨工作区操作。目标会话必须属于调用方工作区。`,
1537
+ };
1538
+ }
1539
+ return { ok: true };
1540
+ };
1499
1541
  if (action === "dispatch") {
1500
1542
  if (!body.conversationId)
1501
1543
  throw new Error("dispatch requires conversationId");
1502
1544
  if (!body.content)
1503
1545
  throw new Error("dispatch requires content");
1546
+ const scope = verifyTargetScope(body.conversationId);
1547
+ if (!scope.ok) {
1548
+ json(403, { error: scope.error });
1549
+ return;
1550
+ }
1504
1551
  const result = await service.dispatch(body.conversationId, body.content, { wait: body.wait });
1505
1552
  json(200, { ok: true, ...result });
1506
1553
  return;
@@ -1508,18 +1555,48 @@ export function createHttpServer(options) {
1508
1555
  if (action === "status") {
1509
1556
  if (!body.conversationId)
1510
1557
  throw new Error("status requires conversationId");
1558
+ const scope = verifyTargetScope(body.conversationId);
1559
+ if (!scope.ok) {
1560
+ json(403, { error: scope.error });
1561
+ return;
1562
+ }
1511
1563
  const summary = await service.status(body.conversationId, { includeOutput: body.includeOutput });
1512
1564
  json(200, { ok: true, summary });
1513
1565
  return;
1514
1566
  }
1515
1567
  if (action === "list") {
1516
- const sessions = await service.list(body.parentConversationId);
1517
- json(200, { ok: true, sessions });
1568
+ const store = context.store;
1569
+ const callerWorkspaceId = store && callerConversationId
1570
+ ? store.conversation(callerConversationId)?.workspaceId
1571
+ : undefined;
1572
+ if (!callerWorkspaceId) {
1573
+ json(403, { error: `调用方会话 ${callerConversationId} 不存在或未关联有效工作区,拒绝编排操作。` });
1574
+ return;
1575
+ }
1576
+ const requestedParentId = body.parentConversationId?.trim();
1577
+ if (requestedParentId) {
1578
+ const targetParent = store?.conversation(requestedParentId);
1579
+ if (targetParent && targetParent.workspaceId !== callerWorkspaceId) {
1580
+ json(403, {
1581
+ error: `调用方会话 ${callerConversationId} 所在工作区为 ${callerWorkspaceId},请求的父会话 ${requestedParentId} 所在工作区为 ${targetParent.workspaceId},拒绝跨工作区查询。父会话必须属于调用方工作区。`,
1582
+ });
1583
+ return;
1584
+ }
1585
+ }
1586
+ const effectiveParentId = requestedParentId || callerConversationId;
1587
+ const sessions = await service.list(effectiveParentId);
1588
+ const allowedSessions = sessions.filter((s) => !s.workspaceId || s.workspaceId === callerWorkspaceId);
1589
+ json(200, { ok: true, sessions: allowedSessions });
1518
1590
  return;
1519
1591
  }
1520
1592
  if (action === "stop") {
1521
1593
  if (!body.conversationId)
1522
1594
  throw new Error("stop requires conversationId");
1595
+ const scope = verifyTargetScope(body.conversationId);
1596
+ if (!scope.ok) {
1597
+ json(403, { error: scope.error });
1598
+ return;
1599
+ }
1523
1600
  const result = await service.stop(body.conversationId, { mode: body.mode, messageId: body.messageId });
1524
1601
  json(200, { ok: true, ...result });
1525
1602
  return;
@@ -1527,6 +1604,11 @@ export function createHttpServer(options) {
1527
1604
  if (action === "resume") {
1528
1605
  if (!body.conversationId)
1529
1606
  throw new Error("resume requires conversationId");
1607
+ const scope = verifyTargetScope(body.conversationId);
1608
+ if (!scope.ok) {
1609
+ json(403, { error: scope.error });
1610
+ return;
1611
+ }
1530
1612
  const result = await service.resume(body.conversationId, body.prompt, { wait: body.wait });
1531
1613
  json(200, { ok: true, ...result });
1532
1614
  return;
@@ -1538,6 +1620,36 @@ export function createHttpServer(options) {
1538
1620
  }
1539
1621
  return;
1540
1622
  }
1623
+ if (url.pathname === "/api/conversation/usage") {
1624
+ const json = (status, payload) => {
1625
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }).end(JSON.stringify(payload));
1626
+ };
1627
+ if (request.method !== "GET" || !isLocalOrPrivateAddress(remote)) {
1628
+ json(403, { error: "Usage readout requires a local/private GET request." });
1629
+ return;
1630
+ }
1631
+ const callerConversationId = url.searchParams.get("callerConversationId") ?? "";
1632
+ const capability = request.headers["x-super-pi-capability"] ?? url.searchParams.get("capability");
1633
+ const verdict = runService.verifyPrimaryCapability(callerConversationId, capability);
1634
+ if (!verdict.ok) {
1635
+ json(403, { error: verdict.message });
1636
+ return;
1637
+ }
1638
+ const target = url.searchParams.get("conversationId") ?? callerConversationId;
1639
+ if (target !== callerConversationId) {
1640
+ json(403, { error: "Only the caller's own conversation usage may be read." });
1641
+ return;
1642
+ }
1643
+ const conversation = store.conversation(callerConversationId);
1644
+ if (!conversation) {
1645
+ json(403, { error: "Caller conversation is unavailable." });
1646
+ return;
1647
+ }
1648
+ json(200, { ok: true, conversationId: callerConversationId, usage: conversation.usage ?? null,
1649
+ usageCostKnown: conversation.usageCostKnown ?? null, contextUsage: conversation.contextUsage ?? null,
1650
+ usageUpdatedAt: conversation.usageUpdatedAt ?? null });
1651
+ return;
1652
+ }
1541
1653
  if (url.pathname === "/api/orchestrator/status" && request.method === "GET") {
1542
1654
  if (!isLocalOrPrivateAddress(remote)) {
1543
1655
  response.writeHead(403).end();
@@ -1559,6 +1671,23 @@ export function createHttpServer(options) {
1559
1671
  response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "缺少 conversationId" }));
1560
1672
  return;
1561
1673
  }
1674
+ const store = context.store;
1675
+ const callerWorkspaceId = store && callerConversationId
1676
+ ? store.conversation(callerConversationId)?.workspaceId
1677
+ : undefined;
1678
+ if (!callerWorkspaceId) {
1679
+ response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({
1680
+ error: `调用方会话 ${callerConversationId} 不存在或未关联有效工作区,拒绝编排操作。`,
1681
+ }));
1682
+ return;
1683
+ }
1684
+ const target = store?.conversation(conversationId);
1685
+ if (target && target.workspaceId !== callerWorkspaceId) {
1686
+ response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({
1687
+ error: `调用方会话 ${callerConversationId} 所在工作区为 ${callerWorkspaceId},目标会话 ${conversationId} 所在工作区为 ${target.workspaceId},拒绝跨工作区操作。目标会话必须属于调用方工作区。`,
1688
+ }));
1689
+ return;
1690
+ }
1562
1691
  try {
1563
1692
  const summary = await service.status(conversationId, { includeOutput: url.searchParams.get("includeOutput") === "true" });
1564
1693
  response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ ok: true, summary }));
@@ -1585,9 +1714,30 @@ export function createHttpServer(options) {
1585
1714
  response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: verdict.message }));
1586
1715
  return;
1587
1716
  }
1717
+ const store = context.store;
1718
+ const callerWorkspaceId = store && callerConversationId
1719
+ ? store.conversation(callerConversationId)?.workspaceId
1720
+ : undefined;
1721
+ if (!callerWorkspaceId) {
1722
+ response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({
1723
+ error: `调用方会话 ${callerConversationId} 不存在或未关联有效工作区,拒绝编排操作。`,
1724
+ }));
1725
+ return;
1726
+ }
1727
+ if (parentId) {
1728
+ const targetParent = store?.conversation(parentId);
1729
+ if (targetParent && targetParent.workspaceId !== callerWorkspaceId) {
1730
+ response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({
1731
+ error: `调用方会话 ${callerConversationId} 所在工作区为 ${callerWorkspaceId},请求的父会话 ${parentId} 所在工作区为 ${targetParent.workspaceId},拒绝跨工作区查询。父会话必须属于调用方工作区。`,
1732
+ }));
1733
+ return;
1734
+ }
1735
+ }
1588
1736
  try {
1589
- const sessions = await service.list(parentId);
1590
- response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ ok: true, sessions }));
1737
+ const effectiveParentId = parentId || callerConversationId;
1738
+ const sessions = await service.list(effectiveParentId);
1739
+ const allowedSessions = sessions.filter((s) => !s.workspaceId || s.workspaceId === callerWorkspaceId);
1740
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ ok: true, sessions: allowedSessions }));
1591
1741
  }
1592
1742
  catch (error) {
1593
1743
  response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: error instanceof Error ? error.message : "list failed" }));
@@ -1644,16 +1794,20 @@ export function createHttpServer(options) {
1644
1794
  json(400, { error: "需要 path 参数", code: "bad_request" });
1645
1795
  return;
1646
1796
  }
1797
+ const root = await resolveSessionCwd(workspace.path, url.searchParams.get("cwd"));
1798
+ const workspaceRelativePath = await workspacePathFromSession(workspace.path, root, path);
1647
1799
  const requested = url.searchParams.get("base")?.trim();
1648
1800
  const preferred = requested === "git" || requested === "agent" ? requested : "auto";
1649
1801
  // The baseline is chosen by the server, but the client can name one - and when it names one, that
1650
1802
  // is the one it gets. A silent fall-back would show the user a comparison they did not ask for.
1651
1803
  const diff = await computeFileDiff({
1652
- root: workspace.path,
1804
+ root,
1653
1805
  path,
1654
1806
  preferred,
1655
1807
  tracker: context.fileChanges,
1656
1808
  workspaceId,
1809
+ gitRoot: workspace.path,
1810
+ trackerPath: workspaceRelativePath,
1657
1811
  });
1658
1812
  json(200, diff);
1659
1813
  }
@@ -1677,7 +1831,9 @@ export function createHttpServer(options) {
1677
1831
  json(404, { error: "找不到这个工作区——它可能已经被删除。", code: "no_workspace" });
1678
1832
  return;
1679
1833
  }
1680
- await resolveInsideRoot(workspace.path, "");
1834
+ const root = await resolveSessionCwd(workspace.path, url.searchParams.get("cwd"));
1835
+ const cwd = relative(resolve(workspace.path), root).replaceAll("\\", "/");
1836
+ const indexKey = sessionScopeKey(workspaceId, cwd);
1681
1837
  if (query.length > 256 || /[\r\n]/.test(query)) {
1682
1838
  json(400, { error: "搜索词只能是一行,且不超过 256 个字符。", code: "bad_query" });
1683
1839
  return;
@@ -1689,17 +1845,17 @@ export function createHttpServer(options) {
1689
1845
  const indexes = context.fileIndexes ?? new Map();
1690
1846
  if (!context.fileIndexes)
1691
1847
  context.fileIndexes = indexes;
1692
- let index = indexes.get(workspaceId);
1848
+ let index = indexes.get(indexKey);
1693
1849
  if (!index) {
1694
- index = new FileIndex(workspaceId, (event) => broadcaster.broadcast(event));
1695
- indexes.set(workspaceId, index);
1850
+ index = new FileIndex(workspaceId, (event) => broadcaster.broadcast(event), cwd);
1851
+ indexes.set(indexKey, index);
1696
1852
  }
1697
- index.start(workspace.path);
1853
+ index.start(root);
1698
1854
  const deadline = Date.now() + 4500;
1699
1855
  while (!index.builtAt && Date.now() < deadline)
1700
1856
  await new Promise((resolve) => setTimeout(resolve, 25));
1701
1857
  const snapshot = index.snapshot();
1702
- const result = await searchByContent(workspace.path, [...index.entries.values()], query, snapshot);
1858
+ const result = await searchByContent(root, [...index.entries.values()], query, snapshot);
1703
1859
  json(200, { mode: "content", query, ...result, maxResults: MAX_CONTENT_RESULTS, maxFileBytes: MAX_CONTENT_FILE_BYTES, maxDepth: MAX_CONTENT_DEPTH });
1704
1860
  }
1705
1861
  catch (error) {
@@ -1738,18 +1894,21 @@ export function createHttpServer(options) {
1738
1894
  json(404, { error: `找不到工作区「${workspaceId ?? "未指定"}」——它可能已经被删除。`, code: "no_workspace" });
1739
1895
  return;
1740
1896
  }
1897
+ const root = await resolveSessionCwd(workspace.path, url.searchParams.get("cwd"));
1898
+ const cwd = relative(resolve(workspace.path), root).replaceAll("\\", "/");
1899
+ const indexKey = sessionScopeKey(workspaceId, cwd);
1741
1900
  const indexes = context.fileIndexes ?? new Map();
1742
1901
  if (!context.fileIndexes)
1743
1902
  context.fileIndexes = indexes;
1744
- let index = indexes.get(workspaceId);
1903
+ let index = indexes.get(indexKey);
1745
1904
  if (!index) {
1746
- index = new FileIndex(workspaceId, (event) => broadcaster.broadcast(event));
1747
- indexes.set(workspaceId, index);
1905
+ index = new FileIndex(workspaceId, (event) => broadcaster.broadcast(event), cwd);
1906
+ indexes.set(indexKey, index);
1748
1907
  }
1749
- index.start(workspace.path);
1908
+ index.start(root);
1750
1909
  // Quick Open requests the full index on demand. Name search can share its background scan;
1751
1910
  // merely selecting a session no longer starts a multi-MB download.
1752
- nameIndexFor(context, workspaceId, workspace.path);
1911
+ nameIndexFor(context, workspaceId, root, cwd);
1753
1912
  const sinceRaw = url.searchParams.get("since");
1754
1913
  const since = sinceRaw === null ? undefined : Number(sinceRaw);
1755
1914
  json(200, index.snapshot(Number.isSafeInteger(since) && since >= 0 ? since : undefined));
@@ -1771,7 +1930,9 @@ export function createHttpServer(options) {
1771
1930
  json(404, { error: `找不到工作区「${workspaceId ?? "未指定"}」——它可能已经被删除。`, code: "no_workspace" });
1772
1931
  return;
1773
1932
  }
1774
- const root = workspace.path;
1933
+ const root = await resolveSessionCwd(workspace.path, url.searchParams.get("cwd"));
1934
+ const cwd = relative(resolve(workspace.path), root).replaceAll("\\", "/");
1935
+ const indexKey = sessionScopeKey(workspaceId, cwd);
1775
1936
  if (url.pathname === "/api/files") {
1776
1937
  if (request.method !== "GET") {
1777
1938
  json(405, { error: "目录列表需要 GET 请求。", code: "method" });
@@ -1787,7 +1948,7 @@ export function createHttpServer(options) {
1787
1948
  // report a limit about. `truncated` and `skipped` stay in the response so the protocol
1788
1949
  // shape does not change under the client, but they are now constants that say the
1789
1950
  // opposite of what they used to.
1790
- const index = nameIndexFor(context, workspaceId, root);
1951
+ const index = nameIndexFor(context, workspaceId, root, cwd);
1791
1952
  // THE WORKSPACE MUST EXIST, AND THE INDEX IS WHAT KNOWS.
1792
1953
  //
1793
1954
  // The background scan records a root it could not read rather than throwing it - a scan
@@ -1827,12 +1988,12 @@ export function createHttpServer(options) {
1827
1988
  const resolved = await resolveInsideRoot(root, rel);
1828
1989
  const meta = await fileMeta(resolved);
1829
1990
  const path = toRelative(root, resolved);
1830
- context.fileIndexes?.get(workspaceId)?.recordFile(path, meta);
1991
+ context.fileIndexes?.get(indexKey)?.recordFile(path, meta);
1831
1992
  json(200, { path, ...meta });
1832
1993
  return;
1833
1994
  }
1834
1995
  const file = await readWorkspaceFile(root, rel);
1835
- context.fileIndexes?.get(workspaceId)?.recordFile(file.path, file.meta);
1996
+ context.fileIndexes?.get(indexKey)?.recordFile(file.path, file.meta);
1836
1997
  json(200, { path: file.path, content: file.content, eol: file.eol, bom: file.bom, meta: file.meta, maxBytes: MAX_FILE_BYTES });
1837
1998
  return;
1838
1999
  }
@@ -1867,11 +2028,12 @@ export function createHttpServer(options) {
1867
2028
  return;
1868
2029
  }
1869
2030
  const outcome = await writeWorkspaceFile(root, parsed.path, parsed.content, parsed.baseEtag);
1870
- context.fileIndexes?.get(workspaceId)?.recordFile(outcome.path, outcome.meta);
2031
+ context.fileIndexes?.get(indexKey)?.recordFile(outcome.path, outcome.meta);
1871
2032
  // The name index learns about the save too. A file the user just saved must be findable
1872
2033
  // by name immediately, and the index is the thing the search reads - so this is where the
1873
2034
  // index stays true without anybody re-scanning the tree.
1874
- context.nameIndexes?.get(workspaceId)?.upsert(outcome.path);
2035
+ if (context.nameIndexes)
2036
+ recordWorkspacePath(context.nameIndexes, workspaceId, workspace.path, await workspacePathFromSession(workspace.path, root, outcome.path));
1875
2037
  json(200, outcome);
1876
2038
  return;
1877
2039
  }
@@ -0,0 +1,46 @@
1
+ /** One model's declared window, as the person's file states it. */
2
+ export type DeclaredWindow = {
3
+ provider: string;
4
+ modelId: string;
5
+ contextWindow: number;
6
+ };
7
+ /**
8
+ * The windows the person's `models.json` declares, keyed by `provider/modelId`.
9
+ *
10
+ * Pi's file shape is `{ providers: { <providerId>: { models: [{ id, contextWindow, ... }] } } }`,
11
+ * and a model without a `contextWindow` is simply not declared - it does not become a zero, because
12
+ * a zero would be a claim ("this model has no window") rather than an absence.
13
+ */
14
+ export declare function declaredWindows(modelsJson: unknown): Map<string, number>;
15
+ /**
16
+ * The window to USE for a model, given what the person's file says.
17
+ *
18
+ * `undefined` means "say nothing" - the caller keeps whatever Pi believes. That is the honest
19
+ * answer for a model the person's file does not mention: Super Pi has no opinion about a model
20
+ * somebody else configured, and inventing one would be the same mistake in the other direction.
21
+ *
22
+ * TWO LOOKUPS, AND THE SECOND ONE IS THE ONE THAT MATTERS:
23
+ *
24
+ * 1. `provider/modelId` - the exact pair. This is the common case.
25
+ * 2. `modelId` ALONE, across every provider the person's file declares.
26
+ *
27
+ * The second lookup exists because a CONTEXT WINDOW IS A PROPERTY OF THE MODEL, NOT OF THE
28
+ * ENDPOINT. Measured on this machine: the person's `models.json` declares the model `MiniMax-M3`
29
+ * under the provider id `minimax` (the OpenAI-compatible endpoint) with `contextWindow: 128000`,
30
+ * while Pi's remote catalogue declares the SAME model under a DIFFERENT provider id,
31
+ * `minimax-cn` (the Anthropic-compatible endpoint), with `contextWindow: 1000000`. The session runs
32
+ * on `minimax-cn`, so the exact-pair lookup finds nothing and the bogus number survives.
33
+ *
34
+ * When several providers declare the same model with DIFFERENT windows, the SMALLEST wins: a model
35
+ * can never really have more context than its own limit, and a window that is too large is exactly
36
+ * the failure this exists to prevent - it pushes compaction past the point where the model refuses.
37
+ */
38
+ export declare function effectiveContextWindow(declared: Map<string, number>, provider: string, modelId: string, reported?: number): number | undefined;
39
+ /**
40
+ * Reads the person's `models.json` and returns its declared windows.
41
+ *
42
+ * A missing or unreadable file is an empty map, not a throw: Super Pi must not refuse to start
43
+ * because a person has not configured a custom provider, and the consequence of an empty map is
44
+ * simply "Super Pi has no opinion about this model's window" - which is where it started.
45
+ */
46
+ export declare function readDeclaredWindows(agentDir: string): Map<string, number>;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * THE CONTEXT WINDOW A MODEL REALLY HAS.
3
+ *
4
+ * Pi reads its model list from two places and one of them wins:
5
+ *
6
+ * - `~/.pi/agent/models.json` - the file a PERSON writes, or that Super Pi writes from its own
7
+ * registry. Its `contextWindow` is a deliberate statement about the model.
8
+ * - `~/.pi/agent/models-store.json` - the REMOTE CATALOGUE Pi fetches from the provider, with
9
+ * an etag and a timestamp. Its `contextWindow` is the PROVIDER'S SELF-REPORT.
10
+ *
11
+ * Pi's own merge lets the remote catalogue overwrite the person's file (`merged[index] = model`),
12
+ * so a provider that reports a generous window wins over the one the person configured.
13
+ *
14
+ * WHY THAT MATTERS, AND HOW IT BREAKS A SESSION:
15
+ *
16
+ * Pi's auto-compaction is `shouldCompact(contextTokens, contextWindow, settings)`, i.e.
17
+ * `contextTokens > contextWindow - reserveTokens`. With `reserveTokens = 16384` and a reported
18
+ * window of 1,000,000, compaction waits for 983,616 tokens. The real window of that model is
19
+ * 128,000. So the model refuses the request at 128K, and the OVERFLOW RECOVERY - which asks the
20
+ * SAME model to summarise the same too-long conversation - fails the same way. The session is
21
+ * then a session that cannot answer anything, and the reader sees a dead conversation.
22
+ *
23
+ * Measured on this machine: `models.json` says `MiniMax-M3` has `contextWindow: 128000`; the
24
+ * remote catalogue says `1000000`; Pi ends up believing 1,000,000.
25
+ *
26
+ * SO: THE PERSON'S FILE WINS. A number somebody typed is a decision; a number a catalogue fetched
27
+ * is a claim, and a claim that is too generous does not fail loudly - it fails by never compacting.
28
+ *
29
+ * This module is deliberately narrow: it reads the person's file, and it says what the model
30
+ * window SHOULD be. It does not touch Pi, does not decide anything else, and is a pure function of
31
+ * two plain objects so it can be tested without a server, a socket, a browser or a model.
32
+ */
33
+ import { readFileSync } from "node:fs";
34
+ import { join } from "node:path";
35
+ /**
36
+ * The windows the person's `models.json` declares, keyed by `provider/modelId`.
37
+ *
38
+ * Pi's file shape is `{ providers: { <providerId>: { models: [{ id, contextWindow, ... }] } } }`,
39
+ * and a model without a `contextWindow` is simply not declared - it does not become a zero, because
40
+ * a zero would be a claim ("this model has no window") rather than an absence.
41
+ */
42
+ export function declaredWindows(modelsJson) {
43
+ const out = new Map();
44
+ if (!modelsJson || typeof modelsJson !== "object")
45
+ return out;
46
+ const providers = modelsJson.providers;
47
+ if (!providers || typeof providers !== "object")
48
+ return out;
49
+ for (const [providerId, provider] of Object.entries(providers)) {
50
+ if (!provider || typeof provider !== "object")
51
+ continue;
52
+ const models = provider.models;
53
+ if (!Array.isArray(models))
54
+ continue;
55
+ for (const model of models) {
56
+ if (!model || typeof model !== "object")
57
+ continue;
58
+ const id = model.id;
59
+ const window = model.contextWindow;
60
+ if (typeof id !== "string" || !id.trim())
61
+ continue;
62
+ if (typeof window !== "number" || !Number.isSafeInteger(window) || window <= 0)
63
+ continue;
64
+ out.set(`${providerId}/${id}`, window);
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+ /**
70
+ * The window to USE for a model, given what the person's file says.
71
+ *
72
+ * `undefined` means "say nothing" - the caller keeps whatever Pi believes. That is the honest
73
+ * answer for a model the person's file does not mention: Super Pi has no opinion about a model
74
+ * somebody else configured, and inventing one would be the same mistake in the other direction.
75
+ *
76
+ * TWO LOOKUPS, AND THE SECOND ONE IS THE ONE THAT MATTERS:
77
+ *
78
+ * 1. `provider/modelId` - the exact pair. This is the common case.
79
+ * 2. `modelId` ALONE, across every provider the person's file declares.
80
+ *
81
+ * The second lookup exists because a CONTEXT WINDOW IS A PROPERTY OF THE MODEL, NOT OF THE
82
+ * ENDPOINT. Measured on this machine: the person's `models.json` declares the model `MiniMax-M3`
83
+ * under the provider id `minimax` (the OpenAI-compatible endpoint) with `contextWindow: 128000`,
84
+ * while Pi's remote catalogue declares the SAME model under a DIFFERENT provider id,
85
+ * `minimax-cn` (the Anthropic-compatible endpoint), with `contextWindow: 1000000`. The session runs
86
+ * on `minimax-cn`, so the exact-pair lookup finds nothing and the bogus number survives.
87
+ *
88
+ * When several providers declare the same model with DIFFERENT windows, the SMALLEST wins: a model
89
+ * can never really have more context than its own limit, and a window that is too large is exactly
90
+ * the failure this exists to prevent - it pushes compaction past the point where the model refuses.
91
+ */
92
+ export function effectiveContextWindow(declared, provider, modelId, reported) {
93
+ const exact = declared.get(`${provider}/${modelId}`);
94
+ if (exact !== undefined)
95
+ return exact;
96
+ let smallest;
97
+ for (const [key, window] of declared) {
98
+ const slash = key.indexOf("/");
99
+ if (slash < 0 || key.slice(slash + 1) !== modelId)
100
+ continue;
101
+ if (smallest === undefined || window < smallest)
102
+ smallest = window;
103
+ }
104
+ return smallest;
105
+ }
106
+ /**
107
+ * Reads the person's `models.json` and returns its declared windows.
108
+ *
109
+ * A missing or unreadable file is an empty map, not a throw: Super Pi must not refuse to start
110
+ * because a person has not configured a custom provider, and the consequence of an empty map is
111
+ * simply "Super Pi has no opinion about this model's window" - which is where it started.
112
+ */
113
+ export function readDeclaredWindows(agentDir) {
114
+ try {
115
+ const raw = readFileSync(join(agentDir, "models.json"), "utf8");
116
+ return declaredWindows(JSON.parse(raw));
117
+ }
118
+ catch {
119
+ return new Map();
120
+ }
121
+ }
@@ -349,6 +349,12 @@ export interface PiSessionLike {
349
349
  provider?: string;
350
350
  id?: string;
351
351
  } | undefined;
352
+ /** Pi's estimate for the current context, including its post-compaction unknown state. */
353
+ getContextUsage?(): {
354
+ tokens: number | null;
355
+ contextWindow: number;
356
+ percent: number | null;
357
+ } | undefined;
352
358
  /**
353
359
  * Switches the model on a LIVE session.
354
360
  *