@evandu/super-pi 0.1.33 → 0.1.35
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.
- package/README.md +4 -0
- package/agents/super-pi-task/index.ts +4 -0
- package/dist/client/assets/index-CQQB4qba.js +50 -0
- package/dist/client/assets/index-DMST8uNT.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/extension.js +7 -2
- package/dist/server/active-memory.d.ts +15 -0
- package/dist/server/active-memory.js +36 -1
- package/dist/server/application/conversation-service.d.ts +39 -0
- package/dist/server/application/conversation-service.js +65 -4
- package/dist/server/application/extension-ui-service.js +16 -0
- package/dist/server/application/run-service.d.ts +21 -0
- package/dist/server/application/run-service.js +130 -19
- package/dist/server/command-handler.d.ts +14 -0
- package/dist/server/command-handler.js +22 -0
- package/dist/server/git.d.ts +37 -0
- package/dist/server/git.js +30 -0
- package/dist/server/http-server.js +126 -8
- package/dist/server/index.js +7 -2
- package/dist/server/memory.d.ts +27 -0
- package/dist/server/memory.js +56 -0
- package/dist/server/persistence/json-state-repository.d.ts +2 -0
- package/dist/server/persistence/json-state-repository.js +24 -5
- package/dist/server/runtime.d.ts +4 -1
- package/dist/server/runtime.js +51 -14
- package/dist/server/store.d.ts +6 -0
- package/dist/server/store.js +46 -3
- package/dist/server/terminal.js +30 -7
- package/dist/server/types.d.ts +36 -2
- package/dist/server/upload.js +8 -19
- package/dist/server/ws-server.js +89 -28
- package/package.json +5 -3
- package/dist/client/assets/index-D6bdUvG_.css +0 -1
- 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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
171
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
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
|