@evandu/super-pi 0.1.33 → 0.1.34

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 (34) hide show
  1. package/README.md +4 -0
  2. package/agents/super-pi-task/index.ts +4 -0
  3. package/dist/client/assets/index-BhsIL1Qa.js +50 -0
  4. package/dist/client/assets/index-Da9PkxxS.css +1 -0
  5. package/dist/client/index.html +2 -2
  6. package/dist/extension.js +7 -2
  7. package/dist/server/active-memory.d.ts +15 -0
  8. package/dist/server/active-memory.js +36 -1
  9. package/dist/server/application/conversation-service.d.ts +39 -0
  10. package/dist/server/application/conversation-service.js +65 -4
  11. package/dist/server/application/extension-ui-service.js +16 -0
  12. package/dist/server/application/run-service.d.ts +21 -0
  13. package/dist/server/application/run-service.js +130 -19
  14. package/dist/server/command-handler.d.ts +14 -0
  15. package/dist/server/command-handler.js +22 -0
  16. package/dist/server/git.d.ts +37 -0
  17. package/dist/server/git.js +30 -0
  18. package/dist/server/http-server.js +126 -8
  19. package/dist/server/index.js +7 -2
  20. package/dist/server/memory.d.ts +27 -0
  21. package/dist/server/memory.js +56 -0
  22. package/dist/server/persistence/json-state-repository.d.ts +2 -0
  23. package/dist/server/persistence/json-state-repository.js +24 -5
  24. package/dist/server/runtime.d.ts +4 -1
  25. package/dist/server/runtime.js +51 -14
  26. package/dist/server/store.d.ts +6 -0
  27. package/dist/server/store.js +46 -3
  28. package/dist/server/terminal.js +20 -7
  29. package/dist/server/types.d.ts +36 -2
  30. package/dist/server/upload.js +8 -19
  31. package/dist/server/ws-server.js +89 -28
  32. package/package.json +5 -3
  33. package/dist/client/assets/index-D6bdUvG_.css +0 -1
  34. package/dist/client/assets/index-dU9RhbHX.js +0 -50
@@ -25,7 +25,10 @@ import { initRunningVersion } from "./version.js";
25
25
  const rootDir = resolve(import.meta.dirname, "../..");
26
26
  initRunningVersion(rootDir);
27
27
  const cliArgs = parseServerArgs(process.argv);
28
- const port = cliArgs.port ?? Number.parseInt(process.env.SUPER_PI_PORT ?? "3080", 10);
28
+ // A non-numeric SUPER_PI_PORT would parse to NaN and die later in `listen` with a confusing error;
29
+ // like the other environment variables here, an unusable value falls back to the default port.
30
+ const envPort = Number.parseInt(process.env.SUPER_PI_PORT ?? "3080", 10);
31
+ const port = cliArgs.port ?? (Number.isNaN(envPort) ? 3080 : envPort);
29
32
  const host = cliArgs.host ?? process.env.SUPER_PI_HOST ?? "0.0.0.0";
30
33
  const internalHost = (host === "0.0.0.0" || host === "::" || !host) ? "127.0.0.1" : host;
31
34
  const defaultDataDir = !rootDir.includes("node_modules") && existsSync(join(rootDir, ".super-pi"))
@@ -40,6 +43,7 @@ await mkdir(uploadsDir, { recursive: true }).catch(() => { });
40
43
  const taskEndpoint = `http://${internalHost}:${port}/api/subagent/dispatch`;
41
44
  const todoEndpoint = `http://${internalHost}:${port}/api/todos`;
42
45
  const workflowEndpoint = `http://${internalHost}:${port}/api/workflow/run`;
46
+ const questionEndpoint = `http://${internalHost}:${port}/api/question`;
43
47
  // ------------------------------------------------------------- Core singletons
44
48
  const store = await openStateStore(dataDir, process.env.SUPER_PI_PERSISTENCE ?? "sqlite");
45
49
  const initialAgentDirs = resolveAgentDirectories({
@@ -58,7 +62,7 @@ const paths = {
58
62
  };
59
63
  const runtime = process.env.SUPER_PI_RUNTIME === "welcome"
60
64
  ? new WelcomeRuntime()
61
- : new PiRpcRuntime(join(dataDir, "pi-sessions"), undefined, { dir: initialAgentDirs, promptDir: agentPromptDir }, { endpoint: taskEndpoint, todoEndpoint, workflowEndpoint, uploadsDir });
65
+ : new PiRpcRuntime(join(dataDir, "pi-sessions"), undefined, { dir: initialAgentDirs, promptDir: agentPromptDir }, { endpoint: taskEndpoint, todoEndpoint, workflowEndpoint, questionEndpoint, uploadsDir });
62
66
  const broadcaster = new WsBroadcaster(() => store.snapshot());
63
67
  const memory = new ActiveMemory(join(dataDir, "memories"), store, runtime);
64
68
  process.env.SUPER_PI_MEMORY_ENDPOINT = `http://${internalHost}:${port}/api/memory/tool`;
@@ -114,6 +118,7 @@ const catalogService = new CatalogService(runtime, broadcaster, initialAgentDirs
114
118
  const runService = new RunService(store, runtime, subagents, broadcaster, extensionUiService, initialAgentDirs, workflows);
115
119
  runService.setMemory(memory);
116
120
  const conversationService = new ConversationService(store, runtime, subagents, broadcaster, catalogService, runService, extensionUiService, initialAgentDirs);
121
+ conversationService.setMemory(memory);
117
122
  const workspaceService = new WorkspaceService(store, runtime, broadcaster, catalogService, runService, extensionUiService, uploadsDir, initialAgentDirs);
118
123
  /**
119
124
  * The terminal, constructed before `serverContext` because that object carries the two predicates
@@ -18,6 +18,33 @@ export declare class MemoryStore {
18
18
  private path;
19
19
  private read;
20
20
  apply(workspaceId: string, conversationId: string, operation: MemoryOperation, review?: boolean): Promise<MemoryEntry[]>;
21
+ /**
22
+ * Drops every entry this conversation contributed at or after `sinceIso`, in all three targets.
23
+ *
24
+ * WHY THIS EXISTS. A revert rewinds the conversation - Pi's session file is truncated and the messages
25
+ * after the boundary are gone from `state.json` - but the memory store is a SECOND, durable copy of what
26
+ * those turns said, and `snapshot` below splices it into every later prompt. So a person who withdrew a
27
+ * message had not actually withdrawn it: the turn was gone from the transcript and the app went on
28
+ * handing its contents to the model. Measured rather than argued: with `SUPER_PI_MEMORY_ENABLED=0` the
29
+ * same revert test passes, and with memory on the model still answered with a codeword from a turn the
30
+ * user had withdrawn.
31
+ *
32
+ * WHY A TIMESTAMP IS THE RIGHT CUT. Entries carry a `conversationId` and a `createdAt`, and reverting to
33
+ * a message removes EVERY message after it - so any entry this conversation created at or after the first
34
+ * removed message can only have come from content that no longer exists. The predicate therefore cannot
35
+ * delete a memory that is still true of the conversation, which is what makes deleting on a timestamp
36
+ * acceptable at all.
37
+ *
38
+ * WHAT IT COSTS, STATED PLAINLY. An entry does not record which message taught it, so an entry learned in
39
+ * a removed turn goes even when it states something that would still be true - a user's name first
40
+ * mentioned in the withdrawn turn. Narrowing that to provenance means stamping every write with its source
41
+ * and reworking consolidation: a larger change than the defect warrants, and not taken here. The
42
+ * alternative is the behaviour this exists to stop.
43
+ *
44
+ * Returns the dropped ids per target so the caller can unlink the mirrored skill files; the JSON alone
45
+ * would leave a skill on disk that no manifest advertises.
46
+ */
47
+ forget(workspaceId: string, conversationId: string, sinceIso: string): Promise<Record<MemoryTarget, string[]>>;
21
48
  snapshot(workspaceId: string): Promise<string>;
22
49
  }
23
50
  /** A separate review session must never reuse or truncate the user's Pi session. */
@@ -72,6 +72,62 @@ export class MemoryStore {
72
72
  locks.delete(path);
73
73
  }
74
74
  }
75
+ /**
76
+ * Drops every entry this conversation contributed at or after `sinceIso`, in all three targets.
77
+ *
78
+ * WHY THIS EXISTS. A revert rewinds the conversation - Pi's session file is truncated and the messages
79
+ * after the boundary are gone from `state.json` - but the memory store is a SECOND, durable copy of what
80
+ * those turns said, and `snapshot` below splices it into every later prompt. So a person who withdrew a
81
+ * message had not actually withdrawn it: the turn was gone from the transcript and the app went on
82
+ * handing its contents to the model. Measured rather than argued: with `SUPER_PI_MEMORY_ENABLED=0` the
83
+ * same revert test passes, and with memory on the model still answered with a codeword from a turn the
84
+ * user had withdrawn.
85
+ *
86
+ * WHY A TIMESTAMP IS THE RIGHT CUT. Entries carry a `conversationId` and a `createdAt`, and reverting to
87
+ * a message removes EVERY message after it - so any entry this conversation created at or after the first
88
+ * removed message can only have come from content that no longer exists. The predicate therefore cannot
89
+ * delete a memory that is still true of the conversation, which is what makes deleting on a timestamp
90
+ * acceptable at all.
91
+ *
92
+ * WHAT IT COSTS, STATED PLAINLY. An entry does not record which message taught it, so an entry learned in
93
+ * a removed turn goes even when it states something that would still be true - a user's name first
94
+ * mentioned in the withdrawn turn. Narrowing that to provenance means stamping every write with its source
95
+ * and reworking consolidation: a larger change than the defect warrants, and not taken here. The
96
+ * alternative is the behaviour this exists to stop.
97
+ *
98
+ * Returns the dropped ids per target so the caller can unlink the mirrored skill files; the JSON alone
99
+ * would leave a skill on disk that no manifest advertises.
100
+ */
101
+ async forget(workspaceId, conversationId, sinceIso) {
102
+ const dropped = { user: [], memory: [], skills: [] };
103
+ const since = Date.parse(sinceIso);
104
+ if (!Number.isFinite(since))
105
+ throw new Error("记忆回滚需要一条可解析的时间戳");
106
+ for (const target of Object.keys(limits)) {
107
+ const path = this.path(workspaceId, target);
108
+ const task = (locks.get(path) ?? Promise.resolve()).catch(() => { }).then(async () => {
109
+ const entries = await this.read(path);
110
+ const kept = entries.filter((entry) => !(entry.conversationId === conversationId && Date.parse(entry.createdAt) >= since));
111
+ dropped[target] = entries.filter((entry) => !kept.includes(entry)).map((entry) => entry.id);
112
+ if (kept.length === entries.length)
113
+ return entries;
114
+ const temporary = `${path}.${randomUUID()}.tmp`;
115
+ await mkdir(this.directory, { recursive: true });
116
+ await writeFile(temporary, JSON.stringify(kept, null, 2), { encoding: "utf8", mode: 0o600 });
117
+ await rename(temporary, path);
118
+ return kept;
119
+ });
120
+ locks.set(path, task);
121
+ try {
122
+ await task;
123
+ }
124
+ finally {
125
+ if (locks.get(path) === task)
126
+ locks.delete(path);
127
+ }
128
+ }
129
+ return dropped;
130
+ }
75
131
  async snapshot(workspaceId) {
76
132
  const blocks = await Promise.all(Object.keys(limits).map(async (target) => {
77
133
  const entries = await this.apply(workspaceId, "", { target, action: "list" });
@@ -4,6 +4,8 @@ import type { AppStateRepository } from "./app-state-repository.js";
4
4
  export declare class JsonStateRepository implements AppStateRepository {
5
5
  readonly filePath: string;
6
6
  private writeQueue;
7
+ /** The error of the most recent FAILED write, cleared by the next successful one. Read by `flush`. */
8
+ private lastWriteError;
7
9
  constructor(filePath: string);
8
10
  load(): Promise<AppState>;
9
11
  save(state: AppState): Promise<void>;
@@ -5,20 +5,39 @@ import { dirname, join } from "node:path";
5
5
  export class JsonStateRepository {
6
6
  filePath;
7
7
  writeQueue = Promise.resolve();
8
+ /** The error of the most recent FAILED write, cleared by the next successful one. Read by `flush`. */
9
+ lastWriteError;
8
10
  constructor(filePath) {
9
11
  this.filePath = filePath;
10
12
  }
11
13
  async load() { return JSON.parse(await readFile(this.filePath, "utf8")); }
12
14
  async save(state) {
13
15
  const write = this.writeQueue.then(async () => {
14
- await mkdir(dirname(this.filePath), { recursive: true });
15
- const temporary = `${this.filePath}.tmp`;
16
- await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, "utf8");
17
- await replaceFile(temporary, this.filePath);
16
+ try {
17
+ await mkdir(dirname(this.filePath), { recursive: true });
18
+ const temporary = `${this.filePath}.tmp`;
19
+ await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, "utf8");
20
+ await replaceFile(temporary, this.filePath);
21
+ this.lastWriteError = undefined;
22
+ }
23
+ catch (error) {
24
+ this.lastWriteError = error;
25
+ throw error;
26
+ }
18
27
  });
28
+ // The queue keeps swallowing: one failed write must not poison every write queued behind it.
29
+ // The failure is remembered above, and `flush` is what reports it.
19
30
  this.writeQueue = write.catch(() => { });
20
31
  await write;
21
32
  }
22
- async flush() { await this.writeQueue; }
33
+ async flush() {
34
+ await this.writeQueue;
35
+ if (this.lastWriteError !== undefined) {
36
+ const error = this.lastWriteError;
37
+ this.lastWriteError = undefined;
38
+ console.error(`Super Pi: the last state write to ${this.filePath} failed (${error instanceof Error ? error.message : String(error)}) - the state on disk may be older than the state in memory.`);
39
+ throw error;
40
+ }
41
+ }
23
42
  }
24
43
  export const defaultJsonStatePath = (dataDir) => join(dataDir, "state.json");
@@ -96,6 +96,8 @@ export interface AnswerInput {
96
96
  * replacement text, and one that shows the call arriving.
97
97
  */
98
98
  onToolPreparing?(call: ToolCallPreparing): void;
99
+ /** Called while the primary model is emitting reasoning, so the UI can show live progress. */
100
+ onThinking?(delta: string): void;
99
101
  }
100
102
  /**
101
103
  * What Pi answered when a `steer` was written to it, from the `disposition` of that command's response
@@ -662,6 +664,7 @@ export declare class PiRpcRuntime implements AgentRuntime {
662
664
  endpoint?: string;
663
665
  todoEndpoint?: string;
664
666
  workflowEndpoint?: string;
667
+ questionEndpoint?: string;
665
668
  uploadsDir?: string;
666
669
  });
667
670
  /**
@@ -703,7 +706,7 @@ export declare class PiRpcRuntime implements AgentRuntime {
703
706
  * own address, so it is the same string on every turn and is read once from the constructor's options.
704
707
  */
705
708
  private taskExtensionEnv;
706
- answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, images, memoryReview, memorySourceConversationId }: AnswerInput): AsyncIterable<string>;
709
+ answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, images, memoryReview, memorySourceConversationId }: AnswerInput): AsyncIterable<string>;
707
710
  /**
708
711
  * Reads back the id of the user message this turn just created, and hands it to the caller.
709
712
  *
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
3
4
  import { existsSync } from "node:fs";
4
5
  import { resolve, join } from "node:path";
@@ -343,6 +344,11 @@ export async function truncateSessionFile(path, boundaryEntryId, expectedSession
343
344
  const expectedLastId = beforeBoundary ? parsed[boundary - 1]?.id : parsed[boundary]?.id;
344
345
  if (beforeBoundary)
345
346
  boundary -= 1;
347
+ // Fail closed: a boundary of -1 here means "revert to before the FIRST entry", which would write an
348
+ // empty session file. The verification below would pass vacuously (0 !== 0 is false), so the refusal
349
+ // must happen here rather than be trusted to a check that cannot fire.
350
+ if (boundary < 0)
351
+ throw new Error(`回退点在第一条消息之前,截断会得到一个空会话文件,拒绝回退:${path}`);
346
352
  // The `parentId` property probe A measured, enforced at the boundary that matters: every entry still
347
353
  // being KEPT must point at something also being kept. A prefix that violates this is not a session,
348
354
  // and writing it would produce a file Pi either rejects or silently misreads.
@@ -371,7 +377,7 @@ export async function truncateSessionFile(path, boundaryEntryId, expectedSession
371
377
  catch {
372
378
  lastEntry = undefined;
373
379
  }
374
- if (verified.length !== boundary + 1 || lastEntry?.id !== expectedLastId) {
380
+ if (verified.length !== boundary + 1 || lastEntry?.id !== expectedLastId || boundary + 1 < 1) {
375
381
  throw new Error(`截断后的 Pi 会话文件没有以要保留的那条消息结尾(写到 ${verified.length} 行,期望 ${boundary + 1} 行,末行 id=${JSON.stringify(lastEntry?.id ?? null)})。原始文件仍在 ${backupPath}。`);
376
382
  }
377
383
  return { linesKept: boundary + 1, entriesDropped: lines.length - (boundary + 1) };
@@ -703,6 +709,7 @@ export class PiRpcRuntime {
703
709
  [TASK_DEPTH_ENV]: "0",
704
710
  [TODO_ENDPOINT_ENV]: this.options.todoEndpoint ?? "",
705
711
  [WORKFLOW_ENDPOINT_ENV]: this.options.workflowEndpoint ?? "",
712
+ SUPER_PI_QUESTION_ENDPOINT: this.options.questionEndpoint ?? "",
706
713
  // MINTED HERE, AT SPAWN, rather than read from anywhere: this is the only moment the value is
707
714
  // decided, so it cannot change underneath a running child. `SubagentSupervisor` never calls this
708
715
  // function, so no sub-agent process is ever handed a capability - which is what makes a sub-agent
@@ -710,7 +717,7 @@ export class PiRpcRuntime {
710
717
  [TASK_CAPABILITY_ENV]: mintCapability(conversationId),
711
718
  };
712
719
  }
713
- async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, images, memoryReview, memorySourceConversationId }) {
720
+ async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, images, memoryReview, memorySourceConversationId }) {
714
721
  await mkdir(this.sessionDir, { recursive: true });
715
722
  // Normalize first: the picker can hand over forward slashes or a trailing separator, and `resolve`
716
723
  // turns both into one absolute native path. The directory must exist on disk before spawn - a
@@ -751,7 +758,7 @@ export class PiRpcRuntime {
751
758
  // other. They also share the extension's existence - `extensionArgs` is non-empty exactly when
752
759
  // `index.ts` is on disk and an endpoint was configured - so there is no case where one is mounted and
753
760
  // the other is not.
754
- const agentArgs = memoryReview ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates", "--tools", "memory,skill_manage,session_search"] : await agentSpawnArgs(this.agents.promptDir, agent, { extraAllowedTools: [...(extensionArgs.length ? [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME] : []), ...(memoryEnabled ? ["memory", "skill_manage", "session_search"] : [])] });
761
+ const agentArgs = memoryReview ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates", "--tools", "memory,skill_manage,session_search"] : await agentSpawnArgs(this.agents.promptDir, agent, { extraAllowedTools: [...(extensionArgs.length ? [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME, "question"] : []), ...(memoryEnabled ? ["memory", "skill_manage", "session_search"] : [])] });
755
762
  // Refused here, with the reason, rather than handed to the spawn: an ENOENT from `spawn` reads as
756
763
  // "the app is broken" and hides the one fact that matters, which is where it looked for Pi.
757
764
  if (!this.pi.found)
@@ -773,8 +780,16 @@ export class PiRpcRuntime {
773
780
  });
774
781
  const channel = new RpcChannel(child);
775
782
  this.runs.set(conversation.id, { child, channel });
776
- const abort = () => { if (!child.killed)
777
- child.stdin.write(`${JSON.stringify({ id: "abort", type: "abort" })}\n`); };
783
+ // Same guard as subagents.ts: a destroyed stdin throws synchronously on write, and an abort
784
+ // listener that throws inside a listener would surface as an unhandled error instead of a cancel.
785
+ const abort = () => {
786
+ if (child.exitCode === null && !child.killed && !child.stdin.destroyed) {
787
+ try {
788
+ child.stdin.write(`${JSON.stringify({ id: "abort", type: "abort" })}\n`);
789
+ }
790
+ catch { /* the child died between the check and the write; the abort is moot */ }
791
+ }
792
+ };
778
793
  signal.addEventListener("abort", abort, { once: true });
779
794
  // A blocking request is awaited inside the drain loop, so the loop cannot observe an abort or a
780
795
  // dead child on its own. Both are raced into the wait so a pending question can never outlive the run.
@@ -1006,6 +1021,8 @@ export class PiRpcRuntime {
1006
1021
  const detail = describeToolCallInProgress(name, argumentText.get(event.contentIndex) ?? "");
1007
1022
  onToolPreparing?.({ contentIndex: event.contentIndex, name, ...(delta ? { delta } : {}), ...(detail ? { detail } : {}) });
1008
1023
  }
1024
+ else if (event?.type === "thinking_delta")
1025
+ onThinking?.(typeof event.delta === "string" ? event.delta : "");
1009
1026
  else
1010
1027
  noteUnhandled(`message_update:${JSON.stringify(event?.type ?? "(missing)")}`);
1011
1028
  continue;
@@ -1335,8 +1352,14 @@ export class PiRpcRuntime {
1335
1352
  console.warn(`PiRpcRuntime: extension_ui_request method=${JSON.stringify(record.method ?? null)} id=${JSON.stringify(record.id ?? null)} is not one of Pi's nine Extension UI methods - consumed without reply.`);
1336
1353
  return;
1337
1354
  }
1338
- const reply = (response) => { if (child.killed || child.stdin.destroyed)
1339
- return; child.stdin.write(`${JSON.stringify(response)}\n`); };
1355
+ const reply = (response) => {
1356
+ if (child.exitCode === null && !child.killed && !child.stdin.destroyed) {
1357
+ try {
1358
+ child.stdin.write(`${JSON.stringify(response)}\n`);
1359
+ }
1360
+ catch { /* the child died between the check and the write; the reply is moot */ }
1361
+ }
1362
+ };
1340
1363
  if (!request.blocking) {
1341
1364
  // Deliberately not awaited: these five are fire-and-forget in Pi, and awaiting would stall the
1342
1365
  // drain loop on a UI that already told us it wants no reply.
@@ -1372,8 +1395,18 @@ export class PiRpcRuntime {
1372
1395
  }
1373
1396
  reply(buildExtensionUiResponse(request, answer));
1374
1397
  }
1375
- async stop(conversationId) { const run = this.runs.get(conversationId); if (run && !run.child.killed)
1376
- run.child.stdin.write(`${JSON.stringify({ id: "abort", type: "abort" })}\n`); }
1398
+ async stop(conversationId) {
1399
+ const run = this.runs.get(conversationId);
1400
+ if (!run)
1401
+ return;
1402
+ // Same guard as the abort listener in `answer`: a destroyed stdin throws synchronously on write.
1403
+ if (run.child.exitCode === null && !run.child.killed && !run.child.stdin.destroyed) {
1404
+ try {
1405
+ run.child.stdin.write(`${JSON.stringify({ id: "abort", type: "abort" })}\n`);
1406
+ }
1407
+ catch { /* the child died between the check and the write; the abort is moot */ }
1408
+ }
1409
+ }
1377
1410
  /**
1378
1411
  * Injects one message into the Pi process that is running right now, at its next turn boundary.
1379
1412
  *
@@ -1404,10 +1437,12 @@ export class PiRpcRuntime {
1404
1437
  if (!this.pi.found)
1405
1438
  throw new Error(this.pi.reason);
1406
1439
  const TIMEOUT_MS = 30_000;
1407
- const MAX_TITLE_CHARS = 40;
1440
+ // Session names live in a narrow sidebar row. Keep the model's budget well below the storage
1441
+ // limit so the result remains a concise label instead of becoming a sentence.
1442
+ const MAX_TITLE_CHARS = 18;
1408
1443
  // A throwaway session id, not the conversation's: this child reads nothing else and its history must
1409
1444
  // not be filed under the conversation whose title it is producing.
1410
- const child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", this.sessionDir, "--session-id", `autotitle-${Date.now()}`], {
1445
+ const child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", this.sessionDir, "--session-id", `autotitle-${randomUUID()}`], {
1411
1446
  cwd: resolve(cwd),
1412
1447
  stdio: ["pipe", "pipe", "pipe"],
1413
1448
  shell: process.platform === "win32",
@@ -1427,7 +1462,7 @@ export class PiRpcRuntime {
1427
1462
  message: [
1428
1463
  "为下面这段对话起一个简短的标题,用作会话列表里的一行字。",
1429
1464
  "只输出标题本身:不要引号,不要句末标点,不要「标题:」之类的前缀,不要解释,不要换行。",
1430
- `长度不超过 ${MAX_TITLE_CHARS} 个字符,用与对话内容相同的语言。`,
1465
+ `只生成一个简短的会话标签,长度严格不超过 ${MAX_TITLE_CHARS} 个字符(中文按一个字计数)。优先控制在 8-12 个字符;不要写成完整句子,不要使用副标题、解释、列表或多个候选标题。用与对话内容相同的语言。`,
1431
1466
  "",
1432
1467
  transcript,
1433
1468
  ].join("\n"),
@@ -1460,8 +1495,10 @@ export class PiRpcRuntime {
1460
1495
  .replace(/[。.!!,,、;;\s]+$/u, "")
1461
1496
  .trim()
1462
1497
  .slice(0, MAX_TITLE_CHARS);
1463
- if (!title)
1464
- throw new Error("模型没有返回可用的标题");
1498
+ if (!title) {
1499
+ const fallback = conversation.messages.find((message) => message.role === "user" && message.content.trim())?.content.trim();
1500
+ return (fallback || "新会话").slice(0, MAX_TITLE_CHARS);
1501
+ }
1465
1502
  return title;
1466
1503
  }
1467
1504
  finally {
@@ -232,6 +232,12 @@ export declare class Store {
232
232
  * instead of as a field it does not understand.
233
233
  */
234
234
  appendToolCall(conversationId: string, messageId: string, call: ToolCallBlock): Promise<ToolCallBlock[]>;
235
+ appendQuestion(conversationId: string, messageId: string, question: Omit<import("./types.js").QuestionBlock, "id" | "status">): Promise<import("./types.js").QuestionBlock>;
236
+ answerQuestion(conversationId: string, messageId: string, questionId: string, answer: {
237
+ selected?: string[];
238
+ extraNote?: string;
239
+ cancelled?: boolean;
240
+ }): Promise<import("./types.js").QuestionBlock[]>;
235
241
  /**
236
242
  * Fills in the outcome of the call at `index`, or returns the array UNCHANGED when there is no such
237
243
  * index.
@@ -1,3 +1,4 @@
1
+ import { rename } from "node:fs/promises";
1
2
  import { JsonStateRepository, defaultJsonStatePath } from "./persistence/json-state-repository.js";
2
3
  import { normalizeTodos } from "./task-tool.js";
3
4
  import { TOOL_ARG_CAP } from "./tool-calls.js";
@@ -481,8 +482,23 @@ export class Store {
481
482
  return store;
482
483
  }
483
484
  catch (error) {
484
- if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT")
485
- throw error;
485
+ const code = error instanceof Error ? error.code : undefined;
486
+ if (code !== "ENOENT") {
487
+ // A file-backed JSON state that cannot be parsed (or read) must not crash startup: the bad file is
488
+ // moved aside so a later run can still inspect it, and the server starts from a fresh state.
489
+ // Repositories without a file behind them keep failing loudly - there is nothing to quarantine.
490
+ if (!(repository instanceof JsonStateRepository))
491
+ throw error;
492
+ const quarantine = `${repository.filePath}.corrupt-${Date.now()}`;
493
+ const reason = error instanceof Error ? error.message : String(error);
494
+ try {
495
+ await rename(repository.filePath, quarantine);
496
+ console.warn(`Super Pi: state file ${repository.filePath} could not be loaded (${reason}) - it was moved to ${quarantine} and the server starts with a fresh state.`);
497
+ }
498
+ catch (moveError) {
499
+ console.warn(`Super Pi: state file ${repository.filePath} could not be loaded (${reason}) and could not be moved aside either (${moveError instanceof Error ? moveError.message : String(moveError)}) - the server starts with a fresh state; the unreadable file is still at ${repository.filePath}.`);
500
+ }
501
+ }
486
502
  const createdAt = now();
487
503
  return new Store(repository, { workspaces: [{ id: "workspace_default", name: "Super Pi", path: process.cwd(), updatedAt: createdAt }], conversations: [], subagentRuns: [], queuedMessages: [], backgroundTasks: [] });
488
504
  }
@@ -723,6 +739,7 @@ export class Store {
723
739
  throw new Error("Conversation name must be 1-80 characters");
724
740
  const conversation = this.requireConversation(conversationId);
725
741
  conversation.title = cleanName;
742
+ conversation.titleLocked = true;
726
743
  await this.persistMetadata(conversationId);
727
744
  return structuredClone(conversation);
728
745
  }
@@ -741,7 +758,8 @@ export class Store {
741
758
  const autoTitle = firstUser?.content.trim()
742
759
  ? firstUser.content.slice(0, 42)
743
760
  : (firstUser?.images?.length ? "【图片】" : DEFAULT_CONVERSATION_TITLE);
744
- conversation.title = autoTitle || DEFAULT_CONVERSATION_TITLE;
761
+ if (!conversation.titleLocked)
762
+ conversation.title = autoTitle || DEFAULT_CONVERSATION_TITLE;
745
763
  conversation.updatedAt = message.createdAt;
746
764
  conversation.status = "running";
747
765
  // The reason this conversation was failed - startup reconciliation's note that a previous run was cut
@@ -881,6 +899,31 @@ export class Store {
881
899
  await this.persist();
882
900
  return structuredClone(calls);
883
901
  }
902
+ async appendQuestion(conversationId, messageId, question) {
903
+ const message = this.requireConversation(conversationId).messages.find((item) => item.id === messageId);
904
+ if (!message)
905
+ throw new Error("Assistant message not found");
906
+ const block = { ...structuredClone(question), id: id("question"), status: "pending" };
907
+ (message.questionBlocks ??= []).push(block);
908
+ await this.persistTransaction({ kind: "message.replace", conversationId, message: structuredClone(message), updatedAt: now() });
909
+ return structuredClone(block);
910
+ }
911
+ async answerQuestion(conversationId, messageId, questionId, answer) {
912
+ const conversation = this.requireConversation(conversationId);
913
+ const message = conversation.messages.find((item) => item.id === messageId);
914
+ if (!message?.questionBlocks)
915
+ throw new Error("Question not found");
916
+ const block = message.questionBlocks.find((item) => item.id === questionId);
917
+ if (!block || block.status !== "pending")
918
+ throw new Error("Question is no longer pending");
919
+ block.status = answer.cancelled ? "cancelled" : "answered";
920
+ if (!answer.cancelled) {
921
+ block.selected = [...(answer.selected ?? [])];
922
+ block.extraNote = answer.extraNote ?? "";
923
+ }
924
+ await this.persistTransaction({ kind: "message.replace", conversationId, message: structuredClone(message), updatedAt: now() });
925
+ return structuredClone(message.questionBlocks);
926
+ }
884
927
  /**
885
928
  * Fills in the outcome of the call at `index`, or returns the array UNCHANGED when there is no such
886
929
  * index.
@@ -156,8 +156,10 @@ const MAX_ROWS = 200;
156
156
  * event loop. Chunks are queued and flushed on a timer, with a byte ceiling per frame so one flush
157
157
  * cannot build a megabyte-long string.
158
158
  *
159
- * The ceiling is applied to the QUEUE, not to the flush, because dropping a partial ANSI escape sequence
160
- * would corrupt the stream: the split is forced at the START of the next flush rather than mid-escape.
159
+ * The per-frame ceiling never DISCARDS data: a flush larger than MAX_CHUNK_BYTES sends its first
160
+ * MAX_CHUNK_BYTES and puts the remainder back at the FRONT of the queue - it is older than anything
161
+ * enqueued since - with the flush timer re-armed, so the split lands at the START of the next flush
162
+ * rather than mid-escape, where it could cut a partial ANSI escape sequence.
161
163
  */
162
164
  const FLUSH_INTERVAL_MS = 16;
163
165
  const MAX_CHUNK_BYTES = 64 * 1024;
@@ -257,7 +259,11 @@ export class TerminalService {
257
259
  this.sessions.set(command.sessionId, session);
258
260
  child.onData((data) => this.enqueue(session, command.sessionId, data, send));
259
261
  child.onExit(({ exitCode }) => {
260
- this.flush(session, command.sessionId, send);
262
+ // Drain everything still queued before reporting the exit: each flush sends at most
263
+ // MAX_CHUNK_BYTES and re-queues the remainder, so a large final burst takes several
264
+ // passes. Without the loop, the exit would cut off output the shell already produced.
265
+ while (session.pending)
266
+ this.flush(session, command.sessionId, send);
261
267
  this.sessions.delete(command.sessionId);
262
268
  // A killed shell on Windows reports 0xC0000139 (STATUS_ENTRYPOINT_NOT_FOUND) because that is how
263
269
  // ConPTY tears down, not because a command failed. Reporting it verbatim would tell the user a
@@ -265,8 +271,6 @@ export class TerminalService {
265
271
  if (session.closed && session.killed)
266
272
  return;
267
273
  session.closed = true;
268
- if (session.timer)
269
- clearTimeout(session.timer);
270
274
  send({ type: "terminal.exit", sessionId: command.sessionId, exitCode: session.killed ? 0 : exitCode });
271
275
  });
272
276
  return;
@@ -319,12 +323,21 @@ export class TerminalService {
319
323
  }
320
324
  let data = session.pending;
321
325
  session.pending = "";
322
- if (data.length > MAX_CHUNK_BYTES)
326
+ if (data.length > MAX_CHUNK_BYTES) {
327
+ // The ceiling bounds ONE FRAME, not the queue: the remainder is older data, so it goes back to
328
+ // the FRONT of the pending buffer and the timer is re-armed so the next flush sends it. Dropping
329
+ // it here would silently lose terminal output - the exact corruption the queue exists to avoid.
330
+ session.pending = data.slice(MAX_CHUNK_BYTES) + session.pending;
323
331
  data = data.slice(0, MAX_CHUNK_BYTES);
332
+ session.timer = setTimeout(() => this.flush(session, sessionId, send), FLUSH_INTERVAL_MS);
333
+ }
324
334
  send({ type: "terminal.output", sessionId, data });
325
335
  }
326
336
  kill(sessionId, session, send) {
327
- this.flush(session, sessionId, send);
337
+ // Drain the backlog before teardown so closing a terminal does not discard output the shell
338
+ // already produced; each flush sends at most MAX_CHUNK_BYTES per frame.
339
+ while (session.pending)
340
+ this.flush(session, sessionId, send);
328
341
  session.closed = true;
329
342
  session.killed = true;
330
343
  if (session.timer) {
@@ -55,6 +55,15 @@ export type MessageImage = {
55
55
  width?: number;
56
56
  height?: number;
57
57
  };
58
+ export type QuestionBlock = {
59
+ id: string;
60
+ question: string;
61
+ options: string[];
62
+ multiple: boolean;
63
+ status: "pending" | "answered" | "cancelled";
64
+ selected?: string[];
65
+ extraNote?: string;
66
+ };
58
67
  export type Message = {
59
68
  id: string;
60
69
  role: MessageRole;
@@ -62,6 +71,8 @@ export type Message = {
62
71
  createdAt: string;
63
72
  piEntryId?: string;
64
73
  toolCalls?: ToolCallBlock[];
74
+ questionBlocks?: QuestionBlock[];
75
+ thinking?: string;
65
76
  images?: MessageImage[];
66
77
  agentIdentity?: {
67
78
  id?: string;
@@ -250,6 +261,8 @@ export type Conversation = {
250
261
  status: RunStatus;
251
262
  messages: Message[];
252
263
  archivedAt?: string;
264
+ /** Set when the user explicitly names the session; automatic first-message titles must not overwrite it. */
265
+ titleLocked?: boolean;
253
266
  /**
254
267
  * Why this conversation is not `running`, in WORDS A PERSON READS - the same role `SubagentRun.error`
255
268
  * plays for a card, one level up.
@@ -678,7 +691,15 @@ export type ExtensionUiAnswer = {
678
691
  export type ClientCommand = ClientCommandBody & {
679
692
  operationRequestId?: string;
680
693
  };
681
- export type ClientCommandBody =
694
+ export type ClientCommandBody = {
695
+ type: "question.answer";
696
+ conversationId: string;
697
+ messageId: string;
698
+ questionId: string;
699
+ selected?: string[];
700
+ extraNote?: string;
701
+ cancelled?: boolean;
702
+ }
682
703
  /**
683
704
  * THE TERMINAL, and the reason `terminal.open` CARRIES NO PATH.
684
705
  *
@@ -689,7 +710,7 @@ export type ClientCommandBody =
689
710
  * the user already configured and a shell scoped to the whole disk, and it is structural rather than a
690
711
  * check that a later edit could forget.
691
712
  */
692
- {
713
+ | {
693
714
  type: "terminal.open";
694
715
  sessionId: string;
695
716
  workspaceId: string;
@@ -948,6 +969,8 @@ export type ServerVersionInfo = {
948
969
  runningVersion: string;
949
970
  diskVersion: string;
950
971
  needsRestart: boolean;
972
+ latestVersion?: string;
973
+ updateAvailable?: boolean;
951
974
  };
952
975
  export type ServerEvent = {
953
976
  type: "operation.result";
@@ -966,6 +989,12 @@ export type ServerEvent = {
966
989
  conversationId: string;
967
990
  messageId: string;
968
991
  delta: string;
992
+ } | {
993
+ type: "message.thinking";
994
+ conversationId: string;
995
+ messageId: string;
996
+ delta: string;
997
+ active: boolean;
969
998
  }
970
999
  /**
971
1000
  * THE ANSWER TO `conversation.fork`, as ONE event with an `ok` discriminant rather than two events or a
@@ -1021,6 +1050,11 @@ export type ServerEvent = {
1021
1050
  conversationId: string;
1022
1051
  messageId: string;
1023
1052
  toolCalls: ToolCallBlock[];
1053
+ } | {
1054
+ type: "message.questions";
1055
+ conversationId: string;
1056
+ messageId: string;
1057
+ questionBlocks: QuestionBlock[];
1024
1058
  } | {
1025
1059
  type: "error";
1026
1060
  message: string;