@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
@@ -49,10 +49,27 @@ export declare class RunService {
49
49
  setMemory(memory: import("../active-memory.js").ActiveMemory): void;
50
50
  reviewMemoryBoundary(conversationId: string): void;
51
51
  private readonly activeRuns;
52
+ /** Per-conversation command lane. All queue mutations and run claims use the same lane, so
53
+ * two browser tabs cannot both observe an idle conversation and start duplicate turns. */
54
+ private readonly conversationOps;
52
55
  /** Detached work (human and background sub-agents) which shutdown must still await. */
53
56
  private readonly detached;
54
57
  constructor(store: Store, runtime: AgentRuntime, subagents: SubagentRunRecorder, broadcaster: EventBroadcaster, extensionUiService: ExtensionUiService, agentsDir: string | string[], workflows: WorkflowEngine);
55
58
  setAgentsDir(dir: string | string[]): void;
59
+ answerQuestion(command: {
60
+ conversationId: string;
61
+ messageId: string;
62
+ questionId: string;
63
+ selected?: string[];
64
+ extraNote?: string;
65
+ cancelled?: boolean;
66
+ }): Promise<void>;
67
+ askQuestion(conversationId: string, question: {
68
+ question: string;
69
+ options: string[];
70
+ multiple?: boolean;
71
+ extraNote?: boolean;
72
+ }): Promise<unknown>;
56
73
  /**
57
74
  * Resolves the conversation and workspace synchronously for a run.
58
75
  */
@@ -82,10 +99,12 @@ export declare class RunService {
82
99
  */
83
100
  waitForIdle(): Promise<void>;
84
101
  private trackDetached;
102
+ private serializeConversation;
85
103
  /**
86
104
  * Stops an active turn run for a conversation.
87
105
  */
88
106
  stopRun(conversationId: string): Promise<void>;
107
+ private stopRunUnsafe;
89
108
  /**
90
109
  * Handles queue snapshot updates from Pi RPC.
91
110
  */
@@ -113,10 +132,12 @@ export declare class RunService {
113
132
  * Steers a queued message into the in-flight turn.
114
133
  */
115
134
  steer(conversationId: string, queueId: string): Promise<void>;
135
+ private steerUnsafe;
116
136
  /**
117
137
  * Cancels a queued message.
118
138
  */
119
139
  cancelQueued(conversationId: string, queueId: string): Promise<void>;
140
+ private cancelQueuedUnsafe;
120
141
  /**
121
142
  * Dispatches a subagent from client command (human dispatch).
122
143
  */
@@ -3,6 +3,40 @@ import { newSubagentRunId } from "../subagents.js";
3
3
  import { readAgents } from "../agents.js";
4
4
  import { verifyCapability } from "../capability.js";
5
5
  import { validateWorkflowPlan, workflowSummary } from "../workflow.js";
6
+ import { execFile } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+ const execFileAsync = promisify(execFile);
9
+ /** Expands composer references into explicit context for the model. */
10
+ async function expandAtReferences(store, workspace, content) {
11
+ const refs = [...content.matchAll(/@(worktree|session):([^\s]+)/gi)];
12
+ if (!refs.length)
13
+ return content;
14
+ const blocks = [];
15
+ for (const [, kind, raw] of refs) {
16
+ const key = raw.trim();
17
+ if (kind.toLowerCase() === "session") {
18
+ const state = store.snapshot();
19
+ const found = state.conversations.find(c => c.id === key || c.title.toLocaleLowerCase().includes(key.toLocaleLowerCase()));
20
+ if (!found)
21
+ continue;
22
+ const text = found.messages.filter(m => m.role === "user" || m.role === "assistant").slice(-12).map(m => `${m.role}: ${m.content}`).join("\n");
23
+ blocks.push(`\n[引用会话 ${found.title} (${found.id})]\n${text.slice(-12000)}\n[/引用会话]`);
24
+ continue;
25
+ }
26
+ try {
27
+ const { stdout } = await execFileAsync("git", ["-C", workspace.path, "worktree", "list", "--porcelain"], { maxBuffer: 1024 * 1024 });
28
+ const entries = stdout.split(/\n\n+/).map(block => {
29
+ const path = block.match(/^worktree (.+)$/m)?.[1];
30
+ return path ? { path, block } : undefined;
31
+ }).filter(Boolean);
32
+ const found = entries.find(e => e.path === key || e.path.endsWith(`\\${key}`) || e.path.endsWith(`/${key}`));
33
+ if (found)
34
+ blocks.push(`\n[引用 worktree ${found.path}]\n该 worktree 的路径是:${found.path}\n[/引用 worktree]`);
35
+ }
36
+ catch { /* non-git workspaces simply have no worktree references */ }
37
+ }
38
+ return blocks.length ? `${content}\n${blocks.join("\n")}` : content;
39
+ }
6
40
  /**
7
41
  * Service managing execution of conversation turns, message queueing,
8
42
  * follow-ups, and sub-agent dispatching.
@@ -23,6 +57,9 @@ export class RunService {
23
57
  this.memory?.review(conversation);
24
58
  }
25
59
  activeRuns = new Map();
60
+ /** Per-conversation command lane. All queue mutations and run claims use the same lane, so
61
+ * two browser tabs cannot both observe an idle conversation and start duplicate turns. */
62
+ conversationOps = new Map();
26
63
  /** Detached work (human and background sub-agents) which shutdown must still await. */
27
64
  detached = new Set();
28
65
  constructor(store, runtime, subagents, broadcaster, extensionUiService, agentsDir, workflows) {
@@ -37,6 +74,32 @@ export class RunService {
37
74
  setAgentsDir(dir) {
38
75
  this.agentsDir = dir;
39
76
  }
77
+ async answerQuestion(command) {
78
+ const blocks = await this.store.answerQuestion(command.conversationId, command.messageId, command.questionId, command);
79
+ this.broadcaster.broadcast({ type: "message.questions", conversationId: command.conversationId, messageId: command.messageId, questionBlocks: blocks });
80
+ }
81
+ async askQuestion(conversationId, question) {
82
+ const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
83
+ const message = conversation?.messages.slice().reverse().find((item) => item.role === "assistant");
84
+ if (!message)
85
+ throw new Error("No active assistant message");
86
+ const block = await this.store.appendQuestion(conversationId, message.id, { question: question.question, options: question.options, multiple: question.multiple === true });
87
+ this.broadcaster.broadcast({ type: "message.questions", conversationId, messageId: message.id, questionBlocks: [...(message.questionBlocks ?? []), block] });
88
+ return new Promise((resolve, reject) => {
89
+ const timer = setInterval(() => {
90
+ const current = this.store.snapshot().conversations.find((item) => item.id === conversationId)?.messages.find((item) => item.id === message.id)?.questionBlocks?.find((item) => item.id === block.id);
91
+ if (current?.status === "answered") {
92
+ clearInterval(timer);
93
+ resolve({ selected: current.selected ?? [], extraNote: current.extraNote ?? "" });
94
+ }
95
+ else if (current?.status === "cancelled") {
96
+ clearInterval(timer);
97
+ resolve({ cancelled: true });
98
+ }
99
+ }, 250);
100
+ setTimeout(() => { clearInterval(timer); reject(new Error("Question timed out")); }, 30 * 60 * 1000);
101
+ });
102
+ }
40
103
  /**
41
104
  * Resolves the conversation and workspace synchronously for a run.
42
105
  */
@@ -95,14 +158,28 @@ export class RunService {
95
158
  void work.then(() => this.detached.delete(work), () => this.detached.delete(work));
96
159
  return work;
97
160
  }
161
+ serializeConversation(conversationId, work) {
162
+ const previous = this.conversationOps.get(conversationId) ?? Promise.resolve();
163
+ const next = previous.catch(() => undefined).then(work);
164
+ const lane = next.then(() => undefined, () => undefined);
165
+ this.conversationOps.set(conversationId, lane);
166
+ void lane.finally(() => { if (this.conversationOps.get(conversationId) === lane)
167
+ this.conversationOps.delete(conversationId); });
168
+ return next;
169
+ }
98
170
  /**
99
171
  * Stops an active turn run for a conversation.
100
172
  */
101
173
  async stopRun(conversationId) {
102
- this.memory?.cancel(conversationId);
103
- this.extensionUiService.cancelPendingUi(conversationId, "aborted");
174
+ // Abort immediately before waiting for the conversation lane; otherwise a stop queued behind a
175
+ // long streaming command could never reach the controller that it is meant to cancel.
104
176
  this.activeRuns.get(conversationId)?.abort();
105
177
  await this.runtime.stop?.(conversationId);
178
+ return this.serializeConversation(conversationId, () => this.stopRunUnsafe(conversationId));
179
+ }
180
+ async stopRunUnsafe(conversationId) {
181
+ this.memory?.cancel(conversationId);
182
+ this.extensionUiService.cancelPendingUi(conversationId, "aborted");
106
183
  await this.store.setStatus(conversationId, "stopped");
107
184
  if (this.subagents.stopConversation(conversationId)) {
108
185
  console.log(`Super Pi: stopped the sub-agent runs of conversation ${conversationId}.`);
@@ -272,6 +349,7 @@ export class RunService {
272
349
  onToolPreparing,
273
350
  onToolUpdate,
274
351
  onToolEnd,
352
+ onThinking: (delta) => this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true }),
275
353
  images,
276
354
  })) {
277
355
  await this.store.appendAssistantDelta(conversation.id, assistant.id, delta);
@@ -319,20 +397,16 @@ export class RunService {
319
397
  * Enqueues or starts execution for a user message.
320
398
  */
321
399
  async enqueueAndRun(conversationId, content, images) {
400
+ // The run itself must not occupy the mutation lane: a second send needs to observe the active run
401
+ // and enqueue immediately while the first model turn is still streaming.
322
402
  this.memory?.cancel(conversationId);
323
403
  const trimmed = content.trim();
324
404
  const validImages = Array.isArray(images) && images.length > 0 ? images : undefined;
325
405
  if (!trimmed && !validImages?.length)
326
406
  return;
327
407
  const { conversation, workspace } = this.turnContext(conversationId);
328
- if (validImages?.length) {
329
- const catalog = await this.runtime.catalog(conversation.model);
330
- const currentModel = catalog.models.find((model) => model.provider === conversation.model?.provider && model.modelId === conversation.model?.modelId)
331
- ?? catalog.models.find((m) => m.provider === catalog.defaultModel?.provider && m.modelId === catalog.defaultModel?.modelId);
332
- if (currentModel && !currentModel.imageInput) {
333
- throw new Error(`当前选择的模型「${currentModel.label || currentModel.modelId}」不支持图片输入`);
334
- }
335
- }
408
+ // Claim the run slot synchronously (no await between has() and set()) so two
409
+ // rapid sends can never both pass the guard and start concurrent turns.
336
410
  if (this.activeRuns.has(conversation.id)) {
337
411
  await this.store.enqueueMessage(conversation.id, trimmed, validImages);
338
412
  this.broadcaster.broadcastState();
@@ -341,7 +415,16 @@ export class RunService {
341
415
  const controller = new AbortController();
342
416
  this.activeRuns.set(conversation.id, controller);
343
417
  try {
344
- await this.runConversationTurns(conversation, workspace, trimmed, controller, validImages);
418
+ if (validImages?.length) {
419
+ const catalog = await this.runtime.catalog(conversation.model);
420
+ const currentModel = catalog.models.find((model) => model.provider === conversation.model?.provider && model.modelId === conversation.model?.modelId)
421
+ ?? catalog.models.find((m) => m.provider === catalog.defaultModel?.provider && m.modelId === catalog.defaultModel?.modelId);
422
+ if (currentModel && !currentModel.imageInput) {
423
+ throw new Error(`当前选择的模型「${currentModel.label || currentModel.modelId}」不支持图片输入`);
424
+ }
425
+ }
426
+ const expanded = await expandAtReferences(this.store, workspace, trimmed);
427
+ await this.runConversationTurns(conversation, workspace, expanded, controller, validImages);
345
428
  await this.store.setStatus(conversation.id, controller.signal.aborted ? "stopped" : "completed");
346
429
  }
347
430
  catch (error) {
@@ -358,6 +441,9 @@ export class RunService {
358
441
  * Steers a queued message into the in-flight turn.
359
442
  */
360
443
  async steer(conversationId, queueId) {
444
+ return this.serializeConversation(conversationId, () => this.steerUnsafe(conversationId, queueId));
445
+ }
446
+ async steerUnsafe(conversationId, queueId) {
361
447
  if (!this.runtime.steer) {
362
448
  throw new Error("当前运行时没有插队能力,无法把排队消息插到正在运行的这轮里");
363
449
  }
@@ -379,6 +465,9 @@ export class RunService {
379
465
  * Cancels a queued message.
380
466
  */
381
467
  async cancelQueued(conversationId, queueId) {
468
+ return this.serializeConversation(conversationId, () => this.cancelQueuedUnsafe(conversationId, queueId));
469
+ }
470
+ async cancelQueuedUnsafe(conversationId, queueId) {
382
471
  await this.store.cancelQueuedMessage(conversationId, queueId);
383
472
  this.broadcaster.broadcastState();
384
473
  }
@@ -460,19 +549,41 @@ export class RunService {
460
549
  void this.trackDetached(dispatch.then(async (run) => {
461
550
  if (run.status === "completed")
462
551
  this.memory?.review(this.turnContext(conversationId).conversation, { task: body.prompt, result: run.output });
463
- const result = run.status === "completed"
464
- ? run.output ?? ""
465
- : `Background task failed: ${run.error ?? run.status}`;
552
+ const result = run.status === "completed" ? run.output ?? "" : `Background task failed: ${run.error ?? run.status}`;
466
553
  const tag = run.status === "completed" ? "task_result" : "task_error";
467
554
  const message = `<task id="${run.id}" state="${run.status === "completed" ? "completed" : "error"}">\n<summary>Background task ${run.status === "completed" ? "completed" : "failed"}: ${body.description?.trim() || subagentType}</summary>\n<${tag}>\n${result}\n</${tag}>\n</task>`;
468
- await this.store.enqueueMessage(conversation.id, message);
469
- await this.store.removeBackgroundTask(run.id);
470
- this.broadcaster.broadcastState();
471
- })).catch(async (error) => {
555
+ // Background results are durable follow-up material. Keep them visible in the queue until the
556
+ // user sends the next prompt; injecting a result into an idle conversation starts an unexpected
557
+ // model turn and makes the result disappear before the Queue Dock can render it.
558
+ try {
559
+ if (this.activeRuns.has(conversation.id))
560
+ await this.enqueueAndRun(conversation.id, message);
561
+ else
562
+ await this.store.enqueueMessage(conversation.id, message);
563
+ }
564
+ catch (error) {
565
+ await this.store.enqueueMessage(conversation.id, message);
566
+ console.warn(`Super Pi: background result could not start a turn: ${error instanceof Error ? error.message : "unknown error"}`);
567
+ }
568
+ finally {
569
+ await this.store.removeBackgroundTask(run.id);
570
+ this.broadcaster.broadcastState();
571
+ }
572
+ }).catch(async (error) => {
573
+ const message = `<task id="${backgroundId}" state="error">\n<summary>Background task failed: ${body.description?.trim() || subagentType}</summary>\n<task_error>Background task failed before completion: ${error instanceof Error ? error.message : "unknown error"}</task_error>\n</task>`;
574
+ try {
575
+ if (this.activeRuns.has(conversationId))
576
+ await this.enqueueAndRun(conversationId, message);
577
+ else
578
+ await this.store.enqueueMessage(conversationId, message);
579
+ }
580
+ catch {
581
+ await this.store.enqueueMessage(conversationId, message);
582
+ }
472
583
  await this.store.removeBackgroundTask(backgroundId);
473
584
  this.broadcaster.broadcastState();
474
585
  console.warn(`Super Pi: background sub-agent dispatch failed: ${error instanceof Error ? error.message : "unknown error"}`);
475
- });
586
+ }));
476
587
  return {
477
588
  mode: "async",
478
589
  status: 202,
@@ -32,5 +32,19 @@ export declare class CommandHandler {
32
32
  * connection came from, because it is the one command family whose blast radius is the whole machine
33
33
  * rather than one workspace. See `TerminalService.handle`.
34
34
  */
35
+ /**
36
+ * Takes the reverting claim for a conversation from the moment the command is RECEIVED.
37
+ *
38
+ * A `message.send` runs outside this socket's command queue - deliberately, so a follow-up can be
39
+ * admitted while a turn streams - so it can be handled before a queued `message.revert` has started
40
+ * cutting anything. Calling `isReverting` from the send path then answers "no" to a send that is
41
+ * racing a cut in every sense that matters, and the message lands on a transcript the model still
42
+ * reads in full. The socket layer calls this the moment it parses the revert, which is the only point
43
+ * on this side of the service that is early enough; `releaseRevert` gives the claim back when the
44
+ * command finishes, so a revert that never reaches the service cannot hold the conversation shut.
45
+ */
46
+ claimRevert(conversationId: string): void;
47
+ /** The other half of `claimRevert`. Safe to call for a conversation nothing claimed. */
48
+ releaseRevert(conversationId: string): void;
35
49
  handleCommand(command: ClientCommand, sendToSocket?: SocketSender, remoteAddress?: string): Promise<string | void>;
36
50
  }
@@ -31,6 +31,24 @@ export class CommandHandler {
31
31
  * connection came from, because it is the one command family whose blast radius is the whole machine
32
32
  * rather than one workspace. See `TerminalService.handle`.
33
33
  */
34
+ /**
35
+ * Takes the reverting claim for a conversation from the moment the command is RECEIVED.
36
+ *
37
+ * A `message.send` runs outside this socket's command queue - deliberately, so a follow-up can be
38
+ * admitted while a turn streams - so it can be handled before a queued `message.revert` has started
39
+ * cutting anything. Calling `isReverting` from the send path then answers "no" to a send that is
40
+ * racing a cut in every sense that matters, and the message lands on a transcript the model still
41
+ * reads in full. The socket layer calls this the moment it parses the revert, which is the only point
42
+ * on this side of the service that is early enough; `releaseRevert` gives the claim back when the
43
+ * command finishes, so a revert that never reaches the service cannot hold the conversation shut.
44
+ */
45
+ claimRevert(conversationId) {
46
+ this.conversationService.claimRevert(conversationId);
47
+ }
48
+ /** The other half of `claimRevert`. Safe to call for a conversation nothing claimed. */
49
+ releaseRevert(conversationId) {
50
+ this.conversationService.releaseRevert(conversationId);
51
+ }
34
52
  async handleCommand(command, sendToSocket, remoteAddress) {
35
53
  if (command.type === "terminal.open" || command.type === "terminal.input" || command.type === "terminal.resize" || command.type === "terminal.close") {
36
54
  if (!this.terminalService) {
@@ -187,6 +205,10 @@ export class CommandHandler {
187
205
  this.extensionUiService.answerUi(command.conversationId, command.requestId, answer);
188
206
  return;
189
207
  }
208
+ if (command.type === "question.answer") {
209
+ await this.runService.answerQuestion(command);
210
+ return;
211
+ }
190
212
  if (command.type === "catalog.fetch") {
191
213
  if (sendToSocket)
192
214
  sendToSocket({ type: "model.catalog", conversationId: command.conversationId, catalog: await this.catalogService.getCatalog(undefined, command.conversationId) });
@@ -0,0 +1,37 @@
1
+ export type GitWorkspace = {
2
+ branch: string | null;
3
+ detached: boolean;
4
+ unborn: boolean;
5
+ ahead: number | null;
6
+ behind: number | null;
7
+ staged: GitFile[];
8
+ unstaged: GitFile[];
9
+ untracked: GitFile[];
10
+ };
11
+ export type GitFile = {
12
+ path: string;
13
+ name: string;
14
+ xy: string;
15
+ };
16
+ export declare function gitStatus(root: string): Promise<GitWorkspace>;
17
+ export declare function gitLog(root: string, limit: number): Promise<{
18
+ commits: {
19
+ hash: string;
20
+ short: string;
21
+ subject: string;
22
+ author: string;
23
+ date: string;
24
+ }[];
25
+ truncated: boolean;
26
+ }>;
27
+ export declare function gitDiff(root: string, path: string, staged: boolean): Promise<{
28
+ path: string;
29
+ staged: boolean;
30
+ diff: null;
31
+ kind: "untracked";
32
+ } | {
33
+ path: string;
34
+ staged: boolean;
35
+ diff: string;
36
+ kind: "text";
37
+ }>;
@@ -0,0 +1,30 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { basename, resolve, sep } from "node:path";
4
+ import { existsSync } from "node:fs";
5
+ const exec = promisify(execFile);
6
+ async function git(cwd, args) { return exec("git", ["-c", "core.quotepath=false", ...args], { cwd, windowsHide: true, maxBuffer: 8 * 1024 * 1024 }); }
7
+ function files(raw) { const staged = [], unstaged = [], untracked = []; for (const line of raw.split("\0").filter(Boolean)) {
8
+ const xy = line.slice(0, 2), path = line.slice(3).split("\0")[0].replaceAll("\\", "/");
9
+ const item = { path, name: basename(path), xy };
10
+ if (xy === "??")
11
+ untracked.push(item);
12
+ else {
13
+ if (xy[0] !== " ")
14
+ staged.push(item);
15
+ if (xy[1] !== " ")
16
+ unstaged.push(item);
17
+ }
18
+ } return { staged, unstaged, untracked }; }
19
+ export async function gitStatus(root) { try {
20
+ await git(root, ["rev-parse", "--git-dir"]);
21
+ }
22
+ catch {
23
+ throw Object.assign(new Error("此工作区不是 Git 仓库"), { code: "not_a_repo" });
24
+ } const status = await git(root, ["status", "--porcelain=v1", "-z"]); const branch = await git(root, ["symbolic-ref", "--short", "-q", "HEAD"]).then(r => r.stdout.trim() || null).catch(() => null); const unborn = !(await git(root, ["rev-parse", "--verify", "HEAD"]).then(() => true).catch(() => false)); const ahead = await git(root, ["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]).then(r => { const [a, b] = r.stdout.trim().split(/\s+/).map(Number); return Number.isFinite(a) ? a : null; }).catch(() => null); const behind = await git(root, ["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]).then(r => { const [a, b] = r.stdout.trim().split(/\s+/).map(Number); return Number.isFinite(b) ? b : null; }).catch(() => null); return { branch, detached: !branch, unborn, ahead, behind, ...files(status.stdout) }; }
25
+ export async function gitLog(root, limit) { const hasHead = await git(root, ["rev-parse", "--verify", "HEAD"]).then(() => true).catch(() => false); if (!hasHead)
26
+ return { commits: [], truncated: false }; const out = await git(root, ["log", `-${Math.min(Math.max(limit, 1), 50)}`, "--format=%H%x00%h%x00%s%x00%an%x00%aI%x00"]); const parts = out.stdout.split("\0").filter(Boolean); const commits = []; for (let i = 0; i + 4 < parts.length; i += 5)
27
+ commits.push({ hash: parts[i], short: parts[i + 1], subject: parts[i + 2], author: parts[i + 3], date: new Date(parts[i + 4]).toISOString() }); return { commits, truncated: commits.length >= limit }; }
28
+ export async function gitDiff(root, path, staged) { const safe = resolve(root, path); if (!safe.startsWith(resolve(root) + sep) || !existsSync(safe))
29
+ throw Object.assign(new Error("文件路径无效"), { code: "path" }); const check = await git(root, ["ls-files", "--error-unmatch", "--", path]).then(() => true).catch(() => false); if (!check)
30
+ return { path, staged, diff: null, kind: "untracked" }; const out = await git(root, ["diff", ...(staged ? ["--cached"] : []), "--", path]); return { path, staged, diff: out.stdout, kind: "text" }; }
@@ -7,6 +7,7 @@ import { basename, dirname, extname, join, relative, resolve } from "node:path";
7
7
  import { describe as describeFilesError, fileMeta, listDirectory, MAX_FILE_BYTES, readWorkspaceFile, resolveInsideRoot, searchByName, toRelative, writeWorkspaceFile, } from "./files.js";
8
8
  import { findUploadFile, MAX_UPLOAD_BODY_BYTES, saveUpload } from "./upload.js";
9
9
  import { checkDiskVersion } from "./version.js";
10
+ import { gitDiff, gitLog, gitStatus } from "./git.js";
10
11
  const MIME_TYPES = {
11
12
  ".css": "text/css; charset=utf-8",
12
13
  ".html": "text/html; charset=utf-8",
@@ -166,10 +167,12 @@ export function createHttpServer(options) {
166
167
  }
167
168
  try {
168
169
  let raw = "";
170
+ let totalBytes = 0;
169
171
  for await (const chunk of request) {
170
- raw += chunk;
171
- if (raw.length > 512000)
172
+ totalBytes += chunk.length;
173
+ if (totalBytes > 512000)
172
174
  throw new Error("记忆请求过大");
175
+ raw += chunk;
173
176
  }
174
177
  const body = raw ? JSON.parse(raw) : {};
175
178
  const result = url.pathname.endsWith("/tool")
@@ -206,10 +209,26 @@ export function createHttpServer(options) {
206
209
  const restart = spawn(process.execPath, [join(rootDir, "scripts", "restart-server.mjs")], { cwd: rootDir, detached: true, stdio: "ignore", env: process.env });
207
210
  restart.unref();
208
211
  });
209
- child.on("error", () => { });
212
+ child.on("error", (error) => { console.warn(`Super Pi: update spawn failed: ${error instanceof Error ? error.message : "unknown error"}`); });
210
213
  child.unref();
211
214
  return;
212
215
  }
216
+ if (url.pathname === "/api/update-check" && request.method === "GET") {
217
+ if (!isLocalOrPrivateAddress(remote)) {
218
+ response.writeHead(403).end("Update check requires a loopback or private network request");
219
+ return;
220
+ }
221
+ try {
222
+ const current = checkDiskVersion(rootDir);
223
+ const remoteVersion = await fetch("https://pi.dev/api/latest-version").then(result => result.ok ? result.json() : Promise.reject(new Error(`HTTP ${result.status}`)));
224
+ const latestVersion = typeof remoteVersion.version === "string" ? remoteVersion.version : undefined;
225
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ ...current, latestVersion, updateAvailable: Boolean(latestVersion && latestVersion !== current.diskVersion) }));
226
+ }
227
+ catch (error) {
228
+ response.writeHead(502, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
229
+ }
230
+ return;
231
+ }
213
232
  if (url.pathname === "/api/health") {
214
233
  const version = checkDiskVersion(rootDir);
215
234
  response.writeHead(200, { "content-type": "application/json; charset=utf-8" })
@@ -234,14 +253,63 @@ export function createHttpServer(options) {
234
253
  .end(JSON.stringify({ enabled, reason: enabled ? "" : (context.terminalDisabledReason?.() ?? "终端未启用。") }));
235
254
  return;
236
255
  }
256
+ if (url.pathname === "/api/git") {
257
+ const json = (status, body) => response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
258
+ try {
259
+ if (request.method !== "GET") {
260
+ json(405, { error: "Git 视图需要 GET", code: "method" });
261
+ return;
262
+ }
263
+ const workspaceId = url.searchParams.get("workspaceId");
264
+ const workspace = context.store.snapshot().workspaces.find((item) => item.id === workspaceId);
265
+ if (!workspace) {
266
+ json(404, { error: `工作区 ${workspaceId ?? "(missing)"} 不存在`, code: "no_workspace" });
267
+ return;
268
+ }
269
+ const op = url.searchParams.get("op") ?? "status";
270
+ if (op === "status")
271
+ json(200, await gitStatus(workspace.path));
272
+ else if (op === "log")
273
+ json(200, await gitLog(workspace.path, Number(url.searchParams.get("limit") ?? 20)));
274
+ else if (op === "diff") {
275
+ const path = url.searchParams.get("path");
276
+ if (!path) {
277
+ json(400, { error: "diff 需要 path", code: "bad_path" });
278
+ return;
279
+ }
280
+ json(200, await gitDiff(workspace.path, path, url.searchParams.get("staged") === "1"));
281
+ }
282
+ else
283
+ json(400, { error: "未知 Git 操作", code: "bad_op" });
284
+ }
285
+ catch (error) {
286
+ const message = error instanceof Error ? error.message : "Git 读取失败";
287
+ const code = error?.code;
288
+ if (code === 128 || code === "not_a_repo" || /not a git repository/i.test(message))
289
+ json(404, { error: "此工作区不是 git 仓库", code: "not_a_repo" });
290
+ else if (code === "path")
291
+ json(400, { error: message, code: "bad_path" });
292
+ else
293
+ json(400, { error: message, code: "git_error" });
294
+ }
295
+ return;
296
+ }
237
297
  if (url.pathname === "/api/subagent/dispatch") {
238
298
  if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
239
299
  response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Sub-agent dispatch requires a loopback or private network POST");
240
300
  return;
241
301
  }
242
302
  let raw = "";
243
- for await (const chunk of request)
303
+ let totalBytes = 0;
304
+ for await (const chunk of request) {
305
+ totalBytes += chunk.length;
306
+ if (totalBytes > MAX_FILE_BYTES * 2) {
307
+ response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
308
+ request.destroy();
309
+ return;
310
+ }
244
311
  raw += chunk;
312
+ }
245
313
  const json = (status, body) => {
246
314
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
247
315
  };
@@ -261,8 +329,16 @@ export function createHttpServer(options) {
261
329
  return;
262
330
  }
263
331
  let raw = "";
264
- for await (const chunk of request)
332
+ let totalBytes = 0;
333
+ for await (const chunk of request) {
334
+ totalBytes += chunk.length;
335
+ if (totalBytes > MAX_FILE_BYTES * 2) {
336
+ response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
337
+ request.destroy();
338
+ return;
339
+ }
265
340
  raw += chunk;
341
+ }
266
342
  const json = (status, body) => {
267
343
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
268
344
  };
@@ -281,8 +357,16 @@ export function createHttpServer(options) {
281
357
  return;
282
358
  }
283
359
  let raw = "";
284
- for await (const chunk of request)
360
+ let totalBytes = 0;
361
+ for await (const chunk of request) {
362
+ totalBytes += chunk.length;
363
+ if (totalBytes > MAX_FILE_BYTES * 2) {
364
+ response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
365
+ request.destroy();
366
+ return;
367
+ }
285
368
  raw += chunk;
369
+ }
286
370
  const json = (status, body) => {
287
371
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
288
372
  };
@@ -300,6 +384,26 @@ export function createHttpServer(options) {
300
384
  }
301
385
  return;
302
386
  }
387
+ if (url.pathname === "/api/question") {
388
+ if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
389
+ response.writeHead(403).end();
390
+ return;
391
+ }
392
+ let raw = "";
393
+ for await (const chunk of request)
394
+ raw += chunk;
395
+ try {
396
+ const body = JSON.parse(raw);
397
+ if (!body.conversationId || !body.question || !Array.isArray(body.options))
398
+ throw new Error("invalid question");
399
+ const result = await runService.askQuestion(body.conversationId, { question: body.question, options: body.options, multiple: body.multiple });
400
+ response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(result));
401
+ }
402
+ catch (error) {
403
+ response.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: error instanceof Error ? error.message : "question failed" }));
404
+ }
405
+ return;
406
+ }
303
407
  if (url.pathname === "/api/files" || url.pathname === "/api/file") {
304
408
  const json = (status, body) => {
305
409
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
@@ -345,8 +449,16 @@ export function createHttpServer(options) {
345
449
  return;
346
450
  }
347
451
  let raw = "";
348
- for await (const chunk of request)
452
+ let totalBytes = 0;
453
+ for await (const chunk of request) {
454
+ totalBytes += chunk.length;
455
+ if (totalBytes > MAX_FILE_BYTES * 2) {
456
+ json(413, { error: "请求体过大", code: "too_large" });
457
+ request.destroy();
458
+ return;
459
+ }
349
460
  raw += chunk;
461
+ }
350
462
  const declared = Number(request.headers["content-length"] ?? "0");
351
463
  if (Number.isFinite(declared) && declared > MAX_FILE_BYTES * 2) {
352
464
  json(413, { error: "请求体过大", code: "too_large" });
@@ -516,7 +628,13 @@ export function createHttpServer(options) {
516
628
  "cache-control": "public, max-age=31536000, immutable",
517
629
  "content-length": found.size,
518
630
  });
519
- createReadStream(found.filePath).pipe(response);
631
+ createReadStream(found.filePath).on("error", () => {
632
+ if (!response.headersSent) {
633
+ response.writeHead(500, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "Failed to read upload file" }));
634
+ return;
635
+ }
636
+ response.destroy();
637
+ }).pipe(response);
520
638
  return;
521
639
  }
522
640
  // Static client asset handling