@evandu/super-pi 0.1.152 → 0.1.154

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 (51) hide show
  1. package/agents/super-pi-task/core.ts +19 -0
  2. package/agents/super-pi-task/index.ts +17 -0
  3. package/dist/client/.super-pi-client-sources.json +6 -4
  4. package/dist/client/assets/{CodeMirrorEditor-Cb6Drhk5.js → CodeMirrorEditor-B7Xs6jNi.js} +2 -2
  5. package/dist/client/assets/{dist-CyFyjl9w.js → dist-BAKEacQv.js} +1 -1
  6. package/dist/client/assets/{dist-DnIfPAlr.js → dist-BLt409fN.js} +1 -1
  7. package/dist/client/assets/{dist-Kd8Ts9Qe.js → dist-BdzEUJSL.js} +1 -1
  8. package/dist/client/assets/{dist-D7zfHJFz.js → dist-Bl5sUV3i.js} +1 -1
  9. package/dist/client/assets/{dist-BvW835kJ.js → dist-C1W6MyDX.js} +1 -1
  10. package/dist/client/assets/{dist-ijCQUPtv.js → dist-C8NpSGxO.js} +1 -1
  11. package/dist/client/assets/{dist-CjbILrLW.js → dist-CAfwLExI.js} +1 -1
  12. package/dist/client/assets/{dist-DgaBkcOR.js → dist-CTHQg4Rx.js} +1 -1
  13. package/dist/client/assets/{dist-DkEKqzzI.js → dist-CV2n3nfk.js} +1 -1
  14. package/dist/client/assets/{dist-BHWjjFy_.js → dist-CVChVTw-.js} +1 -1
  15. package/dist/client/assets/{dist-BorepJPL.js → dist-CpbI--iL.js} +1 -1
  16. package/dist/client/assets/{dist-CFBpvn_F.js → dist-CsTjYHwC.js} +1 -1
  17. package/dist/client/assets/{dist-Cjk1RRIz.js → dist-D-xVK9kg.js} +1 -1
  18. package/dist/client/assets/{dist-ByaeX0Xa.js → dist-D0WeiUzu.js} +1 -1
  19. package/dist/client/assets/{dist-i6E_Fwl0.js → dist-D1WPlyWb.js} +1 -1
  20. package/dist/client/assets/{dist-dPJqK8GC.js → dist-DCKPOd40.js} +1 -1
  21. package/dist/client/assets/{dist-1P3rbXrP.js → dist-DQ4Nk969.js} +1 -1
  22. package/dist/client/assets/{dist-BqN0geUv.js → dist-D_1md4dq.js} +1 -1
  23. package/dist/client/assets/{dist-BzySVYC0.js → dist-Dq0L1Jhh.js} +1 -1
  24. package/dist/client/assets/{dist-BgQmfor3.js → dist-DwQ2g3lZ.js} +1 -1
  25. package/dist/client/assets/{dist-Df1eD154.js → dist-TyClJ5X5.js} +1 -1
  26. package/dist/client/assets/{dist-B8RyLyAM.js → dist-npMrgpJc.js} +1 -1
  27. package/dist/client/assets/{index-CgRiEaG2.js → index-DX_jpcni.js} +16 -16
  28. package/dist/client/assets/{index-DCLaWQin.css → index-srMSVLBy.css} +1 -1
  29. package/dist/client/index.html +2 -2
  30. package/dist/server/agents.d.ts +1 -1
  31. package/dist/server/agents.js +1 -1
  32. package/dist/server/application/project-service.d.ts +70 -0
  33. package/dist/server/application/project-service.js +573 -0
  34. package/dist/server/http-server.js +189 -2
  35. package/dist/server/index.js +3 -0
  36. package/dist/server/persistence/app-state-repository.d.ts +2 -2
  37. package/dist/server/runtime.js +5 -2
  38. package/dist/server/server-context.d.ts +1 -0
  39. package/dist/server/store.d.ts +5 -1
  40. package/dist/server/store.js +29 -3
  41. package/dist/server/task-tool.d.ts +2 -0
  42. package/dist/server/task-tool.js +2 -0
  43. package/dist/server/terminal.d.ts +29 -4
  44. package/dist/server/terminal.js +38 -5
  45. package/dist/server/types.d.ts +103 -0
  46. package/package.json +7 -3
  47. package/dist/.super-pi-check-inputs.json +0 -4
  48. package/dist/server/feishu-config.d.ts +0 -68
  49. package/dist/server/feishu-config.js +0 -84
  50. package/dist/server/feishu.d.ts +0 -187
  51. package/dist/server/feishu.js +0 -695
@@ -314,7 +314,7 @@ export function createHttpServer(options) {
314
314
  // the endpoint that can spend it is exactly the one an agent's own turn can reach, and the
315
315
  // management half (`/api/feishu/config`) stays behind the ordinary token AND the local-address
316
316
  // predicate.
317
- 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)) {
317
+ 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/projects/tool" && requestPath !== "/api/question" && (requestPath !== "/api/tools" || request.method !== "POST") && !requestPath.startsWith("/tools/") && !isAuthorized(request, accessToken)) {
318
318
  response.writeHead(401, { "www-authenticate": "Bearer" }).end("Authentication required");
319
319
  return;
320
320
  }
@@ -1176,6 +1176,10 @@ export function createHttpServer(options) {
1176
1176
  };
1177
1177
  try {
1178
1178
  const body = JSON.parse(raw);
1179
+ if (body.conversationId && context.projectService?.projectForSession?.(body.conversationId)) {
1180
+ json(403, { error: "项目分工必须使用 project 派发,以遵守资源准入限制" });
1181
+ return;
1182
+ }
1179
1183
  const result = await runService.dispatchTask(body);
1180
1184
  json(result.status, result.body);
1181
1185
  }
@@ -1302,7 +1306,12 @@ export function createHttpServer(options) {
1302
1306
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1303
1307
  };
1304
1308
  try {
1305
- const result = await runService.runWorkflow(JSON.parse(raw));
1309
+ const body = JSON.parse(raw);
1310
+ if (body.conversationId && context.projectService?.projectForSession?.(body.conversationId)) {
1311
+ json(403, { error: "项目分工必须使用 project 派发,以遵守资源准入限制" });
1312
+ return;
1313
+ }
1314
+ const result = await runService.runWorkflow(body);
1306
1315
  json(result.status, result.body);
1307
1316
  }
1308
1317
  catch (error) {
@@ -1457,6 +1466,180 @@ export function createHttpServer(options) {
1457
1466
  }
1458
1467
  return;
1459
1468
  }
1469
+ if (url.pathname === "/api/projects" || url.pathname.startsWith("/api/projects/")) {
1470
+ if (!isLocalOrPrivateAddress(remote)) {
1471
+ json(403, { error: "项目控制面板只允许本机或私网访问" });
1472
+ return;
1473
+ }
1474
+ const service = context.projectService;
1475
+ if (!service) {
1476
+ json(503, { error: "项目服务未就绪" });
1477
+ return;
1478
+ }
1479
+ const parts = url.pathname.slice("/api/projects".length).split("/").filter(Boolean).map((part) => decodeURIComponent(part));
1480
+ const projectId = parts[0];
1481
+ const subresource = parts[1];
1482
+ const asked = request.method === "GET" ? { text: "{}", tooLarge: false } : await readRequestText(request, MAX_FILE_BYTES * 2);
1483
+ if (asked.tooLarge) {
1484
+ json(413, { error: "请求体过大", code: "too_large" });
1485
+ return;
1486
+ }
1487
+ try {
1488
+ const body = JSON.parse(asked.text);
1489
+ const callerConversationId = typeof body.conversationId === "string" ? body.conversationId.trim() : "";
1490
+ if (subresource === undefined && projectId === "tool") {
1491
+ const verdict = runService.verifyPrimaryCapability(callerConversationId, body.capability);
1492
+ if (!verdict.ok) {
1493
+ json(403, { error: verdict.message });
1494
+ return;
1495
+ }
1496
+ const caller = store.conversation(callerConversationId);
1497
+ if (!caller || !body.workspaceId || caller.workspaceId !== body.workspaceId) {
1498
+ json(403, { error: "workspaceId 与调用会话不一致或缺失" });
1499
+ return;
1500
+ }
1501
+ const result = await service.tool(body, caller.workspaceId, callerConversationId);
1502
+ broadcaster.broadcastState();
1503
+ json(200, { result });
1504
+ return;
1505
+ }
1506
+ if (!isAuthorized(request, accessToken)) {
1507
+ json(401, { error: "需要管理访问凭据" });
1508
+ return;
1509
+ }
1510
+ if (callerConversationId) {
1511
+ const verdict = runService.verifyPrimaryCapability(callerConversationId, body.capability);
1512
+ if (!verdict.ok) {
1513
+ json(403, { error: verdict.message });
1514
+ return;
1515
+ }
1516
+ }
1517
+ const callerWorkspaceId = callerConversationId ? store.conversation(callerConversationId)?.workspaceId : undefined;
1518
+ const explicitWorkspaceId = url.searchParams.get("workspaceId")?.trim() || (typeof body.workspaceId === "string" ? body.workspaceId.trim() : undefined);
1519
+ if (callerWorkspaceId && explicitWorkspaceId && callerWorkspaceId !== explicitWorkspaceId) {
1520
+ json(403, { error: "workspaceId 与调用会话不一致" });
1521
+ return;
1522
+ }
1523
+ if (callerConversationId || body.capability || request.headers["x-super-pi-capability"]) {
1524
+ json(403, { error: "Agent 必须使用 /api/projects/tool,管理操作仅由 Boss 发起" });
1525
+ return;
1526
+ }
1527
+ const requestedWorkspaceId = callerWorkspaceId ?? explicitWorkspaceId;
1528
+ if (request.method === "GET") {
1529
+ if (!requestedWorkspaceId) {
1530
+ json(400, { error: "workspaceId 不能为空" });
1531
+ return;
1532
+ }
1533
+ if (!projectId) {
1534
+ json(200, { projects: service.list(requestedWorkspaceId) });
1535
+ return;
1536
+ }
1537
+ const project = service.get(projectId, requestedWorkspaceId);
1538
+ if (!subresource) {
1539
+ json(200, { project });
1540
+ return;
1541
+ }
1542
+ if (subresource === "tasks") {
1543
+ json(200, { tasks: project.tasks });
1544
+ return;
1545
+ }
1546
+ if (subresource === "reports") {
1547
+ json(200, { reports: project.reports });
1548
+ return;
1549
+ }
1550
+ if (subresource === "escalations") {
1551
+ json(200, { escalations: project.escalations });
1552
+ return;
1553
+ }
1554
+ json(404, { error: "未知的项目资源" });
1555
+ return;
1556
+ }
1557
+ if (request.method !== "POST" && request.method !== "PATCH") {
1558
+ json(405, { error: "项目接口只支持 GET、POST、PATCH" });
1559
+ return;
1560
+ }
1561
+ if (!projectId && request.method === "POST") {
1562
+ const project = await service.create({ ...body, workspaceId: requestedWorkspaceId });
1563
+ broadcaster.broadcastState();
1564
+ json(201, { project });
1565
+ return;
1566
+ }
1567
+ if (!projectId) {
1568
+ json(400, { error: "缺少项目 id" });
1569
+ return;
1570
+ }
1571
+ const workspaceId = requestedWorkspaceId;
1572
+ if (!workspaceId) {
1573
+ json(400, { error: "workspaceId 不能为空" });
1574
+ return;
1575
+ }
1576
+ if (request.method === "POST" && subresource === "start") {
1577
+ const project = await service.start(projectId, workspaceId);
1578
+ broadcaster.broadcastState();
1579
+ json(200, { project });
1580
+ return;
1581
+ }
1582
+ if (request.method === "PATCH" && subresource === "budget") {
1583
+ const project = await service.updateBudget(projectId, body, workspaceId);
1584
+ broadcaster.broadcastState();
1585
+ json(200, { project });
1586
+ return;
1587
+ }
1588
+ if (request.method === "POST" && subresource === "tasks" && parts[2] && parts[3] === "dispatch") {
1589
+ const task = await service.dispatchTask(projectId, parts[2], workspaceId);
1590
+ broadcaster.broadcastState();
1591
+ json(200, { task });
1592
+ return;
1593
+ }
1594
+ if (request.method === "POST" && subresource === "tasks" && parts[2] && parts[3] === "review") {
1595
+ const task = await service.requestReview(projectId, parts[2], workspaceId);
1596
+ broadcaster.broadcastState();
1597
+ json(200, { task });
1598
+ return;
1599
+ }
1600
+ if (request.method === "PATCH" && !subresource) {
1601
+ const project = await service.updateStatus(projectId, body.status, workspaceId);
1602
+ broadcaster.broadcastState();
1603
+ json(200, { project });
1604
+ return;
1605
+ }
1606
+ if (request.method === "POST" && subresource === "tasks") {
1607
+ const task = await service.addTask(projectId, body, workspaceId);
1608
+ broadcaster.broadcastState();
1609
+ json(201, { task });
1610
+ return;
1611
+ }
1612
+ if (request.method === "PATCH" && subresource === "tasks" && parts[2]) {
1613
+ const task = await service.updateTask(projectId, parts[2], { ...body, workspaceId });
1614
+ broadcaster.broadcastState();
1615
+ json(200, { task });
1616
+ return;
1617
+ }
1618
+ if (request.method === "POST" && subresource === "reports") {
1619
+ const report = await service.addReport(projectId, body, workspaceId);
1620
+ broadcaster.broadcastState();
1621
+ json(201, { report });
1622
+ return;
1623
+ }
1624
+ if (request.method === "POST" && subresource === "escalations") {
1625
+ const escalation = await service.addEscalation(projectId, body, workspaceId);
1626
+ broadcaster.broadcastState();
1627
+ json(201, { escalation });
1628
+ return;
1629
+ }
1630
+ if (request.method === "PATCH" && subresource === "escalations" && parts[2]) {
1631
+ const escalation = await service.resolveEscalation(projectId, parts[2], body.decision, body.status ?? "resolved", workspaceId);
1632
+ broadcaster.broadcastState();
1633
+ json(200, { escalation });
1634
+ return;
1635
+ }
1636
+ json(404, { error: "未知的项目操作" });
1637
+ }
1638
+ catch (error) {
1639
+ json(400, { error: error instanceof Error ? error.message : "project request failed" });
1640
+ }
1641
+ return;
1642
+ }
1460
1643
  if (url.pathname === "/api/orchestrator") {
1461
1644
  if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
1462
1645
  response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Orchestrator requires a loopback or private network POST");
@@ -1484,6 +1667,10 @@ export function createHttpServer(options) {
1484
1667
  return;
1485
1668
  }
1486
1669
  const action = (body.action || "").trim();
1670
+ if ((action === "spawn" || action === "dispatch") && context.projectService?.projectForSession?.(callerConversationId)) {
1671
+ json(403, { error: "项目分工必须使用 project 派发,以遵守资源准入限制" });
1672
+ return;
1673
+ }
1487
1674
  if (action === "spawn") {
1488
1675
  // Capability auth above proves who is calling. The target workspace is a separate, explicit
1489
1676
  // choice: a parentConversationId records a tree edge and never selects that target.
@@ -26,6 +26,7 @@ import { ConversationService } from "./application/conversation-service.js";
26
26
  import { ScheduledTaskService } from "./application/scheduled-task-service.js";
27
27
  import { SessionLedger } from "./session-ledger.js";
28
28
  import { SessionOrchestratorService } from "./application/session-orchestrator-service.js";
29
+ import { ProjectService } from "./application/project-service.js";
29
30
  import { WorkspaceService } from "./application/workspace-service.js";
30
31
  import { CommandHandler } from "./command-handler.js";
31
32
  import { TerminalService, terminalDisabledReason } from "./terminal.js";
@@ -291,6 +292,7 @@ runService.setExecService(execService);
291
292
  const scheduledTaskService = new ScheduledTaskService(store, runService, conversationService, broadcaster);
292
293
  const sessionLedger = new SessionLedger();
293
294
  const sessionOrchestratorService = new SessionOrchestratorService(store, conversationService, runService, sessionLedger, broadcaster);
295
+ const projectService = new ProjectService(store, sessionOrchestratorService);
294
296
  void sessionOrchestratorService.restoreWatchdogs().catch((error) => {
295
297
  console.warn("Super Pi: restoring watchdogs failed:", error instanceof Error ? error.message : String(error));
296
298
  });
@@ -315,6 +317,7 @@ const serverContext = {
315
317
  scheduledTaskService,
316
318
  sessionLedger,
317
319
  sessionOrchestratorService,
320
+ projectService,
318
321
  execService,
319
322
  toolRegistry,
320
323
  toolRunner,
@@ -10,7 +10,7 @@ export type StateTransaction = {
10
10
  run: SubagentRun;
11
11
  } | {
12
12
  kind: "collections.replace";
13
- values: Partial<Pick<AppState, "workflows" | "backgroundTasks" | "scheduledTasks">>;
13
+ values: Partial<Pick<AppState, "workflows" | "backgroundTasks" | "scheduledTasks" | "projects">>;
14
14
  }
15
15
  /**
16
16
  * Removes one collection row entirely.
@@ -22,7 +22,7 @@ export type StateTransaction = {
22
22
  */
23
23
  | {
24
24
  kind: "collections.remove";
25
- name: "workflows" | "backgroundTasks" | "scheduledTasks";
25
+ name: "workflows" | "backgroundTasks" | "scheduledTasks" | "projects";
26
26
  } | {
27
27
  kind: "conversation.metadata";
28
28
  conversationId: string;
@@ -24,7 +24,7 @@ import { LengthExceededError } from "./turn-failure.js";
24
24
  // paths recognise a delivery identically. Re-exported because they were part of this module's surface.
25
25
  export { describeDeliveredUserMessage, describePiQueue } from "./pi-session-events.js";
26
26
  import { createPiSession, createSdkCatalog, piSdkAvailable, resolveTranscriptFile, superPiExtensionPaths } from "./pi-session.js";
27
- import { TASK_CONVERSATION_ENV, PARENT_CONVERSATION_ENV, TASK_DEPTH_ENV, TASK_ENDPOINT_ENV, TASK_EXEC_ENDPOINT_ENV, TASK_ROSTER_ENV, TASK_TOOL_NAME, TODO_ENDPOINT_ENV, TODO_TOOL_NAME, SCHEDULED_TASK_ENDPOINT_ENV, SCHEDULED_TASK_TOOL_NAME, WORKFLOW_ENDPOINT_ENV, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, ORCHESTRATOR_ENDPOINT_ENV, ORCHESTRATOR_WORKSPACE_ENV, SESSION_SPAWN_TOOL_NAME, SESSION_STATUS_TOOL_NAME, SESSION_DISPATCH_TOOL_NAME, SESSION_STOP_TOOL_NAME, SESSION_REPORT_TOOL_NAME, SESSION_STOP_SUBTREE_TOOL_NAME, rosterPayload, } from "./task-tool.js";
27
+ import { TASK_CONVERSATION_ENV, PARENT_CONVERSATION_ENV, TASK_DEPTH_ENV, TASK_ENDPOINT_ENV, TASK_EXEC_ENDPOINT_ENV, TASK_ROSTER_ENV, TASK_TOOL_NAME, TODO_ENDPOINT_ENV, TODO_TOOL_NAME, PROJECT_TOOL_NAME, PROJECT_ENDPOINT_ENV, SCHEDULED_TASK_ENDPOINT_ENV, SCHEDULED_TASK_TOOL_NAME, WORKFLOW_ENDPOINT_ENV, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, ORCHESTRATOR_ENDPOINT_ENV, ORCHESTRATOR_WORKSPACE_ENV, SESSION_SPAWN_TOOL_NAME, SESSION_STATUS_TOOL_NAME, SESSION_DISPATCH_TOOL_NAME, SESSION_STOP_TOOL_NAME, SESSION_REPORT_TOOL_NAME, SESSION_STOP_SUBTREE_TOOL_NAME, rosterPayload, } from "./task-tool.js";
28
28
  import { BLOCKING_UI_METHODS, EXTENSION_UI_METHODS, } from "./types.js";
29
29
  import { loadConversationImages } from "./upload.js";
30
30
  import { describeToolCall, describeToolCallArgs, describeToolCallInProgress, extractPartialResultText, extractToolResultText, readQuestionCall } from "./tool-calls.js";
@@ -711,6 +711,7 @@ export class PiRpcRuntime {
711
711
  [PARENT_CONVERSATION_ENV]: parentConversationId ?? "",
712
712
  [TASK_DEPTH_ENV]: "0",
713
713
  [TODO_ENDPOINT_ENV]: this.options.todoEndpoint ?? "",
714
+ [PROJECT_ENDPOINT_ENV]: this.options.orchestratorEndpoint?.replace(/\/api\/orchestrator$/, "/api/projects/tool") ?? "",
714
715
  [SCHEDULED_TASK_ENDPOINT_ENV]: this.options.scheduledTaskEndpoint ?? "",
715
716
  [WORKFLOW_ENDPOINT_ENV]: this.options.workflowEndpoint ?? "",
716
717
  [ORCHESTRATOR_ENDPOINT_ENV]: this.options.orchestratorEndpoint ?? "",
@@ -1259,6 +1260,8 @@ export class PiRpcRuntime {
1259
1260
  // loaded-but-undeclared tool - broken with no error to explain it.
1260
1261
  if (process.env.SUPER_PI_FEISHU_ENDPOINT)
1261
1262
  names.push(FEISHU_READ_TOOL_NAME, FEISHU_WRITE_TOOL_NAME);
1263
+ if (this.options.orchestratorEndpoint)
1264
+ names.push(PROJECT_TOOL_NAME);
1262
1265
  if (this.options.orchestratorEndpoint)
1263
1266
  names.push(SESSION_SPAWN_TOOL_NAME, SESSION_STATUS_TOOL_NAME, SESSION_DISPATCH_TOOL_NAME, SESSION_STOP_TOOL_NAME, SESSION_REPORT_TOOL_NAME, SESSION_STOP_SUBTREE_TOOL_NAME);
1264
1267
  return names;
@@ -1479,7 +1482,7 @@ export class PiRpcRuntime {
1479
1482
  // allowlist and naming a tool Pi has not registered would change the spawn line for every other
1480
1483
  // mode. The prompt asks for a bare JSON array and nothing else; if the model spends a turn on a
1481
1484
  // tool call anyway the reranker times out and falls back to the lexical order.
1482
- const agentArgs = memoryReview ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates", "--tools", "memory,skill_manage,session_search"] : knowledgeRerank ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates"] : await agentSpawnArgs(this.agents.promptDir, agent, { extraAllowedTools: [...(extensionArgs.length ? [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, SCHEDULED_TASK_TOOL_NAME, "question", "tool_register", ...(this.options.orchestratorEndpoint ? [SESSION_SPAWN_TOOL_NAME, SESSION_STATUS_TOOL_NAME, SESSION_DISPATCH_TOOL_NAME, SESSION_STOP_TOOL_NAME, SESSION_REPORT_TOOL_NAME, SESSION_STOP_SUBTREE_TOOL_NAME] : [])] : []), ...(memoryEnabled ? ["memory", "skill_manage", "session_search"] : []), ...(knowledgeEnabled ? [KNOWLEDGE_TOOL_NAME, KNOWLEDGE_IMPORT_TOOL_NAME] : []), ...(documentsEnabled ? [DOCUMENT_READ_TOOL_NAME, DOCUMENT_WRITE_TOOL_NAME] : []), ...(webEnabled ? [WEB_SEARCH_TOOL_NAME, WEB_FETCH_TOOL_NAME] : []), ...(feishuEnabled ? [FEISHU_READ_TOOL_NAME, FEISHU_WRITE_TOOL_NAME] : [])] });
1485
+ const agentArgs = memoryReview ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates", "--tools", "memory,skill_manage,session_search"] : knowledgeRerank ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates"] : await agentSpawnArgs(this.agents.promptDir, agent, { extraAllowedTools: [...(extensionArgs.length ? [TASK_TOOL_NAME, TODO_TOOL_NAME, PROJECT_TOOL_NAME, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, SCHEDULED_TASK_TOOL_NAME, "question", "tool_register", ...(this.options.orchestratorEndpoint ? [SESSION_SPAWN_TOOL_NAME, SESSION_STATUS_TOOL_NAME, SESSION_DISPATCH_TOOL_NAME, SESSION_STOP_TOOL_NAME, SESSION_REPORT_TOOL_NAME, SESSION_STOP_SUBTREE_TOOL_NAME] : [])] : []), ...(memoryEnabled ? ["memory", "skill_manage", "session_search"] : []), ...(knowledgeEnabled ? [KNOWLEDGE_TOOL_NAME, KNOWLEDGE_IMPORT_TOOL_NAME] : []), ...(documentsEnabled ? [DOCUMENT_READ_TOOL_NAME, DOCUMENT_WRITE_TOOL_NAME] : []), ...(webEnabled ? [WEB_SEARCH_TOOL_NAME, WEB_FETCH_TOOL_NAME] : []), ...(feishuEnabled ? [FEISHU_READ_TOOL_NAME, FEISHU_WRITE_TOOL_NAME] : [])] });
1483
1486
  // Refused here, with the reason, rather than handed to the spawn: an ENOENT from `spawn` reads as
1484
1487
  // "the app is broken" and hides the one fact that matters, which is where it looked for Pi.
1485
1488
  if (!this.pi.found)
@@ -73,6 +73,7 @@ export interface ServerContext {
73
73
  scheduledTaskService?: import("./application/scheduled-task-service.js").ScheduledTaskService;
74
74
  sessionLedger?: import("./session-ledger.js").SessionLedger;
75
75
  sessionOrchestratorService?: import("./application/session-orchestrator-service.js").SessionOrchestratorService;
76
+ projectService?: import("./application/project-service.js").ProjectService;
76
77
  execService: ExecService;
77
78
  toolRegistry: ToolRegistry;
78
79
  toolRunner: ToolRunner;
@@ -2,7 +2,7 @@ import type { AppStateRepository } from "./persistence/app-state-repository.js";
2
2
  import { defaultJsonStatePath } from "./persistence/json-state-repository.js";
3
3
  import type { ScheduledTask } from "./scheduled-tasks.js";
4
4
  import type { WorkflowRun } from "./workflow.js";
5
- import type { AppState, Conversation, Message, MessageDelivery, MessageImage, ModelSelection, SubagentRun, SubagentRunUpdate, TodoItem, ToolCallQuestion, ToolCallArg, ToolCallBlock, ToolResultSummary, Workspace } from "./types.js";
5
+ import type { AppState, Conversation, Message, MessageDelivery, MessageImage, ModelSelection, Project, SubagentRun, SubagentRunUpdate, TodoItem, ToolCallQuestion, ToolCallArg, ToolCallBlock, ToolResultSummary, Workspace } from "./types.js";
6
6
  import type { TurnUsageReadout } from "./conversation-usage.js";
7
7
  /** Mints the id a forked conversation will have, BEFORE its Pi transcript exists.
8
8
  *
@@ -145,6 +145,10 @@ export declare class Store {
145
145
  * `nextRunAt` and then hands the whole task back to the store, which owns the real one.
146
146
  */
147
147
  scheduledTasks(): ScheduledTask[];
148
+ projects(): Project[];
149
+ project(projectId: string): Project | undefined;
150
+ setProject(project: Project): Promise<Project>;
151
+ removeProject(projectId: string): Promise<void>;
148
152
  /** Small detached rows used when refreshing each conversation's scoped model catalog. */
149
153
  catalogConversationTargets(): Array<Pick<Conversation, "id" | "archivedAt" | "model">>;
150
154
  /** Clone only one workspace's transcripts, never unrelated workspaces or app state. */
@@ -612,7 +612,8 @@ export class Store {
612
612
  // un-normalised shape rather than the one the store is actually running on.
613
613
  const defaulted = ["workspaces", "conversations", "subagentRuns"].filter((key) => !Array.isArray(state[key])).length > 0
614
614
  || !Array.isArray(state.backgroundTasks)
615
- || (state.scheduledTasks !== undefined && !Array.isArray(state.scheduledTasks));
615
+ || (state.scheduledTasks !== undefined && !Array.isArray(state.scheduledTasks))
616
+ || (state.projects !== undefined && !Array.isArray(state.projects));
616
617
  if (!Array.isArray(state.backgroundTasks))
617
618
  state.backgroundTasks = [];
618
619
  // A scheduled-task list that is not a list is not a list of tasks. The key is dropped rather than
@@ -620,6 +621,8 @@ export class Store {
620
621
  // fresh install reads - instead of crashing the panel on every poll.
621
622
  if (state.scheduledTasks !== undefined && !Array.isArray(state.scheduledTasks))
622
623
  delete state.scheduledTasks;
624
+ if (state.projects !== undefined && !Array.isArray(state.projects))
625
+ delete state.projects;
623
626
  // Every sweep runs before the store is constructed, so a single persist below publishes them together.
624
627
  // Reconciling them in separate writes would put a file on disk in which one kind of orphan was fixed
625
628
  // and the other was not - a half-repaired state that still needs a second restart to finish.
@@ -767,6 +770,28 @@ export class Store {
767
770
  scheduledTasks() {
768
771
  return this.state.scheduledTasks ? [...this.state.scheduledTasks] : [];
769
772
  }
773
+ projects() { return structuredClone(this.state.projects ?? []); }
774
+ project(projectId) { const project = this.state.projects?.find((item) => item.id === projectId); return project ? structuredClone(project) : undefined; }
775
+ async setProject(project) {
776
+ if (!this.state.projects)
777
+ this.state.projects = [];
778
+ const stored = structuredClone(project);
779
+ const index = this.state.projects.findIndex((item) => item.id === stored.id);
780
+ if (index < 0)
781
+ this.state.projects.unshift(stored);
782
+ else
783
+ this.state.projects[index] = stored;
784
+ await this.persistCollections("projects");
785
+ return structuredClone(stored);
786
+ }
787
+ async removeProject(projectId) {
788
+ if (!this.state.projects)
789
+ return;
790
+ this.state.projects = this.state.projects.filter((item) => item.id !== projectId);
791
+ if (!this.state.projects.length)
792
+ delete this.state.projects;
793
+ await this.persistCollections("projects");
794
+ }
770
795
  /** Small detached rows used when refreshing each conversation's scoped model catalog. */
771
796
  catalogConversationTargets() {
772
797
  return this.state.conversations.map(({ id, archivedAt, model }) => ({
@@ -1030,13 +1055,14 @@ export class Store {
1030
1055
  * sidebar renders `state.workspaces` and never has to know that a closed workspace exists.
1031
1056
  */
1032
1057
  clientState(scope) {
1058
+ const { projects: _projects, ...publicState } = this.state;
1033
1059
  const closed = new Set(this.state.workspaces.filter((item) => item.closedAt).map((item) => item.id));
1034
1060
  const workspaces = this.state.workspaces.filter((item) => !closed.has(item.id));
1035
1061
  const visible = this.state.conversations.filter((item) => !closed.has(item.workspaceId));
1036
1062
  // Legacy sockets still receive complete visible transcripts. Project before cloning so a closed
1037
1063
  // workspace's retained history is never copied just to discard it afterwards.
1038
1064
  if (scope === undefined)
1039
- return structuredClone({ ...this.state, workspaces, conversations: visible, serverTimeZone: serverTimeZone() });
1065
+ return structuredClone({ ...publicState, workspaces, conversations: visible, serverTimeZone: serverTimeZone() });
1040
1066
  // A focused socket keeps every sidebar row, but owns only the selected transcript. An empty scope
1041
1067
  // deliberately loads none; a missing/closed selection must not expose retained workspace history.
1042
1068
  const selectedId = visible.find((item) => item.id === scope.conversationId)?.id;
@@ -1047,7 +1073,7 @@ export class Store {
1047
1073
  hasPendingQuestion: messages.some((message) => message.toolCalls?.some((call) => call.question?.status === "pending")),
1048
1074
  }));
1049
1075
  return structuredClone({
1050
- ...this.state,
1076
+ ...publicState,
1051
1077
  workspaces,
1052
1078
  conversations,
1053
1079
  // THE CLOCK THE SERVER SCHEDULES BY, NAMED. The scheduled-task panel renders 「每天 09:00」 and
@@ -162,6 +162,8 @@ export declare function parentDeniedTools(parent: AgentDefinition | undefined):
162
162
  * `todowrite` and not `todo`, and there is nothing to gain from renaming a tool whose description has
163
163
  * already been written to explain that name.
164
164
  */
165
+ export declare const PROJECT_TOOL_NAME = "project";
166
+ export declare const PROJECT_ENDPOINT_ENV = "SUPER_PI_PROJECT_ENDPOINT";
165
167
  export declare const TODO_TOOL_NAME = "todowrite";
166
168
  /**
167
169
  * The endpoint the `todowrite` extension dials. A FIFTH name in the same wire as the four above, written
@@ -219,6 +219,8 @@ export function parentDeniedTools(parent) {
219
219
  * `todowrite` and not `todo`, and there is nothing to gain from renaming a tool whose description has
220
220
  * already been written to explain that name.
221
221
  */
222
+ export const PROJECT_TOOL_NAME = "project";
223
+ export const PROJECT_ENDPOINT_ENV = "SUPER_PI_PROJECT_ENDPOINT";
222
224
  export const TODO_TOOL_NAME = "todowrite";
223
225
  /**
224
226
  * The endpoint the `todowrite` extension dials. A FIFTH name in the same wire as the four above, written
@@ -29,8 +29,8 @@
29
29
  * nothing else; this file resolves the id against the store and spawns in that workspace's path.
30
30
  * A browser that asked for `C:\` or `/etc` gets its own workspace's directory instead, because
31
31
  * there is no code path on which a client-supplied path reaches `spawn`.
32
- * 4. BOUNDED SESSIONS. `MAX_SESSIONS` shells, one per client tab, capped so a loop of reconnects
33
- * cannot accumulate processes.
32
+ * 4. BOUNDED SESSIONS. `MAX_SESSIONS` live shells, capped so a loop of reconnects cannot accumulate
33
+ * processes.
34
34
  *
35
35
  * What is NOT here is a per-command allowlist. A terminal that refuses `rm` or `curl` is not a terminal,
36
36
  * and pretending otherwise would be worse than the honest version: with (1) and (2) the operator has
@@ -89,8 +89,33 @@ export declare function terminalShellCandidates(env?: NodeJS.ProcessEnv): {
89
89
  args: string[];
90
90
  label: string;
91
91
  }[];
92
- /** One shell per browser tab, and a hard ceiling so reconnects cannot accumulate processes. */
93
- export declare const MAX_SESSIONS = 8;
92
+ /**
93
+ * THE CEILING ON LIVE SHELLS.
94
+ *
95
+ * WHAT THIS COUNTS. Only the browser's terminal panel: `terminal.open` is the ONE place a shell is
96
+ * spawned, and it is a command the browser sends over its own WebSocket. An agent session, a
97
+ * sub-agent run, and the `exec` tool do NOT consume this budget - the exec tool spawns its own
98
+ * short-lived `shell -c` process through a different service.
99
+ *
100
+ * WHY THE COUNT ACCUMULATES. The client opens up to five terminals per session (`MAX_SLOTS` in
101
+ * TerminalPanel), and a shell is deliberately KEPT ALIVE across a session switch: the panel unmounts
102
+ * without closing it, so the work in that shell survives the switch. Every session in the tree is a
103
+ * conversation with its own panel - including a CHILD session the user can click into - and each of
104
+ * them can hold five shells, and all of them land in this one map. A tree multiplies: eight parents
105
+ * with four children each are forty sessions, and forty sessions at four terminals is a hundred and
106
+ * sixty shells.
107
+ *
108
+ * WHY 256. Every shell in this map was opened by the user on purpose, and what runs inside it is the
109
+ * user's own business - so this constant is NOT a memory governor and does not pretend to be one. Its
110
+ * whole job is to be generous enough never to stand in the way of real work, and loud enough that a
111
+ * client looping on `terminal.open` is stopped with a message instead of quietly filling the machine.
112
+ * (For the record, the platform's own overhead per shell is the shell: an idle Windows PowerShell is
113
+ * ~90MB and an idle bash ~8MB, so the cost of "many terminals" is the cost of the shells themselves.)
114
+ * Reconnect storms do not need this constant to be low: the socket-close cleanup already kills a
115
+ * connection's shells, and the 30-second heartbeat reaps the half-open connections that would
116
+ * otherwise leave them behind.
117
+ */
118
+ export declare const MAX_SESSIONS = 256;
94
119
  /**
95
120
  * The terminal's wire protocol, as members of the EXISTING `ClientCommand` / `ServerEvent` unions rather
96
121
  * than a second transport.
@@ -29,8 +29,8 @@
29
29
  * nothing else; this file resolves the id against the store and spawns in that workspace's path.
30
30
  * A browser that asked for `C:\` or `/etc` gets its own workspace's directory instead, because
31
31
  * there is no code path on which a client-supplied path reaches `spawn`.
32
- * 4. BOUNDED SESSIONS. `MAX_SESSIONS` shells, one per client tab, capped so a loop of reconnects
33
- * cannot accumulate processes.
32
+ * 4. BOUNDED SESSIONS. `MAX_SESSIONS` live shells, capped so a loop of reconnects cannot accumulate
33
+ * processes.
34
34
  *
35
35
  * What is NOT here is a per-command allowlist. A terminal that refuses `rm` or `curl` is not a terminal,
36
36
  * and pretending otherwise would be worse than the honest version: with (1) and (2) the operator has
@@ -190,8 +190,33 @@ export function terminalShellCandidates(env = process.env) {
190
190
  // ---------------------------------------------------------------------------------------------
191
191
  // Bounds
192
192
  // ---------------------------------------------------------------------------------------------
193
- /** One shell per browser tab, and a hard ceiling so reconnects cannot accumulate processes. */
194
- export const MAX_SESSIONS = 8;
193
+ /**
194
+ * THE CEILING ON LIVE SHELLS.
195
+ *
196
+ * WHAT THIS COUNTS. Only the browser's terminal panel: `terminal.open` is the ONE place a shell is
197
+ * spawned, and it is a command the browser sends over its own WebSocket. An agent session, a
198
+ * sub-agent run, and the `exec` tool do NOT consume this budget - the exec tool spawns its own
199
+ * short-lived `shell -c` process through a different service.
200
+ *
201
+ * WHY THE COUNT ACCUMULATES. The client opens up to five terminals per session (`MAX_SLOTS` in
202
+ * TerminalPanel), and a shell is deliberately KEPT ALIVE across a session switch: the panel unmounts
203
+ * without closing it, so the work in that shell survives the switch. Every session in the tree is a
204
+ * conversation with its own panel - including a CHILD session the user can click into - and each of
205
+ * them can hold five shells, and all of them land in this one map. A tree multiplies: eight parents
206
+ * with four children each are forty sessions, and forty sessions at four terminals is a hundred and
207
+ * sixty shells.
208
+ *
209
+ * WHY 256. Every shell in this map was opened by the user on purpose, and what runs inside it is the
210
+ * user's own business - so this constant is NOT a memory governor and does not pretend to be one. Its
211
+ * whole job is to be generous enough never to stand in the way of real work, and loud enough that a
212
+ * client looping on `terminal.open` is stopped with a message instead of quietly filling the machine.
213
+ * (For the record, the platform's own overhead per shell is the shell: an idle Windows PowerShell is
214
+ * ~90MB and an idle bash ~8MB, so the cost of "many terminals" is the cost of the shells themselves.)
215
+ * Reconnect storms do not need this constant to be low: the socket-close cleanup already kills a
216
+ * connection's shells, and the 30-second heartbeat reaps the half-open connections that would
217
+ * otherwise leave them behind.
218
+ */
219
+ export const MAX_SESSIONS = 256;
195
220
  /** Below the client panel's own minimum, and small enough that a `ls` cannot stall the socket. */
196
221
  const MIN_COLS = 20;
197
222
  const MIN_ROWS = 5;
@@ -296,7 +321,15 @@ export class TerminalService {
296
321
  if (this.sessions.has(command.sessionId))
297
322
  return;
298
323
  if (this.sessions.size >= MAX_SESSIONS) {
299
- send({ type: "terminal.error", sessionId: command.sessionId, message: `终端会话已达上限(${MAX_SESSIONS} 个),请先关闭一个。` });
324
+ // The count is in the message because the shells eating the budget are usually the BACKGROUND
325
+ // ones of other sessions - a parent session's children included, each of which has its own
326
+ // panel with its own terminals. The user sees three terminals in this panel and a refusal,
327
+ // and has no way to guess that the sessions they left open still hold shells.
328
+ send({
329
+ type: "terminal.error",
330
+ sessionId: command.sessionId,
331
+ message: `终端会话已达上限(服务器最多 ${MAX_SESSIONS} 个,当前已有 ${this.sessions.size} 个在运行)。切换会话后仍在后台运行的终端也计入其中(包括 session 树里的子会话),请到对应的会话里关闭一个再试。`,
332
+ });
300
333
  return;
301
334
  }
302
335
  // The workspace id is the ONLY thing the browser supplies, and it is resolved here. A client that