@oversk7/pi-web-dsh-style 0.1.0-alpha.1

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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -0
  3. package/THIRD_PARTY_NOTICES.md +90 -0
  4. package/abort-session.ts +150 -0
  5. package/attachment-store.ts +161 -0
  6. package/file-preview.ts +94 -0
  7. package/index.ts +177 -0
  8. package/local-session.ts +190 -0
  9. package/package.json +70 -0
  10. package/pi-rpc.ts +253 -0
  11. package/server.ts +3060 -0
  12. package/sse-channel.ts +72 -0
  13. package/state-store.ts +64 -0
  14. package/transcript.ts +328 -0
  15. package/web/app.css +535 -0
  16. package/web/app.js +4228 -0
  17. package/web/assets/fonts/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  18. package/web/assets/fonts/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  19. package/web/assets/fonts/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  20. package/web/assets/fonts/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  21. package/web/assets/fonts/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  22. package/web/assets/fonts/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  23. package/web/assets/fonts/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  24. package/web/assets/fonts/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  25. package/web/assets/fonts/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  26. package/web/assets/fonts/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  27. package/web/assets/fonts/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  28. package/web/assets/fonts/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  29. package/web/assets/fonts/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  30. package/web/assets/fonts/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  31. package/web/assets/fonts/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  32. package/web/assets/fonts/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  33. package/web/assets/fonts/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  34. package/web/assets/fonts/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  35. package/web/assets/fonts/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  36. package/web/assets/fonts/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  37. package/web/assets/fonts/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  38. package/web/assets/fonts/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  39. package/web/assets/fonts/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  40. package/web/assets/fonts/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  41. package/web/assets/fonts/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  42. package/web/assets/fonts/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  43. package/web/assets/fonts/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  44. package/web/assets/fonts/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  45. package/web/assets/fonts/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  46. package/web/assets/fonts/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  47. package/web/assets/fonts/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  48. package/web/assets/fonts/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  49. package/web/assets/fonts/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  50. package/web/assets/fonts/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  51. package/web/assets/fonts/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  52. package/web/assets/fonts/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  53. package/web/assets/fonts/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  54. package/web/assets/fonts/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  55. package/web/assets/fonts/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  56. package/web/assets/fonts/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  57. package/web/assets/fonts/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  58. package/web/assets/fonts/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  59. package/web/assets/fonts/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  60. package/web/assets/fonts/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  61. package/web/assets/fonts/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  62. package/web/assets/fonts/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  63. package/web/assets/fonts/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  64. package/web/assets/fonts/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  65. package/web/assets/fonts/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  66. package/web/assets/fonts/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  67. package/web/assets/fonts/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  68. package/web/assets/fonts/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  69. package/web/assets/fonts/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  70. package/web/assets/fonts/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  71. package/web/assets/fonts/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  72. package/web/assets/fonts/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  73. package/web/assets/fonts/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  74. package/web/assets/fonts/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  75. package/web/assets/fonts/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  76. package/web/favicon.svg +4 -0
  77. package/web/index.html +25 -0
  78. package/web/manifest.webmanifest +9 -0
  79. package/windows-clipboard.ts +81 -0
package/server.ts ADDED
@@ -0,0 +1,3060 @@
1
+ import type { ServerResponse } from "node:http";
2
+ import { createServer } from "node:http";
3
+ import { spawn } from "node:child_process";
4
+ import { randomUUID } from "node:crypto";
5
+ import { existsSync } from "node:fs";
6
+ import { mkdir, readFile, readdir, stat, unlink } from "node:fs/promises";
7
+ import { homedir, platform, tmpdir } from "node:os";
8
+ import { basename, dirname, extname, join, normalize, resolve, sep } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { getAgentDir, SessionManager, type SessionInfo } from "@earendil-works/pi-coding-agent";
11
+ import { getSupportedThinkingLevels } from "@earendil-works/pi-ai/compat";
12
+ import {
13
+ requestRpcAbort,
14
+ requestRpcClearQueue,
15
+ requestRpcWithdrawQueueItem,
16
+ type RpcQueueKind,
17
+ type RpcQueueSnapshot,
18
+ } from "./abort-session.ts";
19
+ import { ImageAttachmentStore } from "./attachment-store.ts";
20
+ import { closeSseClient, createSseClient, sendSseEvent, type SseClient } from "./sse-channel.ts";
21
+ import { spawnPiRpc, type Json, type PiRpc } from "./pi-rpc.ts";
22
+ import { loadLocalSession } from "./local-session.ts";
23
+ import { loadJsonWithLegacyMigration, writeJsonAtomically } from "./state-store.ts";
24
+ import { buildFilePreview } from "./file-preview.ts";
25
+ import { readClipboardImage, readClipboardText } from "./windows-clipboard.ts";
26
+ import {
27
+ applyAssistantDelta,
28
+ applyToolExecution,
29
+ attachToolResults,
30
+ findLast,
31
+ renderAssistantMessage,
32
+ renderCompaction,
33
+ renderMessage,
34
+ renderSessionEntries,
35
+ renderTranscript,
36
+ stripAnsi,
37
+ type RenderedMessage,
38
+ } from "./transcript.ts";
39
+
40
+ const here = dirname(fileURLToPath(import.meta.url));
41
+ const WEB_DIR = join(here, "web");
42
+ const HOST = "127.0.0.1";
43
+ const DEFAULT_PORT = 18789;
44
+ const STATE_DIR = process.env.PI_WEB_STATE_DIR || join(getAgentDir(), "pi-web");
45
+ const STATE_FILE = resolve(process.env.PI_WEB_STATE_FILE || join(STATE_DIR, "state.json"));
46
+ const LEGACY_STATE_FILE = resolve(process.env.PI_WEB_LEGACY_STATE_FILE || join(here, "state.json"));
47
+
48
+ interface Workspace {
49
+ id: string;
50
+ path: string;
51
+ title: string;
52
+ sessionIds: string[];
53
+ createdAt: string;
54
+ updatedAt: string;
55
+ }
56
+
57
+ interface ReorderWorkspaceInput {
58
+ workspaceIds: string[];
59
+ }
60
+
61
+ interface StoredSession {
62
+ id: string;
63
+ workspaceId: string;
64
+ title: string;
65
+ sessionFile?: string;
66
+ model?: { provider: string; modelId: string };
67
+ thinkingLevel?: string;
68
+ needsAttention?: boolean;
69
+ createdAt: string;
70
+ updatedAt: string;
71
+ }
72
+
73
+ interface ModelInfo {
74
+ models: unknown[];
75
+ thinkingLevels: string[];
76
+ defaultModel?: unknown;
77
+ thinkingLevel?: string;
78
+ }
79
+
80
+ interface ModelCatalog {
81
+ models: unknown[];
82
+ defaultModel?: unknown;
83
+ thinkingLevel?: string;
84
+ }
85
+
86
+ interface SlashCommandInfo {
87
+ name: string;
88
+ description?: string;
89
+ source: "extension" | "prompt" | "skill";
90
+ sourceInfo?: Json;
91
+ }
92
+
93
+ interface StateFile {
94
+ version: 1;
95
+ currentWorkspaceId?: string;
96
+ currentSessionId?: string;
97
+ workspaces: Workspace[];
98
+ sessions: StoredSession[];
99
+ modelCache?: Record<string, ModelInfo>;
100
+ }
101
+
102
+ interface ExtensionDialog {
103
+ id: string;
104
+ method: "select" | "confirm" | "input" | "editor";
105
+ title?: string;
106
+ message?: string;
107
+ placeholder?: string;
108
+ options?: string[];
109
+ prefill?: string;
110
+ timeout?: number;
111
+ createdAt: number;
112
+ expiresAt?: number;
113
+ }
114
+
115
+ type TreeRestoreMode = "conversation" | "all";
116
+
117
+ interface PendingTreeNavigation {
118
+ restoreMode: TreeRestoreMode;
119
+ restoreDialogHandled: boolean;
120
+ error?: string;
121
+ }
122
+
123
+ interface LiveUsageSnapshot {
124
+ input: number;
125
+ output: number;
126
+ cacheRead: number;
127
+ cacheWrite: number;
128
+ totalTokens: number;
129
+ cost: number;
130
+ }
131
+
132
+ interface HistoryNode {
133
+ id: string;
134
+ parentId: string | null;
135
+ type: string;
136
+ role?: string;
137
+ customType?: string;
138
+ navigationTargetId?: string;
139
+ text: string;
140
+ timestamp?: string;
141
+ label?: string;
142
+ active: boolean;
143
+ current: boolean;
144
+ rewindable: boolean;
145
+ navigable: boolean;
146
+ branchDepth: number;
147
+ branchStart: boolean;
148
+ branchIndex: number;
149
+ branchCount: number;
150
+ }
151
+
152
+ interface RuntimeSession {
153
+ id: string;
154
+ workspaceId: string;
155
+ title: string;
156
+ sessionFile?: string;
157
+ cwd: string;
158
+ status: "stopped" | "starting" | "running" | "idle" | "error";
159
+ error?: string;
160
+ proc?: PiRpc;
161
+ messages: RenderedMessage[];
162
+ state: Json | null;
163
+ stats: Json | null;
164
+ models: unknown[];
165
+ thinkingLevels: string[];
166
+ commands: SlashCommandInfo[];
167
+ extensionStatuses: Record<string, string>;
168
+ dialogs: ExtensionDialog[];
169
+ streaming: boolean;
170
+ needsAttention: boolean;
171
+ starting?: Promise<void>;
172
+ modelRefresh?: Promise<void>;
173
+ commandRefresh?: Promise<void>;
174
+ lastUsed: number;
175
+ pendingModel?: { provider: string; modelId: string };
176
+ pendingThinkingLevel?: string;
177
+ pendingName?: string;
178
+ liveUsage?: LiveUsageSnapshot;
179
+ statsRevision?: number;
180
+ operationChain?: Promise<void>;
181
+ pendingTreeNavigation?: PendingTreeNavigation;
182
+ compactionNotice?: RenderedMessage;
183
+ pendingQueue?: RpcQueueSnapshot;
184
+ }
185
+
186
+ interface NewSessionPreferences {
187
+ model?: { provider: string; modelId: string };
188
+ thinkingLevel?: string;
189
+ }
190
+
191
+ interface ExportDownload {
192
+ path: string;
193
+ filename: string;
194
+ expiresAt: number;
195
+ }
196
+
197
+ interface MaintenanceCommandResult {
198
+ stdout: string;
199
+ stderr: string;
200
+ code: number;
201
+ killed: boolean;
202
+ }
203
+
204
+ let server: ReturnType<typeof createServer> | undefined;
205
+ let serverUrl: string | undefined;
206
+ let serverInstanceId: string | undefined;
207
+ let updateExtensions: (() => Promise<MaintenanceCommandResult>) | undefined;
208
+ let reloadRuntime: (() => Promise<void>) | undefined;
209
+ let maintenanceRunning = false;
210
+ let sessions = new Map<string, RuntimeSession>();
211
+ let sseClients = new Set<SseClient>();
212
+ let state: StateFile = { version: 1, workspaces: [], sessions: [] };
213
+ let stateSaveChain: Promise<void> = Promise.resolve();
214
+ let shuttingDown = false;
215
+ let heartbeat: ReturnType<typeof setInterval> | undefined;
216
+
217
+ const MAX_IDLE_PROCESSES = 1;
218
+ const modelInfoCache = new Map<string, ModelInfo>();
219
+ const modelCatalogRefreshes = new Map<string, Promise<ModelCatalog>>();
220
+ const commandCache = new Map<string, SlashCommandInfo[]>();
221
+ const exportDownloads = new Map<string, ExportDownload>();
222
+ const imageAttachments = new ImageAttachmentStore();
223
+ const ABORT_RPC_TIMEOUT_MS = 3_000;
224
+ const INTERNAL_TREE_COMMAND = "pi-web-navigate-tree";
225
+ const INTERNAL_RELOAD_COMMAND = "pi-web-reload-runtime";
226
+ const PI_REWIND_KEEP_OPTIONS = ["Keep current files", "Conversation only (keep files)"];
227
+ const PI_REWIND_RESTORE_OPTIONS = ["Restore files to that point", "Restore all (files + conversation)"];
228
+ const PI_REWIND_CANCEL_OPTIONS = ["Cancel navigation", "Cancel"];
229
+
230
+ const MIME: Record<string, string> = {
231
+ ".html": "text/html; charset=utf-8",
232
+ ".js": "text/javascript; charset=utf-8",
233
+ ".css": "text/css; charset=utf-8",
234
+ ".svg": "image/svg+xml",
235
+ ".json": "application/json; charset=utf-8",
236
+ ".map": "application/json; charset=utf-8",
237
+ ".woff": "font/woff",
238
+ ".woff2": "font/woff2",
239
+ ".ttf": "font/ttf",
240
+ ".png": "image/png",
241
+ };
242
+ const MAX_FILE_VIEW_BYTES = 2 * 1024 * 1024;
243
+
244
+ function nowIso(): string {
245
+ return new Date().toISOString();
246
+ }
247
+
248
+ function pathTitle(path: string): string {
249
+ const trimmed = path.replace(/[\/]+$/, "");
250
+ return basename(trimmed) || trimmed;
251
+ }
252
+
253
+ function normalizePath(path: string): string {
254
+ return normalize(resolve(path));
255
+ }
256
+
257
+ function exportFilename(title: string): string {
258
+ const safe = title.trim().replace(/[<>:"/\\|?*\x00-\x1f]/g, "-").replace(/\s+/g, " ").slice(0, 80) || "pi-session";
259
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
260
+ return `${safe}-${timestamp}.html`;
261
+ }
262
+
263
+ function rawContentText(content: unknown): string {
264
+ if (typeof content === "string") return content;
265
+ if (!Array.isArray(content)) return "";
266
+ return (content as Json[]).map((part) => {
267
+ if (!part || typeof part !== "object") return "";
268
+ if (part.type === "text" && typeof part.text === "string") return part.text;
269
+ if (part.type === "thinking" && typeof part.thinking === "string") return part.thinking;
270
+ if (part.type === "image") return "[图片]";
271
+ if (part.type === "toolCall") return `调用 ${String(part.name ?? "工具")}`;
272
+ return "";
273
+ }).filter(Boolean).join("\n");
274
+ }
275
+
276
+ function contentText(content: unknown, limit = Number.POSITIVE_INFINITY): string {
277
+ const normalized = stripAnsi(rawContentText(content)).replace(/\s+/g, " ").trim();
278
+ return normalized.length > limit ? `${normalized.slice(0, Math.max(0, limit - 1))}…` : normalized;
279
+ }
280
+
281
+ function historyEntrySummary(entry: Json): { role?: string; customType?: string; text: string } {
282
+ if (entry.type === "message" && entry.message && typeof entry.message === "object") {
283
+ const message = entry.message as Json;
284
+ const role = typeof message.role === "string" ? message.role : "message";
285
+ let text = contentText(message.content, 280);
286
+ if (!text && role === "toolResult") text = `${String(message.toolName ?? "工具")} 执行结果`;
287
+ return { role, text: text || `${role} 消息` };
288
+ }
289
+ if (entry.type === "custom_message") {
290
+ return { role: "custom", customType: String(entry.customType ?? "custom"), text: contentText(entry.content, 280) || "自定义消息" };
291
+ }
292
+ if (entry.type === "compaction") return { text: contentText(entry.summary, 280) || "上下文压缩" };
293
+ if (entry.type === "branch_summary") return { text: contentText(entry.summary, 280) || "分支摘要" };
294
+ if (entry.type === "model_change") return { text: `切换模型:${String(entry.provider ?? "")}/${String(entry.modelId ?? "")}` };
295
+ if (entry.type === "thinking_level_change") return { text: `思考强度:${String(entry.thinkingLevel ?? "off")}` };
296
+ if (entry.type === "session_info") return { text: `会话名称:${String(entry.name ?? "")}` };
297
+ if (entry.type === "label") return { text: `标签:${String(entry.label ?? "已清除")}` };
298
+ if (entry.type === "custom") {
299
+ const customType = String(entry.customType ?? "custom");
300
+ return { customType, text: customType === "pi-web-tree-navigation" ? "Web 回溯点" : `扩展状态:${customType}` };
301
+ }
302
+ return { text: String(entry.type ?? "会话节点") };
303
+ }
304
+
305
+ function flattenHistoryTree(treeValue: unknown, leafValue: unknown): { nodes: HistoryNode[]; leafId: string | null } {
306
+ const roots = Array.isArray(treeValue) ? treeValue as Json[] : [];
307
+ const leafId = typeof leafValue === "string" ? leafValue : null;
308
+ const entries = new Map<string, Json>();
309
+ const indexStack = [...roots];
310
+ while (indexStack.length > 0) {
311
+ const node = indexStack.pop();
312
+ if (!node || typeof node !== "object") continue;
313
+ const entry = node.entry && typeof node.entry === "object" ? node.entry as Json : {};
314
+ if (typeof entry.id === "string") entries.set(entry.id, entry);
315
+ if (Array.isArray(node.children)) indexStack.push(...node.children as Json[]);
316
+ }
317
+ const activeIds = new Set<string>();
318
+ let current = leafId ? entries.get(leafId) : undefined;
319
+ while (current && typeof current.id === "string" && !activeIds.has(current.id)) {
320
+ activeIds.add(current.id);
321
+ current = typeof current.parentId === "string" ? entries.get(current.parentId) : undefined;
322
+ }
323
+
324
+ const nodes: HistoryNode[] = [];
325
+ const stack = roots.slice().reverse().map((node) => ({ node, branchDepth: 0, branchStart: false, branchIndex: 0, branchCount: roots.length }));
326
+ while (stack.length > 0) {
327
+ const item = stack.pop();
328
+ if (!item?.node || typeof item.node !== "object") continue;
329
+ const entry = item.node.entry && typeof item.node.entry === "object" ? item.node.entry as Json : {};
330
+ if (typeof entry.id !== "string") continue;
331
+ const children = Array.isArray(item.node.children) ? item.node.children as Json[] : [];
332
+ const summary = historyEntrySummary(entry);
333
+ const active = activeIds.has(entry.id);
334
+ nodes.push({
335
+ id: entry.id,
336
+ parentId: typeof entry.parentId === "string" ? entry.parentId : null,
337
+ type: typeof entry.type === "string" ? entry.type : "unknown",
338
+ role: summary.role,
339
+ customType: summary.customType,
340
+ navigationTargetId: entry.type === "custom" && entry.data && typeof entry.data === "object" && typeof (entry.data as Json).targetId === "string"
341
+ ? (entry.data as Json).targetId as string
342
+ : undefined,
343
+ text: summary.text,
344
+ timestamp: typeof entry.timestamp === "string" ? entry.timestamp : undefined,
345
+ label: typeof item.node.label === "string" ? item.node.label : undefined,
346
+ active,
347
+ current: entry.id === leafId,
348
+ rewindable: active && summary.role === "user",
349
+ navigable: entry.id !== leafId,
350
+ branchDepth: item.branchDepth,
351
+ branchStart: item.branchStart,
352
+ branchIndex: item.branchIndex,
353
+ branchCount: children.length,
354
+ });
355
+ const nextDepth = item.branchDepth + (children.length > 1 ? 1 : 0);
356
+ for (let index = children.length - 1; index >= 0; index -= 1) {
357
+ stack.push({
358
+ node: children[index],
359
+ branchDepth: nextDepth,
360
+ branchStart: children.length > 1,
361
+ branchIndex: index,
362
+ branchCount: children.length,
363
+ });
364
+ }
365
+ }
366
+ return { nodes, leafId };
367
+ }
368
+
369
+ async function sessionHistory(record: RuntimeSession): Promise<{ nodes: HistoryNode[]; leafId: string | null }> {
370
+ if (record.proc) {
371
+ const response = await record.proc.send<Json>({ type: "get_tree" });
372
+ return flattenHistoryTree(response.data?.tree, response.data?.leafId);
373
+ }
374
+ if (!record.sessionFile || !existsSync(record.sessionFile)) return { nodes: [], leafId: null };
375
+ const manager = SessionManager.open(record.sessionFile, undefined, record.cwd);
376
+ return flattenHistoryTree(manager.getTree(), manager.getLeafId());
377
+ }
378
+
379
+ function historyEditorText(record: RuntimeSession, entryId: string): string | undefined {
380
+ if (!record.sessionFile || !existsSync(record.sessionFile)) return undefined;
381
+ const manager = SessionManager.open(record.sessionFile, undefined, record.cwd);
382
+ const entry = manager.getEntry(entryId) as Json | undefined;
383
+ if (!entry) return undefined;
384
+ if (entry.type === "message" && entry.message && typeof entry.message === "object") {
385
+ const message = entry.message as Json;
386
+ if (message.role === "user") return rawContentText(message.content);
387
+ }
388
+ if (entry.type === "custom_message") return rawContentText(entry.content);
389
+ return undefined;
390
+ }
391
+
392
+ async function pasteSystemClipboard(): Promise<Json> {
393
+ const image = await readClipboardImage();
394
+ if (image) {
395
+ const attachment = await imageAttachments.create(image.bytes);
396
+ return {
397
+ kind: "image",
398
+ attachment: { ...attachment, url: `/api/attachments/${attachment.id}` },
399
+ };
400
+ }
401
+ const text = await readClipboardText();
402
+ if (text) return { kind: "text", text };
403
+ throw new Error("剪贴板中没有可粘贴的图片或文本");
404
+ }
405
+
406
+ async function withSessionOperation<T>(record: RuntimeSession, operation: () => Promise<T>): Promise<T> {
407
+ const previous = record.operationChain ?? Promise.resolve();
408
+ let release!: () => void;
409
+ const gate = new Promise<void>((resolveGate) => { release = resolveGate; });
410
+ const chain = previous.catch(() => {}).then(() => gate);
411
+ record.operationChain = chain;
412
+ await previous.catch(() => {});
413
+ try {
414
+ return await operation();
415
+ } finally {
416
+ release();
417
+ if (record.operationChain === chain) record.operationChain = undefined;
418
+ }
419
+ }
420
+
421
+ function isGitWorkingTree(cwd: string): Promise<boolean> {
422
+ return new Promise((resolveResult) => {
423
+ const child = spawn("git", ["rev-parse", "--is-inside-work-tree"], {
424
+ cwd,
425
+ stdio: ["ignore", "pipe", "ignore"],
426
+ windowsHide: true,
427
+ });
428
+ let stdout = "";
429
+ let settled = false;
430
+ const finish = (result: boolean) => {
431
+ if (settled) return;
432
+ settled = true;
433
+ clearTimeout(timer);
434
+ resolveResult(result);
435
+ };
436
+ const timer = setTimeout(() => {
437
+ child.kill();
438
+ finish(false);
439
+ }, 5000);
440
+ timer.unref();
441
+ child.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); });
442
+ child.on("error", () => finish(false));
443
+ child.on("close", (code) => finish(code === 0 && stdout.trim() === "true"));
444
+ });
445
+ }
446
+
447
+ function hasPiRewindCommand(value: unknown): boolean {
448
+ if (!Array.isArray(value)) return false;
449
+ return (value as Json[]).some((command) => {
450
+ if (!command || command.source !== "extension" || typeof command.name !== "string") return false;
451
+ if (!/^rewind(?::\d+)?$/.test(command.name.trim().replace(/^\/+/, ""))) return false;
452
+ const sourceInfo = command.sourceInfo && typeof command.sourceInfo === "object" ? command.sourceInfo as Json : {};
453
+ const provenance = `${String(sourceInfo.path ?? "")} ${String(sourceInfo.source ?? "")}`;
454
+ const description = typeof command.description === "string" ? command.description : "";
455
+ return /(^|[\\/])pi-rewind([\\/]|$)/i.test(provenance)
456
+ || (/file/i.test(description) && /conversation/i.test(description) && /checkpoint/i.test(description));
457
+ });
458
+ }
459
+
460
+ function handlePendingTreeRestoreDialog(record: RuntimeSession, proc: PiRpc, event: Json): boolean {
461
+ const pending = record.pendingTreeNavigation;
462
+ if (!pending || event.method !== "select" || typeof event.id !== "string" || !Array.isArray(event.options)) return false;
463
+ const options = (event.options as unknown[]).filter((option): option is string => typeof option === "string");
464
+ const keepOption = PI_REWIND_KEEP_OPTIONS.find((option) => options.includes(option));
465
+ const cancelOption = PI_REWIND_CANCEL_OPTIONS.find((option) => options.includes(option));
466
+ if (!keepOption || !cancelOption) return false;
467
+
468
+ let value = keepOption;
469
+ if (pending.restoreMode === "all") {
470
+ const restoreOption = PI_REWIND_RESTORE_OPTIONS.find((option) => options.includes(option));
471
+ if (restoreOption) value = restoreOption;
472
+ else {
473
+ value = cancelOption;
474
+ pending.error = "所选位置没有可用的文件检查点,未执行回溯";
475
+ }
476
+ }
477
+ pending.restoreDialogHandled = true;
478
+ proc.write({ type: "extension_ui_response", id: event.id, value });
479
+ return true;
480
+ }
481
+
482
+ function isImmediateExtensionCommand(record: RuntimeSession, message: string): boolean {
483
+ const match = /^\/([^\s]+)/.exec(message.trim());
484
+ if (!match) return false;
485
+ return record.commands.some((command) => command.source === "extension" && command.name === match[1]);
486
+ }
487
+
488
+ function debounce<A extends unknown[]>(fn: (...args: A) => void, ms: number) {
489
+ let timer: ReturnType<typeof setTimeout> | undefined;
490
+ return (...args: A) => {
491
+ if (timer) clearTimeout(timer);
492
+ timer = setTimeout(() => fn(...args), ms);
493
+ };
494
+ }
495
+
496
+ const scheduleSave = debounce(() => {
497
+ stateSaveChain = stateSaveChain.then(saveStateNow).catch(() => {});
498
+ }, 120);
499
+
500
+ function isStateFile(value: unknown): value is StateFile {
501
+ if (!value || typeof value !== "object") return false;
502
+ const candidate = value as Partial<StateFile>;
503
+ return candidate.version === 1 && Array.isArray(candidate.workspaces) && Array.isArray(candidate.sessions);
504
+ }
505
+
506
+ async function saveStateNow(): Promise<void> {
507
+ await writeJsonAtomically(STATE_FILE, state);
508
+ }
509
+
510
+ async function loadState(): Promise<void> {
511
+ state = await loadJsonWithLegacyMigration({
512
+ stateFile: STATE_FILE,
513
+ legacyStateFile: LEGACY_STATE_FILE,
514
+ validate: isStateFile,
515
+ }) ?? { version: 1, workspaces: [], sessions: [] };
516
+ sessions = new Map();
517
+ modelInfoCache.clear();
518
+ commandCache.clear();
519
+ if (state.modelCache) {
520
+ for (const [cwd, cached] of Object.entries(state.modelCache)) {
521
+ if (cwd && cached && Array.isArray(cached.models)) {
522
+ modelInfoCache.set(cwd, {
523
+ models: cached.models,
524
+ thinkingLevels: Array.isArray(cached.thinkingLevels) ? cached.thinkingLevels : [],
525
+ defaultModel: cached.defaultModel,
526
+ thinkingLevel: typeof cached.thinkingLevel === "string" ? cached.thinkingLevel : undefined,
527
+ });
528
+ }
529
+ }
530
+ }
531
+ for (const stored of state.sessions) {
532
+ const ws = state.workspaces.find((w) => w.id === stored.workspaceId);
533
+ sessions.set(stored.id, {
534
+ id: stored.id,
535
+ workspaceId: stored.workspaceId,
536
+ title: stored.title,
537
+ sessionFile: stored.sessionFile,
538
+ cwd: ws?.path ?? process.cwd(),
539
+ status: "stopped",
540
+ messages: [],
541
+ state: null,
542
+ stats: null,
543
+ models: [],
544
+ thinkingLevels: [],
545
+ commands: [],
546
+ extensionStatuses: {},
547
+ dialogs: [],
548
+ streaming: false,
549
+ needsAttention: stored.needsAttention === true,
550
+ lastUsed: 0,
551
+ pendingModel: stored.model,
552
+ pendingThinkingLevel: stored.thinkingLevel,
553
+ });
554
+ }
555
+ }
556
+
557
+ async function scanPiSessions(cwd: string): Promise<SessionInfo[]> {
558
+ try {
559
+ return await SessionManager.list(cwd);
560
+ } catch (error) {
561
+ console.error("[pi-web] SessionManager.list failed:", error);
562
+ return [];
563
+ }
564
+ }
565
+
566
+ function firstMessageTitle(session: SessionInfo): string {
567
+ const first = (session.firstMessage ?? "").trim().replace(/\s+/g, " ");
568
+ if (!first) return session.name?.trim() || "新会话";
569
+ const codeStripped = first.replace(/^```[\s\S]*?```/, "").trim();
570
+ return (session.name?.trim() || codeStripped || first).slice(0, 60);
571
+ }
572
+
573
+ // ---------------------------------------------------------------------------
574
+ // broadcasting / serialization
575
+ // ---------------------------------------------------------------------------
576
+
577
+ function broadcastEvent(event: Json): void {
578
+ for (const client of [...sseClients]) sendSseEvent(client, event);
579
+ }
580
+
581
+ function sessionSummary(record: RuntimeSession): Json {
582
+ const stored = state.sessions.find((s) => s.id === record.id);
583
+ return {
584
+ id: record.id,
585
+ workspaceId: record.workspaceId,
586
+ title: record.title,
587
+ sessionFile: record.sessionFile ?? null,
588
+ cwd: record.cwd,
589
+ status: record.status,
590
+ streaming: record.streaming,
591
+ waitingForUser: record.dialogs.length > 0,
592
+ needsAttention: record.needsAttention,
593
+ error: record.error ?? null,
594
+ updatedAt: stored?.updatedAt ?? nowIso(),
595
+ };
596
+ }
597
+
598
+ function snapshotMessages(record: RuntimeSession): RenderedMessage[] {
599
+ const notice = record.compactionNotice;
600
+ if (!notice) return record.messages;
601
+ if (notice.status === "complete") {
602
+ for (let index = record.messages.length - 1; index >= 0; index -= 1) {
603
+ const message = record.messages[index];
604
+ if (message.kind !== "compaction" || message.text !== notice.text || message.tokensBefore !== notice.tokensBefore) continue;
605
+ const merged = [...record.messages];
606
+ merged[index] = {
607
+ ...message,
608
+ reason: notice.reason,
609
+ estimatedTokensAfter: notice.estimatedTokensAfter,
610
+ usage: notice.usage ?? message.usage,
611
+ willRetry: notice.willRetry,
612
+ };
613
+ return merged;
614
+ }
615
+ }
616
+ return [...record.messages, notice];
617
+ }
618
+
619
+ function pendingQueueSnapshot(record: RuntimeSession): RpcQueueSnapshot {
620
+ return {
621
+ steering: [...(record.pendingQueue?.steering ?? [])],
622
+ followUp: [...(record.pendingQueue?.followUp ?? [])],
623
+ };
624
+ }
625
+
626
+ function setPendingQueue(record: RuntimeSession, queue: RpcQueueSnapshot): void {
627
+ record.pendingQueue = {
628
+ steering: [...queue.steering],
629
+ followUp: [...queue.followUp],
630
+ };
631
+ record.state = {
632
+ ...(record.state ?? {}),
633
+ pendingMessageCount: queue.steering.length + queue.followUp.length,
634
+ };
635
+ }
636
+
637
+ function broadcastSessionSnapshot(record: RuntimeSession): void {
638
+ broadcastEvent({
639
+ type: "snapshot",
640
+ sessionId: record.id,
641
+ session: sessionSummary(record),
642
+ state: record.state,
643
+ stats: record.stats,
644
+ models: record.models,
645
+ thinkingLevels: record.thinkingLevels,
646
+ thinkingLevelsByModel: thinkingLevelsByModel(record.models),
647
+ commands: record.commands,
648
+ extensionStatuses: record.extensionStatuses,
649
+ messages: snapshotMessages(record),
650
+ pendingQueue: pendingQueueSnapshot(record),
651
+ dialogs: record.dialogs,
652
+ });
653
+ }
654
+
655
+ function serializeWorkspaces(): Json[] {
656
+ return state.workspaces.map((ws) => {
657
+ const rows = ws.sessionIds.map((id) => {
658
+ const stored = state.sessions.find((s) => s.id === id);
659
+ const rec = sessions.get(id);
660
+ return {
661
+ id,
662
+ title: rec?.title ?? stored?.title ?? "新会话",
663
+ sessionFile: rec?.sessionFile ?? stored?.sessionFile ?? null,
664
+ status: rec?.status ?? "stopped",
665
+ streaming: rec?.streaming ?? false,
666
+ waitingForUser: (rec?.dialogs?.length ?? 0) > 0,
667
+ needsAttention: rec?.needsAttention ?? stored?.needsAttention ?? false,
668
+ dialogCount: rec?.dialogs?.length ?? 0,
669
+ updatedAt: stored?.updatedAt ?? nowIso(),
670
+ };
671
+ });
672
+ return { ...ws, sessions: rows };
673
+ });
674
+ }
675
+
676
+ function broadcastWorkspaces(): void {
677
+ broadcastEvent({ type: "workspaces", workspaces: serializeWorkspaces() });
678
+ }
679
+
680
+ function supportedThinkingLevels(model: unknown): string[] {
681
+ if (!model || typeof model !== "object") return ["off"];
682
+ try {
683
+ return getSupportedThinkingLevels(model as Parameters<typeof getSupportedThinkingLevels>[0]) as string[];
684
+ } catch {
685
+ return ["off"];
686
+ }
687
+ }
688
+
689
+ function thinkingLevelsByModel(models: unknown[]): Record<string, string[]> {
690
+ const levels: Record<string, string[]> = {};
691
+ for (const candidate of models as Json[]) {
692
+ if (!candidate || typeof candidate !== "object") continue;
693
+ if (typeof candidate.provider !== "string" || typeof candidate.id !== "string") continue;
694
+ levels[`${candidate.provider}/${candidate.id}`] = supportedThinkingLevels(candidate);
695
+ }
696
+ return levels;
697
+ }
698
+
699
+ function normalizeCommands(value: unknown): SlashCommandInfo[] {
700
+ if (!Array.isArray(value)) return [];
701
+ const commands: SlashCommandInfo[] = [];
702
+ const seen = new Set<string>();
703
+ for (const candidate of value as Json[]) {
704
+ if (!candidate || typeof candidate !== "object" || typeof candidate.name !== "string") continue;
705
+ const name = candidate.name.trim().replace(/^\/+/, "");
706
+ const source = candidate.source;
707
+ if (!name || name === INTERNAL_TREE_COMMAND || name === INTERNAL_RELOAD_COMMAND || seen.has(name) || (source !== "extension" && source !== "prompt" && source !== "skill")) continue;
708
+ seen.add(name);
709
+ commands.push({
710
+ name,
711
+ description: typeof candidate.description === "string" ? stripAnsi(candidate.description) : undefined,
712
+ source,
713
+ sourceInfo: candidate.sourceInfo && typeof candidate.sourceInfo === "object" ? candidate.sourceInfo as Json : undefined,
714
+ });
715
+ }
716
+ return commands;
717
+ }
718
+
719
+ function cachedCommands(cwd?: string): SlashCommandInfo[] {
720
+ return cwd ? commandCache.get(cwd) ?? [] : [...commandCache.values()][0] ?? [];
721
+ }
722
+
723
+ function applyCachedCommands(record: RuntimeSession): void {
724
+ if (record.commands.length === 0) record.commands = cachedCommands(record.cwd);
725
+ }
726
+
727
+ function bootstrapCommands(): SlashCommandInfo[] {
728
+ const currentRecord = state.currentSessionId ? sessions.get(state.currentSessionId) : undefined;
729
+ const currentWorkspace = state.currentWorkspaceId
730
+ ? state.workspaces.find((workspace) => workspace.id === state.currentWorkspaceId)
731
+ : undefined;
732
+ return cachedCommands(currentRecord?.cwd ?? currentWorkspace?.path);
733
+ }
734
+
735
+ function bootstrapModelInfo(): Json {
736
+ const currentRecord = state.currentSessionId ? sessions.get(state.currentSessionId) : undefined;
737
+ const currentWorkspace = state.currentWorkspaceId
738
+ ? state.workspaces.find((workspace) => workspace.id === state.currentWorkspaceId)
739
+ : undefined;
740
+ const cached = cachedModelInfo(currentRecord?.cwd ?? currentWorkspace?.path);
741
+ const models = cached?.models ?? [];
742
+ return {
743
+ models,
744
+ thinkingLevels: cached?.thinkingLevels ?? [],
745
+ thinkingLevelsByModel: thinkingLevelsByModel(models),
746
+ state: {
747
+ model: cached?.defaultModel ?? null,
748
+ thinkingLevel: cached?.thinkingLevel ?? "off",
749
+ },
750
+ };
751
+ }
752
+
753
+ function setSessionNeedsAttention(record: RuntimeSession, needsAttention: boolean): void {
754
+ record.needsAttention = needsAttention;
755
+ const stored = state.sessions.find((s) => s.id === record.id);
756
+ if (stored) stored.needsAttention = needsAttention;
757
+ scheduleSave();
758
+ }
759
+
760
+ function updateStoredSession(record: RuntimeSession): void {
761
+ const stored = state.sessions.find((s) => s.id === record.id);
762
+ if (!stored) return;
763
+ stored.title = record.title;
764
+ stored.sessionFile = record.sessionFile;
765
+ stored.model = record.pendingModel ?? stored.model;
766
+ stored.thinkingLevel = record.pendingThinkingLevel ?? stored.thinkingLevel;
767
+ stored.needsAttention = record.needsAttention;
768
+ stored.updatedAt = nowIso();
769
+ const ws = state.workspaces.find((w) => w.id === record.workspaceId);
770
+ if (ws) ws.updatedAt = stored.updatedAt;
771
+ scheduleSave();
772
+ }
773
+
774
+ // ---------------------------------------------------------------------------
775
+ // runtime refresh
776
+ // ---------------------------------------------------------------------------
777
+
778
+ async function refreshState(record: RuntimeSession): Promise<void> {
779
+ if (!record.proc) return;
780
+ try {
781
+ const response = await record.proc.send<Json>({ type: "get_state" });
782
+ record.state = (response.data ?? {}) as Json;
783
+ if (record.state.isStreaming === true) record.streaming = true;
784
+ else if (record.status !== "running") record.streaming = false;
785
+ if (typeof record.state.sessionFile === "string") {
786
+ record.sessionFile = record.state.sessionFile;
787
+ const stored = state.sessions.find((s) => s.id === record.id);
788
+ if (stored) stored.sessionFile = record.state.sessionFile;
789
+ scheduleSave();
790
+ }
791
+ if (typeof record.state.sessionName === "string" && record.state.sessionName.trim()) {
792
+ record.title = record.state.sessionName;
793
+ const stored = state.sessions.find((s) => s.id === record.id);
794
+ if (stored) stored.title = record.title;
795
+ scheduleSave();
796
+ }
797
+ } catch (error) {
798
+ console.error("[pi-web] get_state failed:", error);
799
+ }
800
+ }
801
+
802
+ async function refreshMessages(record: RuntimeSession): Promise<void> {
803
+ if (!record.proc) return;
804
+ try {
805
+ const response = await record.proc.send<Json>({ type: "get_entries" });
806
+ const data = (response.data ?? {}) as Json;
807
+ record.messages = renderSessionEntries(data.entries, data.leafId);
808
+ } catch (entriesError) {
809
+ try {
810
+ const response = await record.proc.send<Json>({ type: "get_messages" });
811
+ record.messages = renderTranscript((response.data as Json)?.messages);
812
+ } catch (messagesError) {
813
+ console.error("[pi-web] transcript refresh failed:", entriesError, messagesError);
814
+ }
815
+ }
816
+ }
817
+
818
+ function cachedModelInfo(cwd?: string): ModelInfo | undefined {
819
+ return (cwd ? modelInfoCache.get(cwd) : undefined) ?? [...modelInfoCache.values()][0];
820
+ }
821
+
822
+ function persistModelInfo(cwd: string, info: ModelInfo): void {
823
+ modelInfoCache.set(cwd, info);
824
+ state.modelCache = Object.fromEntries(modelInfoCache);
825
+ scheduleSave();
826
+ }
827
+
828
+ function findCatalogModel(models: unknown[], candidate: unknown): Json | undefined {
829
+ if (!candidate || typeof candidate !== "object") return undefined;
830
+ const value = candidate as Json;
831
+ const id = value.id ?? value.modelId;
832
+ if (typeof value.provider !== "string" || typeof id !== "string") return undefined;
833
+ return (models as Json[]).find((model) => model?.provider === value.provider && model.id === id);
834
+ }
835
+
836
+ function modelInfoFromCatalog(
837
+ catalog: ModelCatalog,
838
+ preferredModel?: unknown,
839
+ preferredThinkingLevel?: string,
840
+ ): ModelInfo {
841
+ const defaultModel = findCatalogModel(catalog.models, preferredModel)
842
+ ?? findCatalogModel(catalog.models, catalog.defaultModel)
843
+ ?? catalog.models[0];
844
+ const thinkingLevels = supportedThinkingLevels(defaultModel);
845
+ const requestedLevel = preferredThinkingLevel ?? catalog.thinkingLevel ?? "off";
846
+ const thinkingLevel = thinkingLevels.includes(requestedLevel)
847
+ ? requestedLevel
848
+ : thinkingLevels.includes("off") ? "off" : thinkingLevels[0] ?? "off";
849
+ return { models: catalog.models, thinkingLevels, defaultModel, thinkingLevel };
850
+ }
851
+
852
+ function sameModelCatalog(left: unknown[], right: unknown[]): boolean {
853
+ return JSON.stringify(left) === JSON.stringify(right);
854
+ }
855
+
856
+ async function discoverFreshModelCatalog(cwd: string): Promise<ModelCatalog> {
857
+ const key = normalizePath(cwd);
858
+ const existing = modelCatalogRefreshes.get(key);
859
+ if (existing) return existing;
860
+ const task = (async () => {
861
+ const probe = spawnPiRpc({ cwd: key, noSession: true });
862
+ try {
863
+ const [modelsResponse, stateResponse] = await Promise.all([
864
+ probe.send<Json>({ type: "get_available_models" }),
865
+ probe.send<Json>({ type: "get_state" }),
866
+ ]);
867
+ return {
868
+ models: (modelsResponse.data?.models ?? []) as unknown[],
869
+ defaultModel: stateResponse.data?.model,
870
+ thinkingLevel: typeof stateResponse.data?.thinkingLevel === "string"
871
+ ? stateResponse.data.thinkingLevel
872
+ : undefined,
873
+ };
874
+ } finally {
875
+ await probe.close();
876
+ }
877
+ })();
878
+ modelCatalogRefreshes.set(key, task);
879
+ try {
880
+ return await task;
881
+ } finally {
882
+ modelCatalogRefreshes.delete(key);
883
+ }
884
+ }
885
+
886
+ function applyModelInfo(record: RuntimeSession, info: ModelInfo): void {
887
+ record.models = info.models;
888
+ record.thinkingLevels = info.thinkingLevels;
889
+ record.state = {
890
+ ...(record.state ?? {}),
891
+ model: info.defaultModel,
892
+ thinkingLevel: info.thinkingLevel ?? "off",
893
+ };
894
+ persistModelInfo(record.cwd, info);
895
+ }
896
+
897
+ async function refreshModelsFresh(record: RuntimeSession): Promise<void> {
898
+ if (record.starting) await record.starting;
899
+ if (record.streaming) {
900
+ await refreshModels(record);
901
+ return;
902
+ }
903
+ const catalog = await discoverFreshModelCatalog(record.cwd);
904
+ if (record.proc) {
905
+ try {
906
+ const runtimeResponse = await record.proc.send<Json>({ type: "get_available_models" }, 8000);
907
+ const runtimeModels = (runtimeResponse.data?.models ?? []) as unknown[];
908
+ if (!sameModelCatalog(runtimeModels, catalog.models)) {
909
+ await Promise.allSettled([refreshState(record), refreshMessages(record)]);
910
+ await stopSessionProcess(record);
911
+ }
912
+ } catch (error) {
913
+ console.error("[pi-web] runtime model refresh failed:", error);
914
+ await stopSessionProcess(record);
915
+ }
916
+ }
917
+ applyModelInfo(record, modelInfoFromCatalog(
918
+ catalog,
919
+ record.state?.model,
920
+ typeof record.state?.thinkingLevel === "string" ? record.state.thinkingLevel : undefined,
921
+ ));
922
+ }
923
+
924
+ async function refreshModels(record: RuntimeSession): Promise<void> {
925
+ if (!record.proc) return;
926
+ if (record.modelRefresh) return record.modelRefresh;
927
+ const task = (async () => {
928
+ try {
929
+ const models = await record.proc!.send<Json>({ type: "get_available_models" });
930
+ record.models = (models.data?.models ?? []) as unknown[];
931
+ const levels = await record.proc!.send<Json>({ type: "get_available_thinking_levels" });
932
+ record.thinkingLevels = (levels.data?.levels ?? ["off"]) as string[];
933
+ persistModelInfo(record.cwd, {
934
+ models: record.models,
935
+ thinkingLevels: record.thinkingLevels,
936
+ defaultModel: record.state?.model,
937
+ thinkingLevel: typeof record.state?.thinkingLevel === "string" ? record.state.thinkingLevel : undefined,
938
+ });
939
+ const current = state.currentSessionId ? sessions.get(state.currentSessionId) : undefined;
940
+ if (current && current !== record && current.models.length === 0) {
941
+ applyCachedModelInfo(current);
942
+ broadcastSessionSnapshot(current);
943
+ }
944
+ } catch (error) {
945
+ console.error("[pi-web] model refresh failed:", error);
946
+ } finally {
947
+ record.modelRefresh = undefined;
948
+ }
949
+ })();
950
+ record.modelRefresh = task;
951
+ return task;
952
+ }
953
+
954
+ async function refreshCommands(record: RuntimeSession): Promise<void> {
955
+ if (!record.proc) return;
956
+ if (record.commandRefresh) return record.commandRefresh;
957
+ const task = (async () => {
958
+ try {
959
+ const response = await record.proc!.send<Json>({ type: "get_commands" });
960
+ record.commands = normalizeCommands(response.data?.commands);
961
+ commandCache.set(record.cwd, record.commands);
962
+ } catch (error) {
963
+ console.error("[pi-web] command refresh failed:", error);
964
+ } finally {
965
+ record.commandRefresh = undefined;
966
+ }
967
+ })();
968
+ record.commandRefresh = task;
969
+ return task;
970
+ }
971
+
972
+ function applyCachedModelInfo(record: RuntimeSession): void {
973
+ const cached = cachedModelInfo(record.cwd);
974
+ if (cached && record.models.length === 0) record.models = cached.models;
975
+ if (cached && record.thinkingLevels.length === 0) record.thinkingLevels = cached.thinkingLevels;
976
+ if (cached?.defaultModel && !record.state?.model) {
977
+ record.state = {
978
+ ...(record.state ?? {}),
979
+ model: cached.defaultModel,
980
+ thinkingLevel: record.state?.thinkingLevel ?? cached.thinkingLevel ?? "off",
981
+ };
982
+ }
983
+ const stateModel = record.state?.model as Json | null | undefined;
984
+ if (stateModel && record.models.length > 0) {
985
+ const full = (record.models as Json[]).find((m) => {
986
+ if (!m || typeof m !== "object") return false;
987
+ const stateId = stateModel.id ?? stateModel.modelId;
988
+ return m.provider === stateModel.provider && m.id === stateId;
989
+ });
990
+ if (full) record.state = { ...(record.state ?? {}), model: full };
991
+ }
992
+ applyStatsContextWindow(record);
993
+ }
994
+
995
+ function applyStatsContextWindow(record: RuntimeSession): void {
996
+ if (!record.stats?.contextUsage || typeof record.stats.contextUsage !== "object") return;
997
+ const context = record.stats.contextUsage as Json;
998
+ if (!("tokens" in context)) return;
999
+ const model = record.state?.model && typeof record.state.model === "object" ? record.state.model as Json : {};
1000
+ const contextWindow = finiteNumber(context.contextWindow) || finiteNumber(model.contextWindow);
1001
+ if (contextWindow <= 0) return;
1002
+ const tokens = typeof context.tokens === "number" && Number.isFinite(context.tokens) ? context.tokens : null;
1003
+ record.stats = {
1004
+ ...record.stats,
1005
+ contextUsage: {
1006
+ ...context,
1007
+ tokens,
1008
+ contextWindow,
1009
+ percent: tokens == null ? null : (tokens / contextWindow) * 100,
1010
+ },
1011
+ };
1012
+ }
1013
+
1014
+ async function refreshStats(record: RuntimeSession): Promise<void> {
1015
+ if (!record.proc) return;
1016
+ const revision = record.statsRevision ?? 0;
1017
+ try {
1018
+ const stats = await record.proc.send<Json>({ type: "get_session_stats" });
1019
+ if ((record.statsRevision ?? 0) === revision) record.stats = (stats.data ?? {}) as Json;
1020
+ } catch {
1021
+ // stats are optional
1022
+ }
1023
+ }
1024
+
1025
+ function finiteNumber(value: unknown): number {
1026
+ const number = Number(value ?? 0);
1027
+ return Number.isFinite(number) ? number : 0;
1028
+ }
1029
+
1030
+ function readLiveUsage(value: unknown): LiveUsageSnapshot | undefined {
1031
+ if (!value || typeof value !== "object") return undefined;
1032
+ const usage = value as Json;
1033
+ const cost = usage.cost && typeof usage.cost === "object"
1034
+ ? finiteNumber((usage.cost as Json).total)
1035
+ : finiteNumber(usage.cost);
1036
+ const input = finiteNumber(usage.input);
1037
+ const output = finiteNumber(usage.output);
1038
+ const cacheRead = finiteNumber(usage.cacheRead);
1039
+ const cacheWrite = finiteNumber(usage.cacheWrite);
1040
+ return {
1041
+ input,
1042
+ output,
1043
+ cacheRead,
1044
+ cacheWrite,
1045
+ totalTokens: finiteNumber(usage.totalTokens) || input + output + cacheRead + cacheWrite,
1046
+ cost,
1047
+ };
1048
+ }
1049
+
1050
+ function applyLiveUsage(record: RuntimeSession, value: unknown): void {
1051
+ const current = readLiveUsage(value);
1052
+ if (!current) return;
1053
+ const previous = record.liveUsage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: 0 };
1054
+ const stats: Json = { ...(record.stats ?? {}) };
1055
+ const existingTokens = stats.tokens && typeof stats.tokens === "object" ? (stats.tokens as Json) : {};
1056
+ const tokens: Json = { ...existingTokens };
1057
+ for (const key of ["input", "output", "cacheRead", "cacheWrite"] as const) {
1058
+ tokens[key] = Math.max(0, finiteNumber(existingTokens[key]) + current[key] - previous[key]);
1059
+ }
1060
+ tokens.total = finiteNumber(tokens.input) + finiteNumber(tokens.output) + finiteNumber(tokens.cacheRead) + finiteNumber(tokens.cacheWrite);
1061
+ stats.tokens = tokens;
1062
+ stats.cost = Math.max(0, finiteNumber(stats.cost) + current.cost - previous.cost);
1063
+
1064
+ const context = stats.contextUsage && typeof stats.contextUsage === "object" ? (stats.contextUsage as Json) : {};
1065
+ const model = record.state?.model && typeof record.state.model === "object" ? (record.state.model as Json) : {};
1066
+ const contextWindow = finiteNumber(context.contextWindow) || finiteNumber(model.contextWindow);
1067
+ if (contextWindow > 0 && current.totalTokens > 0) {
1068
+ stats.contextUsage = {
1069
+ tokens: current.totalTokens,
1070
+ contextWindow,
1071
+ percent: (current.totalTokens / contextWindow) * 100,
1072
+ };
1073
+ }
1074
+ record.stats = stats;
1075
+ record.liveUsage = current;
1076
+ record.statsRevision = (record.statsRevision ?? 0) + 1;
1077
+ }
1078
+
1079
+ // ---------------------------------------------------------------------------
1080
+ // live event application
1081
+ // ---------------------------------------------------------------------------
1082
+
1083
+ function assistantErrorText(message: Json): string | undefined {
1084
+ const isError = message.stopReason === "error" || typeof message.errorMessage === "string";
1085
+ if (!isError) return undefined;
1086
+ return typeof message.errorMessage === "string" && message.errorMessage.trim()
1087
+ ? stripAnsi(message.errorMessage)
1088
+ : "模型调用失败,但 Pi 未返回错误详情";
1089
+ }
1090
+
1091
+ function applyMessageEvent(record: RuntimeSession, event: Json): void {
1092
+ if (event.type === "message_update") {
1093
+ applyAssistantDelta(record.messages, event);
1094
+ return;
1095
+ }
1096
+ const message = (event.message ?? {}) as Json;
1097
+ if (event.type === "message_start") {
1098
+ if (message.role === "user") {
1099
+ const rendered = renderMessage(message);
1100
+ if (rendered) record.messages.push(rendered);
1101
+ }
1102
+ return;
1103
+ }
1104
+ if (event.type === "message_end") {
1105
+ if (message.role === "assistant") {
1106
+ const rendered = renderAssistantMessage(message);
1107
+ const idx = findLast(record.messages, (m) => m.kind === "assistant" && m.streaming === true);
1108
+ if (idx >= 0) record.messages[idx] = rendered;
1109
+ else record.messages.push(rendered);
1110
+ record.messages = attachToolResults(record.messages);
1111
+ return;
1112
+ }
1113
+ const rendered = renderMessage(message);
1114
+ if (rendered) {
1115
+ const last = record.messages[record.messages.length - 1];
1116
+ if (last && last.kind === rendered.kind && last.timestamp === rendered.timestamp && last.text === rendered.text) {
1117
+ record.messages[record.messages.length - 1] = rendered;
1118
+ } else {
1119
+ record.messages.push(rendered);
1120
+ }
1121
+ record.messages = attachToolResults(record.messages);
1122
+ }
1123
+ }
1124
+ }
1125
+
1126
+ function bindSessionEvents(record: RuntimeSession, proc: PiRpc): void {
1127
+ proc.onEvent((event) => {
1128
+ if (event.type === "extension_ui_request") {
1129
+ if (handlePendingTreeRestoreDialog(record, proc, event)) return;
1130
+ const method = event.method;
1131
+ if (method === "select" || method === "confirm" || method === "input" || method === "editor") {
1132
+ const dialog: ExtensionDialog = {
1133
+ id: typeof event.id === "string" ? event.id : randomUUID(),
1134
+ method,
1135
+ title: typeof event.title === "string" ? event.title : undefined,
1136
+ message: typeof event.message === "string" ? event.message : undefined,
1137
+ placeholder: typeof event.placeholder === "string" ? event.placeholder : undefined,
1138
+ options: Array.isArray(event.options) ? (event.options as string[]) : undefined,
1139
+ prefill: typeof event.prefill === "string" ? event.prefill : undefined,
1140
+ timeout: typeof event.timeout === "number" ? event.timeout : undefined,
1141
+ createdAt: Date.now(),
1142
+ };
1143
+ if (dialog.timeout && dialog.timeout > 0) dialog.expiresAt = dialog.createdAt + dialog.timeout;
1144
+ record.dialogs = [...record.dialogs.filter((d) => d.id !== dialog.id), dialog];
1145
+ setSessionNeedsAttention(record, state.currentSessionId !== record.id);
1146
+ broadcastSessionSnapshot(record);
1147
+ broadcastWorkspaces();
1148
+ if (dialog.expiresAt) {
1149
+ const timer = setTimeout(() => {
1150
+ const active = record.dialogs.find((candidate) => candidate.id === dialog.id);
1151
+ if (!active || active.expiresAt !== dialog.expiresAt) return;
1152
+ record.dialogs = record.dialogs.filter((candidate) => candidate.id !== dialog.id);
1153
+ broadcastSessionSnapshot(record);
1154
+ broadcastWorkspaces();
1155
+ }, Math.max(1, dialog.expiresAt - Date.now()));
1156
+ timer.unref();
1157
+ }
1158
+ } else if (method === "setStatus" && typeof event.statusKey === "string") {
1159
+ if (typeof event.statusText === "string" && event.statusText.length > 0) {
1160
+ record.extensionStatuses[event.statusKey] = stripAnsi(event.statusText);
1161
+ } else {
1162
+ delete record.extensionStatuses[event.statusKey];
1163
+ }
1164
+ broadcastSessionSnapshot(record);
1165
+ } else if (method === "notify" && typeof event.message === "string") {
1166
+ broadcastEvent({
1167
+ type: "extension_notify",
1168
+ sessionId: record.id,
1169
+ message: stripAnsi(event.message),
1170
+ notifyType: event.notifyType === "error" || event.notifyType === "warning" ? event.notifyType : "info",
1171
+ });
1172
+ } else if (method === "set_editor_text" && typeof event.text === "string") {
1173
+ broadcastEvent({ type: "editor_text", sessionId: record.id, text: stripAnsi(event.text) });
1174
+ }
1175
+ return;
1176
+ }
1177
+ if (event.type === "message_start" || event.type === "message_update" || event.type === "message_end") {
1178
+ const message = event.message && typeof event.message === "object" ? (event.message as Json) : {};
1179
+ if (event.type === "message_start" && message.role === "assistant") {
1180
+ record.liveUsage = undefined;
1181
+ record.statsRevision = (record.statsRevision ?? 0) + 1;
1182
+ }
1183
+ applyMessageEvent(record, event);
1184
+ if (event.type === "message_update") applyLiveUsage(record, event.usage);
1185
+ else if (event.type === "message_end" && message.role === "assistant") applyLiveUsage(record, message.usage);
1186
+ broadcastSessionSnapshot(record);
1187
+ if (event.type === "message_end" && message.role === "assistant") {
1188
+ const errorMessage = assistantErrorText(message);
1189
+ if (errorMessage) {
1190
+ broadcastEvent({ type: "session_error", sessionId: record.id, message: errorMessage });
1191
+ }
1192
+ }
1193
+ } else if (event.type === "tool_execution_start" || event.type === "tool_execution_update" || event.type === "tool_execution_end") {
1194
+ applyToolExecution(record.messages, event);
1195
+ broadcastSessionSnapshot(record);
1196
+ } else if (event.type === "queue_update") {
1197
+ const steering = Array.isArray(event.steering)
1198
+ ? event.steering.filter((message): message is string => typeof message === "string")
1199
+ : [];
1200
+ const followUp = Array.isArray(event.followUp)
1201
+ ? event.followUp.filter((message): message is string => typeof message === "string")
1202
+ : [];
1203
+ setPendingQueue(record, { steering, followUp });
1204
+ broadcastSessionSnapshot(record);
1205
+ } else if (event.type === "agent_start") {
1206
+ record.status = "running";
1207
+ record.streaming = true;
1208
+ setSessionNeedsAttention(record, false);
1209
+ record.liveUsage = undefined;
1210
+ record.statsRevision = (record.statsRevision ?? 0) + 1;
1211
+ broadcastSessionSnapshot(record);
1212
+ } else if (event.type === "agent_end") {
1213
+ broadcastSessionSnapshot(record);
1214
+ } else if (event.type === "compaction_start") {
1215
+ const reason = typeof event.reason === "string" ? event.reason : undefined;
1216
+ record.state = { ...(record.state ?? {}), isCompacting: true };
1217
+ record.compactionNotice = {
1218
+ kind: "compaction",
1219
+ id: `compaction-running:${Date.now()}`,
1220
+ status: "running",
1221
+ reason,
1222
+ timestamp: Date.now(),
1223
+ };
1224
+ broadcastSessionSnapshot(record);
1225
+ } else if (event.type === "compaction_end") {
1226
+ const reason = typeof event.reason === "string" ? event.reason : undefined;
1227
+ const result = event.result && typeof event.result === "object" ? event.result as Json : undefined;
1228
+ record.state = { ...(record.state ?? {}), isCompacting: false };
1229
+ const notice: RenderedMessage = result
1230
+ ? {
1231
+ ...renderCompaction({ ...result, timestamp: Date.now() }),
1232
+ id: `compaction-complete:${Date.now()}`,
1233
+ reason,
1234
+ willRetry: event.willRetry === true,
1235
+ }
1236
+ : {
1237
+ kind: "compaction",
1238
+ id: `compaction-failed:${Date.now()}`,
1239
+ status: event.aborted === true ? "aborted" : "error",
1240
+ reason,
1241
+ errorMessage: typeof event.errorMessage === "string"
1242
+ ? stripAnsi(event.errorMessage)
1243
+ : event.aborted === true ? "压缩已取消" : "未返回错误详情",
1244
+ willRetry: event.willRetry === true,
1245
+ timestamp: Date.now(),
1246
+ };
1247
+ record.compactionNotice = notice;
1248
+ broadcastSessionSnapshot(record);
1249
+ if (result) {
1250
+ Promise.allSettled([refreshMessages(record), refreshStats(record)]).then(() => {
1251
+ broadcastSessionSnapshot(record);
1252
+ });
1253
+ }
1254
+ } else if (event.type === "agent_settled") {
1255
+ record.streaming = false;
1256
+ record.status = "idle";
1257
+ setPendingQueue(record, { steering: [], followUp: [] });
1258
+ setSessionNeedsAttention(record, state.currentSessionId !== record.id);
1259
+ record.liveUsage = undefined;
1260
+ record.statsRevision = (record.statsRevision ?? 0) + 1;
1261
+ refreshMessages(record).then(() => broadcastSessionSnapshot(record));
1262
+ refreshState(record).then(() => broadcastSessionSnapshot(record));
1263
+ refreshStats(record).then(() => broadcastSessionSnapshot(record));
1264
+ updateStoredSession(record);
1265
+ broadcastWorkspaces();
1266
+ evictIdleProcesses();
1267
+ } else if (event.type === "process_exit") {
1268
+ if (record.proc !== proc) return;
1269
+ record.proc = undefined;
1270
+ record.status = "error";
1271
+ record.error = "pi rpc process exited";
1272
+ record.streaming = false;
1273
+ record.extensionStatuses = {};
1274
+ record.dialogs = [];
1275
+ broadcastSessionSnapshot(record);
1276
+ broadcastWorkspaces();
1277
+ } else if (event.type === "spawn_error") {
1278
+ record.status = "error";
1279
+ record.error = typeof event.error === "string" ? event.error : String(event.error);
1280
+ record.streaming = false;
1281
+ record.extensionStatuses = {};
1282
+ record.dialogs = [];
1283
+ broadcastSessionSnapshot(record);
1284
+ broadcastWorkspaces();
1285
+ }
1286
+ });
1287
+ }
1288
+
1289
+ // ---------------------------------------------------------------------------
1290
+ // session lifecycle
1291
+ // ---------------------------------------------------------------------------
1292
+
1293
+ function evictIdleProcesses(): void {
1294
+ const idle: RuntimeSession[] = [];
1295
+ for (const record of sessions.values()) {
1296
+ if (record.proc && !record.streaming && record.status !== "running" && record.status !== "starting" && record.dialogs.length === 0 && record.id !== state.currentSessionId) {
1297
+ idle.push(record);
1298
+ }
1299
+ }
1300
+ if (idle.length <= MAX_IDLE_PROCESSES) return;
1301
+ idle.sort((a, b) => (a.lastUsed || 0) - (b.lastUsed || 0));
1302
+ for (const record of idle.slice(0, idle.length - MAX_IDLE_PROCESSES)) {
1303
+ record.proc?.close();
1304
+ record.proc = undefined;
1305
+ if (record.status !== "error") record.status = "stopped";
1306
+ broadcastSessionSnapshot(record);
1307
+ }
1308
+ }
1309
+
1310
+ async function applyPendingSettings(record: RuntimeSession): Promise<void> {
1311
+ const proc = record.proc;
1312
+ if (!proc) return;
1313
+ if (record.pendingModel) {
1314
+ const pending = record.pendingModel;
1315
+ try {
1316
+ await proc.send({ type: "set_model", provider: pending.provider, modelId: pending.modelId });
1317
+ record.pendingModel = undefined;
1318
+ } catch (error) {
1319
+ console.error("[pi-web] applying pending model failed:", error);
1320
+ record.pendingModel = undefined;
1321
+ }
1322
+ }
1323
+ if (record.pendingThinkingLevel) {
1324
+ const level = record.pendingThinkingLevel;
1325
+ try {
1326
+ await proc.send({ type: "set_thinking_level", level });
1327
+ record.pendingThinkingLevel = undefined;
1328
+ } catch (error) {
1329
+ console.error("[pi-web] applying pending thinking level failed:", error);
1330
+ record.pendingThinkingLevel = undefined;
1331
+ }
1332
+ }
1333
+ if (record.pendingName) {
1334
+ const name = record.pendingName;
1335
+ try {
1336
+ await proc.send({ type: "set_session_name", name });
1337
+ record.pendingName = undefined;
1338
+ } catch (error) {
1339
+ console.error("[pi-web] applying pending session name failed:", error);
1340
+ record.pendingName = undefined;
1341
+ }
1342
+ }
1343
+ }
1344
+
1345
+ async function ensureProcess(record: RuntimeSession): Promise<void> {
1346
+ record.lastUsed = Date.now();
1347
+ if (record.proc) return;
1348
+ if (record.starting) return record.starting;
1349
+ record.status = "starting";
1350
+ record.error = undefined;
1351
+ record.extensionStatuses = {};
1352
+ broadcastSessionSnapshot(record);
1353
+
1354
+ const task = (async () => {
1355
+ const proc = spawnPiRpc({
1356
+ cwd: record.cwd,
1357
+ sessionId: record.sessionFile ? undefined : record.id,
1358
+ sessionFile: record.sessionFile,
1359
+ name: record.title === "新会话" ? undefined : record.title,
1360
+ });
1361
+ record.proc = proc;
1362
+ bindSessionEvents(record, proc);
1363
+ try {
1364
+ await applyPendingSettings(record);
1365
+ await refreshState(record);
1366
+ await refreshMessages(record);
1367
+ refreshModels(record).then(() => broadcastSessionSnapshot(record));
1368
+ refreshCommands(record).then(() => broadcastSessionSnapshot(record));
1369
+ refreshStats(record).then(() => broadcastSessionSnapshot(record));
1370
+ record.status = record.streaming ? "running" : "idle";
1371
+ updateStoredSession(record);
1372
+ broadcastSessionSnapshot(record);
1373
+ broadcastWorkspaces();
1374
+ evictIdleProcesses();
1375
+ } catch (error) {
1376
+ record.status = "error";
1377
+ record.error = error instanceof Error ? error.message : String(error);
1378
+ broadcastSessionSnapshot(record);
1379
+ } finally {
1380
+ record.starting = undefined;
1381
+ }
1382
+ })();
1383
+ record.starting = task;
1384
+ return task;
1385
+ }
1386
+
1387
+ async function openSessionRecord(record: RuntimeSession): Promise<void> {
1388
+ record.lastUsed = Date.now();
1389
+ applyCachedCommands(record);
1390
+ setSessionNeedsAttention(record, false);
1391
+ state.currentSessionId = record.id;
1392
+ const ws = state.workspaces.find((w) => w.id === record.workspaceId);
1393
+ if (ws) state.currentWorkspaceId = ws.id;
1394
+ scheduleSave();
1395
+ broadcastWorkspaces();
1396
+ evictIdleProcesses();
1397
+
1398
+ // Missing session file: report instead of spawning a pi process just to
1399
+ // browse an empty/removed session.
1400
+ if ((!record.proc || record.status === "starting") && record.sessionFile && !existsSync(record.sessionFile)) {
1401
+ record.messages = [];
1402
+ record.state = { sessionFile: record.sessionFile, isStreaming: false };
1403
+ record.stats = null;
1404
+ record.status = "error";
1405
+ record.error = "会话文件不存在(可能已在终端中删除)";
1406
+ applyCachedModelInfo(record);
1407
+ broadcastSessionSnapshot(record);
1408
+ return;
1409
+ }
1410
+
1411
+ // Fast path: show the transcript straight from disk. No pi process needed
1412
+ // just for browsing, which is why switching feels as fast as DSH. This also
1413
+ // covers the window where a background pi process is still booting.
1414
+ if (record.sessionFile && existsSync(record.sessionFile) && (!record.proc || record.status === "starting")) {
1415
+ try {
1416
+ const local = loadLocalSession(record.sessionFile, record.cwd);
1417
+ record.messages = local.messages;
1418
+ record.state = { ...local.state, ...(record.state ?? {}) };
1419
+ record.state.isStreaming = Boolean(record.streaming);
1420
+ record.state.isCompacting = false;
1421
+ if (local.title) {
1422
+ record.title = local.title;
1423
+ const stored = state.sessions.find((x) => x.id === record.id);
1424
+ if (stored) stored.title = local.title;
1425
+ }
1426
+ record.stats = local.stats ?? record.stats;
1427
+ record.sessionFile = local.sessionFile ?? record.sessionFile;
1428
+ applyCachedModelInfo(record);
1429
+ record.status = record.streaming ? "running" : record.proc ? "idle" : "stopped";
1430
+ record.error = undefined;
1431
+ scheduleSave();
1432
+ broadcastSessionSnapshot(record);
1433
+ broadcastWorkspaces();
1434
+ return;
1435
+ } catch (error) {
1436
+ console.error("[pi-web] local session load failed, falling back to rpc:", error);
1437
+ record.error = error instanceof Error ? error.message : String(error);
1438
+ }
1439
+ }
1440
+
1441
+ if (!record.sessionFile) {
1442
+ // Keep a brand-new session virtual until the first RPC-backed action.
1443
+ applyCachedModelInfo(record);
1444
+ record.status = "stopped";
1445
+ broadcastSessionSnapshot(record);
1446
+ return;
1447
+ }
1448
+
1449
+ if (!record.proc) {
1450
+ await ensureProcess(record);
1451
+ } else {
1452
+ await Promise.allSettled([refreshState(record), refreshMessages(record)]);
1453
+ }
1454
+ if (record.models.length === 0) {
1455
+ try {
1456
+ await refreshModels(record);
1457
+ } catch {
1458
+ // model picker will populate on a later retry/refresh
1459
+ }
1460
+ }
1461
+ applyCachedModelInfo(record);
1462
+ refreshStats(record).then(() => broadcastSessionSnapshot(record));
1463
+ broadcastSessionSnapshot(record);
1464
+ }
1465
+
1466
+ async function stopSessionProcess(record: RuntimeSession): Promise<void> {
1467
+ const proc = record.proc;
1468
+ record.proc = undefined;
1469
+ if (proc) {
1470
+ try {
1471
+ await proc.close(2500);
1472
+ } catch (error) {
1473
+ console.error("[pi-web] stop session process failed:", error);
1474
+ }
1475
+ }
1476
+ record.status = "stopped";
1477
+ record.streaming = false;
1478
+ record.dialogs = [];
1479
+ record.extensionStatuses = {};
1480
+ record.starting = undefined;
1481
+ }
1482
+
1483
+ async function deleteWorkspaceSession(workspace: Workspace, sessionId: string): Promise<void> {
1484
+ if (!workspace.sessionIds.includes(sessionId)) throw new Error("session is not in this workspace");
1485
+ const record = sessions.get(sessionId);
1486
+ const stored = state.sessions.find((session) => session.id === sessionId);
1487
+ if (!record && !stored) throw new Error("session not found");
1488
+
1489
+ if (record?.proc && !record.sessionFile) {
1490
+ await refreshState(record).catch(() => {});
1491
+ }
1492
+ const knownFile = record?.sessionFile ?? stored?.sessionFile;
1493
+ const discovered = (await SessionManager.list(workspace.path)).find((session) => session.id === sessionId);
1494
+ const knownExists = Boolean(knownFile && existsSync(knownFile));
1495
+ const comparablePath = (path: string) => {
1496
+ const normalized = normalizePath(path);
1497
+ return platform() === "win32" ? normalized.toLowerCase() : normalized;
1498
+ };
1499
+ if (knownExists && (!discovered || comparablePath(discovered.path) !== comparablePath(knownFile!))) {
1500
+ throw new Error("无法确认会话文件属于当前工作区,已取消删除");
1501
+ }
1502
+
1503
+ if (record) await stopSessionProcess(record);
1504
+ if (discovered?.path) {
1505
+ try {
1506
+ await unlink(discovered.path);
1507
+ } catch (error) {
1508
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
1509
+ }
1510
+ }
1511
+
1512
+ const updatedAt = nowIso();
1513
+ for (const candidate of state.workspaces) {
1514
+ if (!candidate.sessionIds.includes(sessionId)) continue;
1515
+ candidate.sessionIds = candidate.sessionIds.filter((id) => id !== sessionId);
1516
+ candidate.updatedAt = updatedAt;
1517
+ }
1518
+ state.sessions = state.sessions.filter((session) => session.id !== sessionId);
1519
+ sessions.delete(sessionId);
1520
+ if (state.currentSessionId === sessionId) state.currentSessionId = undefined;
1521
+ scheduleSave();
1522
+ broadcastWorkspaces();
1523
+ }
1524
+
1525
+ async function createSession(
1526
+ workspaceId: string,
1527
+ title?: string,
1528
+ preferences: NewSessionPreferences = {},
1529
+ ): Promise<RuntimeSession> {
1530
+ const ws = state.workspaces.find((w) => w.id === workspaceId);
1531
+ if (!ws) throw new Error("workspace not found");
1532
+ const id = randomUUID();
1533
+ const cleanTitle = (title ?? "").trim().replace(/\s+/g, " ").slice(0, 60) || "新会话";
1534
+ const stored: StoredSession = {
1535
+ id,
1536
+ workspaceId,
1537
+ title: cleanTitle,
1538
+ model: preferences.model,
1539
+ thinkingLevel: preferences.thinkingLevel,
1540
+ needsAttention: false,
1541
+ createdAt: nowIso(),
1542
+ updatedAt: nowIso(),
1543
+ };
1544
+ state.sessions.unshift(stored);
1545
+ ws.sessionIds.unshift(id);
1546
+ ws.updatedAt = stored.updatedAt;
1547
+ const record: RuntimeSession = {
1548
+ id,
1549
+ workspaceId,
1550
+ title: cleanTitle,
1551
+ cwd: ws.path,
1552
+ status: "stopped",
1553
+ messages: [],
1554
+ state: null,
1555
+ stats: null,
1556
+ models: [],
1557
+ thinkingLevels: [],
1558
+ commands: [],
1559
+ extensionStatuses: {},
1560
+ dialogs: [],
1561
+ streaming: false,
1562
+ needsAttention: false,
1563
+ lastUsed: Date.now(),
1564
+ pendingModel: preferences.model,
1565
+ pendingThinkingLevel: preferences.thinkingLevel,
1566
+ };
1567
+ applyCachedModelInfo(record);
1568
+ applyCachedCommands(record);
1569
+ if (preferences.model) {
1570
+ const full = (record.models as Json[]).find(
1571
+ (model) => model?.provider === preferences.model?.provider && model.id === preferences.model?.modelId,
1572
+ );
1573
+ record.state = {
1574
+ ...(record.state ?? {}),
1575
+ model: full ?? { provider: preferences.model.provider, id: preferences.model.modelId, name: preferences.model.modelId },
1576
+ };
1577
+ if (full) record.thinkingLevels = supportedThinkingLevels(full);
1578
+ }
1579
+ if (preferences.thinkingLevel) {
1580
+ const effectiveLevel = record.thinkingLevels.includes(preferences.thinkingLevel)
1581
+ ? preferences.thinkingLevel
1582
+ : record.thinkingLevels.includes("off") ? "off" : record.thinkingLevels[0] ?? "off";
1583
+ record.pendingThinkingLevel = effectiveLevel;
1584
+ stored.thinkingLevel = effectiveLevel;
1585
+ record.state = { ...(record.state ?? {}), thinkingLevel: effectiveLevel };
1586
+ }
1587
+ sessions.set(id, record);
1588
+ scheduleSave();
1589
+ broadcastWorkspaces();
1590
+ return record;
1591
+ }
1592
+
1593
+ async function importSessionsForWorkspace(ws: Workspace): Promise<number> {
1594
+ const infos = await scanPiSessions(ws.path);
1595
+ let added = 0;
1596
+ for (const info of infos) {
1597
+ if (!info.id) continue;
1598
+ const existing = state.sessions.find((s) => s.id === info.id);
1599
+ if (existing) {
1600
+ if (!ws.sessionIds.includes(info.id)) ws.sessionIds.push(info.id);
1601
+ continue;
1602
+ }
1603
+ const title = firstMessageTitle(info);
1604
+ const stored: StoredSession = {
1605
+ id: info.id,
1606
+ workspaceId: ws.id,
1607
+ title,
1608
+ sessionFile: info.path,
1609
+ needsAttention: false,
1610
+ createdAt: info.created.toISOString(),
1611
+ updatedAt: info.modified.toISOString(),
1612
+ };
1613
+ state.sessions.push(stored);
1614
+ ws.sessionIds.push(info.id);
1615
+ sessions.set(info.id, {
1616
+ id: info.id,
1617
+ workspaceId: ws.id,
1618
+ title,
1619
+ sessionFile: info.path,
1620
+ cwd: ws.path,
1621
+ status: "stopped",
1622
+ messages: [],
1623
+ state: null,
1624
+ stats: null,
1625
+ models: [],
1626
+ thinkingLevels: [],
1627
+ commands: [],
1628
+ extensionStatuses: {},
1629
+ dialogs: [],
1630
+ streaming: false,
1631
+ needsAttention: false,
1632
+ lastUsed: 0,
1633
+ });
1634
+ added += 1;
1635
+ }
1636
+ if (added > 0) {
1637
+ ws.updatedAt = nowIso();
1638
+ scheduleSave();
1639
+ }
1640
+ return added;
1641
+ }
1642
+
1643
+ async function addWorkspace(path: string): Promise<Workspace> {
1644
+ const cwd = normalizePath(path);
1645
+ const existing = state.workspaces.find((w) => normalizePath(w.path) === cwd);
1646
+ if (existing) return existing;
1647
+ const ws: Workspace = {
1648
+ id: randomUUID(),
1649
+ path: cwd,
1650
+ title: pathTitle(cwd),
1651
+ sessionIds: [],
1652
+ createdAt: nowIso(),
1653
+ updatedAt: nowIso(),
1654
+ };
1655
+ state.workspaces.push(ws);
1656
+ if (state.workspaces.length === 1 && !state.currentWorkspaceId) state.currentWorkspaceId = ws.id;
1657
+ scheduleSave();
1658
+ await importSessionsForWorkspace(ws);
1659
+ scheduleSave();
1660
+ broadcastWorkspaces();
1661
+ return ws;
1662
+ }
1663
+
1664
+ // ---------------------------------------------------------------------------
1665
+ // HTTP
1666
+ // ---------------------------------------------------------------------------
1667
+
1668
+ function sendJson(res: ServerResponse, status: number, value: unknown): boolean {
1669
+ res.writeHead(status, {
1670
+ "content-type": "application/json; charset=utf-8",
1671
+ "cache-control": "no-store",
1672
+ });
1673
+ res.end(JSON.stringify(value));
1674
+ return true;
1675
+ }
1676
+
1677
+ function readJsonBody(req: import("node:http").IncomingMessage, limit = 12 * 1024 * 1024): Promise<Json> {
1678
+ return new Promise((resolveBody, reject) => {
1679
+ let size = 0;
1680
+ const chunks: Buffer[] = [];
1681
+ req.on("data", (chunk: Buffer) => {
1682
+ size += chunk.length;
1683
+ if (size > limit) {
1684
+ reject(new Error("payload too large"));
1685
+ req.destroy();
1686
+ return;
1687
+ }
1688
+ chunks.push(chunk);
1689
+ });
1690
+ req.on("end", () => {
1691
+ if (chunks.length === 0) return resolveBody({});
1692
+ try {
1693
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
1694
+ resolveBody(parsed && typeof parsed === "object" ? (parsed as Json) : {});
1695
+ } catch {
1696
+ reject(new Error("invalid JSON body"));
1697
+ }
1698
+ });
1699
+ req.on("error", reject);
1700
+ });
1701
+ }
1702
+
1703
+ function isTrustedLocalMutation(req: import("node:http").IncomingMessage): boolean {
1704
+ const host = req.headers.host ?? "";
1705
+ let hostname = "";
1706
+ try {
1707
+ hostname = new URL(`http://${host}`).hostname.toLowerCase();
1708
+ } catch {
1709
+ return false;
1710
+ }
1711
+ if (hostname !== "127.0.0.1" && hostname !== "localhost" && hostname !== "[::1]") return false;
1712
+ const fetchSite = req.headers["sec-fetch-site"];
1713
+ if (fetchSite === "cross-site") return false;
1714
+ const origin = req.headers.origin;
1715
+ if (origin) {
1716
+ try {
1717
+ if (new URL(origin).host.toLowerCase() !== host.toLowerCase()) return false;
1718
+ } catch {
1719
+ return false;
1720
+ }
1721
+ }
1722
+ return true;
1723
+ }
1724
+
1725
+ function findRecord(id: string): RuntimeSession | undefined {
1726
+ return sessions.get(id);
1727
+ }
1728
+
1729
+ function findSessionFromRequest(req: import("node:http").IncomingMessage): RuntimeSession | undefined {
1730
+ const match = /^\/api\/sessions\/([^/]+)/.exec(req.url ?? "");
1731
+ if (!match) return undefined;
1732
+ return findRecord(decodeURIComponent(match[1]));
1733
+ }
1734
+
1735
+ function fsRoots(): Json {
1736
+ if (platform() === "win32") {
1737
+ const drives: Json[] = [];
1738
+ for (let i = 65; i <= 90; i += 1) {
1739
+ const letter = String.fromCharCode(i);
1740
+ const root = `${letter}:\\`;
1741
+ if (existsSync(root)) drives.push({ name: root, path: root });
1742
+ }
1743
+ return { roots: drives, home: homedir() };
1744
+ }
1745
+ return { roots: [{ name: "/", path: "/" }], home: homedir() };
1746
+ }
1747
+
1748
+ function runPickerCommand(command: string, args: string[], cancelCodes: number[]): Promise<Json> {
1749
+ return new Promise((resolve) => {
1750
+ const child = spawn(command, args, { windowsHide: true });
1751
+ let stdout = "";
1752
+ let stderr = "";
1753
+ child.stdout.on("data", (d: Buffer) => (stdout += d.toString()));
1754
+ child.stderr.on("data", (d: Buffer) => (stderr += d.toString()));
1755
+ const timer = setTimeout(() => {
1756
+ child.kill();
1757
+ resolve({ error: "directory picker timed out" });
1758
+ }, 600_000);
1759
+ child.on("error", (error) => {
1760
+ clearTimeout(timer);
1761
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") resolve({ error: `no native directory picker found: ${command}` });
1762
+ else resolve({ error: error.message });
1763
+ });
1764
+ child.on("close", (code) => {
1765
+ clearTimeout(timer);
1766
+ const path = stdout.trim();
1767
+ if (path) resolve({ path });
1768
+ else if (typeof code === "number" && cancelCodes.includes(code)) resolve({ cancelled: true });
1769
+ else if (stderr.trim()) resolve({ error: stderr.trim() });
1770
+ else resolve({ cancelled: true });
1771
+ });
1772
+ });
1773
+ }
1774
+
1775
+ /** Open the platform's native directory chooser (DSH native-picker port). */
1776
+ async function pickNativeDirectory(): Promise<Json> {
1777
+ if (platform() === "win32") {
1778
+ // The WinForms FolderBrowserDialog lives in a separate powershell.exe
1779
+ // process, so Windows' foreground lock can leave it behind the browser.
1780
+ // While ShowDialog() runs its modal loop we poll for the dialog window
1781
+ // (by title, scoped to this process) and raise it to TOPMOST.
1782
+ const script = [
1783
+ "Add-Type -AssemblyName System.Windows.Forms",
1784
+ "Add-Type @\"",
1785
+ "using System;",
1786
+ "using System.Text;",
1787
+ "using System.Runtime.InteropServices;",
1788
+ "public static class PickerWin32 {",
1789
+ " public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);",
1790
+ " [DllImport(\"user32.dll\")] public static extern bool EnumWindows(EnumWindowsProc cb, IntPtr lParam);",
1791
+ " [DllImport(\"user32.dll\")] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int max);",
1792
+ " [DllImport(\"user32.dll\")] public static extern int GetClassName(IntPtr hWnd, StringBuilder text, int max);",
1793
+ " [DllImport(\"user32.dll\")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);",
1794
+ " [DllImport(\"user32.dll\")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr insertAfter, int x, int y, int cx, int cy, uint flags);",
1795
+ " [DllImport(\"user32.dll\")] public static extern bool SetForegroundWindow(IntPtr hWnd);",
1796
+ " public static bool EnumProc(IntPtr hWnd, IntPtr lParam) {",
1797
+ " uint pid;",
1798
+ " GetWindowThreadProcessId(hWnd, out pid);",
1799
+ " if (pid != (uint)System.Diagnostics.Process.GetCurrentProcess().Id) return true;",
1800
+ " StringBuilder sb = new StringBuilder(512);",
1801
+ " GetClassName(hWnd, sb, 512);",
1802
+ " if (sb.ToString() == \"#32770\") {",
1803
+ " SetWindowPos(hWnd, new IntPtr(-1), 0, 0, 0, 0, 0x0002 | 0x0001);",
1804
+ " SetForegroundWindow(hWnd);",
1805
+ " return false;",
1806
+ " }",
1807
+ " return true;",
1808
+ " }",
1809
+ " public static void ForceOnTop() {",
1810
+ " EnumWindows(new EnumWindowsProc(EnumProc), IntPtr.Zero);",
1811
+ " }",
1812
+ "}",
1813
+ "\"@",
1814
+ "$f = New-Object System.Windows.Forms.FolderBrowserDialog",
1815
+ "$f.Description = 'Select Workspace Directory'",
1816
+ "$f.ShowNewFolderButton = $true",
1817
+ "$t = New-Object System.Windows.Forms.Timer",
1818
+ "$t.Interval = 300",
1819
+ "$t.Add_Tick({ [PickerWin32]::ForceOnTop() })",
1820
+ "$t.Start()",
1821
+ "$r = $f.ShowDialog()",
1822
+ "$t.Stop()",
1823
+ "if ($r -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($f.SelectedPath) }",
1824
+ ].join("\n");
1825
+ return await runPickerCommand("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], []);
1826
+ }
1827
+ if (platform() === "darwin") {
1828
+ return await runPickerCommand(
1829
+ "osascript",
1830
+ ["-e", "set selectedFolder to choose folder with prompt \"Select Workspace Directory\"", "-e", "POSIX path of selectedFolder"],
1831
+ [1],
1832
+ );
1833
+ }
1834
+ if (platform() === "linux") {
1835
+ const zenity = await runPickerCommand("zenity", ["--file-selection", "--directory", "--title=Select Workspace Directory"], [1]);
1836
+ if (!(zenity as { error?: string }).error) return zenity;
1837
+ return await runPickerCommand("kdialog", ["--getexistingdirectory", ".", "--title", "Select Workspace Directory"], [1]);
1838
+ }
1839
+ return { error: `native directory picker is unsupported on ${platform()}` };
1840
+ }
1841
+
1842
+ async function listDirectories(input: string): Promise<Json> {
1843
+ const target = normalizePath(input);
1844
+ const home = homedir();
1845
+ const st = await stat(target);
1846
+ if (!st.isDirectory()) throw new Error("not a directory");
1847
+ const entries = await readdir(target, { withFileTypes: true });
1848
+ const dirs = entries
1849
+ .filter((e) => e.isDirectory())
1850
+ .map((e) => ({ name: e.name, path: join(target, e.name), hidden: e.name.startsWith(".") }))
1851
+ .sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
1852
+ const crumbs: { name: string; path: string; hidden: boolean }[] = [];
1853
+ {
1854
+ const parts: string[] = [];
1855
+ let current = target;
1856
+ for (;;) {
1857
+ parts.unshift(current);
1858
+ const up = dirname(current);
1859
+ if (up === current) break;
1860
+ current = up;
1861
+ }
1862
+ let acc = parts[0];
1863
+ crumbs.push({ name: parts[0], path: parts[0], hidden: false });
1864
+ for (const part of parts.slice(1)) {
1865
+ acc = join(acc, part);
1866
+ crumbs.push({ name: part, path: acc, hidden: part.startsWith(".") });
1867
+ }
1868
+ }
1869
+ const parent = dirname(target) === target ? null : dirname(target);
1870
+ return { path: target, home, parent, crumbs, entries: dirs, truncated: false };
1871
+ }
1872
+
1873
+ async function openWindowsExplorerOnTop(target: string, directory: string, selectFile: boolean): Promise<void> {
1874
+ const script = [
1875
+ "Add-Type @\"",
1876
+ "using System;",
1877
+ "using System.Runtime.InteropServices;",
1878
+ "public static class ExplorerForeground {",
1879
+ " static readonly IntPtr HWND_TOPMOST = new IntPtr(-1);",
1880
+ " static readonly IntPtr HWND_NOTOPMOST = new IntPtr(-2);",
1881
+ " const uint SWP_NOSIZE = 0x0001;",
1882
+ " const uint SWP_NOMOVE = 0x0002;",
1883
+ " const uint SWP_SHOWWINDOW = 0x0040;",
1884
+ " [DllImport(\"user32.dll\")] static extern bool ShowWindow(IntPtr hWnd, int command);",
1885
+ " [DllImport(\"user32.dll\")] static extern bool SetWindowPos(IntPtr hWnd, IntPtr insertAfter, int x, int y, int cx, int cy, uint flags);",
1886
+ " [DllImport(\"user32.dll\")] static extern bool BringWindowToTop(IntPtr hWnd);",
1887
+ " [DllImport(\"user32.dll\")] static extern bool SetForegroundWindow(IntPtr hWnd);",
1888
+ " [DllImport(\"user32.dll\")] static extern IntPtr GetForegroundWindow();",
1889
+ " [DllImport(\"user32.dll\")] static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);",
1890
+ " [DllImport(\"kernel32.dll\")] static extern uint GetCurrentThreadId();",
1891
+ " [DllImport(\"user32.dll\")] static extern bool AttachThreadInput(uint attach, uint attachTo, bool value);",
1892
+ " [DllImport(\"user32.dll\")] static extern void keybd_event(byte virtualKey, byte scanCode, uint flags, UIntPtr extraInfo);",
1893
+ " public static void Raise(IntPtr hWnd) {",
1894
+ " uint ignored;",
1895
+ " uint currentThread = GetCurrentThreadId();",
1896
+ " uint foregroundThread = GetWindowThreadProcessId(GetForegroundWindow(), out ignored);",
1897
+ " uint targetThread = GetWindowThreadProcessId(hWnd, out ignored);",
1898
+ " if (currentThread != foregroundThread) AttachThreadInput(currentThread, foregroundThread, true);",
1899
+ " if (currentThread != targetThread) AttachThreadInput(currentThread, targetThread, true);",
1900
+ " ShowWindow(hWnd, 9);",
1901
+ " SetWindowPos(hWnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW);",
1902
+ " BringWindowToTop(hWnd);",
1903
+ " keybd_event(0x12, 0, 0, UIntPtr.Zero);",
1904
+ " SetForegroundWindow(hWnd);",
1905
+ " keybd_event(0x12, 0, 0x0002, UIntPtr.Zero);",
1906
+ " System.Threading.Thread.Sleep(120);",
1907
+ " SetWindowPos(hWnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW);",
1908
+ " BringWindowToTop(hWnd);",
1909
+ " SetForegroundWindow(hWnd);",
1910
+ " if (currentThread != targetThread) AttachThreadInput(currentThread, targetThread, false);",
1911
+ " if (currentThread != foregroundThread) AttachThreadInput(currentThread, foregroundThread, false);",
1912
+ " }",
1913
+ "}",
1914
+ "\"@",
1915
+ "$target = $env:PI_WEB_REVEAL_TARGET",
1916
+ "$directory = $env:PI_WEB_REVEAL_DIRECTORY",
1917
+ "$selectFile = $env:PI_WEB_REVEAL_SELECT -eq '1'",
1918
+ "$shell = New-Object -ComObject Shell.Application",
1919
+ "function Normalize-Folder([string]$path) {",
1920
+ " $full = [IO.Path]::GetFullPath($path)",
1921
+ " if ($full.Length -gt 3) { $full = $full.TrimEnd('\\') }",
1922
+ " return $full",
1923
+ "}",
1924
+ "$directoryKey = Normalize-Folder $directory",
1925
+ "function Find-TargetWindows {",
1926
+ " foreach ($window in @($shell.Windows())) {",
1927
+ " try {",
1928
+ " $windowPath = Normalize-Folder ([string]$window.Document.Folder.Self.Path)",
1929
+ " if ($windowPath -eq $directoryKey) { [Int64]$window.HWND }",
1930
+ " } catch {}",
1931
+ " }",
1932
+ "}",
1933
+ "$before = @(Find-TargetWindows)",
1934
+ "$arguments = if ($selectFile) { '/select,\"' + $target.Replace('\"', '\"\"') + '\"' } else { '\"' + $directory.Replace('\"', '\"\"') + '\"' }",
1935
+ "$startInfo = New-Object System.Diagnostics.ProcessStartInfo",
1936
+ "$startInfo.FileName = 'explorer.exe'",
1937
+ "$startInfo.Arguments = $arguments",
1938
+ "$startInfo.UseShellExecute = $true",
1939
+ "[void][System.Diagnostics.Process]::Start($startInfo)",
1940
+ "$handle = 0",
1941
+ "for ($attempt = 0; $attempt -lt 40 -and $handle -eq 0; $attempt++) {",
1942
+ " Start-Sleep -Milliseconds 100",
1943
+ " $matches = @(Find-TargetWindows)",
1944
+ " $fresh = @($matches | Where-Object { $before -notcontains $_ })",
1945
+ " if ($fresh.Count) { $handle = $fresh[-1] }",
1946
+ " elseif ($matches.Count -and $attempt -ge 10) { $handle = $matches[-1] }",
1947
+ "}",
1948
+ "if ($handle -eq 0) { [Console]::Error.Write('Explorer window not found'); exit 2 }",
1949
+ "[ExplorerForeground]::Raise([IntPtr]$handle)",
1950
+ ].join("\n");
1951
+
1952
+ await new Promise<void>((resolveOpen, rejectOpen) => {
1953
+ const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
1954
+ env: {
1955
+ ...process.env,
1956
+ PI_WEB_REVEAL_TARGET: target,
1957
+ PI_WEB_REVEAL_DIRECTORY: directory,
1958
+ PI_WEB_REVEAL_SELECT: selectFile ? "1" : "0",
1959
+ },
1960
+ stdio: ["ignore", "ignore", "pipe"],
1961
+ windowsHide: true,
1962
+ });
1963
+ let stderr = "";
1964
+ child.stderr.on("data", (data: Buffer) => (stderr += data.toString()));
1965
+ const timer = setTimeout(() => {
1966
+ child.kill();
1967
+ rejectOpen(new Error("timed out while bringing Explorer to the foreground"));
1968
+ }, 10_000);
1969
+ child.once("error", (error) => {
1970
+ clearTimeout(timer);
1971
+ rejectOpen(error);
1972
+ });
1973
+ child.once("close", (code) => {
1974
+ clearTimeout(timer);
1975
+ if (code === 0) resolveOpen();
1976
+ else rejectOpen(new Error(stderr.trim() || `Explorer foreground helper exited with code ${code}`));
1977
+ });
1978
+ });
1979
+ }
1980
+
1981
+ async function revealInFileManager(input: string): Promise<string> {
1982
+ const target = normalizePath(input);
1983
+ const info = await stat(target);
1984
+ const isDirectory = info.isDirectory();
1985
+ const directory = isDirectory ? target : dirname(target);
1986
+ const currentPlatform = platform();
1987
+
1988
+ if (currentPlatform === "win32") {
1989
+ await openWindowsExplorerOnTop(target, directory, !isDirectory);
1990
+ return directory;
1991
+ }
1992
+
1993
+ const command = currentPlatform === "darwin" ? "open" : "xdg-open";
1994
+ const args = currentPlatform === "darwin" && !isDirectory ? ["-R", target] : [directory];
1995
+ await new Promise<void>((resolveStart, rejectStart) => {
1996
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
1997
+ child.once("error", rejectStart);
1998
+ child.once("spawn", () => {
1999
+ child.unref();
2000
+ resolveStart();
2001
+ });
2002
+ });
2003
+ return directory;
2004
+ }
2005
+
2006
+ function filePathFromUrl(url: string): string {
2007
+ const clean = url.split("?")[0].split("#")[0];
2008
+ const relative = clean === "/" ? "index.html" : clean.replace(/^\/+/, "");
2009
+ const path = resolve(WEB_DIR, relative);
2010
+ if (path !== WEB_DIR && !path.startsWith(`${WEB_DIR}${sep}`)) return join(WEB_DIR, "index.html");
2011
+ return path;
2012
+ }
2013
+
2014
+ async function serveStatic(res: ServerResponse, url: string): Promise<void> {
2015
+ const filePath = filePathFromUrl(url);
2016
+ try {
2017
+ const data = await readFile(filePath);
2018
+ const ext = extname(filePath).toLowerCase();
2019
+ res.writeHead(200, {
2020
+ "content-type": MIME[ext] ?? "application/octet-stream",
2021
+ "cache-control": "no-cache",
2022
+ });
2023
+ res.end(data);
2024
+ } catch {
2025
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
2026
+ res.end("not found");
2027
+ }
2028
+ }
2029
+
2030
+ function escapeFileViewHtml(value: string): string {
2031
+ return value.replace(/[&<>"']/g, (char) => ({
2032
+ "&": "&amp;",
2033
+ "<": "&lt;",
2034
+ ">": "&gt;",
2035
+ '"': "&quot;",
2036
+ "'": "&#39;",
2037
+ })[char]!);
2038
+ }
2039
+
2040
+ function renderFileView(filePath: string, data: Buffer): string {
2041
+ const preview = buildFilePreview(filePath, data);
2042
+ const title = escapeFileViewHtml(preview.name);
2043
+ const displayPath = escapeFileViewHtml(filePath);
2044
+ const numbers = Array.from({ length: preview.lineCount }, (_, index) => index + 1).join("\n");
2045
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>${title} - Pi Web</title><style>
2046
+ :root{color-scheme:light dark;font-family:ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",monospace;background:#fff;color:#1f2328}*{box-sizing:border-box}body{margin:0;background:inherit;color:inherit}.header{position:sticky;top:0;z-index:2;padding:10px 16px;border-bottom:1px solid #d0d7de;background:#f6f8fa;color:#59636e;font:12px/20px ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.source{display:grid;grid-template-columns:auto max-content;min-width:100%;width:max-content;padding:8px 0 40px}.source pre{margin:0;font:13px/20px ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre}.numbers{position:sticky;left:0;z-index:1;padding:0 14px 0 8px;background:inherit;color:#8c959f;text-align:right;user-select:none}.code{min-width:100%;padding-right:24px}.hljs-comment,.hljs-quote{color:#6e7781}.hljs-keyword,.hljs-selector-tag,.hljs-literal{color:#cf222e}.hljs-string,.hljs-attr,.hljs-regexp{color:#0a3069}.hljs-title,.hljs-title.function_,.hljs-section{color:#8250df}.hljs-number,.hljs-symbol,.hljs-variable,.hljs-template-variable{color:#0550ae}.hljs-type,.hljs-built_in,.hljs-selector-class{color:#953800}.hljs-meta,.hljs-doctag{color:#57606a}@media(prefers-color-scheme:dark){:root{background:#0d1117;color:#e6edf3}.header{background:#161b22;border-color:#30363d;color:#8d96a0}.numbers{color:#6e7681}.hljs-comment,.hljs-quote{color:#8b949e}.hljs-keyword,.hljs-selector-tag,.hljs-literal{color:#ff7b72}.hljs-string,.hljs-attr,.hljs-regexp{color:#a5d6ff}.hljs-title,.hljs-title.function_,.hljs-section{color:#d2a8ff}.hljs-number,.hljs-symbol,.hljs-variable,.hljs-template-variable{color:#79c0ff}.hljs-type,.hljs-built_in,.hljs-selector-class{color:#ffa657}.hljs-meta,.hljs-doctag{color:#8b949e}}
2047
+ </style></head><body><header class="header">${displayPath}</header><main class="source"><pre class="numbers" aria-hidden="true">${numbers}</pre><pre class="code"><code class="hljs language-${escapeFileViewHtml(preview.language)}">${preview.highlightedHtml}</code></pre></main><script>const line=Number(location.hash.match(/^#L(\\d+)$/)?.[1]);if(line>0)scrollTo(0,Math.max(0,(line-1)*20-80));</script></body></html>`;
2048
+ }
2049
+
2050
+ function maintenanceBlocker(): RuntimeSession | undefined {
2051
+ return [...sessions.values()].find((record) => (
2052
+ record.streaming
2053
+ || record.status === "starting"
2054
+ || Boolean(record.operationChain)
2055
+ || record.dialogs.length > 0
2056
+ ));
2057
+ }
2058
+
2059
+ function maintenanceOutput(result: MaintenanceCommandResult): string {
2060
+ return stripAnsi([result.stdout, result.stderr].filter(Boolean).join("\n").trim()).slice(-8000);
2061
+ }
2062
+
2063
+ function scheduleRuntimeReload(): void {
2064
+ const callback = reloadRuntime;
2065
+ const timer = setTimeout(() => {
2066
+ void callback?.().catch((error) => {
2067
+ maintenanceRunning = false;
2068
+ const message = error instanceof Error ? error.message : String(error);
2069
+ console.error("[pi-web] runtime reload failed:", error);
2070
+ broadcastEvent({ type: "maintenance_error", message });
2071
+ });
2072
+ }, 100);
2073
+ timer.unref();
2074
+ }
2075
+
2076
+ async function handleApi(req: import("node:http").IncomingMessage, res: ServerResponse, url: URL): Promise<boolean> {
2077
+ const path = url.pathname;
2078
+ const method = req.method ?? "GET";
2079
+ if (method !== "GET" && method !== "HEAD" && !isTrustedLocalMutation(req)) {
2080
+ return sendJson(res, 403, { error: "forbidden origin" });
2081
+ }
2082
+
2083
+ if (method === "GET" && path === "/api/bootstrap") {
2084
+ return sendJson(res, 200, {
2085
+ workspaces: serializeWorkspaces(),
2086
+ currentWorkspaceId: state.currentWorkspaceId ?? null,
2087
+ currentSessionId: state.currentSessionId ?? null,
2088
+ modelInfo: bootstrapModelInfo(),
2089
+ commands: bootstrapCommands(),
2090
+ url: serverUrl,
2091
+ instanceId: serverInstanceId,
2092
+ });
2093
+ }
2094
+
2095
+ if (method === "POST" && (path === "/api/maintenance/update-and-reload" || path === "/api/maintenance/reload")) {
2096
+ if (maintenanceRunning) return sendJson(res, 409, { error: "维护操作正在执行" });
2097
+ if (!reloadRuntime || (path.endsWith("update-and-reload") && !updateExtensions)) {
2098
+ return sendJson(res, 503, { error: "当前 Pi 运行时不支持 Web 维护操作,请先执行一次 /reload" });
2099
+ }
2100
+ const blocker = maintenanceBlocker();
2101
+ if (blocker) {
2102
+ return sendJson(res, 409, { error: `会话“${blocker.title}”仍在运行或等待输入,请处理完成后重试` });
2103
+ }
2104
+
2105
+ maintenanceRunning = true;
2106
+ let output = "";
2107
+ try {
2108
+ if (path.endsWith("update-and-reload")) {
2109
+ const result = await updateExtensions!();
2110
+ output = maintenanceOutput(result);
2111
+ if (result.code !== 0 || result.killed) {
2112
+ const detail = output || `pi update --extensions 退出码 ${result.code}`;
2113
+ throw new Error(detail);
2114
+ }
2115
+ }
2116
+ sendJson(res, 200, { ok: true, output, instanceId: serverInstanceId });
2117
+ scheduleRuntimeReload();
2118
+ return true;
2119
+ } catch (error) {
2120
+ maintenanceRunning = false;
2121
+ return sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2122
+ }
2123
+ }
2124
+
2125
+ if (method === "POST" && path === "/api/clipboard/paste") {
2126
+ try {
2127
+ return sendJson(res, 200, await pasteSystemClipboard());
2128
+ } catch (error) {
2129
+ return sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
2130
+ }
2131
+ }
2132
+
2133
+ const attachmentMatch = /^\/api\/attachments\/([a-f0-9-]+)$/.exec(path);
2134
+ if (attachmentMatch && (method === "GET" || method === "HEAD")) {
2135
+ try {
2136
+ const { meta, bytes } = await imageAttachments.read(attachmentMatch[1]);
2137
+ res.writeHead(200, {
2138
+ "content-type": meta.mimeType,
2139
+ "content-length": bytes.byteLength,
2140
+ "cache-control": "private, no-store",
2141
+ "x-content-type-options": "nosniff",
2142
+ });
2143
+ res.end(method === "HEAD" ? undefined : bytes);
2144
+ return true;
2145
+ } catch (error) {
2146
+ return sendJson(res, 404, { error: error instanceof Error ? error.message : String(error) });
2147
+ }
2148
+ }
2149
+
2150
+ if (attachmentMatch && method === "DELETE") {
2151
+ const removed = await imageAttachments.remove(attachmentMatch[1]);
2152
+ return sendJson(res, removed ? 200 : 404, removed ? { ok: true } : { error: "attachment not found" });
2153
+ }
2154
+
2155
+ if (method === "GET" && path === "/api/fs/roots") {
2156
+ return sendJson(res, 200, fsRoots());
2157
+ }
2158
+
2159
+ if (method === "GET" && path === "/api/fs/list") {
2160
+ const input = url.searchParams.get("path") ?? "";
2161
+ try {
2162
+ return sendJson(res, 200, await listDirectories(input));
2163
+ } catch (error) {
2164
+ return sendJson(res, 200, { error: error instanceof Error ? error.message : String(error) });
2165
+ }
2166
+ }
2167
+
2168
+ if (method === "POST" && path === "/api/fs/reveal") {
2169
+ const body = await readJsonBody(req);
2170
+ const input = typeof body.path === "string" ? body.path.trim() : "";
2171
+ if (!input) return sendJson(res, 400, { error: "path is required" });
2172
+ try {
2173
+ const directory = await revealInFileManager(input);
2174
+ return sendJson(res, 200, { ok: true, path: directory });
2175
+ } catch (error) {
2176
+ return sendJson(res, 404, { error: error instanceof Error ? error.message : String(error) });
2177
+ }
2178
+ }
2179
+
2180
+ if (method === "GET" && path === "/api/fs/view") {
2181
+ const input = url.searchParams.get("path")?.trim();
2182
+ if (!input) return sendJson(res, 400, { error: "path is required" });
2183
+ try {
2184
+ const target = normalizePath(input);
2185
+ const info = await stat(target);
2186
+ if (!info.isFile()) return sendJson(res, 400, { error: "path is not a file" });
2187
+ if (info.size > MAX_FILE_VIEW_BYTES) return sendJson(res, 413, { error: "file is too large to preview" });
2188
+ const data = await readFile(target);
2189
+ if (data.includes(0)) return sendJson(res, 415, { error: "binary files cannot be previewed" });
2190
+ if (url.searchParams.get("format") === "json") {
2191
+ return sendJson(res, 200, buildFilePreview(target, data));
2192
+ }
2193
+ res.writeHead(200, {
2194
+ "content-type": "text/html; charset=utf-8",
2195
+ "cache-control": "no-store",
2196
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'",
2197
+ "x-content-type-options": "nosniff",
2198
+ });
2199
+ res.end(renderFileView(target, data));
2200
+ return true;
2201
+ } catch (error) {
2202
+ return sendJson(res, 404, { error: error instanceof Error ? error.message : String(error) });
2203
+ }
2204
+ }
2205
+
2206
+ if (method === "POST" && path === "/api/fs/mkdir") {
2207
+ const body = await readJsonBody(req);
2208
+ const parentPath = typeof body.path === "string" ? body.path.trim() : "";
2209
+ const name = typeof body.name === "string" ? body.name.trim() : "";
2210
+ if (!parentPath || !name) return sendJson(res, 400, { error: "path and name are required" });
2211
+ try {
2212
+ const created = join(normalizePath(parentPath), name);
2213
+ await mkdir(created);
2214
+ return sendJson(res, 200, { path: created });
2215
+ } catch (error) {
2216
+ return sendJson(res, 200, { error: error instanceof Error ? error.message : String(error) });
2217
+ }
2218
+ }
2219
+
2220
+ if (method === "POST" && path === "/api/fs/pick") {
2221
+ return sendJson(res, 200, await pickNativeDirectory());
2222
+ }
2223
+
2224
+ const exportMatch = method === "GET" ? /^\/api\/exports\/([a-f0-9-]+)$/.exec(path) : null;
2225
+ if (exportMatch) {
2226
+ const token = exportMatch[1];
2227
+ const download = exportDownloads.get(token);
2228
+ exportDownloads.delete(token);
2229
+ if (!download || download.expiresAt < Date.now()) return sendJson(res, 404, { error: "export not found or expired" });
2230
+ try {
2231
+ const data = await readFile(download.path);
2232
+ res.writeHead(200, {
2233
+ "content-type": "text/html; charset=utf-8",
2234
+ "content-disposition": `attachment; filename="pi-session.html"; filename*=UTF-8''${encodeURIComponent(download.filename)}`,
2235
+ "content-length": String(data.byteLength),
2236
+ "cache-control": "no-store",
2237
+ });
2238
+ res.end(data);
2239
+ return true;
2240
+ } catch (error) {
2241
+ return sendJson(res, 404, { error: error instanceof Error ? error.message : String(error) });
2242
+ } finally {
2243
+ unlink(download.path).catch(() => {});
2244
+ }
2245
+ }
2246
+
2247
+ if (method === "GET" && path === "/api/events") {
2248
+ res.writeHead(200, {
2249
+ "content-type": "text/event-stream; charset=utf-8",
2250
+ "cache-control": "no-store",
2251
+ connection: "keep-alive",
2252
+ "x-accel-buffering": "no",
2253
+ });
2254
+ res.write(": connected\n\n");
2255
+ let client: SseClient;
2256
+ const removeClient = () => {
2257
+ closeSseClient(client);
2258
+ sseClients.delete(client);
2259
+ };
2260
+ client = createSseClient(res, removeClient);
2261
+ sseClients.add(client);
2262
+ req.on("close", removeClient);
2263
+ return true;
2264
+ }
2265
+
2266
+ if (method === "POST" && path === "/api/workspaces") {
2267
+ const body = await readJsonBody(req);
2268
+ const inputPath = typeof body.path === "string" ? body.path.trim() : "";
2269
+ if (!inputPath) return sendJson(res, 400, { error: "path is required" });
2270
+ try {
2271
+ const info = await stat(inputPath);
2272
+ if (!info.isDirectory()) return sendJson(res, 400, { error: "not a directory" });
2273
+ } catch {
2274
+ return sendJson(res, 400, { error: "directory does not exist" });
2275
+ }
2276
+ const ws = await addWorkspace(inputPath);
2277
+ return sendJson(res, 200, { workspace: serializeWorkspaces().find((w) => w.id === ws.id) });
2278
+ }
2279
+
2280
+ if (method === "POST" && path === "/api/workspaces/reorder") {
2281
+ const body = await readJsonBody(req) as ReorderWorkspaceInput;
2282
+ const workspaceIds = Array.isArray(body.workspaceIds)
2283
+ ? body.workspaceIds.filter((id): id is string => typeof id === "string")
2284
+ : [];
2285
+ const currentIds = state.workspaces.map((workspace) => workspace.id);
2286
+ const uniqueIds = new Set(workspaceIds);
2287
+ if (
2288
+ workspaceIds.length !== currentIds.length
2289
+ || uniqueIds.size !== currentIds.length
2290
+ || currentIds.some((id) => !uniqueIds.has(id))
2291
+ ) {
2292
+ return sendJson(res, 400, { error: "workspaceIds must contain every workspace exactly once" });
2293
+ }
2294
+ const byId = new Map(state.workspaces.map((workspace) => [workspace.id, workspace]));
2295
+ state.workspaces = workspaceIds.map((id) => byId.get(id)!);
2296
+ scheduleSave();
2297
+ broadcastWorkspaces();
2298
+ return sendJson(res, 200, { ok: true, workspaces: serializeWorkspaces() });
2299
+ }
2300
+
2301
+ if (method === "DELETE" && /^\/api\/workspaces\/([^/]+)$/.test(path)) {
2302
+ const id = decodeURIComponent(path.split("/")[3]);
2303
+ const ws = state.workspaces.find((w) => w.id === id);
2304
+ if (!ws) return sendJson(res, 404, { error: "workspace not found" });
2305
+ const removed = new Set(ws.sessionIds);
2306
+ for (const sessionId of removed) {
2307
+ const rec = sessions.get(sessionId);
2308
+ if (rec) await stopSessionProcess(rec);
2309
+ }
2310
+ state.workspaces = state.workspaces.filter((w) => w.id !== id);
2311
+ if (state.currentWorkspaceId === id) state.currentWorkspaceId = state.workspaces[0]?.id;
2312
+ if (state.currentSessionId && state.sessions.find((s) => s.id === state.currentSessionId)?.workspaceId === id) {
2313
+ state.currentSessionId = undefined;
2314
+ }
2315
+ scheduleSave();
2316
+ broadcastWorkspaces();
2317
+ return sendJson(res, 200, { ok: true });
2318
+ }
2319
+
2320
+ if (method === "POST" && /^\/api\/workspaces\/([^/]+)\/discover$/.test(path)) {
2321
+ const ws = state.workspaces.find((w) => w.id === decodeURIComponent(path.split("/")[3]));
2322
+ if (!ws) return sendJson(res, 404, { error: "workspace not found" });
2323
+ const added = await importSessionsForWorkspace(ws);
2324
+ broadcastWorkspaces();
2325
+ return sendJson(res, 200, { added });
2326
+ }
2327
+
2328
+ if (method === "POST" && /^\/api\/workspaces\/([^/]+)\/models$/.test(path)) {
2329
+ const ws = state.workspaces.find((w) => w.id === decodeURIComponent(path.split("/")[3]));
2330
+ if (!ws) return sendJson(res, 404, { error: "workspace not found" });
2331
+ try {
2332
+ const body = await readJsonBody(req);
2333
+ const catalog = await discoverFreshModelCatalog(ws.path);
2334
+ const info = modelInfoFromCatalog(
2335
+ catalog,
2336
+ body.model,
2337
+ typeof body.thinkingLevel === "string" ? body.thinkingLevel : undefined,
2338
+ );
2339
+ persistModelInfo(ws.path, info);
2340
+ return sendJson(res, 200, {
2341
+ state: { model: info.defaultModel ?? null, thinkingLevel: info.thinkingLevel ?? "off" },
2342
+ models: info.models,
2343
+ thinkingLevels: info.thinkingLevels,
2344
+ thinkingLevelsByModel: thinkingLevelsByModel(info.models),
2345
+ });
2346
+ } catch (error) {
2347
+ return sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2348
+ }
2349
+ }
2350
+
2351
+ if (method === "POST" && /^\/api\/workspaces\/([^/]+)\/sessions$/.test(path)) {
2352
+ const ws = state.workspaces.find((w) => w.id === decodeURIComponent(path.split("/")[3]));
2353
+ if (!ws) return sendJson(res, 404, { error: "workspace not found" });
2354
+ const body = await readJsonBody(req);
2355
+ const requestedModel = body.model as Json | undefined;
2356
+ const model = requestedModel
2357
+ && typeof requestedModel.provider === "string"
2358
+ && typeof requestedModel.modelId === "string"
2359
+ ? { provider: requestedModel.provider, modelId: requestedModel.modelId }
2360
+ : undefined;
2361
+ const record = await createSession(
2362
+ ws.id,
2363
+ typeof body.title === "string" ? body.title : undefined,
2364
+ {
2365
+ model,
2366
+ thinkingLevel: typeof body.thinkingLevel === "string" ? body.thinkingLevel : undefined,
2367
+ },
2368
+ );
2369
+ state.currentWorkspaceId = ws.id;
2370
+ state.currentSessionId = record.id;
2371
+ scheduleSave();
2372
+ broadcastWorkspaces();
2373
+ return sendJson(res, 200, {
2374
+ session: sessionSummary(record),
2375
+ state: record.state,
2376
+ messages: record.messages,
2377
+ pendingQueue: pendingQueueSnapshot(record),
2378
+ models: record.models,
2379
+ thinkingLevels: record.thinkingLevels,
2380
+ thinkingLevelsByModel: thinkingLevelsByModel(record.models),
2381
+ commands: record.commands,
2382
+ extensionStatuses: record.extensionStatuses,
2383
+ stats: record.stats,
2384
+ });
2385
+ }
2386
+
2387
+ if (method === "POST" && /^\/api\/workspaces\/([^/]+)\/remove-session$/.test(path)) {
2388
+ const ws = state.workspaces.find((w) => w.id === decodeURIComponent(path.split("/")[3]));
2389
+ const body = await readJsonBody(req);
2390
+ const sessionId = typeof body.sessionId === "string" ? body.sessionId : "";
2391
+ if (!ws) return sendJson(res, 404, { error: "workspace not found" });
2392
+ await deleteWorkspaceSession(ws, sessionId);
2393
+ return sendJson(res, 200, { ok: true });
2394
+ }
2395
+
2396
+ const session = findSessionFromRequest(req);
2397
+ if (session) {
2398
+ const match = /^\/api\/sessions\/([^/]+)(?:\/([^/]+))?$/.exec(path);
2399
+ const action = match?.[2] ?? "";
2400
+
2401
+ if (method === "POST" && action === "models") {
2402
+ try {
2403
+ await refreshModelsFresh(session);
2404
+ broadcastSessionSnapshot(session);
2405
+ return sendJson(res, 200, {
2406
+ state: session.state,
2407
+ models: session.models,
2408
+ thinkingLevels: session.thinkingLevels,
2409
+ thinkingLevelsByModel: thinkingLevelsByModel(session.models),
2410
+ });
2411
+ } catch (error) {
2412
+ return sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2413
+ }
2414
+ }
2415
+
2416
+ if (method === "POST" && action === "open") {
2417
+ await openSessionRecord(session);
2418
+ return sendJson(res, 200, {
2419
+ session: sessionSummary(session),
2420
+ state: session.state,
2421
+ messages: snapshotMessages(session),
2422
+ pendingQueue: pendingQueueSnapshot(session),
2423
+ models: session.models,
2424
+ thinkingLevels: session.thinkingLevels,
2425
+ thinkingLevelsByModel: thinkingLevelsByModel(session.models),
2426
+ commands: session.commands,
2427
+ extensionStatuses: session.extensionStatuses,
2428
+ stats: session.stats,
2429
+ dialogs: session.dialogs,
2430
+ });
2431
+ }
2432
+
2433
+ if (method === "POST" && action === "history") {
2434
+ try {
2435
+ return sendJson(res, 200, await sessionHistory(session));
2436
+ } catch (error) {
2437
+ return sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2438
+ }
2439
+ }
2440
+
2441
+ if (method === "POST" && action === "navigate_tree") {
2442
+ const body = await readJsonBody(req);
2443
+ const entryId = typeof body.entryId === "string" ? body.entryId.trim() : "";
2444
+ const restoreMode = body.restoreMode === "conversation" || body.restoreMode === "all"
2445
+ ? body.restoreMode as TreeRestoreMode
2446
+ : undefined;
2447
+ if (!entryId) return sendJson(res, 400, { error: "entryId is required" });
2448
+ if (body.restoreMode !== undefined && !restoreMode) return sendJson(res, 400, { error: "restoreMode is invalid" });
2449
+ try {
2450
+ return await withSessionOperation(session, async () => {
2451
+ if (session.streaming) return sendJson(res, 409, { error: "请等待当前生成结束后再回溯会话" });
2452
+ const history = await sessionHistory(session);
2453
+ const target = history.nodes.find((node) => node.id === entryId);
2454
+ if (!target) return sendJson(res, 404, { error: "会话树节点不存在" });
2455
+ if (!target.navigable) return sendJson(res, 400, { error: "已经位于这个会话节点" });
2456
+ const editorText = historyEditorText(session, entryId);
2457
+ await ensureProcess(session);
2458
+ const commandsResponse = await session.proc!.send<Json>({ type: "get_commands" });
2459
+ const commands = commandsResponse.data?.commands;
2460
+ const hasNavigationCommand = Array.isArray(commands)
2461
+ && (commands as Json[]).some((command) => command?.name === INTERNAL_TREE_COMMAND);
2462
+ if (!hasNavigationCommand) throw new Error("当前 RPC 会话尚未加载 Web 回溯命令,请重启 pi-web 后重试");
2463
+ if (restoreMode === "all") {
2464
+ if (!hasPiRewindCommand(commands)) {
2465
+ return sendJson(res, 409, { error: "文件回溯需要启用 pi-rewind 扩展" });
2466
+ }
2467
+ if (!await isGitWorkingTree(session.cwd)) {
2468
+ return sendJson(res, 409, { error: "当前工作区不是 Git 仓库,无法回溯文件" });
2469
+ }
2470
+ }
2471
+
2472
+ const pendingNavigation: PendingTreeNavigation | undefined = restoreMode
2473
+ ? { restoreMode, restoreDialogHandled: false }
2474
+ : undefined;
2475
+ if (pendingNavigation) session.pendingTreeNavigation = pendingNavigation;
2476
+ try {
2477
+ await session.proc!.send({ type: "prompt", message: `/${INTERNAL_TREE_COMMAND} ${entryId}` });
2478
+ } catch (error) {
2479
+ if (pendingNavigation?.error) throw new Error(pendingNavigation.error);
2480
+ throw error;
2481
+ } finally {
2482
+ if (session.pendingTreeNavigation === pendingNavigation) session.pendingTreeNavigation = undefined;
2483
+ }
2484
+ if (pendingNavigation?.error) throw new Error(pendingNavigation.error);
2485
+
2486
+ session.liveUsage = undefined;
2487
+ session.statsRevision = (session.statsRevision ?? 0) + 1;
2488
+ await Promise.allSettled([refreshState(session), refreshMessages(session), refreshStats(session)]);
2489
+ const nextHistory = await sessionHistory(session);
2490
+ const leaf = nextHistory.nodes.find((node) => node.current);
2491
+ if (leaf?.customType !== "pi-web-tree-navigation" || leaf.navigationTargetId !== entryId) {
2492
+ throw new Error("会话回溯未能持久化,请重试");
2493
+ }
2494
+ broadcastSessionSnapshot(session);
2495
+ return sendJson(res, 200, {
2496
+ editorText,
2497
+ restoreMode: restoreMode ?? null,
2498
+ history: nextHistory,
2499
+ session: sessionSummary(session),
2500
+ state: session.state,
2501
+ messages: snapshotMessages(session),
2502
+ pendingQueue: pendingQueueSnapshot(session),
2503
+ stats: session.stats,
2504
+ });
2505
+ });
2506
+ } catch (error) {
2507
+ return sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2508
+ }
2509
+ }
2510
+
2511
+ if (method === "POST" && action === "prompt") {
2512
+ const body = await readJsonBody(req);
2513
+ const message = typeof body.message === "string" ? body.message : "";
2514
+ const attachmentIds = Array.isArray(body.attachmentIds)
2515
+ ? body.attachmentIds.filter((id): id is string => typeof id === "string")
2516
+ : [];
2517
+ if (!message.trim() && attachmentIds.length === 0) {
2518
+ return sendJson(res, 400, { error: "message or image attachment is required" });
2519
+ }
2520
+ try {
2521
+ return await withSessionOperation(session, async () => {
2522
+ await ensureProcess(session);
2523
+ const wasStreaming = session.streaming;
2524
+ if (attachmentIds.length > 0 && wasStreaming) {
2525
+ return sendJson(res, 409, { error: "生成过程中暂不支持排队图片,请停止当前生成后再发送" });
2526
+ }
2527
+ if (attachmentIds.length > 0 && message.trimStart().startsWith("/")) {
2528
+ return sendJson(res, 400, { error: "斜杠命令不能附带图片" });
2529
+ }
2530
+ const model = session.state?.model;
2531
+ const modelInput = model && typeof model === "object" && Array.isArray((model as Json).input)
2532
+ ? (model as Json).input as unknown[]
2533
+ : [];
2534
+ if (attachmentIds.length > 0 && !modelInput.includes("image")) {
2535
+ return sendJson(res, 400, { error: "当前模型不支持图片输入,请切换视觉模型" });
2536
+ }
2537
+ let images: Array<{ type: "image"; data: string; mimeType: string }> = [];
2538
+ try {
2539
+ images = await imageAttachments.imagesForRpc(attachmentIds);
2540
+ } catch (error) {
2541
+ return sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
2542
+ }
2543
+ const promptMessage = message.trim() || "请分析附加图片。";
2544
+ if (promptMessage.trimStart().startsWith("/")) await refreshCommands(session);
2545
+ const optimisticStreaming = !wasStreaming && !isImmediateExtensionCommand(session, promptMessage);
2546
+ const command: Json = { type: "prompt", message: promptMessage };
2547
+ if (images.length > 0) command.images = images;
2548
+ if (wasStreaming) command.streamingBehavior = "steer";
2549
+ if (optimisticStreaming) {
2550
+ session.streaming = true;
2551
+ session.status = "running";
2552
+ broadcastSessionSnapshot(session);
2553
+ }
2554
+ try {
2555
+ const response = await session.proc!.send(command);
2556
+ await Promise.all(attachmentIds.map((id) => imageAttachments.remove(id)));
2557
+ if (!session.title || session.title === "新会话") {
2558
+ const nextTitle = message.trim().replace(/\s+/g, " ").slice(0, 60) || "图片会话";
2559
+ session.title = nextTitle;
2560
+ updateStoredSession(session);
2561
+ session.proc?.send({ type: "set_session_name", name: nextTitle }).catch(() => {});
2562
+ broadcastWorkspaces();
2563
+ }
2564
+ return sendJson(res, 200, { ok: true, response, queued: wasStreaming });
2565
+ } catch (error) {
2566
+ if (optimisticStreaming) {
2567
+ session.streaming = false;
2568
+ session.status = session.proc ? "idle" : "stopped";
2569
+ broadcastSessionSnapshot(session);
2570
+ }
2571
+ throw error;
2572
+ }
2573
+ });
2574
+ } catch (error) {
2575
+ return sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2576
+ }
2577
+ }
2578
+
2579
+ if (method === "POST" && action === "dequeue") {
2580
+ try {
2581
+ return await withSessionOperation(session, async () => {
2582
+ const proc = session.proc;
2583
+ if (!proc) {
2584
+ const queue = pendingQueueSnapshot(session);
2585
+ const restoredMessages = [...queue.steering, ...queue.followUp];
2586
+ setPendingQueue(session, { steering: [], followUp: [] });
2587
+ broadcastSessionSnapshot(session);
2588
+ return sendJson(res, 200, {
2589
+ ok: true,
2590
+ restoredMessages,
2591
+ pendingQueue: pendingQueueSnapshot(session),
2592
+ });
2593
+ }
2594
+ const cleared = await requestRpcClearQueue(proc, ABORT_RPC_TIMEOUT_MS);
2595
+ if (!cleared.supported) {
2596
+ return sendJson(res, 409, {
2597
+ error: "当前 Pi 版本不支持恢复队列消息",
2598
+ cause: cleared.errorMessage,
2599
+ });
2600
+ }
2601
+ setPendingQueue(session, { steering: [], followUp: [] });
2602
+ broadcastSessionSnapshot(session);
2603
+ return sendJson(res, 200, {
2604
+ ok: true,
2605
+ restoredMessages: [...cleared.steering, ...cleared.followUp],
2606
+ pendingQueue: pendingQueueSnapshot(session),
2607
+ });
2608
+ });
2609
+ } catch (error) {
2610
+ return sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2611
+ }
2612
+ }
2613
+
2614
+ if (method === "POST" && action === "withdraw_queue") {
2615
+ const body = await readJsonBody(req);
2616
+ const kind: RpcQueueKind | undefined = body.kind === "steering" || body.kind === "followUp"
2617
+ ? body.kind
2618
+ : undefined;
2619
+ const index = typeof body.index === "number" && Number.isInteger(body.index) ? body.index : -1;
2620
+ const message = typeof body.message === "string" ? body.message : "";
2621
+ if (!kind || index < 0 || !message) {
2622
+ return sendJson(res, 400, { error: "kind, index, and message are required" });
2623
+ }
2624
+ try {
2625
+ return await withSessionOperation(session, async () => {
2626
+ const proc = session.proc;
2627
+ if (!proc) return sendJson(res, 409, { error: "消息队列已不可用,目标消息可能已经发出" });
2628
+ const result = await requestRpcWithdrawQueueItem(proc, { kind, index, message }, ABORT_RPC_TIMEOUT_MS);
2629
+ if (!result.supported) {
2630
+ return sendJson(res, 409, {
2631
+ error: "当前 Pi 版本不支持安全撤回队列消息",
2632
+ cause: result.errorMessage,
2633
+ });
2634
+ }
2635
+
2636
+ broadcastSessionSnapshot(session);
2637
+ const unrestoredMessages = [...result.unrestored.steering, ...result.unrestored.followUp];
2638
+ if (unrestoredMessages.length > 0) {
2639
+ return sendJson(res, 500, {
2640
+ error: "撤回后未能恢复全部剩余消息,未恢复内容已放回输入框",
2641
+ cause: result.errorMessage,
2642
+ withdrawn: result.withdrawn,
2643
+ pendingQueue: pendingQueueSnapshot(session),
2644
+ unrestoredMessages,
2645
+ });
2646
+ }
2647
+ if (!result.withdrawn) {
2648
+ return sendJson(res, 409, {
2649
+ error: "目标消息已发出或队列顺序已变化,请重试",
2650
+ pendingQueue: pendingQueueSnapshot(session),
2651
+ });
2652
+ }
2653
+ return sendJson(res, 200, {
2654
+ ok: true,
2655
+ pendingQueue: pendingQueueSnapshot(session),
2656
+ });
2657
+ });
2658
+ } catch (error) {
2659
+ return sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2660
+ }
2661
+ }
2662
+
2663
+ if (method === "POST" && action === "abort") {
2664
+ const proc = session.proc;
2665
+ if (!proc) {
2666
+ const pendingQueue = session.pendingQueue ?? { steering: [], followUp: [] };
2667
+ const restoredMessages = [...pendingQueue.steering, ...pendingQueue.followUp];
2668
+ setPendingQueue(session, { steering: [], followUp: [] });
2669
+ session.streaming = false;
2670
+ session.status = "stopped";
2671
+ session.dialogs = [];
2672
+ broadcastSessionSnapshot(session);
2673
+ broadcastWorkspaces();
2674
+ return sendJson(res, 200, { ok: true, forced: false, alreadyStopped: true, restoredMessages });
2675
+ }
2676
+
2677
+ const fallbackQueue = session.pendingQueue ?? { steering: [], followUp: [] };
2678
+ const clearedQueue = await requestRpcClearQueue(proc, ABORT_RPC_TIMEOUT_MS);
2679
+ const restoredMessages = clearedQueue.supported
2680
+ ? [...clearedQueue.steering, ...clearedQueue.followUp]
2681
+ : [...fallbackQueue.steering, ...fallbackQueue.followUp];
2682
+ setPendingQueue(session, { steering: [], followUp: [] });
2683
+
2684
+ const abort = await requestRpcAbort(proc, ABORT_RPC_TIMEOUT_MS);
2685
+ const mustCloseForLegacyQueue = !clearedQueue.supported && restoredMessages.length > 0;
2686
+ if (abort.acknowledged && !mustCloseForLegacyQueue) {
2687
+ broadcastSessionSnapshot(session);
2688
+ return sendJson(res, 200, {
2689
+ ok: true,
2690
+ forced: false,
2691
+ response: abort.response,
2692
+ restoredMessages,
2693
+ });
2694
+ }
2695
+
2696
+ // A wedged tool, or an older Pi that cannot clear queued messages, needs
2697
+ // this session process replaced so restored drafts cannot run twice.
2698
+ if (session.proc === proc) {
2699
+ session.proc = undefined;
2700
+ await proc.close().catch((error) => {
2701
+ console.error("[pi-web] forced session close failed:", error);
2702
+ });
2703
+ }
2704
+ session.status = "stopped";
2705
+ session.streaming = false;
2706
+ session.dialogs = [];
2707
+ session.extensionStatuses = {};
2708
+ session.starting = undefined;
2709
+ session.error = undefined;
2710
+ if (session.state) session.state = { ...session.state, isStreaming: false };
2711
+ broadcastSessionSnapshot(session);
2712
+ broadcastWorkspaces();
2713
+ return sendJson(res, 200, {
2714
+ ok: true,
2715
+ forced: true,
2716
+ warning: mustCloseForLegacyQueue
2717
+ ? "当前 Pi 不支持清空消息队列,已重启会话并恢复待处理消息"
2718
+ : "生成未响应,已关闭该会话进程;下次发送消息时会自动恢复",
2719
+ cause: abort.errorMessage ?? clearedQueue.errorMessage,
2720
+ restoredMessages,
2721
+ });
2722
+ }
2723
+
2724
+ if (method === "POST" && action === "set_model") {
2725
+ const body = await readJsonBody(req);
2726
+ const provider = typeof body.provider === "string" ? body.provider : "";
2727
+ const modelId = typeof body.modelId === "string" ? body.modelId : "";
2728
+ if (!provider || !modelId) return sendJson(res, 400, { error: "provider and modelId are required" });
2729
+ session.lastUsed = Date.now();
2730
+ if (!session.proc) {
2731
+ session.pendingModel = { provider, modelId };
2732
+ const full = (session.models as Json[]).find(
2733
+ (model) => model && typeof model === "object" && model.provider === provider && model.id === modelId,
2734
+ );
2735
+ session.state = { ...(session.state ?? {}), model: full ?? { provider, id: modelId, name: modelId } };
2736
+ if (full) {
2737
+ session.thinkingLevels = supportedThinkingLevels(full);
2738
+ const currentLevel = typeof session.state.thinkingLevel === "string" ? session.state.thinkingLevel : "off";
2739
+ if (!session.thinkingLevels.includes(currentLevel)) {
2740
+ session.state.thinkingLevel = session.thinkingLevels.includes("off") ? "off" : session.thinkingLevels[0] ?? "off";
2741
+ session.pendingThinkingLevel = session.state.thinkingLevel as string;
2742
+ }
2743
+ }
2744
+ persistModelInfo(session.cwd, {
2745
+ models: session.models,
2746
+ thinkingLevels: session.thinkingLevels,
2747
+ defaultModel: session.state.model,
2748
+ thinkingLevel: typeof session.state.thinkingLevel === "string" ? session.state.thinkingLevel : undefined,
2749
+ });
2750
+ updateStoredSession(session);
2751
+ broadcastSessionSnapshot(session);
2752
+ return sendJson(res, 200, { ok: true, pending: true });
2753
+ }
2754
+ try {
2755
+ const response = await session.proc!.send({ type: "set_model", provider, modelId });
2756
+ session.pendingModel = undefined;
2757
+ await refreshState(session);
2758
+ await refreshModels(session);
2759
+ broadcastSessionSnapshot(session);
2760
+ return sendJson(res, 200, { ok: true, response });
2761
+ } catch (error) {
2762
+ return sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2763
+ }
2764
+ }
2765
+
2766
+ if (method === "POST" && action === "set_thinking_level") {
2767
+ const body = await readJsonBody(req);
2768
+ const level = typeof body.level === "string" ? body.level : "";
2769
+ if (!level) return sendJson(res, 400, { error: "level is required" });
2770
+ session.lastUsed = Date.now();
2771
+ if (!session.proc) {
2772
+ session.pendingThinkingLevel = level;
2773
+ session.state = { ...(session.state ?? {}), thinkingLevel: level };
2774
+ persistModelInfo(session.cwd, {
2775
+ models: session.models,
2776
+ thinkingLevels: session.thinkingLevels,
2777
+ defaultModel: session.state.model,
2778
+ thinkingLevel: level,
2779
+ });
2780
+ updateStoredSession(session);
2781
+ broadcastSessionSnapshot(session);
2782
+ return sendJson(res, 200, { ok: true, pending: true });
2783
+ }
2784
+ try {
2785
+ const response = await session.proc!.send({ type: "set_thinking_level", level });
2786
+ session.pendingThinkingLevel = undefined;
2787
+ await refreshState(session);
2788
+ persistModelInfo(session.cwd, {
2789
+ models: session.models,
2790
+ thinkingLevels: session.thinkingLevels,
2791
+ defaultModel: session.state?.model,
2792
+ thinkingLevel: typeof session.state?.thinkingLevel === "string" ? session.state.thinkingLevel : undefined,
2793
+ });
2794
+ broadcastSessionSnapshot(session);
2795
+ return sendJson(res, 200, { ok: true, response });
2796
+ } catch (error) {
2797
+ return sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2798
+ }
2799
+ }
2800
+
2801
+ if (method === "POST" && action === "set_name") {
2802
+ const body = await readJsonBody(req);
2803
+ const name = typeof body.name === "string" ? body.name.trim().replace(/\s+/g, " ").slice(0, 60) : "";
2804
+ if (!name) return sendJson(res, 400, { error: "name is required" });
2805
+ session.title = name;
2806
+ updateStoredSession(session);
2807
+ if (session.proc) {
2808
+ session.proc.send({ type: "set_session_name", name }).catch(() => {});
2809
+ } else {
2810
+ session.pendingName = name;
2811
+ scheduleSave();
2812
+ }
2813
+ broadcastWorkspaces();
2814
+ broadcastSessionSnapshot(session);
2815
+ return sendJson(res, 200, { ok: true });
2816
+ }
2817
+
2818
+ if (method === "POST" && action === "compact") {
2819
+ await ensureProcess(session);
2820
+ try {
2821
+ const response = await session.proc!.send({ type: "compact" });
2822
+ return sendJson(res, 200, { ok: true, response });
2823
+ } catch (error) {
2824
+ return sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2825
+ }
2826
+ }
2827
+
2828
+ if (method === "POST" && action === "export_html") {
2829
+ await ensureProcess(session);
2830
+ const filename = exportFilename(session.title);
2831
+ const outputPath = join(tmpdir(), `${randomUUID()}-${filename}`);
2832
+ try {
2833
+ const response = await session.proc!.send<Json>({ type: "export_html", outputPath });
2834
+ const exportedPath = typeof response.data?.path === "string" ? response.data.path : outputPath;
2835
+ const token = randomUUID();
2836
+ const download: ExportDownload = { path: exportedPath, filename, expiresAt: Date.now() + 5 * 60_000 };
2837
+ exportDownloads.set(token, download);
2838
+ const expiry = setTimeout(() => {
2839
+ if (exportDownloads.get(token) !== download) return;
2840
+ exportDownloads.delete(token);
2841
+ unlink(download.path).catch(() => {});
2842
+ }, 5 * 60_000);
2843
+ expiry.unref();
2844
+ return sendJson(res, 200, { ok: true, filename, downloadUrl: `/api/exports/${token}` });
2845
+ } catch (error) {
2846
+ unlink(outputPath).catch(() => {});
2847
+ return sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2848
+ }
2849
+ }
2850
+
2851
+ if (method === "POST" && action === "extension_ui_response") {
2852
+ const body = await readJsonBody(req);
2853
+ const dialog = session.dialogs.find((d) => d.id === body.id);
2854
+ if (!dialog) return sendJson(res, 404, { error: "dialog not found" });
2855
+ if (dialog.expiresAt && dialog.expiresAt <= Date.now()) {
2856
+ session.dialogs = session.dialogs.filter((d) => d.id !== body.id);
2857
+ broadcastSessionSnapshot(session);
2858
+ broadcastWorkspaces();
2859
+ return sendJson(res, 410, { error: "dialog expired" });
2860
+ }
2861
+ session.dialogs = session.dialogs.filter((d) => d.id !== body.id);
2862
+ const response: Json = { type: "extension_ui_response", id: body.id };
2863
+ if (body.cancelled === true) response.cancelled = true;
2864
+ else if (dialog.method === "confirm") response.confirmed = Boolean(body.confirmed);
2865
+ else response.value = body.value ?? "";
2866
+ try {
2867
+ session.proc!.write(response);
2868
+ broadcastSessionSnapshot(session);
2869
+ broadcastWorkspaces();
2870
+ return sendJson(res, 200, { ok: true });
2871
+ } catch (error) {
2872
+ broadcastSessionSnapshot(session);
2873
+ broadcastWorkspaces();
2874
+ return sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2875
+ }
2876
+ }
2877
+
2878
+ if (method === "POST" && action === "commands") {
2879
+ await ensureProcess(session);
2880
+ await refreshCommands(session);
2881
+ broadcastSessionSnapshot(session);
2882
+ return sendJson(res, 200, { commands: session.commands });
2883
+ }
2884
+
2885
+ if (method === "POST" && action === "refresh") {
2886
+ await ensureProcess(session);
2887
+ await Promise.allSettled([refreshState(session), refreshMessages(session), refreshModels(session), refreshStats(session)]);
2888
+ return sendJson(res, 200, {
2889
+ session: sessionSummary(session),
2890
+ state: session.state,
2891
+ messages: snapshotMessages(session),
2892
+ pendingQueue: pendingQueueSnapshot(session),
2893
+ models: session.models,
2894
+ thinkingLevels: session.thinkingLevels,
2895
+ thinkingLevelsByModel: thinkingLevelsByModel(session.models),
2896
+ commands: session.commands,
2897
+ extensionStatuses: session.extensionStatuses,
2898
+ stats: session.stats,
2899
+ dialogs: session.dialogs,
2900
+ });
2901
+ }
2902
+
2903
+ if (method === "DELETE") {
2904
+ const ws = state.workspaces.find((workspace) => workspace.sessionIds.includes(session.id));
2905
+ if (!ws) return sendJson(res, 404, { error: "workspace not found" });
2906
+ await deleteWorkspaceSession(ws, session.id);
2907
+ return sendJson(res, 200, { ok: true });
2908
+ }
2909
+ }
2910
+
2911
+ return false;
2912
+ }
2913
+
2914
+ // ---------------------------------------------------------------------------
2915
+ // server lifecycle
2916
+ // ---------------------------------------------------------------------------
2917
+
2918
+ function listenOn(app: ReturnType<typeof createServer>, preferredPort: number): Promise<number> {
2919
+ return new Promise((resolvePort, reject) => {
2920
+ const onError = (error: NodeJS.ErrnoException) => {
2921
+ if (error.code === "EADDRINUSE" && preferredPort !== 0) {
2922
+ app.removeListener("error", onError);
2923
+ listenOn(app, 0).then(resolvePort, reject);
2924
+ return;
2925
+ }
2926
+ app.removeListener("error", onError);
2927
+ reject(error);
2928
+ };
2929
+ app.once("error", onError);
2930
+ app.listen(preferredPort, HOST, () => {
2931
+ app.removeListener("error", onError);
2932
+ const address = app.address();
2933
+ resolvePort(address && typeof address === "object" ? address.port : preferredPort);
2934
+ });
2935
+ });
2936
+ }
2937
+
2938
+ function openBrowser(url: string): void {
2939
+ const cmd = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
2940
+ const args = platform() === "win32" ? ["/c", "start", "", url] : [url];
2941
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true });
2942
+ child.unref();
2943
+ }
2944
+
2945
+ function exitHook(): void {
2946
+ for (const record of sessions.values()) {
2947
+ try {
2948
+ record.proc?.close();
2949
+ } catch {
2950
+ // ignore
2951
+ }
2952
+ }
2953
+ void imageAttachments.close();
2954
+ }
2955
+
2956
+ function sigintHook(): void {
2957
+ void shutdown();
2958
+ }
2959
+
2960
+ async function shutdown(): Promise<void> {
2961
+ if (shuttingDown) return;
2962
+ shuttingDown = true;
2963
+ for (const client of [...sseClients]) {
2964
+ closeSseClient(client);
2965
+ try {
2966
+ (client.res as ServerResponse).end();
2967
+ } catch {
2968
+ // ignore
2969
+ }
2970
+ }
2971
+ sseClients.clear();
2972
+ for (const record of sessions.values()) {
2973
+ await stopSessionProcess(record);
2974
+ }
2975
+ for (const download of exportDownloads.values()) unlink(download.path).catch(() => {});
2976
+ exportDownloads.clear();
2977
+ await imageAttachments.close();
2978
+ if (heartbeat) clearInterval(heartbeat);
2979
+ heartbeat = undefined;
2980
+ await saveStateNow().catch(() => {});
2981
+ const activeServer = server;
2982
+ server = undefined;
2983
+ serverUrl = undefined;
2984
+ serverInstanceId = undefined;
2985
+ updateExtensions = undefined;
2986
+ reloadRuntime = undefined;
2987
+ maintenanceRunning = false;
2988
+ if (activeServer) {
2989
+ await new Promise<void>((resolveClose) => {
2990
+ activeServer.close(() => resolveClose());
2991
+ });
2992
+ }
2993
+ process.off("exit", exitHook);
2994
+ process.off("SIGINT", sigintHook);
2995
+ process.off("SIGTERM", sigintHook);
2996
+ }
2997
+
2998
+ export async function startWebServer(options: {
2999
+ defaultCwd?: string;
3000
+ port?: number;
3001
+ open?: boolean;
3002
+ initialModelInfo?: ModelInfo;
3003
+ initialCommands?: unknown[];
3004
+ updateExtensions?: () => Promise<MaintenanceCommandResult>;
3005
+ reloadRuntime?: () => Promise<void>;
3006
+ } = {}): Promise<string> {
3007
+ const initialCwd = normalizePath(options.defaultCwd ?? process.cwd());
3008
+ updateExtensions = options.updateExtensions;
3009
+ reloadRuntime = options.reloadRuntime;
3010
+ if (serverUrl && server) {
3011
+ if (options.initialCommands?.length) commandCache.set(initialCwd, normalizeCommands(options.initialCommands));
3012
+ if (options.open !== false) openBrowser(serverUrl);
3013
+ return serverUrl;
3014
+ }
3015
+ shuttingDown = false;
3016
+ await loadState();
3017
+ if (options.initialModelInfo?.models.length) {
3018
+ persistModelInfo(initialCwd, {
3019
+ models: options.initialModelInfo.models,
3020
+ thinkingLevels: options.initialModelInfo.thinkingLevels,
3021
+ defaultModel: options.initialModelInfo.defaultModel,
3022
+ thinkingLevel: options.initialModelInfo.thinkingLevel,
3023
+ });
3024
+ }
3025
+ if (options.initialCommands?.length) commandCache.set(initialCwd, normalizeCommands(options.initialCommands));
3026
+ if (state.workspaces.length === 0 && options.defaultCwd) await addWorkspace(options.defaultCwd);
3027
+ const requestedPort = options.port && Number.isFinite(options.port) ? options.port : DEFAULT_PORT;
3028
+ const app = createServer(async (req, res) => {
3029
+ try {
3030
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? `${HOST}:${requestedPort}`}`);
3031
+ if (url.pathname.startsWith("/api/")) {
3032
+ const handled = await handleApi(req, res, url);
3033
+ if (!handled) sendJson(res, 404, { error: "not found" });
3034
+ return;
3035
+ }
3036
+ await serveStatic(res, url.pathname);
3037
+ } catch (error) {
3038
+ if (!res.headersSent) sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
3039
+ else res.end();
3040
+ }
3041
+ });
3042
+ const port = await listenOn(app, requestedPort);
3043
+ server = app;
3044
+ serverUrl = `http://${HOST}:${port}`;
3045
+ serverInstanceId = randomUUID();
3046
+ heartbeat = setInterval(() => broadcastEvent({ type: "heartbeat", at: Date.now() }), 25_000);
3047
+ process.on("exit", exitHook);
3048
+ process.on("SIGINT", sigintHook);
3049
+ process.on("SIGTERM", sigintHook);
3050
+ if (options.open !== false) openBrowser(serverUrl);
3051
+ return serverUrl;
3052
+ }
3053
+
3054
+ export async function stopWebServer(): Promise<void> {
3055
+ await shutdown();
3056
+ }
3057
+
3058
+ export function getWebUrl(): string | undefined {
3059
+ return serverUrl;
3060
+ }