@evandu/super-pi 0.1.144 → 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-DfQHX1Hy.js → CodeMirrorEditor-CEwBCVM6.js} +2 -2
  6. package/dist/client/assets/{dist-Cu3B0JQX.js → dist-76ArZ5M4.js} +1 -1
  7. package/dist/client/assets/{dist-BEbEv2KM.js → dist-9F1wL-T5.js} +1 -1
  8. package/dist/client/assets/{dist-BRjyQs_L.js → dist-B0RXZeXQ.js} +1 -1
  9. package/dist/client/assets/{dist-SzZEAk24.js → dist-BCNaDgNW.js} +1 -1
  10. package/dist/client/assets/{dist-qAUj_2Ue.js → dist-BXBSIuTD.js} +1 -1
  11. package/dist/client/assets/{dist-BGBinEie.js → dist-Bh8Q7Y_n.js} +1 -1
  12. package/dist/client/assets/{dist-DKkMk3XX.js → dist-BoyICDlL.js} +1 -1
  13. package/dist/client/assets/{dist-ISoxXlmW.js → dist-BuR5Iagd.js} +1 -1
  14. package/dist/client/assets/{dist-LRNzDUVb.js → dist-Bx6ZWYUW.js} +1 -1
  15. package/dist/client/assets/{dist-DOvzruAQ.js → dist-CAOcjKAQ.js} +1 -1
  16. package/dist/client/assets/{dist-CDtFD-PW.js → dist-CHt8PX8R.js} +1 -1
  17. package/dist/client/assets/{dist-BuPimjY-.js → dist-CSCUT1PQ.js} +1 -1
  18. package/dist/client/assets/{dist-BsMikSTM.js → dist-CUB_Chdh.js} +1 -1
  19. package/dist/client/assets/{dist-KQJCVwxy.js → dist-CbVf2Cjn.js} +1 -1
  20. package/dist/client/assets/{dist-Dnru8_uX.js → dist-DBUqwiWG.js} +1 -1
  21. package/dist/client/assets/{dist-B8oH6dsc.js → dist-DC1IocrS.js} +1 -1
  22. package/dist/client/assets/{dist-D7aAMRTb.js → dist-DFMtP3wG.js} +1 -1
  23. package/dist/client/assets/{dist-Cw_KCDH9.js → dist-DN0GlnnZ.js} +1 -1
  24. package/dist/client/assets/{dist-BCLhQXYY.js → dist-DftD4ibj.js} +1 -1
  25. package/dist/client/assets/{dist-CgQ7zaWY.js → dist-Do52MRwm.js} +1 -1
  26. package/dist/client/assets/{dist-BS-FBUKW.js → dist-JvQIIdqB.js} +1 -1
  27. package/dist/client/assets/{dist-C5BQsmB7.js → dist-oLq6HXLG.js} +1 -1
  28. package/dist/client/assets/{index-Xaui0uUI.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-BLWr70ej.js +0 -100
@@ -27,6 +27,7 @@ import { existsSync, statSync } from "node:fs";
27
27
  import { open, readdir } from "node:fs/promises";
28
28
  import { StringDecoder } from "node:string_decoder";
29
29
  import { dirname, join, resolve } from "node:path";
30
+ import { effectiveContextWindow, readDeclaredWindows } from "./model-context-window.js";
30
31
  import { fileURLToPath, pathToFileURL } from "node:url";
31
32
  const traceSession = (conversationId, phase, startedAt, extra = "") => {
32
33
  if (process.env.SUPER_PI_TRACE_LATENCY !== "1")
@@ -326,11 +327,24 @@ export async function createPiSession(creation) {
326
327
  // It is attached to the session rather than kept beside it because the session is what outlives a
327
328
  // turn: a model change applies to a LIVE session, and the live session is the thing that has to be
328
329
  // able to answer "give me this model whole".
330
+ // THE WINDOW THE MODEL REALLY HAS. Pi's remote catalogue can overwrite the `contextWindow` the
331
+ // PERSON configured in `models.json` (Pi's own merge lets the catalogue win), and a provider
332
+ // that reports a million tokens makes Pi's auto-compaction wait for 983,616 tokens - while the
333
+ // model refuses at its real 128,000. The overflow recovery then asks the same too-long
334
+ // conversation of the same too-small model, and the session stops working entirely.
335
+ //
336
+ // So the person's file wins: a number somebody typed is a decision, and a number a catalogue
337
+ // fetched is a claim. See `model-context-window.ts` for the whole reasoning.
338
+ const declared = readDeclaredWindows(agentDir);
329
339
  live.resolveModel = (provider, modelId) => {
330
340
  const found = modelRuntime.getModel?.(provider, modelId);
331
341
  if (!found || typeof found !== "object")
332
342
  return undefined;
333
- return found;
343
+ const model = found;
344
+ const window = effectiveContextWindow(declared, provider, modelId, model.contextWindow);
345
+ // The model is handed to Pi WHOLE - it is what the agent runs on - so this is where the
346
+ // corrected window has to be. A model Super Pi has no opinion about is handed over untouched.
347
+ return window === undefined ? model : { ...model, contextWindow: window };
334
348
  };
335
349
  return live;
336
350
  })();
@@ -2,6 +2,7 @@ import { type RpcRecord } from "./rpc-channel.js";
2
2
  export { RpcChannel, type RpcRecord } from "./rpc-channel.js";
3
3
  import type { ActiveMemory } from "./active-memory.js";
4
4
  import { type KnowledgeBase } from "./knowledge.js";
5
+ import { type TurnUsageReadout } from "./conversation-usage.js";
5
6
  export { forkSessionFile, truncateSessionFile } from "./session-repository.js";
6
7
  export { describeDeliveredUserMessage, describePiQueue } from "./pi-session-events.js";
7
8
  import { type PiSessionFactory } from "./pi-session.js";
@@ -171,6 +172,8 @@ export interface AnswerInput {
171
172
  onThinking?(delta: string): void | Promise<void>;
172
173
  /** Called when the reasoning phase ends - the only signal that it has. */
173
174
  onThinkingEnd?(): void;
175
+ /** One turn's completed-message usage and final context snapshot, also reported on abort/failure. */
176
+ onUsage?(readout: TurnUsageReadout): void | Promise<void>;
174
177
  }
175
178
  /**
176
179
  * What Pi answered when a `steer` was written to it, from the `disposition` of that command's response
@@ -7,6 +7,8 @@ import { DOCUMENT_READ_TOOL_NAME, DOCUMENT_WRITE_TOOL_NAME } from "./documents.j
7
7
  import { FEISHU_READ_TOOL_NAME, FEISHU_WRITE_TOOL_NAME } from "./feishu-cli.js";
8
8
  import { WEB_FETCH_TOOL_NAME, WEB_SEARCH_TOOL_NAME } from "./web.js";
9
9
  import { existsSync } from "node:fs";
10
+ import { ConversationUsageCollector } from "./conversation-usage.js";
11
+ import { defaultAgentDir } from "./pi-session.js";
10
12
  import { mkdir, stat } from "node:fs/promises";
11
13
  import { forkSessionFile, truncateSessionFile } from "./session-repository.js";
12
14
  export { forkSessionFile, truncateSessionFile } from "./session-repository.js";
@@ -21,7 +23,7 @@ import { describeDeliveredUserMessage, describePiQueue, handleSessionEvent, Turn
21
23
  // paths recognise a delivery identically. Re-exported because they were part of this module's surface.
22
24
  export { describeDeliveredUserMessage, describePiQueue } from "./pi-session-events.js";
23
25
  import { createPiSession, createSdkCatalog, piSdkAvailable, resolveTranscriptFile, superPiExtensionPaths } from "./pi-session.js";
24
- 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, SESSION_SPAWN_TOOL_NAME, SESSION_STATUS_TOOL_NAME, SESSION_DISPATCH_TOOL_NAME, SESSION_STOP_TOOL_NAME, rosterPayload, } from "./task-tool.js";
26
+ 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, rosterPayload, } from "./task-tool.js";
25
27
  import { BLOCKING_UI_METHODS, EXTENSION_UI_METHODS, } from "./types.js";
26
28
  import { loadConversationImages } from "./upload.js";
27
29
  import { describeToolCall, describeToolCallArgs, describeToolCallInProgress, extractPartialResultText, extractToolResultText, readQuestionCall } from "./tool-calls.js";
@@ -696,7 +698,7 @@ export class PiRpcRuntime {
696
698
  * its environment once, when it is loaded) and a token minted per turn would be replaced before the
697
699
  * second turn of the conversation, leaving the extension holding a value the server had forgotten.
698
700
  */
699
- async taskExtensionEnv(conversationId, parentConversationId) {
701
+ async taskExtensionEnv(conversationId, workspaceId, parentConversationId) {
700
702
  const { agents, problems } = await readAgents(this.agents.dir, { mode: "subagent" });
701
703
  for (const problem of problems)
702
704
  console.warn(`PiRpcRuntime: ${problem.file} is not a usable sub-agent - ${problem.message}`);
@@ -711,6 +713,7 @@ export class PiRpcRuntime {
711
713
  [SCHEDULED_TASK_ENDPOINT_ENV]: this.options.scheduledTaskEndpoint ?? "",
712
714
  [WORKFLOW_ENDPOINT_ENV]: this.options.workflowEndpoint ?? "",
713
715
  [ORCHESTRATOR_ENDPOINT_ENV]: this.options.orchestratorEndpoint ?? "",
716
+ [ORCHESTRATOR_WORKSPACE_ENV]: workspaceId,
714
717
  SUPER_PI_ORCHESTRATOR_CONVERSATION: conversationId,
715
718
  SUPER_PI_QUESTION_ENDPOINT: this.options.questionEndpoint ?? "",
716
719
  SUPER_PI_TOOLS_ENDPOINT: this.options.toolsEndpoint ?? "",
@@ -738,9 +741,9 @@ export class PiRpcRuntime {
738
741
  * here. A session lives for many turns, so its credential is minted when the session is created (see
739
742
  * `PiSessionPool.acquire`) and the server separately requires a running turn for it to be usable.
740
743
  */
741
- async oneShotExtensionEnv(conversationId, capability, parentConversationId) {
744
+ async oneShotExtensionEnv(conversationId, workspaceId, capability, parentConversationId) {
742
745
  return {
743
- ...(await this.taskExtensionEnv(conversationId, parentConversationId)),
746
+ ...(await this.taskExtensionEnv(conversationId, workspaceId, parentConversationId)),
744
747
  [TASK_CAPABILITY_ENV]: capability,
745
748
  SUPER_PI_TOOLS_CAPABILITY: capability,
746
749
  };
@@ -801,7 +804,7 @@ export class PiRpcRuntime {
801
804
  ...(systemPromptFile ? { systemPromptFile } : {}),
802
805
  ...(agent?.promptMode ? { systemPromptMode: agent.promptMode } : {}),
803
806
  extensionPaths: superPiExtensionPaths(repositoryRootFrom()),
804
- extensionEnv: await this.taskExtensionEnv(conversation.id, conversation.parentConversationId),
807
+ extensionEnv: await this.taskExtensionEnv(conversation.id, conversation.workspaceId, conversation.parentConversationId),
805
808
  ...(conversation.model ? { model: { provider: conversation.model.provider, modelId: conversation.model.modelId } } : {}),
806
809
  ...(conversation.thinkingLevel ? { thinkingLevel: conversation.thinkingLevel } : {}),
807
810
  };
@@ -902,8 +905,12 @@ export class PiRpcRuntime {
902
905
  // turn failed. The bridge clears it when a new assistant message starts, and raises it when Pi
903
906
  // settles.
904
907
  const failure = { current: undefined };
908
+ const usage = new ConversationUsageCollector();
905
909
  const unsubscribe = session.subscribe((event) => {
906
910
  try {
911
+ const record = event;
912
+ if (record?.type === "message_end" && record.message?.role === "assistant")
913
+ usage.add(record.message.usage);
907
914
  handleSessionEvent(event, queue, handlers, report, failure);
908
915
  }
909
916
  catch (error) {
@@ -1086,6 +1093,14 @@ export class PiRpcRuntime {
1086
1093
  if (input.host)
1087
1094
  this.sessionHosts.delete(conversation.id);
1088
1095
  await finished.catch(() => undefined);
1096
+ if (input.onUsage) {
1097
+ let context;
1098
+ try {
1099
+ context = session.getContextUsage?.();
1100
+ }
1101
+ catch { /* Unavailable context must not discard usage or keep a stale occupancy. */ }
1102
+ await input.onUsage(usage.readout(context, session.model, defaultAgentDir()));
1103
+ }
1089
1104
  }
1090
1105
  }
1091
1106
  /**
@@ -1477,7 +1492,7 @@ export class PiRpcRuntime {
1477
1492
  // every turn would silently replace the child's environment with whatever THIS process has at
1478
1493
  // spawn time. Inheriting is what an omitted `env` already does, so the two paths differ in nothing
1479
1494
  // but the four documented additions.
1480
- ...((extensionArgs.length || memoryEnabled || knowledgeEnabled || documentsEnabled || webEnabled || feishuEnabled) ? { env: { ...process.env, ...(oneShotCapability ? await this.oneShotExtensionEnv(conversation.id, oneShotCapability, conversation.parentConversationId) : {}), ...(memoryEnabled ? {
1495
+ ...((extensionArgs.length || memoryEnabled || knowledgeEnabled || documentsEnabled || webEnabled || feishuEnabled) ? { env: { ...process.env, ...(oneShotCapability ? await this.oneShotExtensionEnv(conversation.id, conversation.workspaceId, oneShotCapability, conversation.parentConversationId) : {}), ...(memoryEnabled ? {
1481
1496
  SUPER_PI_MEMORY_ENDPOINT: process.env.SUPER_PI_MEMORY_ENDPOINT,
1482
1497
  SUPER_PI_MEMORY_TOKEN: memoryToken,
1483
1498
  SUPER_PI_MEMORY_REVIEW: memoryReview ? "1" : "0",
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Resolves a conversation's optional relative cwd without ever letting it leave the workspace.
3
+ * An absent value preserves the workspace root; an invalid or escaping value is rejected back to
4
+ * that same root rather than thrown or allowed to select another directory.
5
+ */
6
+ export declare function resolveSessionCwd(workspaceRoot: string, cwd?: string | null): Promise<string>;
7
+ /**
8
+ * Validates an explicitly requested session cwd at creation time. Unlike the request-time fallback
9
+ * above, an invalid value is an error: the caller must not receive a session rooted somewhere else.
10
+ */
11
+ export declare function assertSessionCwdInsideWorkspace(workspaceRoot: string, cwd?: string | null): Promise<void>;
12
+ /** The canonical, workspace-relative spelling persisted on a conversation. */
13
+ export declare function normalizeSessionCwd(workspaceRoot: string, cwd?: string | null): Promise<string | undefined>;
14
+ /** Converts a path relative to the effective session root into a workspace-relative Git path. */
15
+ export declare function workspacePathFromSession(workspaceRoot: string, sessionRoot: string, path: string): Promise<string>;
16
+ /** One map identity per workspace/cwd pair. The root-session key remains the bare workspace id. */
17
+ export declare function sessionScopeKey(workspaceId: string, cwd?: string): string;
@@ -0,0 +1,43 @@
1
+ import { relative, resolve, sep } from "node:path";
2
+ import { resolveInsideRoot } from "./files.js";
3
+ /**
4
+ * Resolves a conversation's optional relative cwd without ever letting it leave the workspace.
5
+ * An absent value preserves the workspace root; an invalid or escaping value is rejected back to
6
+ * that same root rather than thrown or allowed to select another directory.
7
+ */
8
+ export async function resolveSessionCwd(workspaceRoot, cwd) {
9
+ if (!cwd?.trim())
10
+ return resolveInsideRoot(workspaceRoot, "");
11
+ try {
12
+ return await resolveInsideRoot(workspaceRoot, cwd);
13
+ }
14
+ catch {
15
+ return resolveInsideRoot(workspaceRoot, "");
16
+ }
17
+ }
18
+ /**
19
+ * Validates an explicitly requested session cwd at creation time. Unlike the request-time fallback
20
+ * above, an invalid value is an error: the caller must not receive a session rooted somewhere else.
21
+ */
22
+ export async function assertSessionCwdInsideWorkspace(workspaceRoot, cwd) {
23
+ if (!cwd?.trim())
24
+ return;
25
+ await resolveInsideRoot(workspaceRoot, cwd);
26
+ }
27
+ /** The canonical, workspace-relative spelling persisted on a conversation. */
28
+ export async function normalizeSessionCwd(workspaceRoot, cwd) {
29
+ if (!cwd?.trim())
30
+ return undefined;
31
+ const effective = await resolveSessionCwd(workspaceRoot, cwd);
32
+ const normalized = relative(resolve(workspaceRoot), effective).split(sep).join("/");
33
+ return normalized ? normalized : undefined;
34
+ }
35
+ /** Converts a path relative to the effective session root into a workspace-relative Git path. */
36
+ export async function workspacePathFromSession(workspaceRoot, sessionRoot, path) {
37
+ const absolute = await resolveInsideRoot(sessionRoot, path);
38
+ return relative(resolve(workspaceRoot), absolute).split(sep).join("/");
39
+ }
40
+ /** One map identity per workspace/cwd pair. The root-session key remains the bare workspace id. */
41
+ export function sessionScopeKey(workspaceId, cwd) {
42
+ return cwd ? `${workspaceId}\0${cwd}` : workspaceId;
43
+ }
@@ -19,6 +19,13 @@ export interface SessionLedgerEntry {
19
19
  updatedAt: number;
20
20
  /** High-level progress snippet or latest outcome */
21
21
  lastProgress?: string;
22
+ /** Currently executing tool, if any */
23
+ activeTool?: {
24
+ label: string;
25
+ detail?: string;
26
+ };
27
+ /** Error message if execution failed */
28
+ error?: string;
22
29
  }
23
30
  /**
24
31
  * In-memory relationship ledger for sibling and child sessions.
@@ -3,6 +3,7 @@ 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
5
  import type { AppState, Conversation, Message, MessageDelivery, MessageImage, ModelSelection, SubagentRun, SubagentRunUpdate, TodoItem, ToolCallQuestion, ToolCallArg, ToolCallBlock, ToolResultSummary, Workspace } from "./types.js";
6
+ import type { TurnUsageReadout } from "./conversation-usage.js";
6
7
  /** Mints the id a forked conversation will have, BEFORE its Pi transcript exists.
7
8
  *
8
9
  * Exported because the ordering demands it and no other arrangement provides it. Pi's copied file's header
@@ -271,6 +272,7 @@ export declare class Store {
271
272
  thinkingLevel?: string | null;
272
273
  modelNotice?: string;
273
274
  agentNotice?: string;
275
+ cwd?: string;
274
276
  parentConversationId?: string;
275
277
  origin?: "user" | "orchestrator";
276
278
  }): Promise<Conversation>;
@@ -388,6 +390,24 @@ export declare class Store {
388
390
  * whatever was typed into a box that was not listening yet. `openTurnOnMessage` applies them at the
389
391
  * moment a turn genuinely begins.
390
392
  */
393
+ /**
394
+ * Writes ONE INSTRUCTION INTO THE TRANSCRIPT AS SOMETHING THE SYSTEM SAID.
395
+ *
396
+ * `addUserMessage` above is the only way a person's words come into being, and everything written
397
+ * through it wears the reader's own name and the reader's bubble. That is right for a person and
398
+ * wrong for the orchestrator: when a primary agent dispatches a task to a child session, the
399
+ * child receives an ORDER, not something the reader typed - and the child transcript showed it
400
+ * under the reader's avatar, so the reader saw their own name on an instruction they never wrote.
401
+ *
402
+ * The role is `tool` for a reason that is not cosmetic: `tool` is already the product's word for
403
+ * "the system delivered this", it renders as the 系统 side of the transcript rather than as a
404
+ * person, and Pi's queue already knows how to hand a `tool` message to a model (see
405
+ * `composePrompt` in the run service). So the instruction reaches the model the same way, and the
406
+ * reader sees who spoke.
407
+ */
408
+ addAgentMessage(conversationId: string, content: string, options?: {
409
+ delivery?: Message["delivery"];
410
+ }): Promise<Message>;
391
411
  addUserMessage(conversationId: string, content: string, images?: MessageImage[], attachments?: import("./types.js").MessageAttachments, options?: {
392
412
  clientId?: string;
393
413
  delivery?: Message["delivery"];
@@ -578,6 +598,8 @@ export declare class Store {
578
598
  */
579
599
  clearPreparingToolCalls(conversationId: string, messageId: string, rows: readonly number[]): Promise<ToolCallBlock[]>;
580
600
  setStatus(conversationId: string, status: Conversation["status"]): Promise<void>;
601
+ /** Commit once at turn end; metadata transactions work for both JSON and SQLite repositories. */
602
+ recordConversationUsage(conversationId: string, readout: TurnUsageReadout): Promise<void>;
581
603
  /**
582
604
  * Records WHY a turn failed in a way the reader can see. `Conversation.error` is rendered as a red
583
605
  * banner above the transcript (`App.tsx:conversation-error`), and it is the only field that survives
@@ -1103,6 +1103,8 @@ export class Store {
1103
1103
  // the sidebar draws after a restart is the tree that was true when each session was opened.
1104
1104
  // Written only when present, so a conversation created without them (the user's own "+") reads
1105
1105
  // as a top-level one on every code path, including every state.json from before this field.
1106
+ if (configuration.cwd)
1107
+ conversation.cwd = configuration.cwd;
1106
1108
  if (configuration.parentConversationId)
1107
1109
  conversation.parentConversationId = configuration.parentConversationId;
1108
1110
  if (configuration.origin)
@@ -1212,6 +1214,8 @@ export class Store {
1212
1214
  conversation.thinkingLevel = source.thinkingLevel;
1213
1215
  if (source.agentId)
1214
1216
  conversation.agentId = source.agentId;
1217
+ if (source.cwd)
1218
+ conversation.cwd = source.cwd;
1215
1219
  this.state.conversations.unshift(conversation);
1216
1220
  await this.persist();
1217
1221
  return structuredClone(conversation);
@@ -1350,6 +1354,33 @@ export class Store {
1350
1354
  * whatever was typed into a box that was not listening yet. `openTurnOnMessage` applies them at the
1351
1355
  * moment a turn genuinely begins.
1352
1356
  */
1357
+ /**
1358
+ * Writes ONE INSTRUCTION INTO THE TRANSCRIPT AS SOMETHING THE SYSTEM SAID.
1359
+ *
1360
+ * `addUserMessage` above is the only way a person's words come into being, and everything written
1361
+ * through it wears the reader's own name and the reader's bubble. That is right for a person and
1362
+ * wrong for the orchestrator: when a primary agent dispatches a task to a child session, the
1363
+ * child receives an ORDER, not something the reader typed - and the child transcript showed it
1364
+ * under the reader's avatar, so the reader saw their own name on an instruction they never wrote.
1365
+ *
1366
+ * The role is `tool` for a reason that is not cosmetic: `tool` is already the product's word for
1367
+ * "the system delivered this", it renders as the 系统 side of the transcript rather than as a
1368
+ * person, and Pi's queue already knows how to hand a `tool` message to a model (see
1369
+ * `composePrompt` in the run service). So the instruction reaches the model the same way, and the
1370
+ * reader sees who spoke.
1371
+ */
1372
+ async addAgentMessage(conversationId, content, options) {
1373
+ console.error("STORE addAgentMessage conv=" + conversationId + " content=" + content.slice(0, 60));
1374
+ const conversation = this.requireConversation(conversationId);
1375
+ if (conversation.archivedAt)
1376
+ throw new Error("无法向已归档的会话下发指令,请先恢复该会话");
1377
+ const message = { id: id("message"), role: "tool", content, createdAt: now() };
1378
+ if (options?.delivery)
1379
+ message.delivery = options.delivery;
1380
+ conversation.messages.push(message);
1381
+ await this.persistMessages(conversation);
1382
+ return structuredClone(message);
1383
+ }
1353
1384
  async addUserMessage(conversationId, content, images, attachments, options) {
1354
1385
  const conversation = this.requireConversation(conversationId);
1355
1386
  // The browser cannot reach an archived conversation, so this guard exists for any other caller -
@@ -1429,6 +1460,7 @@ export class Store {
1429
1460
  * state it also enters `waitingMessages` and `runConversationTurns` will pick it up next.
1430
1461
  */
1431
1462
  async addToolMessage(conversationId, content, options) {
1463
+ console.error("STORE addToolMessage conv=" + conversationId + " content=" + content.slice(0, 60));
1432
1464
  const conversation = this.requireConversation(conversationId);
1433
1465
  if (conversation.archivedAt)
1434
1466
  throw new Error("无法向已归档的会话追加后台回传,请先恢复该会话");
@@ -1836,6 +1868,26 @@ export class Store {
1836
1868
  return structuredClone(calls);
1837
1869
  }
1838
1870
  async setStatus(conversationId, status) { const conversation = this.requireConversation(conversationId); conversation.status = status; conversation.updatedAt = now(); await this.persistMetadata(conversationId); }
1871
+ /** Commit once at turn end; metadata transactions work for both JSON and SQLite repositories. */
1872
+ async recordConversationUsage(conversationId, readout) {
1873
+ const conversation = this.requireConversation(conversationId);
1874
+ if (readout.usage) {
1875
+ const previous = conversation.usage;
1876
+ const total = { ...readout.usage };
1877
+ if (previous)
1878
+ for (const key of ["input", "output", "cacheRead", "cacheWrite", "cost", "turns"])
1879
+ total[key] += previous[key];
1880
+ conversation.usage = total;
1881
+ conversation.usageCostKnown = (conversation.usageCostKnown ?? true) && readout.costKnown;
1882
+ }
1883
+ if (readout.contextUsage)
1884
+ conversation.contextUsage = { ...readout.contextUsage };
1885
+ else
1886
+ delete conversation.contextUsage;
1887
+ if (readout.usage || readout.contextUsage)
1888
+ conversation.usageUpdatedAt = now();
1889
+ await this.persistMetadata(conversationId);
1890
+ }
1839
1891
  /**
1840
1892
  * Records WHY a turn failed in a way the reader can see. `Conversation.error` is rendered as a red
1841
1893
  * banner above the transcript (`App.tsx:conversation-error`), and it is the only field that survives
@@ -251,13 +251,14 @@ export declare function todoSummary(todos: readonly TodoItem[]): string;
251
251
  * `agents/super-pi-task/core.ts` is the other end; `scripts/task-tool.test.mjs` asserts the two copies agree.
252
252
  */
253
253
  export declare const ORCHESTRATOR_ENDPOINT_ENV = "SUPER_PI_ORCHESTRATOR_ENDPOINT";
254
+ export declare const ORCHESTRATOR_WORKSPACE_ENV = "SUPER_PI_ORCHESTRATOR_WORKSPACE";
254
255
  /** Tool names registered with Pi. */
255
256
  export declare const SESSION_SPAWN_TOOL_NAME = "session_spawn";
256
257
  export declare const SESSION_STATUS_TOOL_NAME = "session_status";
257
258
  export declare const SESSION_DISPATCH_TOOL_NAME = "session_dispatch";
258
259
  export declare const SESSION_STOP_TOOL_NAME = "session_stop";
259
260
  /** Model-facing description for session_spawn. */
260
- export declare const SESSION_SPAWN_DESCRIPTION = "Create a new sibling conversation in the current workspace and immediately dispatch its initial task.\n\nDIFFERENCE FROM THE 'task' TOOL:\n- The 'task' tool delegates to a transient, one-shot subagent process (scout, critic, patcher) that runs once, returns its final text, and terminates. It has no conversation history and does not appear in the workspace sidebar.\n- 'session_spawn' creates a real, persistent sibling CONVERSATION in Super Pi. It appears in the sidebar, retains multi-turn message history, can receive follow-up messages via session_dispatch, can be inspected via session_status, and can be stopped via session_stop. The user can also switch to it in the Web UI.\n\nWHEN TO USE THIS:\n- Long-running, multi-phase tracks of work that require ongoing context, multiple turns of dialogue, or independent user visibility.\n- Parallel work streams (e.g., Track A: backend refactor, Track B: frontend component update) where both need full conversational capabilities.\n- When the user asks you to act as an orchestrator/manager who delegates to multiple sessions.\n\nWHEN NOT TO USE THIS:\n- For one-off, focused delegation with a single answer: use 'task' (with scout/critic/patcher). It is faster, cheaper, and returns directly to your transcript.\n- For running shell commands or build watchers: use 'run_async'.\n- For a linear multi-step subagent pipeline: use 'workflow'.\n\nUsage notes:\n1. Spawning is ASYNCHRONOUS by default: it creates the conversation, enqueues the initial task, and returns the new conversationId immediately. The sibling session runs in the background.\n2. The initial task should provide clear context and goals since the new session starts with a fresh conversation history.\n3. Use session_status with the returned conversationId to check progress, or session_dispatch to send further messages.\n4. The new session reports completion to its DIRECT parent automatically. 'parentConversationId' optionally selects that parent; by default it is the calling session.";
261
+ export declare const SESSION_SPAWN_DESCRIPTION = "Create a new sibling conversation in the current workspace and immediately dispatch its initial task.\n\nDIFFERENCE FROM THE 'task' TOOL:\n- The 'task' tool delegates to a transient, one-shot subagent process (scout, critic, patcher) that runs once, returns its final text, and terminates. It has no conversation history and does not appear in the workspace sidebar.\n- 'session_spawn' creates a real, persistent sibling CONVERSATION in Super Pi. It appears in the sidebar, retains multi-turn message history, can receive follow-up messages via session_dispatch, can be inspected via session_status, and can be stopped via session_stop. The user can also switch to it in the Web UI.\n\nWHEN TO USE THIS:\n- Long-running, multi-phase tracks of work that require ongoing context, multiple turns of dialogue, or independent user visibility.\n- Parallel work streams (e.g., Track A: backend refactor, Track B: frontend component update) where both need full conversational capabilities.\n- When the user asks you to act as an orchestrator/manager who delegates to multiple sessions.\n\nWHEN NOT TO USE THIS:\n- For one-off, focused delegation with a single answer: use 'task' (with scout/critic/patcher). It is faster, cheaper, and returns directly to your transcript.\n- For running shell commands or build watchers: use 'run_async'.\n- For a linear multi-step subagent pipeline: use 'workflow'.\n\nUsage notes:\n1. Spawning is ASYNCHRONOUS by default: it creates the conversation, enqueues the initial task, and returns the new conversationId immediately. The sibling session runs in the background.\n2. The initial task should provide clear context and goals since the new session starts with a fresh conversation history.\n3. Use session_status with the returned conversationId to check progress, or session_dispatch to send further messages.\n4. The new session reports completion to its DIRECT parent automatically. 'parentConversationId' optionally selects that parent; by default it is the calling session.\n5. The new session always belongs to the calling session's workspace. Pass 'cwd' as a workspace-relative subdirectory (for example, 'packages/a') when it should work below the workspace root; absolute paths and traversal are rejected.";
261
262
  /** Model-facing description for session_status. */
262
263
  export declare const SESSION_STATUS_DESCRIPTION = "Inspect the progress, run state, and latest activity of sibling conversations created by the orchestrator.\n\nWHEN TO USE THIS:\n- To check whether a spawned sibling conversation is still running, has finished (idle), is stopped, or encountered an error.\n- To read the latest assistant progress snippet without bloating your context with the session's entire raw message transcript.\n- To list all sibling conversations under your coordination when conversationId is omitted.\n\nUsage notes:\n1. When 'conversationId' is provided: returns a compact summary for that specific session, including its run status ('idle', 'running', 'waiting', 'stopped', 'failed'), messageCount, turnCount, lastAssistantSnippet, lastUserSnippet, activeTool, and error (if any).\n2. When 'conversationId' is omitted: returns a summary list of sibling conversations whose direct parent is 'parentConversationId' (the calling session by default).\n3. By default it never returns full transcripts; concise summaries keep your context window lean. Set 'includeOutput' with a conversationId to read the final output from its result file, capped to the last 64Ki characters with an explicit truncation notice.";
263
264
  /** Model-facing description for session_dispatch. */
@@ -356,6 +356,7 @@ export function todoSummary(todos) {
356
356
  * `agents/super-pi-task/core.ts` is the other end; `scripts/task-tool.test.mjs` asserts the two copies agree.
357
357
  */
358
358
  export const ORCHESTRATOR_ENDPOINT_ENV = "SUPER_PI_ORCHESTRATOR_ENDPOINT";
359
+ export const ORCHESTRATOR_WORKSPACE_ENV = "SUPER_PI_ORCHESTRATOR_WORKSPACE";
359
360
  /** Tool names registered with Pi. */
360
361
  export const SESSION_SPAWN_TOOL_NAME = "session_spawn";
361
362
  export const SESSION_STATUS_TOOL_NAME = "session_status";
@@ -382,7 +383,8 @@ Usage notes:
382
383
  1. Spawning is ASYNCHRONOUS by default: it creates the conversation, enqueues the initial task, and returns the new conversationId immediately. The sibling session runs in the background.
383
384
  2. The initial task should provide clear context and goals since the new session starts with a fresh conversation history.
384
385
  3. Use session_status with the returned conversationId to check progress, or session_dispatch to send further messages.
385
- 4. The new session reports completion to its DIRECT parent automatically. 'parentConversationId' optionally selects that parent; by default it is the calling session.`;
386
+ 4. The new session reports completion to its DIRECT parent automatically. 'parentConversationId' optionally selects that parent; by default it is the calling session.
387
+ 5. The new session always belongs to the calling session's workspace. Pass 'cwd' as a workspace-relative subdirectory (for example, 'packages/a') when it should work below the workspace root; absolute paths and traversal are rejected.`;
386
388
  /** Model-facing description for session_status. */
387
389
  export const SESSION_STATUS_DESCRIPTION = `Inspect the progress, run state, and latest activity of sibling conversations created by the orchestrator.
388
390
 
@@ -104,6 +104,7 @@ export type TerminalCommand = {
104
104
  type: "terminal.open";
105
105
  sessionId: string;
106
106
  workspaceId: string;
107
+ cwd?: string;
107
108
  cols?: number;
108
109
  rows?: number;
109
110
  } | {
@@ -52,6 +52,7 @@ import { createRequire } from "node:module";
52
52
  import { existsSync, accessSync, constants } from "node:fs";
53
53
  import { delimiter, join } from "node:path";
54
54
  import { isLocalOrPrivateAddress } from "./http-server.js";
55
+ import { resolveSessionCwd } from "./session-cwd.js";
55
56
  let ptyModule;
56
57
  let ptyLoadFailure;
57
58
  /**
@@ -305,10 +306,11 @@ export class TerminalService {
305
306
  send({ type: "terminal.error", sessionId: command.sessionId, message: "工作区不存在,无法打开终端。" });
306
307
  return;
307
308
  }
309
+ const cwd = await resolveSessionCwd(workspace.path, command.cwd);
308
310
  // A workspace whose directory was deleted or renamed must not spawn a shell in `process.cwd()`,
309
311
  // which would silently run the terminal in the SERVER's directory instead of an error.
310
- if (!existsSync(workspace.path)) {
311
- send({ type: "terminal.error", sessionId: command.sessionId, message: `工作区目录不存在:${workspace.path}` });
312
+ if (!existsSync(cwd)) {
313
+ send({ type: "terminal.error", sessionId: command.sessionId, message: `会话工作目录不存在:${cwd}` });
312
314
  return;
313
315
  }
314
316
  const pty = loadPty();
@@ -319,7 +321,7 @@ export class TerminalService {
319
321
  name: "xterm-256color",
320
322
  cols: clamp(command.cols, MIN_COLS, MAX_COLS, 80),
321
323
  rows: clamp(command.rows, MIN_ROWS, MAX_ROWS, 24),
322
- cwd: workspace.path,
324
+ cwd,
323
325
  env: { ...process.env, TERM: "xterm-256color" },
324
326
  });
325
327
  const session = { pty: child, closed: false, killed: false, pending: "" };
@@ -43,7 +43,9 @@ export function formatTurnFailure(error) {
43
43
  // WORKSPACE PATH - turn refused on directory the contract forbids. Refusal happens before
44
44
  // anything is on screen, so the banner is also the only place the reason lives.
45
45
  if (lower.includes("workspace directory") || lower.includes("does not exist or is not a directory")) {
46
- return `工作区目录无效:${raw}`;
46
+ const match = raw.match(/does not exist or is not a directory:\s*(.+?)(?:\.\s*Refusing|$)/);
47
+ const path = match?.[1]?.trim();
48
+ return `工作区目录不存在:${path || "未知路径"}`;
47
49
  }
48
50
  // PROVIDER UNREACHABLE - the request never reached the model. This is the branch a custom
49
51
  // provider with a wrong Base URL lands in, and the person editing that URL needs to be told that
@@ -63,6 +65,19 @@ export function formatTurnFailure(error) {
63
65
  if (code >= 500 && code <= 599 || /internal server error|bad gateway|service unavailable|gateway timeout/.test(lower)) {
64
66
  return `模型供应商服务端出错了${code ? `(HTTP ${code})` : ""},通常稍后重试即可。`;
65
67
  }
68
+ // CONTEXT OVERFLOW - the conversation outgrew the model's window.
69
+ //
70
+ // This is the branch a reader hits when the session stops working entirely, so it has to name the
71
+ // cause AND the way out. The upstream wording varies by provider, and the shapes that show up are
72
+ // "context length exceeded", "maximum context length", "too many tokens", "prompt is too long",
73
+ // "reduce the length of the messages", and the 400s some providers use for it.
74
+ //
75
+ // The action is the same in every case: the conversation is too long for one request, and the
76
+ // reader's choices are to start a new conversation or use a model with a bigger window. Saying
77
+ // "the model refused" would hide both.
78
+ if (/context[\s_]*(?:length|window|size)[\s_]*(?:exceeded|exceeds|too large|limit)|maximum context|context limit|too many tokens|prompt is too long|reduce the length of the messages|exceeds the maximum|上下文(?:超|过)限|上下文长度/.test(lower)) {
79
+ return "这一轮的内容超过了模型的上下文上限,模型没能读下整段对话。可以新开一个会话继续,或换一个上下文窗口更大的模型(Pi 会在超限前自动压缩,这轮没能完成压缩)。";
80
+ }
66
81
  // IMAGE MISMATCH - explicit by the caller, no need to translate.
67
82
  if (lower.includes("不支持图片输入")) {
68
83
  return raw;
@@ -464,6 +464,12 @@ export type Conversation = {
464
464
  status: RunStatus;
465
465
  messages: Message[];
466
466
  archivedAt?: string;
467
+ /** Cumulative primary-assistant usage observed since readout collection began; excludes subagents. */
468
+ usage?: SubagentUsage;
469
+ /** False means cost is only a sum of reported costs, not a complete price (zero is not "free"). */
470
+ usageCostKnown?: boolean;
471
+ contextUsage?: ConversationContextUsage;
472
+ usageUpdatedAt?: string;
467
473
  /** Transport projection only: false means messages were omitted, rather than an empty transcript. */
468
474
  messagesLoaded?: boolean;
469
475
  /** Pending question summary, retained when a focused snapshot omits this conversation's messages. */
@@ -519,6 +525,8 @@ export type Conversation = {
519
525
  * deleted agent degrades to "no role" instead of leaving a stale snapshot in state.json.
520
526
  */
521
527
  agentId?: string;
528
+ /** Relative to the workspace root. Absent or empty preserves the original workspace-root cwd. */
529
+ cwd?: string;
522
530
  /**
523
531
  * The conversation that opened this one, when an orchestrator agent created it. Absent for a
524
532
  * conversation the user started. This is the session tree's edge, and it PERSISTS: the sidebar
@@ -655,6 +663,12 @@ export type SubagentUsage = {
655
663
  cost: number;
656
664
  turns: number;
657
665
  };
666
+ /** Current context occupancy, distinct from cumulative billed tokens; null means unknown after compaction. */
667
+ export type ConversationContextUsage = {
668
+ tokens: number | null;
669
+ contextWindow: number;
670
+ percent: number | null;
671
+ };
658
672
  /**
659
673
  * ONE TOOL CALL, as the card shows it, and the boundary that keeps a child's turn out of `state.json`.
660
674
  *
@@ -1022,6 +1036,7 @@ export type ClientCommandBody =
1022
1036
  type: "terminal.open";
1023
1037
  sessionId: string;
1024
1038
  workspaceId: string;
1039
+ cwd?: string;
1025
1040
  cols?: number;
1026
1041
  rows?: number;
1027
1042
  }
@@ -1182,6 +1197,7 @@ export type ClientCommandBody =
1182
1197
  } | {
1183
1198
  type: "conversation.create";
1184
1199
  workspaceId: string;
1200
+ cwd?: string;
1185
1201
  } | {
1186
1202
  type: "conversation.select";
1187
1203
  conversationId: string;
@@ -1284,6 +1300,7 @@ export type ClientCommandBody =
1284
1300
  | {
1285
1301
  type: "files.index.subscribe";
1286
1302
  workspaceId?: string;
1303
+ cwd?: string;
1287
1304
  } | {
1288
1305
  type: "state.subscribe";
1289
1306
  conversationId?: string;
@@ -1601,6 +1618,7 @@ export type ServerEvent = {
1601
1618
  } | {
1602
1619
  type: "files.index";
1603
1620
  workspaceId: string;
1621
+ cwd?: string;
1604
1622
  version: number;
1605
1623
  changed: {
1606
1624
  path: string;
@@ -1,10 +1,33 @@
1
1
  import type { ServerVersionInfo } from "./types.js";
2
+ export declare const UNKNOWN_VERSION = "0.0.0-unknown";
3
+ /**
4
+ * Reads and validates the version string from a package.json located in the given directory.
5
+ */
6
+ export declare function readPackageVersion(dir?: string): string | undefined;
7
+ /**
8
+ * Resolves the version of Super Pi itself by searching relative to this module's location.
9
+ * In both development (`src/server/`) and production build (`dist/server/`), two levels up
10
+ * is the repository / package root containing package.json.
11
+ */
12
+ export declare function resolveSelfVersion(fromDir?: string): string | undefined;
2
13
  /**
3
14
  * Initializes the version captured when this server process booted up.
15
+ * If the provided rootDir fails to yield a valid package.json version,
16
+ * attempts to self-heal using the module's own location before falling back to UNKNOWN_VERSION.
17
+ */
18
+ export declare function initRunningVersion(rootDir: string, selfFromDir?: string): string;
19
+ /**
20
+ * Returns the currently running version captured in memory.
21
+ */
22
+ export declare function getRunningVersion(): string;
23
+ /**
24
+ * Resets the in-memory running version, primarily for testing purposes.
4
25
  */
5
- export declare function initRunningVersion(rootDir: string): string;
26
+ export declare function resetRunningVersion(toVersion?: string): string;
6
27
  /**
7
28
  * Checks whether the package.json version on disk has been updated beyond
8
29
  * the version currently executing in this process.
30
+ * If rootDir fails to yield a valid package.json version, attempts to self-heal
31
+ * using the module's own location.
9
32
  */
10
- export declare function checkDiskVersion(rootDir: string): ServerVersionInfo;
33
+ export declare function checkDiskVersion(rootDir: string, selfFromDir?: string): ServerVersionInfo;