pi-web-ui 0.28.2 → 0.29.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 (40) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +295 -295
  3. package/README.zh-CN.md +279 -279
  4. package/bin/pi-web-ui.mjs +0 -0
  5. package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
  6. package/deploy/nginx-subpath.conf +88 -88
  7. package/deploy/pi-web-ui-task.xml +71 -71
  8. package/deploy/pi-web-ui.service +31 -31
  9. package/dist/server/agent-service.js +408 -3851
  10. package/dist/server/attachments.js +621 -0
  11. package/dist/server/bg-servers.js +138 -0
  12. package/dist/server/client-state.js +148 -0
  13. package/dist/server/files-service.js +633 -0
  14. package/dist/server/goal-service.js +869 -0
  15. package/dist/server/index.js +144 -8
  16. package/dist/server/model-admin.js +727 -0
  17. package/dist/server/process-utils.js +86 -0
  18. package/dist/server/protocol-version.js +11 -0
  19. package/dist/server/scm.js +298 -0
  20. package/dist/server/settings-service.js +268 -0
  21. package/dist/server/slash-commands.js +245 -0
  22. package/dist/server/terminals.js +98 -0
  23. package/dist/server/text-sniff.js +268 -0
  24. package/dist/server/uploads.js +107 -0
  25. package/dist/server/webui-context.js +208 -0
  26. package/extensions/webui.ts +192 -192
  27. package/package.json +12 -5
  28. package/themes/light.css +6318 -6318
  29. package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +32 -0
  30. package/web/dist/assets/TerminalPanel-B5TqtKa7.js +2 -0
  31. package/web/dist/assets/index-CU7j-jm3.js +13 -0
  32. package/web/dist/assets/index-Dsb8Bak1.css +10 -0
  33. package/web/dist/assets/markdown-DRBrS2Nf.js +51 -0
  34. package/web/dist/assets/react-C9ovnpIm.js +24 -0
  35. package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
  36. package/web/dist/favicon.svg +8 -8
  37. package/web/dist/index.html +17 -15
  38. package/web/public/favicon.svg +8 -8
  39. package/web/dist/assets/index-BnDkdKFN.css +0 -41
  40. package/web/dist/assets/index-DmmSVSzk.js +0 -129
@@ -16,14 +16,30 @@ import { basename, dirname, join, relative, resolve, sep } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashTool, createLocalBashOperations, defineTool, getAgentDir, ModelRuntime, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
18
18
  import { Type } from "typebox";
19
+ import { BgServerTracker } from "./bg-servers.js";
20
+ import { SettingsService } from "./settings-service.js";
21
+ import { GoalService } from "./goal-service.js";
22
+ import { SlashCommandsService, parseSlash } from "./slash-commands.js";
23
+ import { ModelAdminService } from "./model-admin.js";
24
+ import { FilesService, workspacePath } from "./files-service.js";
25
+ import { extensionKey, ClientStateStore, } from "./client-state.js";
26
+ import { saveUpload } from "./uploads.js";
27
+ import { makePersistentTerminalTools } from "./terminals.js";
28
+ import { WebUIContext } from "./webui-context.js";
29
+ import { buildAttachmentMessages, parseModelSpec, } from "./attachments.js";
19
30
  import { serializeMessage, serializeStreamingMessage, } from "./serialize.js";
20
31
  import { loadCommands, saveCommandsFile, TerminalManager, } from "./terminals.js";
21
- import { buildVisionBridgePrompt, findVisionModels, SYSTEM_PROMPT, transcribeImages, } from "./vision-bridge.js";
32
+ import { buildVisionBridgePrompt, SYSTEM_PROMPT, transcribeImages, } from "./vision-bridge.js";
22
33
  const SNAPSHOT_INTERVAL_MS = 60;
34
+ /** While assistant deltas are flowing, live rendering is carried by
35
+ * message_delta — full snapshots become pure reconciliation checkpoints, so
36
+ * send them on a slow event-driven cadence (see flushSnapshot call-sites:
37
+ * agent_end / tool_execution_end always checkpoint immediately). */
38
+ const STREAMING_SNAPSHOT_INTERVAL_MS = 2000;
39
+ /** Deltas newer than this keep the streaming (low-frequency) snapshot cadence. */
40
+ const DELTA_ACTIVE_WINDOW_MS = 1500;
23
41
  const WIDGET_REFRESH_MS = 2000;
24
- const WIDGET_WIDTH = 80;
25
42
  /** Preview panel cap: only the first 512KB of a file is ever read/sent. */
26
- const MAX_PREVIEW_BYTES = 512 * 1024;
27
43
  /** Thrown when the service is quiesced (draining) and the request is NEW work
28
44
  * the admission controller refuses: a brand-new client attach, a prompt,
29
45
  * a fork, a session resume, or a goal wizard start. index.ts closes the
@@ -36,237 +52,13 @@ export class QuiesceRejectedError extends Error {
36
52
  this.name = "QuiesceRejectedError";
37
53
  }
38
54
  }
39
- const PREVIEW_IMAGE_EXTS = new Set([
40
- "png",
41
- "jpg",
42
- "jpeg",
43
- "gif",
44
- "webp",
45
- "svg",
46
- "bmp",
47
- "ico",
48
- "avif",
49
- "jfif",
50
- "tif",
51
- "tiff",
52
- ]);
53
- const PREVIEW_VIDEO_EXTS = new Set([
54
- "mp4",
55
- "webm",
56
- "mov",
57
- "mkv",
58
- "avi",
59
- "m4v",
60
- "ogv",
61
- "mpg",
62
- "mpeg",
63
- "wmv",
64
- "flv",
65
- ]);
66
- const PREVIEW_TEXT_EXTS = new Set([
67
- // code
68
- "ts",
69
- "tsx",
70
- "js",
71
- "jsx",
72
- "mjs",
73
- "cjs",
74
- "jsm",
75
- "es6",
76
- "vue",
77
- "svelte",
78
- "py",
79
- "pyw",
80
- "ipynb",
81
- "go",
82
- "rs",
83
- "c",
84
- "h",
85
- "cpp",
86
- "hpp",
87
- "cc",
88
- "cxx",
89
- "hh",
90
- "csh",
91
- "java",
92
- "kt",
93
- "kts",
94
- "scala",
95
- "sc",
96
- "cs",
97
- "fs",
98
- "fsx",
99
- "fsi",
100
- "sh",
101
- "bash",
102
- "zsh",
103
- "fish",
104
- "bat",
105
- "cmd",
106
- "ps1",
107
- "psd1",
108
- "psm1",
109
- "rb",
110
- "php",
111
- "pl",
112
- "pm",
113
- "tcl",
114
- "lua",
115
- "r",
116
- "rmd",
117
- "sql",
118
- "swift",
119
- "dart",
120
- "groovy",
121
- "gradle",
122
- "tf",
123
- "tfvars",
124
- "hcl",
125
- "nim",
126
- "zig",
127
- "v",
128
- "vala",
129
- "d",
130
- "clj",
131
- "cljs",
132
- "cljc",
133
- "edn",
134
- "ex",
135
- "exs",
136
- "erl",
137
- "hrl",
138
- "ml",
139
- "mli",
140
- // markup / config / data
141
- "json",
142
- "jsonc",
143
- "json5",
144
- "jsonl",
145
- "md",
146
- "mdx",
147
- "markdown",
148
- "html",
149
- "htm",
150
- "xhtml",
151
- "css",
152
- "scss",
153
- "sass",
154
- "less",
155
- "styl",
156
- "xml",
157
- "dtd",
158
- "yaml",
159
- "yml",
160
- "toml",
161
- "ini",
162
- "cfg",
163
- "conf",
164
- "properties",
165
- "env",
166
- "log",
167
- "txt",
168
- "text",
169
- "csv",
170
- "tsv",
171
- "lock",
172
- "sqlite",
173
- "graphql",
174
- "gql",
175
- "proto",
176
- "prisma",
177
- "asm",
178
- "s",
179
- ]);
180
- /**
181
- * Classify a file name into its preview category. Files with no extension
182
- * (README, Makefile, .gitignore, …) are treated as text. Everything not in an
183
- * allowlist (exe, jar, dll, zip, …) is "none" — never previewed.
184
- */
185
- export function previewKind(name) {
186
- const dot = name.lastIndexOf(".");
187
- // A leading dot with nothing after it (.gitignore, .env) counts as no ext.
188
- const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
189
- if (PREVIEW_IMAGE_EXTS.has(ext))
190
- return "image";
191
- if (PREVIEW_VIDEO_EXTS.has(ext))
192
- return "video";
193
- if (ext === "" || PREVIEW_TEXT_EXTS.has(ext))
194
- return "text";
195
- return "none";
196
- }
197
- /**
198
- * Content sniff for the preview: any data that has no NUL bytes and no
199
- * meaningful control-char ratio is treated as text — so files with unknown
200
- * or absent extensions (jsonl, .log.1, …) still open as text. NULs catch
201
- * zip/sqlite/png/… even when the extension claims text.
202
- */
203
- function looksLikeText(buf) {
204
- if (buf.length === 0)
205
- return true;
206
- if (buf.includes(0))
207
- return false;
208
- const text = buf.toString("utf8");
209
- let control = 0;
210
- for (const ch of text) {
211
- const c = ch.charCodeAt(0);
212
- // Keep \t \n \r \f (and \b); everything else < 0x20 is binary-ish.
213
- if (c < 0x20 && c !== 9 && c !== 10 && c !== 12 && c !== 13)
214
- control++;
215
- }
216
- return control / Math.max(text.length, 1) < 0.02;
217
- }
218
- /** Decode bytes: strict UTF-8 first, falling back to GBK (Windows legacy
219
- * Chinese files), then latin1 as a last resort — so previews and inline
220
- * attachments never show mojibake for GBK/GB2312 encoded files. */
221
- function decodeText(buf) {
222
- try {
223
- return new TextDecoder("utf-8", { fatal: true }).decode(buf);
224
- }
225
- catch {
226
- try {
227
- return new TextDecoder("gbk").decode(buf);
228
- }
229
- catch {
230
- return buf.toString("latin1");
231
- }
232
- }
233
- }
234
- /** Sniff an image MIME type from magic bytes (extension is only a hint).
235
- * Returns null when the bytes don't look like a known raster format —
236
- * callers keep such files as plain path references. */
237
- function sniffImageMime(buf, ext) {
238
- if (buf.length >= 8 &&
239
- buf[0] === 0x89 &&
240
- buf[1] === 0x50 &&
241
- buf[2] === 0x4e &&
242
- buf[3] === 0x47) {
243
- return "image/png";
244
- }
245
- if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
246
- return "image/jpeg";
247
- }
248
- const head = buf.slice(0, 6).toString("ascii");
249
- if (head === "GIF87a" || head === "GIF89a")
250
- return "image/gif";
251
- if (buf.length >= 12 &&
252
- buf.slice(0, 4).toString("ascii") === "RIFF" &&
253
- buf.slice(8, 12).toString("ascii") === "WEBP") {
254
- return "image/webp";
255
- }
256
- if (buf.length >= 2 && buf[0] === 0x42 && buf[1] === 0x4d)
257
- return "image/bmp";
258
- // Unknown but raster-looking extension — trust the extension so existing
259
- // image attachments keep working.
260
- const known = {
261
- ".png": "image/png",
262
- ".jpg": "image/jpeg",
263
- ".jpeg": "image/jpeg",
264
- ".gif": "image/gif",
265
- ".webp": "image/webp",
266
- ".bmp": "image/bmp",
267
- };
268
- return known[ext] ?? null;
269
- }
55
+ // ---------------------------------------------------------------------------
56
+ // Preview kind classification. The preview panel only opens image / video /
57
+ // text-editable files; everything else (exe, jar, archives, …) is refused so
58
+ // it is never read or sent to the browser. Media files are served over the
59
+ // /api/file HTTP endpoint instead of the WebSocket, so they are classified
60
+ // here but never read into the snapshot path.
61
+ // ---------------------------------------------------------------------------
270
62
  /** Windows persona appendix — appended to the SDK system prompt on win32 only.
271
63
  * Two failure modes it guards against: (1) the SDK bash tool has NO default
272
64
  * timeout, so a long-running command hangs the whole conversation forever;
@@ -321,87 +113,6 @@ function makeKillableBashTool(cwd, kills) {
321
113
  execute: (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate),
322
114
  };
323
115
  }
324
- /**
325
- * Snapshot currently LISTENING TCP ports → owning pid. Windows: netstat;
326
- * POSIX: lsof. Used to detect servers the agent started in the background
327
- * (the bash tool itself exits, leaving e.g. `npm run dev &` listening).
328
- */
329
- async function snapshotListeningPorts() {
330
- const m = new Map();
331
- try {
332
- const { execFile } = await import("node:child_process");
333
- if (process.platform === "win32") {
334
- const out = await new Promise((resolve, reject) => execFile("netstat", ["-ano", "-p", "tcp"], { windowsHide: true, timeout: 8000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))));
335
- for (const line of out.split(/\r?\n/)) {
336
- const p = line.trim().split(/\s+/);
337
- // TCP 0.0.0.0:5173 0.0.0.0:0 LISTENING 12345
338
- if (p.length >= 5 && p[0] === "TCP" && p[3] === "LISTENING") {
339
- const port = Number(p[1].split(":").pop());
340
- const pid = Number(p[4]);
341
- if (Number.isFinite(port) && Number.isFinite(pid))
342
- m.set(port, pid);
343
- }
344
- }
345
- }
346
- else {
347
- const out = await new Promise((resolve, reject) => execFile("lsof", ["-iTCP", "-sTCP:LISTEN", "-P", "-n"], { timeout: 8000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))));
348
- for (const line of out.split(/\r?\n/).slice(1)) {
349
- const p = line.trim().split(/\s+/);
350
- if (p.length >= 9) {
351
- // NAME column tail: "*:5173 (LISTEN)" or "[::1]:5173 (LISTEN)"
352
- const mm = (p[p.length - 1] ?? "").match(/(\d+)\)?\s*$/);
353
- const port = mm ? Number(mm[1]) : NaN;
354
- const pid = Number(p[1]);
355
- if (Number.isFinite(port) && Number.isFinite(pid))
356
- m.set(port, pid);
357
- }
358
- }
359
- }
360
- }
361
- catch {
362
- // best effort — snapshot failure just means no tracking this round
363
- }
364
- return m;
365
- }
366
- /** Kill a pid and its whole process tree (cross-platform). */
367
- function killPidTree(pid) {
368
- try {
369
- if (process.platform === "win32") {
370
- void import("node:child_process").then(({ spawn }) => {
371
- spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
372
- stdio: "ignore",
373
- detached: true,
374
- windowsHide: true,
375
- }).unref();
376
- });
377
- }
378
- else {
379
- process.kill(-pid, "SIGKILL");
380
- }
381
- }
382
- catch {
383
- // already dead
384
- }
385
- }
386
- /** Best-effort process name for a pid (tasklist on win32, ps on POSIX).
387
- * Returns undefined when the process is gone or the lookup fails. */
388
- async function lookupProcessName(pid) {
389
- try {
390
- const { execFile } = await import("node:child_process");
391
- if (process.platform === "win32") {
392
- const out = await new Promise((resolve, reject) => execFile("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], { windowsHide: true, timeout: 4000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))));
393
- // CSV: "node.exe","12345",...
394
- const m = out.match(/"([^"]+)"/);
395
- return m ? m[1] : undefined;
396
- }
397
- const out = await new Promise((resolve, reject) => execFile("ps", ["-o", "comm=", "-p", String(pid)], { timeout: 4000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))));
398
- const name = out.trim();
399
- return name || undefined;
400
- }
401
- catch {
402
- return undefined;
403
- }
404
- }
405
116
  /**
406
117
  * Cheap per-message discriminator for the serialization cache key. Persisted
407
118
  * message content never changes, so this is stable across snapshots, while
@@ -412,23 +123,6 @@ async function lookupProcessName(pid) {
412
123
  * payloads by data length (identical lengths within the same ms are far too
413
124
  * unlikely to matter).
414
125
  */
415
- /** System prompt for the goal-wizard session. The wizard asks the user a few
416
- * questions (via its goal_ask tool) to scope a raw requirement into a precise,
417
- * reviewable goal, then emits ONLY the final goal text as its last message. */
418
- function wizardPrompt(draft) {
419
- return [
420
- `You are a goal-clarification wizard. The user has stated a raw requirement. Your job is to turn it into ONE precise, actionable goal that a coding agent can fully satisfy and that can be strictly reviewed.`, // eslint-disable-line max-len
421
- ``,
422
- `# User's raw requirement`, // eslint-disable-line no-regex-spaces
423
- draft,
424
- ``,
425
- `Use your goal_ask tool to ask the user focused questions to pin down the essential, ambiguous details. Keep it concise — usually 2 to 4 questions: what exactly to build/do, scope boundaries (what NOT to do), acceptance criteria / done-definition, and any constraints (style, performance, environment).`, // eslint-disable-line max-len
426
- `Prefer multiple-choice (goal_ask with options) when you can offer clear choices; use open questions only for things that genuinely need free text.`, // eslint-disable-line max-len
427
- `Once you have enough to write an unambiguous, reviewable goal, STOP asking and reply with EXACTLY this format and nothing else (no preamble, no bullets):`, // eslint-disable-line max-len
428
- `GOAL: <one concrete, verifiable sentence describing the deliverable and its acceptance criteria>`, // eslint-disable-line max-len
429
- `If the user cancels or stops answering (the tool reports a cancellation), still produce a sensible best-effort goal from what you already know.`, // eslint-disable-line max-len
430
- ].join("\n");
431
- }
432
126
  function contentFingerprint(m) {
433
127
  const content = m.content;
434
128
  if (!Array.isArray(content) || content.length === 0)
@@ -445,283 +139,12 @@ function contentFingerprint(m) {
445
139
  }
446
140
  return `txt:${h.toString(36)}:${text.length}`;
447
141
  }
448
- /** First few KB of binary data as a classic hex + ASCII dump (preview only). */
449
- function hexDump(buf, maxBytes = 4096) {
450
- const data = buf.subarray(0, Math.min(buf.length, maxBytes));
451
- const rows = [];
452
- for (let off = 0; off < data.length; off += 16) {
453
- const chunk = data.subarray(off, off + 16);
454
- const hex = [...chunk]
455
- .map((b) => b.toString(16).padStart(2, "0"))
456
- .join(" ");
457
- const ascii = [...chunk]
458
- .map((b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : "."))
459
- .join("");
460
- rows.push(`${off.toString(16).padStart(8, "0")} ${hex.padEnd(47, " ")} ${ascii}`);
461
- }
462
- return rows.join("\n");
463
- }
464
142
  // ---------------------------------------------------------------------------
465
143
  // Web UI context adapter — bridges extension UI calls (setWidget/notify) to the
466
144
  // browser. Extensions like rpiv-todo render a TUI widget via
467
145
  // `ui.setWidget(key, (tui, theme) => comp)`; we capture the component, render it
468
146
  // with a mock theme to plain text lines, and push them to the client.
469
147
  // ---------------------------------------------------------------------------
470
- /** Mock theme: TUI color functions degrade to identity so widget text survives. */
471
- const mockTheme = new Proxy({
472
- fg: (_color, text) => text,
473
- bold: (text) => text,
474
- strikethrough: (text) => text,
475
- dim: (text) => text,
476
- }, {
477
- get(target, prop) {
478
- if (prop in target)
479
- return target[prop];
480
- // Unknown theme methods → no-op passthrough.
481
- return (_arg, text) => text !== undefined ? text : "";
482
- },
483
- });
484
- /** Mock TUI: any method call is a safe no-op. */
485
- const mockTui = new Proxy({
486
- requestRender: () => { },
487
- render: () => { },
488
- }, {
489
- get(target, prop) {
490
- if (prop in target)
491
- return target[prop];
492
- return () => { };
493
- },
494
- });
495
- /**
496
- * Implements the subset of ExtensionUIContext that makes sense for a web UI.
497
- * TUI-only affordances (select/confirm/input dialogs, terminal input, custom
498
- * footer) are inert: dialogs resolve to cancellation instead of blocking.
499
- */
500
- export class WebUIContext {
501
- theme = mockTheme;
502
- widgets = new Map();
503
- lastLines = new Map();
504
- emit;
505
- constructor(emit) {
506
- this.emit = emit;
507
- }
508
- // -- widgets -------------------------------------------------------------
509
- /** Matches ExtensionUIContext's overloaded setWidget exactly. */
510
- setWidget = (key, content, options) => {
511
- void options;
512
- if (content === undefined) {
513
- this.widgets.delete(key);
514
- this.lastLines.delete(key);
515
- this.push();
516
- return;
517
- }
518
- if (typeof content === "function") {
519
- let comp;
520
- try {
521
- // Mock TUI/theme: extensions only read a handful of theme helpers;
522
- // everything else is a no-op, so the widget renders to plain text.
523
- comp = content(mockTui, mockTheme);
524
- }
525
- catch {
526
- comp = undefined;
527
- }
528
- this.widgets.set(key, {
529
- render: (w) => comp?.render?.(w),
530
- dispose: comp?.dispose,
531
- });
532
- }
533
- else {
534
- this.widgets.set(key, { render: () => content });
535
- }
536
- this.push();
537
- };
538
- /** Re-render all widgets and push when content changed (polled + on demand). */
539
- refresh() {
540
- let changed = false;
541
- for (const [key, w] of this.widgets) {
542
- let lines;
543
- try {
544
- lines = w.render(WIDGET_WIDTH);
545
- }
546
- catch {
547
- lines = undefined;
548
- }
549
- const prev = this.lastLines.get(key);
550
- if (JSON.stringify(lines ?? null) !== JSON.stringify(prev ?? null)) {
551
- this.lastLines.set(key, lines ?? []);
552
- changed = true;
553
- }
554
- }
555
- if (changed)
556
- this.push();
557
- }
558
- push() {
559
- const widgets = this.snapshot();
560
- this.emit({ type: "widgets", widgets });
561
- }
562
- /** Render all widgets to their current text lines (without emitting). */
563
- snapshot() {
564
- return [...this.widgets.entries()].map(([key, w]) => {
565
- let lines;
566
- try {
567
- lines = w.render(WIDGET_WIDTH);
568
- }
569
- catch {
570
- lines = undefined;
571
- }
572
- this.lastLines.set(key, lines ?? []);
573
- return { key, lines: lines ?? [] };
574
- });
575
- }
576
- // -- notifications --------------------------------------------------------
577
- notify(message, type) {
578
- this.emit({ type: "notice", level: type ?? "info", text: message });
579
- }
580
- // -- footer status (pi-lens "LSP Inactive", pi-cache-optimizer cache stats) --
581
- statuses = new Map();
582
- setStatus(key, text) {
583
- if (text === undefined || text === "") {
584
- this.statuses.delete(key);
585
- }
586
- else {
587
- this.statuses.set(key, text);
588
- }
589
- this.pushStatuses();
590
- }
591
- pushStatuses() {
592
- this.emit({
593
- type: "statuses",
594
- statuses: [...this.statuses.entries()].map(([k, v]) => ({
595
- key: k,
596
- text: v,
597
- })),
598
- });
599
- }
600
- /** Current footer status entries (for replay on socket attach). */
601
- statusSnapshot() {
602
- return [...this.statuses.entries()].map(([k, v]) => ({ key: k, text: v }));
603
- }
604
- // -- dialogs (select/confirm/input bridged to the browser) ---------------
605
- dialogSeq = 0;
606
- pendingDialogs = new Map();
607
- select = (title, options) => this.openDialog("select", title, [options]);
608
- confirm = (title, message) => this.openDialog("confirm", title, [message]);
609
- input = (title, placeholder) => this.openDialog("input", title, [placeholder ?? ""]);
610
- openDialog(kind, title, args) {
611
- return new Promise((resolve) => {
612
- const id = ++this.dialogSeq;
613
- this.pendingDialogs.set(id, resolve);
614
- this.emit({ type: "dialog", id, kind, title, args });
615
- });
616
- }
617
- /** Resolve a pending dialog with the user's choice (called from the client). */
618
- resolveDialog(id, value) {
619
- const resolve = this.pendingDialogs.get(id);
620
- if (resolve) {
621
- this.pendingDialogs.delete(id);
622
- resolve(value);
623
- this.emit({ type: "dialog_closed", id });
624
- }
625
- }
626
- /** Close every pending dialog as cancelled (used when a goal wizard aborts —
627
- * its unanswered browser dialogs must vanish, not linger). */
628
- cancelPendingDialogs() {
629
- for (const [id, resolve] of this.pendingDialogs) {
630
- this.pendingDialogs.delete(id);
631
- resolve(null);
632
- this.emit({ type: "dialog_closed", id });
633
- }
634
- }
635
- // -- inert TUI-only affordances ------------------------------------------
636
- onTerminalInput = () => () => { };
637
- setWorkingMessage = () => { };
638
- setWorkingVisible = () => { };
639
- setWorkingIndicator = () => { };
640
- setHiddenThinkingLabel = () => { };
641
- setFooter = () => { };
642
- setHeader = () => { };
643
- setTitle = () => { };
644
- custom = (_factory, _done) => new Promise(() => { });
645
- pasteToEditor = () => { };
646
- setEditorText = () => { };
647
- getEditorText = () => "";
648
- editor = async () => undefined;
649
- addAutocompleteProvider = () => { };
650
- setEditorComponent = () => { };
651
- getEditorComponent = () => undefined;
652
- getAllThemes = () => [];
653
- getTheme = () => undefined;
654
- setTheme = () => ({ success: false });
655
- getToolsExpanded = () => false;
656
- setToolsExpanded = () => { };
657
- /** Dispose all widgets (extension reload / session teardown). */
658
- dispose() {
659
- for (const w of this.widgets.values()) {
660
- try {
661
- w.dispose?.();
662
- }
663
- catch {
664
- // best effort
665
- }
666
- }
667
- this.widgets.clear();
668
- this.lastLines.clear();
669
- // Cancel any pending dialogs.
670
- for (const [id, resolve] of this.pendingDialogs) {
671
- resolve(null);
672
- this.emit({ type: "dialog_closed", id });
673
- }
674
- this.pendingDialogs.clear();
675
- }
676
- }
677
- const IS_WIN32 = process.platform === "win32";
678
- // mac/linux: hide build & dependency noise (original behavior).
679
- const IGNORED_ENTRIES = new Set([
680
- "node_modules",
681
- ".git",
682
- ".svn",
683
- ".hg",
684
- "dist",
685
- ".next",
686
- ".nuxt",
687
- ".cache",
688
- ".venv",
689
- "venv",
690
- "__pycache__",
691
- "coverage",
692
- ".pi-web",
693
- ".DS_Store",
694
- "Thumbs.db",
695
- ]);
696
- // Windows: the file tree is the primary way to navigate a project, so only
697
- // hide what would flood or destabilize the panel (dependency trees, VCS
698
- // internals, session data) plus pure junk. Build output (dist/.next/…) and
699
- // local env dirs (venv/__pycache__/…) stay visible — "所有文件可查看".
700
- const IGNORED_ENTRIES_WIN = new Set([
701
- "node_modules",
702
- ".git",
703
- ".pi-web",
704
- ".DS_Store",
705
- "Thumbs.db",
706
- "desktop.ini",
707
- ]);
708
- /** The ignore set for the current platform — keeps win/posix lists separate. */
709
- function ignoredEntries() {
710
- return IS_WIN32 ? IGNORED_ENTRIES_WIN : IGNORED_ENTRIES;
711
- }
712
- function countLines(buf) {
713
- if (buf.length === 0)
714
- return 0;
715
- const hasTrailingNewline = buf[buf.length - 1] === 10; /* \n */
716
- let lines = 0;
717
- for (let i = 0; i < buf.length; i++) {
718
- if (buf[i] === 10)
719
- lines++;
720
- }
721
- // A trailing newline terminates the last line instead of starting an empty
722
- // one — matches the client preview's split-based line numbering.
723
- return hasTrailingNewline ? lines : lines + 1;
724
- }
725
148
  function extractPartialText(partial) {
726
149
  const content = partial
727
150
  ?.content;
@@ -735,222 +158,7 @@ function extractPartialText(partial) {
735
158
  }
736
159
  return null;
737
160
  }
738
- /**
739
- * Resolve a workspace-relative path against a root, refusing traversal
740
- * (".." escapes). Returns { abs, rel } — rel is normalized and slash-
741
- * separated — or null when the path leaves the workspace.
742
- */
743
- export function workspacePath(root, raw) {
744
- const abs = resolve(root, raw);
745
- const rawRel = relative(root, abs);
746
- if (rawRel.startsWith("..") || rawRel.includes(`${sep}..`))
747
- return null;
748
- // Normalize to forward slashes: the wire protocol and the frontend always
749
- // use "/", but relative() returns "\\" on Windows.
750
- return { abs, rel: rawRel.split(sep).join("/") };
751
- }
752
- /**
753
- * Read a directory for the file panel. The two platforms intentionally use
754
- * different strategies — do NOT unify them:
755
- *
756
- * darwin/linux (posix): original behavior — hide build/dependency noise,
757
- * small cap, hard error notice when the directory itself is unreadable.
758
- *
759
- * win32: stability and completeness first, preview second. ACL-protected
760
- * system dirs (C:\$Recycle.Bin, Program Files internals, OneDrive placeholders)
761
- * throw EPERM/EACCES on open — that must not kill the panel, so it degrades
762
- * to an empty listing plus a warning. Directory symlinks/junctions are
763
- * followed so mklink /D folders stay navigable; broken links still show as
764
- * files instead of vanishing. The cap is 4x posix and truncation is reported
765
- * via `truncated` instead of happening silently.
766
- */
767
- async function readDirForUI(abs, rel) {
768
- const fs = await import("node:fs/promises");
769
- const ignored = ignoredEntries();
770
- const MAX = IS_WIN32 ? 2000 : 500;
771
- let dirents;
772
- try {
773
- dirents = await fs.readdir(abs, { withFileTypes: true });
774
- }
775
- catch (err) {
776
- if (!IS_WIN32)
777
- throw err;
778
- // Windows ACL-protected/system dirs throw EPERM/EACCES on open —
779
- // degrade to an empty listing; listFiles turns this into a warning.
780
- return { entries: [], truncated: false, error: err.message };
781
- }
782
- const out = [];
783
- for (const d of dirents) {
784
- if (ignored.has(d.name))
785
- continue;
786
- let type;
787
- if (IS_WIN32 && d.isSymbolicLink()) {
788
- // mklink /D symlinks and junctions are reparse points — libuv
789
- // classifies them as links, so isDirectory() is false. Follow the
790
- // target so folder links stay navigable; broken links still show.
791
- try {
792
- const st = await fs.stat(join(abs, d.name));
793
- type = st.isDirectory() ? "dir" : "file";
794
- }
795
- catch {
796
- type = "file";
797
- }
798
- }
799
- else {
800
- type = d.isDirectory() ? "dir" : "file";
801
- }
802
- const entry = {
803
- name: d.name,
804
- path: rel === "" ? d.name : `${rel}/${d.name}`,
805
- type,
806
- };
807
- if (type === "file")
808
- entry.kind = previewKind(d.name);
809
- out.push(entry);
810
- }
811
- out.sort((a, b) => a.type === b.type
812
- ? a.name.localeCompare(b.name)
813
- : a.type === "dir"
814
- ? -1
815
- : 1);
816
- const truncated = out.length > MAX;
817
- if (truncated)
818
- out.length = MAX;
819
- return { entries: out, truncated };
820
- }
821
- /** Stable identity of an extension for the enable/disable toggle: the npm
822
- * spec for packages (survives version bumps), the resolved entry path
823
- * otherwise. */
824
- function extensionKey(e) {
825
- const src = e.sourceInfo;
826
- if (src?.origin === "package" && src.source)
827
- return src.source;
828
- return src?.path ?? e.path;
829
- }
830
- /**
831
- * Persists which workspace each browser client last used + which workspaces it
832
- * has opened, so a server restart / page reload restores the same project and
833
- * the UI can offer a one-click recent-project list. File I/O is best-effort:
834
- * persistence problems must never crash the server or block a session.
835
- */
836
- class ClientStateStore {
837
- filePath;
838
- cache = null;
839
- constructor(filePath) {
840
- this.filePath = filePath;
841
- }
842
- load() {
843
- if (this.cache)
844
- return this.cache;
845
- try {
846
- const parsed = JSON.parse(readFileSync(this.filePath, "utf8"));
847
- this.cache = parsed && typeof parsed === "object" ? parsed : {};
848
- }
849
- catch {
850
- this.cache = {};
851
- }
852
- return this.cache;
853
- }
854
- save() {
855
- try {
856
- mkdirSync(dirname(this.filePath), { recursive: true });
857
- writeFileSync(this.filePath, JSON.stringify(this.cache, null, 2) + "\n");
858
- }
859
- catch {
860
- // best effort
861
- }
862
- }
863
- get(clientId) {
864
- return this.load()[clientId] ?? { projects: [] };
865
- }
866
- /** Remember which workspace a client last used; bumps its project entry. */
867
- remember(clientId, cwd) {
868
- const all = this.load();
869
- const state = (all[clientId] ??= { projects: [] });
870
- state.lastCwd = cwd;
871
- const now = Date.now();
872
- state.projects = [
873
- { path: cwd, lastUsed: now },
874
- ...state.projects.filter((p) => p.path !== cwd),
875
- ].slice(0, 30);
876
- this.save();
877
- }
878
- /** Last-used goal/review prefs for a client, or undefined if never set. */
879
- getGoalPrefs(clientId) {
880
- const s = this.load()[clientId];
881
- if (!s?.goalPrefs)
882
- return undefined;
883
- return {
884
- reviewModel: s.goalPrefs.reviewModel ?? null,
885
- maxRounds: s.goalPrefs.maxRounds ?? 0,
886
- locked: s.goalPrefs.locked ?? true,
887
- };
888
- }
889
- /** Persist the client's goal/review preferences (model choice, rounds, lock). */
890
- saveGoalPrefs(clientId, prefs) {
891
- const all = this.load();
892
- const state = (all[clientId] ??= { projects: [] });
893
- state.goalPrefs = {
894
- reviewModel: prefs?.reviewModel ?? null,
895
- maxRounds: prefs?.maxRounds ?? 0,
896
- locked: prefs?.locked ?? true,
897
- };
898
- this.save();
899
- }
900
- /** Last-used settings-panel state for a client, or defaults. */
901
- getSettings(clientId) {
902
- const s = this.load()[clientId];
903
- return {
904
- promptMode: s?.settings?.promptMode === "replace" ? "replace" : "append",
905
- customSystemPrompt: s?.settings?.customSystemPrompt ?? "",
906
- disabledSkills: s?.settings?.disabledSkills ?? [],
907
- disabledExtensions: s?.settings?.disabledExtensions ?? [],
908
- visionBridgeEnabled: s?.settings?.visionBridgeEnabled ?? true,
909
- visionBridgeModel: s?.settings?.visionBridgeModel ?? null,
910
- visionBridgePromptMode: s?.settings?.visionBridgePromptMode === "replace" ? "replace" : "append",
911
- visionBridgePrompt: s?.settings?.visionBridgePrompt ?? "",
912
- reviewPrompt: s?.settings?.reviewPrompt ?? "",
913
- reviewDisabledSkills: s?.settings?.reviewDisabledSkills ?? [],
914
- };
915
- }
916
- /** Persist the client's settings-panel state (partial merge). */
917
- saveSettings(clientId, settings) {
918
- const all = this.load();
919
- const state = (all[clientId] ??= { projects: [] });
920
- const cur = state.settings ?? {};
921
- state.settings = {
922
- promptMode: settings.promptMode ?? cur.promptMode ?? "append",
923
- customSystemPrompt: settings.customSystemPrompt ?? cur.customSystemPrompt ?? "",
924
- disabledSkills: settings.disabledSkills ?? cur.disabledSkills ?? [],
925
- disabledExtensions: settings.disabledExtensions ?? cur.disabledExtensions ?? [],
926
- visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
927
- visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
928
- visionBridgePromptMode: settings.visionBridgePromptMode ??
929
- cur.visionBridgePromptMode ??
930
- "append",
931
- visionBridgePrompt: settings.visionBridgePrompt ?? cur.visionBridgePrompt ?? "",
932
- reviewPrompt: settings.reviewPrompt ?? cur.reviewPrompt ?? "",
933
- reviewDisabledSkills: settings.reviewDisabledSkills ?? cur.reviewDisabledSkills ?? [],
934
- };
935
- this.save();
936
- }
937
- /** Named settings presets for a client (empty if never saved). */
938
- getPresets(clientId) {
939
- return (this.load()[clientId]?.presets ?? []).map((p) => ({
940
- ...p,
941
- // Older client-state files predate review settings.
942
- reviewPrompt: p.reviewPrompt ?? "",
943
- reviewDisabledSkills: p.reviewDisabledSkills ?? [],
944
- }));
945
- }
946
- /** Persist the client's named settings presets. */
947
- savePresets(clientId, presets) {
948
- const all = this.load();
949
- const state = (all[clientId] ??= { projects: [] });
950
- state.presets = presets;
951
- this.save();
952
- }
953
- }
161
+ export { workspacePath };
954
162
  /** Hard cap on how long ONE tool call may run before the watchdog aborts the
955
163
  * session. The SDK bash tool has NO default timeout, so a command that never
956
164
  * finishes (servers, watchers, infinite loops) would otherwise hang the whole
@@ -964,102 +172,6 @@ const TOOL_WATCHDOG_TIMEOUT_MS = (() => {
964
172
  * runtime alive; conversations of other projects keep their own lists). */
965
173
  const MAX_OPEN_CONVERSATIONS = 8;
966
174
  const DEFAULT_CONV_TITLE = "新对话";
967
- /** Build the agent-facing persistent terminal tools for one conversation. */
968
- export function makePersistentTerminalTools(terminals, cwd) {
969
- const result = (text, details = {}) => ({ content: [{ type: "text", text }], details });
970
- const failIf = (error) => {
971
- if (error)
972
- throw new Error(error);
973
- };
974
- return [
975
- defineTool({
976
- name: "terminal_create",
977
- label: "Create terminal",
978
- description: "Create a named persistent interactive PTY in the current workspace. Use terminal_input or terminal_key to interact with it and terminal_read to inspect incremental output.",
979
- promptSnippet: "create persistent interactive PTY terminals",
980
- parameters: Type.Object({
981
- terminalId: Type.String({ description: "Stable terminal name" }),
982
- cwd: Type.Optional(Type.String({ description: "Workspace-relative directory" })),
983
- cols: Type.Optional(Type.Integer({ minimum: 2, maximum: 500 })),
984
- rows: Type.Optional(Type.Integer({ minimum: 2, maximum: 200 })),
985
- }),
986
- execute: async (_id, p) => {
987
- const info = terminals.create(p.terminalId, p.cwd ?? cwd, p.cols ?? 120, p.rows ?? 40, cwd, p.terminalId);
988
- if (!info)
989
- throw new Error(`创建终端失败:${p.terminalId}`);
990
- return result(`终端已创建:${JSON.stringify(info)}`, info);
991
- },
992
- }),
993
- defineTool({
994
- name: "terminal_list",
995
- label: "List terminals",
996
- description: "List all persistent PTY terminals owned by this conversation.",
997
- promptSnippet: "list persistent terminals",
998
- parameters: Type.Object({}),
999
- execute: async () => result(JSON.stringify(terminals.list()), terminals.list()),
1000
- }),
1001
- defineTool({
1002
- name: "terminal_close",
1003
- label: "Close terminal",
1004
- description: "Close a persistent PTY and terminate its process tree.",
1005
- parameters: Type.Object({ terminalId: Type.String() }),
1006
- execute: async (_id, p) => {
1007
- if (!terminals.has(p.terminalId))
1008
- throw new Error(`终端不存在:${p.terminalId}`);
1009
- terminals.kill(p.terminalId);
1010
- return result(`终端已关闭:${p.terminalId}`);
1011
- },
1012
- }),
1013
- defineTool({
1014
- name: "terminal_input",
1015
- label: "Send terminal input",
1016
- description: "Send arbitrary text to a persistent PTY. Include newline when a command should be submitted.",
1017
- parameters: Type.Object({ terminalId: Type.String(), data: Type.String() }),
1018
- execute: async (_id, p) => {
1019
- failIf(terminals.inputChecked(p.terminalId, p.data));
1020
- return result(`已发送 ${p.data.length} 个字符到 ${p.terminalId}`);
1021
- },
1022
- }),
1023
- defineTool({
1024
- name: "terminal_key",
1025
- label: "Send terminal key",
1026
- description: "Send Enter, Tab, arrows, function keys, or Ctrl/Alt combinations to a persistent PTY.",
1027
- parameters: Type.Object({
1028
- terminalId: Type.String(),
1029
- key: Type.String({ description: "Enter, Tab, ArrowUp, c, etc." }),
1030
- modifiers: Type.Optional(Type.Object({
1031
- ctrl: Type.Optional(Type.Boolean()),
1032
- alt: Type.Optional(Type.Boolean()),
1033
- shift: Type.Optional(Type.Boolean()),
1034
- })),
1035
- }),
1036
- execute: async (_id, p) => {
1037
- failIf(terminals.key(p.terminalId, p.key, p.modifiers));
1038
- return result(`已发送按键 ${p.key} 到 ${p.terminalId}`);
1039
- },
1040
- }),
1041
- defineTool({
1042
- name: "terminal_read",
1043
- label: "Read terminal output",
1044
- description: "Read incremental output from a persistent PTY. Keep the returned cursor and pass it on the next read; optionally wait for new output or process exit.",
1045
- parameters: Type.Object({
1046
- terminalId: Type.String(),
1047
- cursor: Type.Optional(Type.Integer({ minimum: 0 })),
1048
- maxBytes: Type.Optional(Type.Integer({ minimum: 1, maximum: 100000 })),
1049
- waitMs: Type.Optional(Type.Integer({ minimum: 0, maximum: 120000 })),
1050
- }),
1051
- execute: async (_id, p, signal) => {
1052
- const cursor = p.cursor ?? 0;
1053
- if (p.waitMs)
1054
- await terminals.waitForOutput(p.terminalId, cursor, p.waitMs, signal);
1055
- const read = terminals.read(p.terminalId, cursor, p.maxBytes ?? 20000);
1056
- if (!read)
1057
- throw new Error(`终端不存在:${p.terminalId}`);
1058
- return result(JSON.stringify(read), read);
1059
- },
1060
- }),
1061
- ];
1062
- }
1063
175
  /** First user text in a session, truncated for the conversation list. */
1064
176
  function conversationTitle(session) {
1065
177
  try {
@@ -1114,55 +226,14 @@ export class ClientSession {
1114
226
  * the first conversation and reused by later ones. */
1115
227
  sharedModelRuntime;
1116
228
  // -----------------------------------------------------------------------
1117
- // Goal / review state. When a goal is active, every finished agent run
1118
- // (agent_end) is checked by an ISOLATED reviewer agent; a failing review
1119
- // injects its feedback back into the main session to revise. All goal
1120
- // mutation goes through setGoal/clearGoal so UI state stays consistent.
229
+ // Goal / review / wizard —— 自包含模块,见 goal-service.ts。每个对话有独立
230
+ // GoalStatus,审查可并发;宿主回调在构造函数里接入。
1121
231
  // -----------------------------------------------------------------------
1122
- /** Defaults remembered for newly-created conversations. Each conversation
1123
- * receives its own GoalStatus, so reviews can run concurrently. */
1124
- goalReviewPrefs = {
1125
- reviewModel: null,
1126
- maxRounds: 0,
1127
- locked: true,
1128
- };
1129
- /** Goal state exposed by the goal bar for the ACTIVE conversation. */
1130
- get goal() {
1131
- return this.conv.goal;
1132
- }
1133
- /** The browser has one dialog at a time, so wizard UI plumbing remains
1134
- * client-wide; review execution itself is per conversation. */
1135
- /** Settings-panel state (system prompt + disabled skills/extensions). The
1136
- * resource-loader overrides in makeRuntimeFactory() read this at every
1137
- * reload(), so session.reload() applies changes to the running runtime. */
1138
- settings;
1139
- /** Named settings presets (saved combos the user can re-apply). */
1140
- presets = [];
1141
- /** Settings changed while the run was streaming — the runtime reload is
1142
- * deferred to the next agent_end so an in-flight run is never torn down. */
1143
- pendingSettingsReload = false;
1144
- /** Full (incl. disabled) skill/extension lists seen so far — disabled
1145
- * entries disappear from the loader after reload, so this cache keeps them
1146
- * visible (and re-enableable) in the settings panel. */
1147
- knownSkills = new Map();
1148
- knownExtensions = new Map();
1149
- /** Aborts the currently-running goal wizard (user clicked ✗ / timed out). Drives
1150
- * the in-flight goal_ask dialog to resolve as cancelled and (via the run
1151
- * signal) stops the wizard session's agent run. Recreated per wizard. */
1152
- wizardAbort = null;
1153
- /** The wizard's AgentSession while it runs — lets clearGoal truly terminate it
1154
- * (abort the run), not just flip a flag. */
1155
- wizardSession = null;
1156
- /** Conversation that owns the one browser wizard currently in flight. */
1157
- wizardOwnerId = null;
1158
- /** True when the wizard was cancelled externally (✗ / clear_goal / timeout) —
1159
- * startGoalWizard reads this after the run to avoid setting a goal. */
1160
- wizardCancelled = false;
1161
- /** Idle-timeout for the wizard: if no answer arrives within this window (a
1162
- * dialog is up but the user doesn't respond), the wizard is auto-cancelled. */
1163
- static WIZARD_IDLE_TIMEOUT_MS = 5 * 60_000;
1164
- /** Absolute deadline for the whole wizard session (model latency guard). */
1165
- static WIZARD_MAX_TOTAL_MS = 20 * 60_000;
232
+ goalSvc;
233
+ /** Settings-panel state (system prompt + disabled skills/extensions)
234
+ * 自包含模块,见 settings-service.ts。resource-loader overrides 在每次
235
+ * reload() 时读 current 的最新值,session.reload() 即可应用到运行中 runtime。 */
236
+ settingsSvc; // 构造函数里创建(需要 clientId/stateStore)
1166
237
  /** How long a hard abort waits for session.abort() to make the run idle
1167
238
  * before force-resetting the conversation (model streams that ignore the
1168
239
  * abort signal would otherwise leave the chat stuck forever). */
@@ -1174,19 +245,20 @@ export class ClientSession {
1174
245
  /** Live AbortControllers of THIS client's running bash tool calls — aborting
1175
246
  * them kills only the command (agent run and conversation continue). */
1176
247
  bashKills = new Set();
1177
- /** LISTENING-port snapshot taken when the current bash tool started the
1178
- * end-of-execution diff reveals servers the agent left running in the
1179
- * background (e.g. `npm run dev &`). Keyed by port pid. */
1180
- bashListenBefore = null;
1181
- /** Background servers the agent started (port → pid). Managed from the
1182
- * 后台任务 panel: each can be stopped individually or all at once. The
1183
- * map lives on the CLIENT (not a conversation), so it survives
1184
- * conversation switches/ends and only empties when a task is stopped or
1185
- * its process exits on its own (pruned by refreshBgServers). */
1186
- bgServers = new Map();
1187
- /** Periodic pruner: re-snapshots listening ports and drops tracked entries
1188
- * whose process died on its own, keeping the panel honest. */
1189
- bgTimer = null;
248
+ /** Background-server tracking (port snapshots + 后台任务 panel state)
249
+ * 自包含模块,见 bg-servers.ts。列表按 CLIENT 存活,不随对话切换/结束消失。 */
250
+ /** 文件树 / 预览读写 / SCM 查询 / watcher —— 自包含模块,见 files-service.ts。 */
251
+ files = new FilesService({
252
+ emit: (msg) => this.emit(msg),
253
+ isDisposed: () => this.disposed,
254
+ getCwd: () => this.cwd,
255
+ getActiveCwd: () => this.conv?.cwd ?? this.cwd,
256
+ });
257
+ bg = new BgServerTracker({
258
+ emit: (msg) => this.emit(msg),
259
+ flushSnapshot: () => this.flushSnapshot(),
260
+ isDisposed: () => this.disposed,
261
+ });
1190
262
  /** The active conversation (all session operations target it). */
1191
263
  get conv() {
1192
264
  const conv = this.convs.get(this.activeId);
@@ -1247,7 +319,6 @@ export class ClientSession {
1247
319
  * prompt with the same images skips the vision API call entirely — editing
1248
320
  * a question doesn't re-burn tokens on re-transcribing identical screenshots.
1249
321
  */
1250
- visionBridgeCache = new Map();
1251
322
  /** Most recent built-in (default) system prompt observed by the
1252
323
  * resource-loader override — surfaced via settings_state so the
1253
324
  * replace-mode editor can show the prompt it would otherwise replace.
@@ -1279,6 +350,9 @@ export class ClientSession {
1279
350
  sinks = new Set();
1280
351
  pendingNotices = [];
1281
352
  snapshotTimer = null;
353
+ /** Timestamp of the most recent message_delta push — while fresh, snapshots
354
+ * use the slower STREAMING_SNAPSHOT_INTERVAL_MS cadence. */
355
+ lastDeltaAt = 0;
1282
356
  sessionsTimer = null;
1283
357
  version = 0;
1284
358
  /**
@@ -1296,31 +370,67 @@ export class ClientSession {
1296
370
  * filesystem — failures silently fall back to the poll. */
1297
371
  fsWatcher = null;
1298
372
  watchPath = null;
373
+ /** fs.watch on the active repo's git dir — external changes (CLI commit,
374
+ * IDE branch switch) push `scm_changed` so the panel refreshes itself.
375
+ * One watcher per client session, re-targeted when the queried cwd
376
+ * changes; failures (bare repo, unsupported fs) silently disable it. */
377
+ gitWatcher = null;
378
+ gitWatchCwd = null;
379
+ gitDirtyTimer = null;
1299
380
  watchTimer = null;
1300
381
  constructor(clientId, cwd, agentDir, stateStore) {
1301
382
  this.clientId = clientId;
1302
383
  this.cwd = cwd;
1303
384
  this.agentDir = agentDir;
1304
385
  this.stateStore = stateStore;
1305
- this.settings = stateStore.getSettings(clientId);
1306
- this.presets = stateStore.getPresets(clientId);
386
+ this.settingsSvc = new SettingsService({
387
+ clientId,
388
+ stateStore,
389
+ emit: (msg) => this.emit(msg),
390
+ flushSnapshot: () => this.flushSnapshot(),
391
+ isDisposed: () => this.disposed,
392
+ getSession: () => this.session,
393
+ isStreaming: () => this.session.isStreaming,
394
+ reloadSession: async () => {
395
+ await this.session.reload();
396
+ await this.pushSlashCommands();
397
+ },
398
+ effectiveDefaultSystemPrompt: () => this.effectiveDefaultSystemPrompt(),
399
+ });
400
+ this.goalSvc = new GoalService({
401
+ clientId,
402
+ agentDir,
403
+ stateStore,
404
+ webUi: this.webUi,
405
+ emit: (msg) => this.emit(msg),
406
+ flushSnapshot: () => this.flushSnapshot(),
407
+ isDisposed: () => this.disposed,
408
+ quiesceBlocked: () => this.quiesceBlocked(),
409
+ activeConvId: () => this.activeId,
410
+ activeConv: () => this.conv,
411
+ getConv: (id) => this.convs.get(id),
412
+ cwd: () => this.cwd,
413
+ reviewSettings: () => this.settingsSvc.reviewPrefs,
414
+ gitDiff: (dir) => this.gitDiff(dir),
415
+ });
416
+ this.modelAdmin = new ModelAdminService({
417
+ agentDir,
418
+ emit: (msg) => this.emit(msg),
419
+ flushSnapshot: () => this.flushSnapshot(),
420
+ isDisposed: () => this.disposed,
421
+ modelRuntime: () => this.runtime.services.modelRuntime,
422
+ invalidatePiConfig: () => {
423
+ this.piCheckCache = null;
424
+ },
425
+ pushModels: async () => this.listModels(),
426
+ });
1307
427
  // Prune dead background tasks every 30s (only spawns netstat/lsof while
1308
428
  // the list is non-empty). unref: must not keep the process alive.
1309
- this.bgTimer = setInterval(() => void this.refreshBgServers(), 30_000);
1310
- this.bgTimer.unref?.();
429
+ this.bg.start();
1311
430
  }
1312
431
  static async create(clientId, cwd, stateStore) {
1313
432
  const agentDir = process.env.PI_CODING_AGENT_DIR ?? getAgentDir();
1314
433
  const cs = new ClientSession(clientId, cwd, agentDir, stateStore);
1315
- // Restore last-used goal/review preferences so model & rounds survive reload.
1316
- const gPrefs = stateStore.getGoalPrefs(clientId);
1317
- if (gPrefs) {
1318
- cs.goalReviewPrefs = {
1319
- reviewModel: gPrefs.reviewModel,
1320
- maxRounds: gPrefs.maxRounds,
1321
- locked: gPrefs.locked,
1322
- };
1323
- }
1324
434
  const conversationId = cs.nextConversationId();
1325
435
  const terminals = cs.makeTerminalManager(conversationId, cwd);
1326
436
  const runtime = await createAgentSessionRuntime(cs.makeRuntimeFactory(terminals), {
@@ -1371,15 +481,15 @@ export class ClientSession {
1371
481
  if (typeof base === "string" && base) {
1372
482
  this.lastBaseSystemPrompt = base;
1373
483
  }
1374
- return this.settings.promptMode === "replace" &&
1375
- this.settings.customSystemPrompt.trim()
1376
- ? this.settings.customSystemPrompt
484
+ return this.settingsSvc.current.promptMode === "replace" &&
485
+ this.settingsSvc.current.customSystemPrompt.trim()
486
+ ? this.settingsSvc.current.customSystemPrompt
1377
487
  : base;
1378
488
  },
1379
489
  appendSystemPromptOverride: (base) => {
1380
490
  const out = [...base];
1381
- const custom = this.settings.customSystemPrompt.trim();
1382
- if (this.settings.promptMode === "append" && custom) {
491
+ const custom = this.settingsSvc.current.customSystemPrompt.trim();
492
+ if (this.settingsSvc.current.promptMode === "append" && custom) {
1383
493
  out.push(custom);
1384
494
  }
1385
495
  if (process.platform === "win32") {
@@ -1393,12 +503,12 @@ export class ClientSession {
1393
503
  // 技能开关:禁用的技能从系统提示词和 /skill: 目录中剔除。
1394
504
  skillsOverride: (res) => ({
1395
505
  ...res,
1396
- skills: res.skills.filter((s) => !this.settings.disabledSkills.includes(s.name)),
506
+ skills: res.skills.filter((s) => !this.settingsSvc.current.disabledSkills.includes(s.name)),
1397
507
  }),
1398
508
  // 插件开关:禁用的扩展整个卸载(工具 / 命令随之消失)。
1399
509
  extensionsOverride: (res) => ({
1400
510
  ...res,
1401
- extensions: res.extensions.filter((e) => !this.settings.disabledExtensions.includes(extensionKey(e))),
511
+ extensions: res.extensions.filter((e) => !this.settingsSvc.current.disabledExtensions.includes(extensionKey(e))),
1402
512
  }),
1403
513
  },
1404
514
  });
@@ -1422,25 +532,7 @@ export class ClientSession {
1422
532
  /** Create independent goal state for one conversation. Preferences are
1423
533
  * client-wide defaults, while goal text/review progress is not shared. */
1424
534
  makeGoalStatus() {
1425
- return {
1426
- conversationId: null,
1427
- goal: null,
1428
- reviewModel: this.goalReviewPrefs.reviewModel,
1429
- maxRounds: this.goalReviewPrefs.maxRounds,
1430
- locked: this.goalReviewPrefs.locked,
1431
- reviewing: false,
1432
- round: 0,
1433
- status: "",
1434
- verdict: "pending",
1435
- wizard: {
1436
- active: false,
1437
- draft: "",
1438
- model: null,
1439
- step: 0,
1440
- maxSteps: 6,
1441
- status: "",
1442
- },
1443
- };
535
+ return this.goalSvc.makeGoalStatus();
1444
536
  }
1445
537
  /** Allocate a stable conversation id before constructing its runtime/tools. */
1446
538
  nextConversationId() {
@@ -1464,6 +556,7 @@ export class ClientSession {
1464
556
  goalGeneration: 0,
1465
557
  goalReviewGeneration: 0,
1466
558
  wizardRunning: false,
559
+ deltaSeq: 0,
1467
560
  terminals,
1468
561
  msgIds: new Map(),
1469
562
  nextMsgId: 1,
@@ -1500,13 +593,13 @@ export class ClientSession {
1500
593
  void this.pushSlashCommands();
1501
594
  // Reconnect: push the remembered goal prefs (model choice, rounds cap,
1502
595
  // locked) so the goal bar restores them on reload — "全局记忆".
1503
- this.emitGoalStatus();
596
+ this.goalSvc.emitGoalStatus();
1504
597
  // Reconnect: push the settings panel state (prompt text/mode, skill &
1505
598
  // extension toggles, saved presets).
1506
599
  this.pushSettings();
1507
600
  // Reconnect: push the background-task list — it must survive reconnects
1508
601
  // and outlive the conversation that started the tasks.
1509
- this.emitBgServers();
602
+ this.bg.push();
1510
603
  // PTYs are conversation-owned and survive a socket reconnect.
1511
604
  this.pushTerminals();
1512
605
  }
@@ -1516,7 +609,7 @@ export class ClientSession {
1516
609
  // conversation and can be inspected after reconnecting. Only conversation
1517
610
  // disposal or server shutdown kills them.
1518
611
  if (this.sinks.size === 0) {
1519
- this.unwatchDir();
612
+ this.files.unwatchDir();
1520
613
  }
1521
614
  }
1522
615
  /** Broadcast to every connected socket of this client. */
@@ -1596,6 +689,8 @@ export class ClientSession {
1596
689
  if (event.id) {
1597
690
  this.emit({
1598
691
  type: "tool_delta",
692
+ conversationId: conv.id,
693
+ seq: ++conv.deltaSeq,
1599
694
  toolCallId: event.id,
1600
695
  toolName: "bash",
1601
696
  delta: event.delta,
@@ -1610,9 +705,7 @@ export class ClientSession {
1610
705
  // Snapshot listeners before a bash run — the post-run diff catches
1611
706
  // servers the agent started in the background.
1612
707
  if (event.toolName === "bash") {
1613
- void snapshotListeningPorts().then((m) => {
1614
- this.bashListenBefore = m;
1615
- });
708
+ this.bg.snapshotBefore();
1616
709
  }
1617
710
  this.armToolWatchdog(conv, event.toolCallId);
1618
711
  break;
@@ -1624,7 +717,7 @@ export class ClientSession {
1624
717
  // Bash finished — wait briefly for background servers to bind their
1625
718
  // ports, then diff against the pre-run snapshot and record them.
1626
719
  if (event.toolName === "bash")
1627
- void this.trackBackgroundServers();
720
+ void this.bg.trackAfterBash();
1628
721
  const durationMs = startedAt !== undefined ? Date.now() - startedAt : undefined;
1629
722
  // The bash tool does not put its exit code in result.details — on
1630
723
  // failure it throws "Command exited with code N" and the agent
@@ -1667,6 +760,8 @@ export class ClientSession {
1667
760
  if (text) {
1668
761
  this.emit({
1669
762
  type: "tool_delta",
763
+ conversationId: conv.id,
764
+ seq: ++conv.deltaSeq,
1670
765
  toolCallId: event.toolCallId,
1671
766
  toolName: event.toolName,
1672
767
  delta: text,
@@ -1682,7 +777,6 @@ export class ClientSession {
1682
777
  // (new chat + first message, completed turns, compaction, etc.).
1683
778
  case "agent_end": {
1684
779
  this.scheduleSessionsRefresh();
1685
- const g = conv.goal;
1686
780
  // Manual interrupt (Stop button / abort): the last assistant message
1687
781
  // carries stopReason "aborted". A half-finished run should NOT be
1688
782
  // reviewed (it would fail and inject a revision, only to be stopped
@@ -1693,38 +787,19 @@ export class ClientSession {
1693
787
  return a.role === "assistant" && a.stopReason === "aborted";
1694
788
  });
1695
789
  if (aborted) {
1696
- if (g.goal && g.conversationId === conv.id) {
1697
- conv.goalGeneration += 1;
1698
- g.conversationId = null;
1699
- g.goal = null;
1700
- g.reviewing = false;
1701
- g.verdict = "pending";
1702
- g.feedback = undefined;
1703
- g.status = "已手动停止,目标审查已中止";
1704
- this.emitGoalStatus();
1705
- this.emit({
1706
- type: "notice",
1707
- level: "warning",
1708
- text: "⏹ 已手动停止,目标审查已中止(想继续可重新设定目标)",
1709
- });
790
+ const stopNotice = this.goalSvc.onAgentEnd(conv, true);
791
+ if (stopNotice) {
792
+ this.emit({ type: "notice", level: "warning", text: stopNotice });
1710
793
  }
1711
794
  break;
1712
795
  }
1713
- // Goal review hook: after the run finished normally, if a goal is
1714
- // active (and it belonged to the ACTIVE conversation) and we're not
1715
- // already mid-review, spawn the isolated reviewer.
1716
- if (g.goal &&
1717
- g.conversationId === conv.id &&
1718
- !g.reviewing &&
1719
- !conv.wizardRunning &&
1720
- !this.disposed) {
1721
- void this.runGoalReview(conv);
1722
- }
796
+ // Goal review hook lives in GoalService.onAgentEnd(conv, false).
797
+ this.goalSvc.onAgentEnd(conv, false);
1723
798
  // Deferred settings reload: settings (system prompt / skills /
1724
799
  // extensions) changed while the run was streaming — applying now
1725
800
  // would have torn down the in-flight run.
1726
- if (this.pendingSettingsReload && !this.disposed) {
1727
- this.pendingSettingsReload = false;
801
+ if (this.settingsSvc.hasPendingReload() && !this.disposed) {
802
+ this.settingsSvc.consumePendingReload();
1728
803
  void this.applySettingsReload();
1729
804
  }
1730
805
  break;
@@ -1732,10 +807,57 @@ export class ClientSession {
1732
807
  case "entry_appended":
1733
808
  this.scheduleSessionsRefresh();
1734
809
  break;
810
+ case "message_update": {
811
+ // Live assistant-message increment, deliberately OUTSIDE the snapshot
812
+ // channel: send() drops snapshots under backpressure (big sessions),
813
+ // but this small message must always get through or the UI freezes on
814
+ // stale state. Only the ACTIVE conversation streams to the browser —
815
+ // background conversations would clobber the streaming view; their
816
+ // state arrives via snapshot when switched to.
817
+ if (conv.id !== this.conv.id)
818
+ break;
819
+ const ame = event.assistantMessageEvent;
820
+ const m = event.message;
821
+ this.lastDeltaAt = Date.now();
822
+ this.emit({
823
+ type: "message_delta",
824
+ conversationId: conv.id,
825
+ seq: ++conv.deltaSeq,
826
+ // Must match serializeStreamingMessage()'s stable id so deltas
827
+ // patch onto the snapshot's streamingMessage and reconcile.
828
+ messageId: `stream-${m?.timestamp ?? 0}`,
829
+ usage: (() => {
830
+ try {
831
+ const t = this.session.getSessionStats().tokens;
832
+ return t ? { input: t.input, output: t.output, total: t.total } : null;
833
+ }
834
+ catch {
835
+ return null;
836
+ }
837
+ })(),
838
+ // Strip `partial` (the cumulative message): re-serializing it per
839
+ // token is exactly what we're trying to avoid. The next snapshot
840
+ // carries the authoritative full message anyway.
841
+ assistantMessageEvent: {
842
+ type: ame.type,
843
+ contentIndex: "contentIndex" in ame ? ame.contentIndex : undefined,
844
+ delta: "delta" in ame ? ame.delta : undefined,
845
+ },
846
+ });
847
+ break;
848
+ }
1735
849
  default:
1736
850
  break;
1737
851
  }
1738
- this.scheduleSnapshot();
852
+ // Snapshot checkpoint policy: deltas carry live rendering during streaming;
853
+ // full snapshots are reconciliation checkpoints taken immediately at
854
+ // run/tool boundaries and on a slow timer otherwise.
855
+ if (event.type === "agent_end" || event.type === "tool_execution_end") {
856
+ this.flushSnapshot();
857
+ }
858
+ else {
859
+ this.scheduleSnapshot();
860
+ }
1739
861
  }
1740
862
  /** Debounced push of the persisted session list + open conversations. */
1741
863
  scheduleSessionsRefresh() {
@@ -1748,6 +870,7 @@ export class ClientSession {
1748
870
  this.emitConversations();
1749
871
  void this.pushSessions();
1750
872
  }, 800);
873
+ // pushSessions no-ops unless the client opted in via list_sessions.
1751
874
  }
1752
875
  /** Serialize a persisted message with a STABLE id + cached object reference. */
1753
876
  serializeCached(m) {
@@ -2147,727 +1270,73 @@ export class ClientSession {
2147
1270
  }
2148
1271
  this.flushSnapshot();
2149
1272
  }
2150
- /** Persist an api-key credential for a provider (auth.json) and apply it now. */
2151
- async setProviderApiKey(provider, apiKey) {
2152
- const key = apiKey.trim();
2153
- if (!provider.trim()) {
2154
- this.emit({ type: "notice", level: "error", text: "请填写服务商 ID" });
2155
- return;
2156
- }
2157
- if (!key) {
2158
- this.emit({ type: "notice", level: "error", text: "请填写 API 密钥" });
2159
- return;
2160
- }
2161
- try {
2162
- // Persist to auth.json (auth.json shape: { <provider>: { type: "api_key", key } }).
2163
- const authPath = join(this.agentDir, "auth.json");
2164
- mkdirSync(this.agentDir, { recursive: true });
2165
- let data = {};
2166
- try {
2167
- data = JSON.parse(readFileSync(authPath, "utf8"));
2168
- }
2169
- catch {
2170
- // no file yet / unparsable — start fresh
2171
- }
2172
- data[provider.trim()] = { type: "api_key", key };
2173
- writeFileSync(authPath, JSON.stringify(data, null, 2) + "\n");
2174
- // Apply immediately for this session (runtime credentials are cached), then
2175
- // refresh models. allowNetwork downloads the provider's official model
2176
- // catalog (openai/anthropic/… are dynamic providers with no built-in list).
2177
- const mr = this.runtime.services.modelRuntime;
2178
- await mr.setRuntimeApiKey(provider.trim(), key);
2179
- await mr.refresh({ allowNetwork: true });
2180
- this.piCheckCache = null;
2181
- this.emit({
2182
- type: "notice",
2183
- level: "info",
2184
- text: `✅ 已保存 ${provider.trim()} 的 API 密钥并刷新模型列表`,
2185
- });
2186
- await this.listModels();
2187
- await this.listProviders();
2188
- }
2189
- catch (err) {
2190
- this.emit({
2191
- type: "notice",
2192
- level: "error",
2193
- text: `保存 API 密钥失败:${err.message}`,
2194
- });
1273
+ /** Send a snapshot immediately (cancels any pending throttled one). */
1274
+ flushSnapshot() {
1275
+ if (this.snapshotTimer) {
1276
+ clearTimeout(this.snapshotTimer);
1277
+ this.snapshotTimer = null;
2195
1278
  }
2196
- this.flushSnapshot();
1279
+ if (!this.disposed)
1280
+ this.emit({ type: "snapshot", state: this.snapshot() });
2197
1281
  }
2198
- /** Enumerate pi's built-in providers with auth status (key-only config). */
2199
- async listProviders() {
2200
- const mr = this.runtime.services.modelRuntime;
2201
- let providers;
2202
- try {
2203
- providers = mr.getProviders().map((p) => {
2204
- try {
2205
- const st = mr.getProviderAuthStatus(p.id);
2206
- return {
2207
- id: p.id,
2208
- name: p.name,
2209
- configured: st?.configured ?? false,
2210
- source: st?.source,
2211
- };
2212
- }
2213
- catch {
2214
- // One odd provider must not blank the whole list.
2215
- return { id: p.id, name: p.name, configured: false };
2216
- }
2217
- });
2218
- }
2219
- catch (err) {
2220
- this.emit({
2221
- type: "notice",
2222
- level: "error",
2223
- text: `获取服务商列表失败:${err.message}`,
2224
- });
1282
+ scheduleSnapshot() {
1283
+ if (this.snapshotTimer || this.disposed)
2225
1284
  return;
2226
- }
2227
- if (providers.length === 0) {
2228
- this.emit({
2229
- type: "notice",
2230
- level: "warning",
2231
- text: "服务商列表为空——pi 运行时未注册任何提供商",
2232
- });
2233
- }
2234
- this.emit({ type: "providers_status", providers });
2235
- }
2236
- // ---------------------------------------------------------------------------
2237
- // Custom model config (agentDir/models.json)
2238
- // ---------------------------------------------------------------------------
2239
- modelsConfigPath() {
2240
- return join(this.agentDir, "models.json");
2241
- }
2242
- /** Strip // and /* *\/ comments without touching string literals (URLs contain //). */
2243
- static stripJsonComments(src) {
2244
- let out = "";
2245
- let inString = false;
2246
- let i = 0;
2247
- while (i < src.length) {
2248
- const c = src[i];
2249
- const next = src[i + 1];
2250
- if (inString) {
2251
- out += c;
2252
- if (c === "\\") {
2253
- out += next ?? "";
2254
- i += 2;
2255
- continue;
2256
- }
2257
- if (c === '"')
2258
- inString = false;
2259
- i++;
2260
- continue;
2261
- }
2262
- if (c === '"') {
2263
- inString = true;
2264
- out += c;
2265
- i++;
2266
- continue;
2267
- }
2268
- if (c === "/" && next === "/") {
2269
- while (i < src.length && src[i] !== "\n")
2270
- i++;
2271
- continue;
2272
- }
2273
- if (c === "/" && next === "*") {
2274
- i += 2;
2275
- while (i < src.length && !(src[i] === "*" && src[i + 1] === "/"))
2276
- i++;
2277
- i += 2;
2278
- continue;
2279
- }
2280
- out += c;
2281
- i++;
2282
- }
2283
- return out;
2284
- }
2285
- /** Read + parse models.json (tolerating // and /* *\/ comments like the SDK). */
2286
- readModelsConfig() {
2287
- const path = this.modelsConfigPath();
2288
- try {
2289
- const raw = readFileSync(path, "utf8");
2290
- const parsed = JSON.parse(ClientSession.stripJsonComments(raw));
2291
- return { providers: parsed?.providers ?? {} };
2292
- }
2293
- catch {
2294
- return { providers: {} };
2295
- }
2296
- }
2297
- /** Send the current models.json custom providers to the client. */
2298
- async listModelsConfig() {
2299
- const { providers } = this.readModelsConfig();
2300
- const list = Object.entries(providers).map(([providerId, p]) => {
2301
- const models = Array.isArray(p.models)
2302
- ? p.models.map((m) => ({
2303
- id: String(m.id ?? ""),
2304
- name: m.name,
2305
- reasoning: m.reasoning,
2306
- input: Array.isArray(m.input) ? m.input : undefined,
2307
- contextWindow: m.contextWindow,
2308
- maxTokens: m.maxTokens,
2309
- }))
2310
- : [];
2311
- return {
2312
- providerId,
2313
- name: p.name,
2314
- api: p.api,
2315
- baseUrl: p.baseUrl,
2316
- apiKey: p.apiKey,
2317
- authHeader: p.authHeader,
2318
- // headers are intentionally NOT sent to the browser — they may
2319
- // contain Authorization / API-key values; kept server-side only.
2320
- models,
2321
- };
2322
- });
2323
- this.emit({ type: "models_config", providers: list });
2324
- }
2325
- /** Numeric metadata value (NaN/string "unknown" → undefined). */
2326
- static numMeta(v) {
2327
- return typeof v === "number" && Number.isFinite(v) ? v : undefined;
2328
- }
2329
- static boolMeta(v) {
2330
- return typeof v === "boolean" ? v : undefined;
2331
- }
2332
- static strArrMeta(v) {
2333
- return Array.isArray(v)
2334
- ? v.filter((x) => typeof x === "string")
2335
- : undefined;
2336
- }
2337
- /** Best-effort extraction of model metadata from an OpenAI-compatible
2338
- * /models `data[]` item. Most endpoints only return `{ id }` — the extra
2339
- * fields (context_window / max_model_len / modalities / supports_vision /
2340
- * reasoning / display_name) come from vLLM and other extended
2341
- * implementations, and are filled into the form when present. */
2342
- static parseOpenAiModel(m) {
2343
- const r = (m ?? {});
2344
- const id = typeof r.id === "string" ? r.id : "";
2345
- const name = (typeof r.name === "string" && r.name.trim() ? r.name : undefined) ??
2346
- (typeof r.display_name === "string" && r.display_name.trim()
2347
- ? r.display_name
2348
- : undefined);
2349
- const modalities = ClientSession.strArrMeta(r.modalities) ??
2350
- ClientSession.strArrMeta(r.input_modalities);
2351
- const vision = modalities?.includes("image") === true ||
2352
- ClientSession.boolMeta(r.supports_vision) === true ||
2353
- ClientSession.boolMeta(r.vision) === true ||
2354
- ClientSession.strArrMeta(r.input)?.includes("image") === true;
2355
- const reasoning = ClientSession.boolMeta(r.reasoning) === true ||
2356
- ClientSession.boolMeta(r.supports_reasoning) === true ||
2357
- modalities?.includes("reasoning") === true;
2358
- const contextWindow = ClientSession.numMeta(r.context_window) ??
2359
- ClientSession.numMeta(r.context_length) ??
2360
- ClientSession.numMeta(r.max_model_len) ??
2361
- ClientSession.numMeta(r.max_context_length);
2362
- const maxTokens = ClientSession.numMeta(r.max_tokens) ??
2363
- ClientSession.numMeta(r.max_output_tokens) ??
2364
- ClientSession.numMeta(r.max_completion_tokens);
2365
- return {
2366
- id,
2367
- ...(name ? { name } : {}),
2368
- ...(reasoning ? { reasoning: true } : {}),
2369
- ...(vision ? { input: ["text", "image"] } : {}),
2370
- ...(contextWindow ? { contextWindow } : {}),
2371
- ...(maxTokens ? { maxTokens } : {}),
2372
- };
2373
- }
2374
- /** google-generative-ai /models shape:
2375
- * { models: [{ name: "models/gemini-flash", displayName, inputTokenLimit,
2376
- * outputTokenLimit, supportedGenerationMethods }] } */
2377
- static parseGoogleModel(m) {
2378
- const r = (m ?? {});
2379
- const rawName = typeof r.name === "string" ? r.name : "";
2380
- const id = rawName.replace(/^models\//, "");
2381
- const displayName = typeof r.displayName === "string" ? r.displayName : undefined;
2382
- return {
2383
- id,
2384
- ...(displayName && displayName !== id ? { name: displayName } : {}),
2385
- ...(ClientSession.numMeta(r.inputTokenLimit)
2386
- ? { contextWindow: ClientSession.numMeta(r.inputTokenLimit) }
2387
- : {}),
2388
- ...(ClientSession.numMeta(r.outputTokenLimit)
2389
- ? { maxTokens: ClientSession.numMeta(r.outputTokenLimit) }
2390
- : {}),
2391
- };
1285
+ // During active streaming the deltas carry live rendering — full snapshots
1286
+ // are just a periodic reconciliation checkpoint, so send them far less
1287
+ // often (they serialize the whole session; big sessions made this path OOM).
1288
+ const interval = Date.now() - this.lastDeltaAt < DELTA_ACTIVE_WINDOW_MS
1289
+ ? STREAMING_SNAPSHOT_INTERVAL_MS
1290
+ : SNAPSHOT_INTERVAL_MS;
1291
+ this.snapshotTimer = setTimeout(() => {
1292
+ this.snapshotTimer = null;
1293
+ if (!this.disposed)
1294
+ this.emit({ type: "snapshot", state: this.snapshot() });
1295
+ }, interval);
1296
+ }
1297
+ /** Slash-command catalog + native command execution — 自包含模块,见
1298
+ * slash-commands.ts(内置命令拦截 + 扩展/模板/技能目录推送)。 */
1299
+ slash = new SlashCommandsService({
1300
+ emit: (msg) => this.emit(msg),
1301
+ cwd: () => this.cwd,
1302
+ getSession: () => this.session,
1303
+ newChat: () => this.newChat(),
1304
+ setModel: (id) => this.setModel(id),
1305
+ setCwd: (path) => this.setCwd(path),
1306
+ setThinking: (level) => this.setThinking(level),
1307
+ refreshSessions: () => this.refreshSessions(),
1308
+ onQuit: () => this.onQuit?.() ?? false,
1309
+ });
1310
+ /** Catalog push — index.ts get_commands / attach / cwd 切换等都会调用。 */
1311
+ pushSlashCommands() {
1312
+ return this.slash.push();
2392
1313
  }
2393
- /** Probe a custom provider's OpenAI-compatible /models endpoint (server-side
2394
- * because the baseUrl is often a LAN/loopback host the browser can't reach
2395
- * cross-origin) and return the advertised models. reqId is echoed back
2396
- * in fetch_models_result so the UI can match concurrent requests. */
2397
- async fetchModelsList(reqId, baseUrl, apiKey, authHeader, api) {
2398
- const emitError = (error) => this.emit({ type: "fetch_models_result", reqId, ok: false, error });
2399
- const base = (baseUrl ?? "").trim().replace(/\/+$/, "");
2400
- if (!base)
2401
- return emitError("请先填写 baseUrl");
2402
- let url;
2403
- try {
2404
- url = new URL(base);
2405
- }
2406
- catch {
2407
- return emitError(`baseUrl 无效:${base}`);
2408
- }
2409
- if (url.protocol !== "http:" && url.protocol !== "https:") {
2410
- return emitError("baseUrl 仅支持 http/https");
2411
- }
2412
- const headers = {};
2413
- // Per-api auth conventions (mirror pi's built-in provider configs):
2414
- // openai-*: Authorization: Bearer <key>
2415
- // anthropic: x-api-key + anthropic-version
2416
- // google: x-goog-api-key
2417
- // authHeader=false → no auth header at all (custom gateways).
2418
- if (apiKey?.trim() && authHeader !== false) {
2419
- const key = apiKey.trim();
2420
- if (api === "anthropic-messages") {
2421
- headers["x-api-key"] = key;
2422
- headers["anthropic-version"] = "2023-06-01";
2423
- }
2424
- else if (api === "google-generative-ai") {
2425
- headers["x-goog-api-key"] = key;
2426
- }
2427
- else {
2428
- headers["Authorization"] = `Bearer ${key}`;
2429
- }
2430
- }
2431
- const tryFetch = async (u) => {
2432
- const ac = new AbortController();
2433
- const timer = setTimeout(() => ac.abort(), 15000);
2434
- try {
2435
- return await fetch(u, { headers, signal: ac.signal });
2436
- }
2437
- catch (err) {
2438
- if (err.name === "AbortError") {
2439
- emitError("请求超时(15 秒)");
2440
- }
2441
- else {
2442
- emitError(`请求失败:${err.message}`);
2443
- }
2444
- return null;
2445
- }
2446
- finally {
2447
- clearTimeout(timer);
2448
- }
2449
- };
2450
- let res = await tryFetch(`${base}/models`);
2451
- // BaseUrls that omit the /v1 prefix (e.g. https://api.openai.com) 404 on
2452
- // the bare path — retry under /v1.
2453
- if (res && res.status === 404 && !/\/v\d+[a-z-]*$/.test(base)) {
2454
- res = await tryFetch(`${base}/v1/models`);
2455
- }
2456
- if (!res)
2457
- return;
2458
- if (!res.ok) {
2459
- let detail = "";
2460
- try {
2461
- detail = (await res.text()).slice(0, 200);
2462
- }
2463
- catch {
2464
- // response body already consumed / not text — ignore
2465
- }
2466
- return emitError(`接口返回 HTTP ${res.status}${detail ? `:${detail}` : ""}`);
2467
- }
2468
- let models = [];
2469
- try {
2470
- const json = (await res.json());
2471
- const data = Array.isArray(json.data) ? json.data : null;
2472
- if (data) {
2473
- // OpenAI-compatible: { data: [{ id, context_window, modalities, … }] }
2474
- models = data
2475
- .map((m) => ClientSession.parseOpenAiModel(m))
2476
- .filter((m) => m.id);
2477
- }
2478
- else if (Array.isArray(json.models)) {
2479
- // Google: { models: [{ name: "models/…", displayName, … }] }
2480
- models = json.models
2481
- .map((m) => ClientSession.parseGoogleModel(m))
2482
- .filter((m) => m.id);
2483
- }
2484
- }
2485
- catch {
2486
- return emitError("响应不是有效的 JSON");
2487
- }
2488
- // Dedupe by id (keep the first, most complete entry) and sort by id.
2489
- const seen = new Set();
2490
- models = models
2491
- .filter((m) => (seen.has(m.id) ? false : (seen.add(m.id), true)))
2492
- .sort((a, b) => a.id.localeCompare(b.id));
2493
- if (models.length === 0)
2494
- return emitError("接口未返回任何模型");
2495
- this.emit({ type: "fetch_models_result", reqId, ok: true, models });
1314
+ /** 模型/服务商配置管理 —— 自包含模块,见 model-admin.ts。 */
1315
+ modelAdmin;
1316
+ /** Persist an api-key credential for a provider (auth.json). */
1317
+ setProviderApiKey(provider, apiKey) {
1318
+ return this.modelAdmin.setProviderApiKey(provider, apiKey);
2496
1319
  }
2497
- /** Upsert one provider into models.json and hot-reload the model runtime. */
2498
- async saveModelConfig(providerId, config) {
2499
- const pid = providerId.trim();
2500
- if (!pid || !/^[\w.-]+$/.test(pid)) {
2501
- this.emit({
2502
- type: "notice",
2503
- level: "error",
2504
- text: "服务商 ID 无效(仅字母/数字/._-)",
2505
- });
2506
- return;
2507
- }
2508
- const models = (config.models ?? [])
2509
- .filter((m) => m.id && m.id.trim())
2510
- .map((m) => ({
2511
- id: m.id.trim(),
2512
- ...(m.name?.trim() ? { name: m.name.trim() } : {}),
2513
- ...(m.reasoning ? { reasoning: true } : {}),
2514
- ...(m.input?.length ? { input: m.input } : {}),
2515
- ...(m.contextWindow ? { contextWindow: Number(m.contextWindow) } : {}),
2516
- ...(m.maxTokens ? { maxTokens: Number(m.maxTokens) } : {}),
2517
- }));
2518
- if (models.length === 0) {
2519
- this.emit({ type: "notice", level: "error", text: "至少需要一个模型" });
2520
- return;
2521
- }
2522
- try {
2523
- const { providers } = this.readModelsConfig();
2524
- // headers never reach the browser, so the incoming config can't carry
2525
- // them — preserve the previously stored values when they are absent.
2526
- const prevHeaders = providers[pid]?.headers;
2527
- providers[pid] = {
2528
- ...(config.name?.trim() ? { name: config.name.trim() } : {}),
2529
- ...(config.api?.trim() ? { api: config.api.trim() } : {}),
2530
- ...(config.baseUrl?.trim() ? { baseUrl: config.baseUrl.trim() } : {}),
2531
- ...(config.apiKey?.trim() ? { apiKey: config.apiKey.trim() } : {}),
2532
- ...(config.authHeader ? { authHeader: true } : {}),
2533
- ...(prevHeaders && Object.keys(prevHeaders).length > 0
2534
- ? { headers: prevHeaders }
2535
- : {}),
2536
- models,
2537
- };
2538
- mkdirSync(this.agentDir, { recursive: true });
2539
- writeFileSync(this.modelsConfigPath(), JSON.stringify({ providers }, null, 2) + "\n");
2540
- // Allow a custom models.json entry to reuse the provider credential
2541
- // already stored in auth.json. Seed the shared runtime too, because
2542
- // older pi-ai versions did not always fall back to stored credentials
2543
- // for a newly-created custom provider. Never copy the secret into
2544
- // models.json.
2545
- try {
2546
- const auth = JSON.parse(readFileSync(join(this.agentDir, "auth.json"), "utf8"));
2547
- const credential = auth[pid];
2548
- if (credential &&
2549
- typeof credential === "object" &&
2550
- "key" in credential &&
2551
- typeof credential.key === "string" &&
2552
- credential.key.trim()) {
2553
- await this.runtime.services.modelRuntime.setRuntimeApiKey(pid, credential.key);
2554
- }
2555
- }
2556
- catch {
2557
- // auth.json is optional; models.json can still use its own apiKey.
2558
- }
2559
- await this.runtime.services.modelRuntime.refresh();
2560
- await this.listModelsConfig();
2561
- await this.listModels();
2562
- this.emit({
2563
- type: "notice",
2564
- level: "info",
2565
- text: `✅ 已保存服务商 ${pid}(${models.length} 个模型)并刷新模型列表`,
2566
- });
2567
- }
2568
- catch (err) {
2569
- this.emit({
2570
- type: "notice",
2571
- level: "error",
2572
- text: `保存模型配置失败:${err.message}`,
2573
- });
2574
- }
2575
- this.flushSnapshot();
1320
+ clearProviderApiKey(provider) {
1321
+ return this.modelAdmin.clearProviderApiKey(provider);
2576
1322
  }
2577
- /** Remove a provider from models.json and hot-reload. */
2578
- async deleteModelConfig(providerId) {
2579
- try {
2580
- const { providers } = this.readModelsConfig();
2581
- if (!(providerId in providers)) {
2582
- this.emit({
2583
- type: "notice",
2584
- level: "info",
2585
- text: `服务商 ${providerId} 不存在`,
2586
- });
2587
- return;
2588
- }
2589
- delete providers[providerId];
2590
- writeFileSync(this.modelsConfigPath(), JSON.stringify({ providers }, null, 2) + "\n");
2591
- await this.runtime.services.modelRuntime.refresh();
2592
- await this.listModelsConfig();
2593
- await this.listModels();
2594
- this.emit({
2595
- type: "notice",
2596
- level: "info",
2597
- text: `🗑 已删除服务商 ${providerId}`,
2598
- });
2599
- }
2600
- catch (err) {
2601
- this.emit({
2602
- type: "notice",
2603
- level: "error",
2604
- text: `删除模型配置失败:${err.message}`,
2605
- });
2606
- }
2607
- this.flushSnapshot();
1323
+ listProviders() {
1324
+ return this.modelAdmin.listProviders();
2608
1325
  }
2609
- /** Send a snapshot immediately (cancels any pending throttled one). */
2610
- flushSnapshot() {
2611
- if (this.snapshotTimer) {
2612
- clearTimeout(this.snapshotTimer);
2613
- this.snapshotTimer = null;
2614
- }
2615
- if (!this.disposed)
2616
- this.emit({ type: "snapshot", state: this.snapshot() });
1326
+ listModelsConfig() {
1327
+ return this.modelAdmin.listModelsConfig();
2617
1328
  }
2618
- scheduleSnapshot() {
2619
- if (this.snapshotTimer || this.disposed)
2620
- return;
2621
- this.snapshotTimer = setTimeout(() => {
2622
- this.snapshotTimer = null;
2623
- if (!this.disposed)
2624
- this.emit({ type: "snapshot", state: this.snapshot() });
2625
- }, SNAPSHOT_INTERVAL_MS);
1329
+ fetchModelsList(reqId, baseUrl, apiKey, authHeader, api) {
1330
+ return this.modelAdmin.fetchModelsList(reqId, baseUrl, apiKey, authHeader, api);
2626
1331
  }
2627
- // ---------------------------------------------------------------------------
2628
- // Slash commands
2629
- // ---------------------------------------------------------------------------
2630
- /**
2631
- * Slash commands implemented natively by the web server (the pi CLI's built-in
2632
- * interactive commands like /model and /new are NOT handled by the SDK's
2633
- * prompt() — without this they'd be sent to the model as plain text). Keep in
2634
- * sync with execNativeCommand(). /help and /copy are client-side UI actions
2635
- * (they never reach the server) but stay listed so the picker shows them.
2636
- */
2637
- static NATIVE_COMMANDS = [
2638
- { name: "new", description: "新建对话", descriptionEn: "New chat" },
2639
- { name: "model", description: "切换模型", descriptionEn: "Switch model", argumentHint: "[名称]", argumentHintEn: "[name]" },
2640
- { name: "compact", description: "压缩上下文", descriptionEn: "Compact context", argumentHint: "[说明]", argumentHintEn: "[instructions]" },
2641
- { name: "cwd", description: "切换工作目录", descriptionEn: "Switch workspace", argumentHint: "<路径>", argumentHintEn: "<path>" },
2642
- {
2643
- name: "thinking",
2644
- description: "设置思考强度",
2645
- descriptionEn: "Set thinking level",
2646
- argumentHint: "<off|low|medium|high|xhigh|max>",
2647
- argumentHintEn: "<off|low|medium|high|xhigh|max>",
2648
- },
2649
- { name: "resume", description: "刷新会话列表", descriptionEn: "Refresh session list" },
2650
- { name: "reload", description: "重新加载扩展、技能与模板", descriptionEn: "Reload extensions, skills & templates" },
2651
- { name: "help", description: "显示全部命令", descriptionEn: "Show all commands" },
2652
- { name: "copy", description: "复制上一条助手回复", descriptionEn: "Copy last assistant reply" },
2653
- { name: "pi-web-ui:quit", description: "退出服务", descriptionEn: "Quit server (supervisor will restart)" },
2654
- ];
2655
- /** Parse a prompt into "/command args" — returns null when it isn't one. */
2656
- parseSlash(text) {
2657
- const trimmed = text.trim();
2658
- if (!trimmed.startsWith("/"))
2659
- return null;
2660
- const m = trimmed.match(/^\/([^\s]+)\s*([\s\S]*)$/);
2661
- if (!m || !m[1])
2662
- return null;
2663
- return { name: m[1], args: m[2].trim() };
1332
+ refreshProviderModels(providerId, reqId) {
1333
+ return this.modelAdmin.refreshProviderModels(providerId, reqId);
2664
1334
  }
2665
- /** Run a native slash command (see NATIVE_COMMANDS). Returns false when the
2666
- * name is not a native command (the prompt falls through to the SDK). */
2667
- async execNativeCommand(name, args) {
2668
- switch (name) {
2669
- case "new":
2670
- await this.newChat();
2671
- return true;
2672
- case "model": {
2673
- if (!args) {
2674
- const current = this.session.model;
2675
- this.emit({
2676
- type: "notice",
2677
- level: "info",
2678
- text: current
2679
- ? `当前模型:${current.name}(${current.provider}/${current.id})。用法:/model <名称>`
2680
- : `用法:/model <名称>`,
2681
- });
2682
- return true;
2683
- }
2684
- const query = args.toLowerCase();
2685
- const available = await this.session.modelRuntime.getAvailable();
2686
- // Prefer an exact "provider/id" match, else id/name substring.
2687
- const exact = available.find((m) => m.provider + "/" + m.id === args.trim());
2688
- const matches = exact
2689
- ? [exact]
2690
- : available.filter((m) => m.id.toLowerCase().includes(query) ||
2691
- m.name.toLowerCase().includes(query) ||
2692
- m.provider.toLowerCase().includes(query));
2693
- if (matches.length === 0) {
2694
- this.emit({
2695
- type: "notice",
2696
- level: "error",
2697
- text: `没有匹配到模型:${args}(可用模型见顶栏模型列表)`,
2698
- });
2699
- return true;
2700
- }
2701
- const pick = matches[0];
2702
- if (matches.length > 1) {
2703
- this.emit({
2704
- type: "notice",
2705
- level: "warning",
2706
- text: `找到 ${matches.length} 个匹配模型,已选用:${pick.name}(精确匹配请用 provider/id)`,
2707
- });
2708
- }
2709
- await this.setModel(`${pick.provider}/${pick.id}`);
2710
- return true;
2711
- }
2712
- case "compact":
2713
- try {
2714
- await this.session.compact(args || undefined);
2715
- }
2716
- catch (err) {
2717
- this.emit({
2718
- type: "notice",
2719
- level: "error",
2720
- text: `压缩上下文失败:${err.message}`,
2721
- });
2722
- }
2723
- return true;
2724
- case "cwd":
2725
- if (!args) {
2726
- this.emit({
2727
- type: "notice",
2728
- level: "info",
2729
- text: `当前工作目录:${this.cwd}。用法:/cwd <路径>`,
2730
- });
2731
- }
2732
- else {
2733
- await this.setCwd(args);
2734
- }
2735
- return true;
2736
- case "thinking": {
2737
- const ALIAS = {
2738
- off: "off",
2739
- minimal: "minimal",
2740
- low: "low",
2741
- medium: "medium",
2742
- high: "high",
2743
- xhigh: "xhigh",
2744
- max: "max",
2745
- 关闭: "off",
2746
- 极简: "minimal",
2747
- 低: "low",
2748
- 中: "medium",
2749
- 高: "high",
2750
- 极高: "xhigh",
2751
- 最大: "max",
2752
- };
2753
- const level = ALIAS[args.trim().toLowerCase()];
2754
- if (!level) {
2755
- this.emit({
2756
- type: "notice",
2757
- level: "error",
2758
- text: `无效的思考强度:${args || "(空)"}。可用:off / minimal / low / medium / high / xhigh / max`,
2759
- });
2760
- return true;
2761
- }
2762
- this.setThinking(level);
2763
- return true;
2764
- }
2765
- case "resume":
2766
- await this.refreshSessions();
2767
- this.emit({
2768
- type: "notice",
2769
- level: "info",
2770
- text: "会话列表已刷新,请在左侧「历史对话」中选择",
2771
- });
2772
- return true;
2773
- case "reload":
2774
- try {
2775
- // Re-discovers extensions / skills / prompt templates from disk and
2776
- // re-pushes the picker catalog (the CLI's /reload semantics).
2777
- await this.session.reload();
2778
- await this.pushSlashCommands();
2779
- this.emit({
2780
- type: "notice",
2781
- level: "info",
2782
- text: "已重新加载扩展、技能与提示模板",
2783
- });
2784
- }
2785
- catch (err) {
2786
- this.emit({
2787
- type: "notice",
2788
- level: "error",
2789
- text: `重新加载失败:${err.message}`,
2790
- });
2791
- }
2792
- return true;
2793
- case "pi-web-ui:quit": {
2794
- this.emit({
2795
- type: "notice",
2796
- level: "info",
2797
- text: "正在退出 pi-web-ui… supervisor 将自动重启服务",
2798
- });
2799
- setTimeout(() => {
2800
- const didSchedule = this.onQuit?.() ?? false;
2801
- if (!didSchedule) {
2802
- setTimeout(() => process.exit(0), 100);
2803
- }
2804
- }, 300);
2805
- return true;
2806
- }
2807
- case "help":
2808
- case "copy":
2809
- // Client-side UI actions — the client handles them before sending;
2810
- // swallow here so the SDK never sees them as plain prompt text.
2811
- return true;
2812
- default:
2813
- return false;
2814
- }
1335
+ saveModelConfig(providerId, config) {
1336
+ return this.modelAdmin.saveModelConfig(providerId, config);
2815
1337
  }
2816
- /**
2817
- * Catalog of slash commands for the chat input: web-native builtins first,
2818
- * then the SDK's invokable commands for the ACTIVE conversation (extension
2819
- * commands, prompt templates, skills) — the same set the SDK expands when a
2820
- * prompt text starts with "/" (see AgentSession.prompt).
2821
- */
2822
- async pushSlashCommands() {
2823
- const commands = [];
2824
- const seen = new Set();
2825
- for (const c of ClientSession.NATIVE_COMMANDS) {
2826
- commands.push({ ...c, source: "builtin" });
2827
- seen.add(c.name);
2828
- }
2829
- try {
2830
- const s = this.session;
2831
- // Extension commands — the SDK already suffixes collisions with builtin
2832
- // names ("new:2"), and those still reach the SDK since execNativeCommand
2833
- // only intercepts the exact native names.
2834
- for (const cmd of s.extensionRunner.getRegisteredCommands()) {
2835
- if (seen.has(cmd.invocationName))
2836
- continue;
2837
- commands.push({
2838
- name: cmd.invocationName,
2839
- description: cmd.description,
2840
- source: "extension",
2841
- });
2842
- seen.add(cmd.invocationName);
2843
- }
2844
- // Prompt templates: /templatename args
2845
- for (const t of s.promptTemplates) {
2846
- if (seen.has(t.name))
2847
- continue;
2848
- commands.push({
2849
- name: t.name,
2850
- description: t.description,
2851
- source: "prompt",
2852
- });
2853
- seen.add(t.name);
2854
- }
2855
- // Skills: /skill:name args
2856
- for (const skill of s.resourceLoader.getSkills().skills) {
2857
- const name = `skill:${skill.name}`;
2858
- if (seen.has(name))
2859
- continue;
2860
- commands.push({
2861
- name,
2862
- description: skill.description,
2863
- source: "skill",
2864
- });
2865
- }
2866
- }
2867
- catch {
2868
- // Session not ready yet — native-only catalog still serves the picker.
2869
- }
2870
- this.emit({ type: "slash_commands", commands });
1338
+ deleteModelConfig(providerId) {
1339
+ return this.modelAdmin.deleteModelConfig(providerId);
2871
1340
  }
2872
1341
  // ---------------------------------------------------------------------------
2873
1342
  // Settings (system prompt / skills / extensions / presets)
@@ -2876,231 +1345,31 @@ export class ClientSession {
2876
1345
  * with enabled flags + saved presets). Pushed on attach and after every
2877
1346
  * settings change. */
2878
1347
  pushSettings() {
2879
- const disabledSkills = new Set(this.settings.disabledSkills);
2880
- const reviewDisabledSkills = new Set(this.settings.reviewDisabledSkills);
2881
- const disabledExts = new Set(this.settings.disabledExtensions);
2882
- try {
2883
- // Refresh the cache with the CURRENTLY loaded set (post-filter).
2884
- for (const s of this.session.resourceLoader.getSkills().skills) {
2885
- this.knownSkills.set(s.name, {
2886
- name: s.name,
2887
- description: s.description,
2888
- enabled: true,
2889
- });
2890
- }
2891
- for (const e of this.session.resourceLoader.getExtensions().extensions) {
2892
- const id = extensionKey(e);
2893
- const p = e.sourceInfo?.path ?? e.path;
2894
- this.knownExtensions.set(id, {
2895
- id,
2896
- name: e.sourceInfo?.origin === "package" && e.sourceInfo.source
2897
- ? e.sourceInfo.source
2898
- : basename(p),
2899
- path: p,
2900
- enabled: true,
2901
- });
2902
- }
2903
- }
2904
- catch {
2905
- // Session not ready yet — keep whatever we already know.
2906
- }
2907
- // Disabled entries are filtered out of the loader — keep them in the
2908
- // panel (with the last-known description) so they can be re-enabled.
2909
- for (const name of this.settings.disabledSkills) {
2910
- if (!this.knownSkills.has(name)) {
2911
- this.knownSkills.set(name, { name, description: "", enabled: false });
2912
- }
2913
- }
2914
- for (const id of this.settings.disabledExtensions) {
2915
- if (!this.knownExtensions.has(id)) {
2916
- this.knownExtensions.set(id, {
2917
- id,
2918
- name: id.startsWith("npm:") ? id : basename(id),
2919
- path: "",
2920
- enabled: false,
2921
- });
2922
- }
2923
- }
2924
- const skills = [...this.knownSkills.values()]
2925
- .map((s) => ({ ...s, enabled: !disabledSkills.has(s.name) }))
2926
- .sort((a, b) => a.name.localeCompare(b.name));
2927
- const reviewSkills = [...this.knownSkills.values()]
2928
- .map((s) => ({ ...s, enabled: !reviewDisabledSkills.has(s.name) }))
2929
- .sort((a, b) => a.name.localeCompare(b.name));
2930
- const extensions = [...this.knownExtensions.values()]
2931
- .map((e) => ({ ...e, enabled: !disabledExts.has(e.id) }))
2932
- .sort((a, b) => a.name.localeCompare(b.name));
2933
- this.emit({
2934
- type: "settings_state",
2935
- settings: {
2936
- promptMode: this.settings.promptMode,
2937
- customSystemPrompt: this.settings.customSystemPrompt,
2938
- visionBridgeEnabled: this.settings.visionBridgeEnabled,
2939
- visionBridgeModel: this.settings.visionBridgeModel,
2940
- visionBridgePromptMode: this.settings.visionBridgePromptMode,
2941
- visionBridgePrompt: this.settings.visionBridgePrompt,
2942
- reviewPrompt: this.settings.reviewPrompt,
2943
- reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
2944
- // The built-in prompts, so the replace-mode editors can prefill the
2945
- // text they would otherwise replace (empty until the resource-loader
2946
- // has run once for the system prompt).
2947
- defaultSystemPrompt: this.effectiveDefaultSystemPrompt(),
2948
- visionBridgeDefaultPrompt: SYSTEM_PROMPT,
2949
- visionModels: this.collectVisionModels(),
2950
- disabledSkills: [...this.settings.disabledSkills],
2951
- disabledExtensions: [...this.settings.disabledExtensions],
2952
- skills,
2953
- reviewSkills,
2954
- extensions,
2955
- presets: this.presets.map((p) => ({ ...p })),
2956
- },
2957
- });
2958
- }
2959
- /** Vision-capable configured models, for the settings-panel picker. */
2960
- collectVisionModels() {
2961
- try {
2962
- return findVisionModels(this.session.modelRuntime).map((m) => ({
2963
- provider: m.provider,
2964
- id: m.id,
2965
- label: m.label,
2966
- }));
2967
- }
2968
- catch {
2969
- // Session not ready yet — the picker stays empty until next push.
2970
- return [];
2971
- }
1348
+ this.settingsSvc.push();
2972
1349
  }
2973
1350
  /** Persist + apply a partial settings update (prompt text/mode, toggles). */
2974
1351
  async setSettings(partial) {
2975
- const needsReload = partial.promptMode !== undefined ||
2976
- partial.customSystemPrompt !== undefined ||
2977
- partial.disabledSkills !== undefined ||
2978
- partial.disabledExtensions !== undefined;
2979
- if (partial.promptMode !== undefined)
2980
- this.settings.promptMode = partial.promptMode;
2981
- if (partial.customSystemPrompt !== undefined) {
2982
- this.settings.customSystemPrompt = partial.customSystemPrompt;
2983
- }
2984
- if (partial.disabledSkills !== undefined) {
2985
- this.settings.disabledSkills = partial.disabledSkills;
2986
- }
2987
- if (partial.disabledExtensions !== undefined) {
2988
- this.settings.disabledExtensions = partial.disabledExtensions;
2989
- }
2990
- if (partial.visionBridgeEnabled !== undefined) {
2991
- this.settings.visionBridgeEnabled = partial.visionBridgeEnabled;
2992
- }
2993
- if (partial.visionBridgeModel !== undefined) {
2994
- this.settings.visionBridgeModel = partial.visionBridgeModel ?? null;
2995
- }
2996
- if (partial.visionBridgePromptMode !== undefined) {
2997
- this.settings.visionBridgePromptMode = partial.visionBridgePromptMode;
2998
- }
2999
- if (partial.visionBridgePrompt !== undefined) {
3000
- this.settings.visionBridgePrompt = partial.visionBridgePrompt;
3001
- }
3002
- if (partial.reviewPrompt !== undefined) {
3003
- this.settings.reviewPrompt = partial.reviewPrompt;
3004
- }
3005
- if (partial.reviewDisabledSkills !== undefined) {
3006
- this.settings.reviewDisabledSkills = partial.reviewDisabledSkills;
3007
- }
3008
- this.stateStore.saveSettings(this.clientId, this.settings);
3009
- this.pushSettings();
3010
- if (needsReload)
3011
- await this.applyRuntimeSettings();
1352
+ await this.settingsSvc.set(partial);
3012
1353
  }
3013
1354
  /** Save the CURRENT settings as a named preset (overwrites if exists). */
3014
1355
  async savePreset(name) {
3015
- const n = name.trim();
3016
- if (!n) {
3017
- this.emit({ type: "notice", level: "error", text: "预设名称不能为空" });
3018
- return;
3019
- }
3020
- const preset = {
3021
- name: n,
3022
- promptMode: this.settings.promptMode,
3023
- customSystemPrompt: this.settings.customSystemPrompt,
3024
- disabledSkills: [...this.settings.disabledSkills],
3025
- disabledExtensions: [...this.settings.disabledExtensions],
3026
- reviewPrompt: this.settings.reviewPrompt,
3027
- reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
3028
- };
3029
- const existing = this.presets.findIndex((p) => p.name === n);
3030
- if (existing >= 0)
3031
- this.presets[existing] = preset;
3032
- else
3033
- this.presets.push(preset);
3034
- this.stateStore.savePresets(this.clientId, this.presets);
3035
- this.pushSettings();
1356
+ return this.settingsSvc.savePreset(name);
3036
1357
  }
3037
1358
  /** Replace the current settings with the named preset and apply it. */
3038
1359
  async applyPreset(name) {
3039
- const p = this.presets.find((x) => x.name === name);
3040
- if (!p) {
3041
- this.emit({ type: "notice", level: "error", text: `预设不存在:${name}` });
3042
- return;
3043
- }
3044
- this.settings = {
3045
- promptMode: p.promptMode,
3046
- customSystemPrompt: p.customSystemPrompt,
3047
- disabledSkills: [...p.disabledSkills],
3048
- disabledExtensions: [...p.disabledExtensions],
3049
- reviewPrompt: p.reviewPrompt ?? this.settings.reviewPrompt,
3050
- reviewDisabledSkills: [
3051
- ...(p.reviewDisabledSkills ?? this.settings.reviewDisabledSkills),
3052
- ],
3053
- // Presets don't capture vision-bridge prefs — keep the current ones.
3054
- visionBridgeEnabled: this.settings.visionBridgeEnabled,
3055
- visionBridgeModel: this.settings.visionBridgeModel,
3056
- visionBridgePromptMode: this.settings.visionBridgePromptMode,
3057
- visionBridgePrompt: this.settings.visionBridgePrompt,
3058
- };
3059
- this.stateStore.saveSettings(this.clientId, this.settings);
3060
- this.pushSettings();
3061
- await this.applyRuntimeSettings();
1360
+ return this.settingsSvc.applyPreset(name);
3062
1361
  }
3063
1362
  /** Remove a named preset. */
3064
1363
  async deletePreset(name) {
3065
- this.presets = this.presets.filter((p) => p.name !== name);
3066
- this.stateStore.savePresets(this.clientId, this.presets);
3067
- this.pushSettings();
1364
+ return this.settingsSvc.deletePreset(name);
3068
1365
  }
3069
- /**
3070
- * Make settings changes effective in the running runtime. The resource-loader
3071
- * overrides read this.settings at call time, so a reload re-applies them.
3072
- * Reloading mid-stream would tear down the in-flight run — defer instead.
3073
- */
1366
+ /** Make settings effective in the running runtime(流式中则延迟到 agent_end)。 */
3074
1367
  async applyRuntimeSettings() {
3075
- if (this.disposed)
3076
- return;
3077
- if (this.session.isStreaming) {
3078
- this.pendingSettingsReload = true;
3079
- this.emit({
3080
- type: "notice",
3081
- level: "info",
3082
- text: "当前回复进行中,设置将在回复结束后自动应用",
3083
- });
3084
- return;
3085
- }
3086
- await this.applySettingsReload();
1368
+ return this.settingsSvc.applyRuntime();
3087
1369
  }
3088
- /** session.reload() + refresh the slash-command catalog + push state. */
3089
1370
  async applySettingsReload() {
3090
- try {
3091
- await this.session.reload();
3092
- await this.pushSlashCommands();
3093
- this.pushSettings();
3094
- this.flushSnapshot();
3095
- this.emit({ type: "notice", level: "info", text: "设置已应用" });
3096
- }
3097
- catch (err) {
3098
- this.emit({
3099
- type: "notice",
3100
- level: "error",
3101
- text: `设置应用失败:${err.message}`,
3102
- });
3103
- }
1371
+ // 兼容旧入口:reload + 刷目录在宿主回调里完成
1372
+ return this.settingsSvc.applyRuntime();
3104
1373
  }
3105
1374
  // ---------------------------------------------------------------------------
3106
1375
  // Commands
@@ -3149,695 +1418,88 @@ export class ClientSession {
3149
1418
  * after the WHOLE run finishes (补充 button — "AI 生成结束才发送").
3150
1419
  * false/undefined = steer: the pi CLI Enter semantic — injected right
3151
1420
  * after the current turn settles, skipping remaining planned tool calls.
3152
- */
3153
- queue = false) {
3154
- try {
3155
- const s = this.session;
3156
- // Native slash commands (see NATIVE_COMMANDS) are executed here and
3157
- // never reach the SDK. Extension / skill / template commands fall
3158
- // through — AgentSession.prompt() handles those itself.
3159
- const slash = this.parseSlash(text);
3160
- if (slash && (await this.execNativeCommand(slash.name, slash.args))) {
3161
- this.flushSnapshot();
3162
- return;
3163
- }
3164
- // Native commands above are pure config tweaks (no tokens) — allow them
3165
- // even while quiesced. Everything that reaches the SDK is NEW work and
3166
- // is refused until admission reopens.
3167
- if (this.quiesceBlocked())
3168
- return;
3169
- // Attach files as independent nextTurn context messages (asides) so the
3170
- // user message stays clean; they render as separate attachment cards.
3171
- const asides = await this.buildAttachmentMessages(attachments);
3172
- for (const aside of asides) {
3173
- await s.sendCustomMessage(aside.message, { deliverAs: "nextTurn" });
3174
- }
3175
- if (s.isStreaming) {
3176
- // queue=true (补充 button) → followUp: the message is delivered only
3177
- // after the whole run finishes — the agent finishes what it started,
3178
- // then responds to the queued message. queue=false/undefined
3179
- // (plain Enter) → steer: interrupts the current run — the message
3180
- // is delivered right after the current assistant turn settles
3181
- // (remaining planned tool calls are skipped) and the agent
3182
- // immediately responds to it. This is the pi CLI
3183
- // Enter-during-streaming semantic (docs/usage: Enter queues a
3184
- // steering message); followUp would wait for the whole run
3185
- // to finish, which users perceive as ordinary queueing.
3186
- await s.prompt(text, {
3187
- streamingBehavior: queue ? "followUp" : "steer",
3188
- });
3189
- }
3190
- else {
3191
- await s.prompt(text);
3192
- }
3193
- }
3194
- catch (err) {
3195
- this.emit({
3196
- type: "notice",
3197
- level: "error",
3198
- text: `提示发送失败:${err.message}`,
3199
- });
3200
- }
3201
- // Name the conversation after its first user prompt.
3202
- const conv = this.conv;
3203
- if (conv.title === DEFAULT_CONV_TITLE && text.trim()) {
3204
- const trimmed = text.trim().replace(/\s+/g, " ");
3205
- conv.title = trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
3206
- this.emitConversations();
3207
- }
3208
- // The active conversation has been continued since it was opened — it
3209
- // must not be dismissed when the user switches away. (Also bumps the
3210
- // per-project "most recently active" order used by set_cwd.)
3211
- conv.promptedSinceActive = true;
3212
- conv.lastActiveAt = Date.now();
3213
- this.flushSnapshot();
3214
- }
3215
- /**
3216
- * Turn attached files into custom-message payloads.
3217
- *
3218
- * Text files are size-aware: small files are inlined into the message so the
3219
- * model sees them immediately; large files are passed as a <file path="...">
3220
- * reference and the model reads them on demand with its read tool (which has
3221
- * built-in truncation). Images are always passed as image content. Mode
3222
- * "lines" inlines only a 1-based inclusive line range of the file. Raw
3223
- * pasted/dropped/uploaded images (attachment.imageData) skip the workspace
3224
- * path entirely and go straight to the model as image content. Raw uploaded
3225
- * files (attachment.fileData) are persisted under <dataDir>/uploads/ and
3226
- * attached as absolute-path references (small text ones are inlined).
3227
- */
3228
- async buildAttachmentMessages(attachments) {
3229
- if (!attachments || attachments.length === 0)
3230
- return [];
3231
- const fs = await import("node:fs/promises");
3232
- const { resolve, sep, relative, extname, join } = await import("node:path");
3233
- const root = resolve(this.cwd);
3234
- const MAX_ATTACHMENT_BYTES = 200 * 1024;
3235
- // Files at or below this size are inlined; larger files are referenced by
3236
- // path only (the model reads them on demand — saves tokens for small edits).
3237
- const MAX_INLINE_BYTES = Number(process.env.PI_WEB_INLINE_FILE_MAX ?? 12 * 1024);
3238
- const IMAGE_EXT = new Set([
3239
- ".png",
3240
- ".jpg",
3241
- ".jpeg",
3242
- ".gif",
3243
- ".webp",
3244
- ".bmp",
3245
- ".svg",
3246
- ]);
3247
- const MIME = {
3248
- ".png": "image/png",
3249
- ".jpg": "image/jpeg",
3250
- ".jpeg": "image/jpeg",
3251
- ".gif": "image/gif",
3252
- ".webp": "image/webp",
3253
- ".bmp": "image/bmp",
3254
- ".svg": "image/svg+xml",
3255
- };
3256
- const out = [];
3257
- // -- Vision bridge ------------------------------------------------------
3258
- // When the active model can't accept images (DeepSeek, GLM, …), pasted
3259
- // images are transcribed by a configured vision model first and the
3260
- // transcript is fed to the text-only model as text evidence (see
3261
- // vision-bridge.ts — any model in models.json whose input includes
3262
- // "image" works, zero extra config). Vision-capable main models keep
3263
- // the raw image-content path untouched.
3264
- const mainModel = this.session.model;
3265
- const mainSupportsVision = mainModel?.input?.includes("image") ?? false;
3266
- const bridgedImages = [];
3267
- /** Raw image bytes for path-referenced image files (idx → info), pre-read
3268
- * so the loop below doesn't re-read them. SVG stays a plain text file —
3269
- * the model reads its source, far more useful than a rasterized blob. */
3270
- const pathImageData = new Map();
3271
- /** Cap for path images (fully read + base64'd); larger ones fall back to
3272
- * a plain path reference (the model can still attempt to read them). */
3273
- const MAX_PATH_IMAGE_BYTES = 5 * 1024 * 1024;
3274
- for (const [idx, att] of attachments.entries()) {
3275
- if (att.imageData) {
3276
- const raw = att.imageData.replace(/^data:[^;]*;base64,/, "");
3277
- const mimeType = att.mimeType?.startsWith("image/")
3278
- ? att.mimeType
3279
- : "image/png";
3280
- const bytes = Buffer.byteLength(raw, "base64");
3281
- // Only images that would actually be sent (non-empty, under the cap).
3282
- if (bytes > 0 && bytes <= 2 * 1024 * 1024) {
3283
- if (!mainSupportsVision) {
3284
- bridgedImages.push({ idx, att, raw, mimeType, bytes });
3285
- }
3286
- }
3287
- continue;
3288
- }
3289
- if (att.fileData || !att.path)
3290
- continue;
3291
- const ext = extname(att.path).toLowerCase();
3292
- if (!IMAGE_EXT.has(ext) || ext === ".svg")
3293
- continue;
3294
- const abs = resolve(root, att.path);
3295
- const rawRel = relative(root, abs);
3296
- if (rawRel.startsWith("..") || rawRel.includes(`${sep}..`))
3297
- continue;
3298
- let st;
3299
- try {
3300
- st = await fs.stat(abs);
3301
- }
3302
- catch {
3303
- continue;
3304
- }
3305
- if (!st.isFile() || st.size === 0 || st.size > MAX_PATH_IMAGE_BYTES) {
3306
- continue;
3307
- }
3308
- const buf = await fs.readFile(abs);
3309
- const mime = sniffImageMime(buf, ext);
3310
- if (!mime)
3311
- continue;
3312
- const raw = buf.toString("base64");
3313
- pathImageData.set(idx, { raw, mimeType: mime, bytes: st.size });
3314
- if (!mainSupportsVision) {
3315
- bridgedImages.push({ idx, att, raw, mimeType: mime, bytes: st.size });
3316
- }
3317
- }
3318
- /** Transcript per attachment index (filled below, keyed by bridgedImages idx). */
3319
- const bridgeTranscripts = new Map();
3320
- if (bridgedImages.length > 0) {
3321
- if (!this.settings.visionBridgeEnabled) {
3322
- this.emit({
3323
- type: "notice",
3324
- level: "warning",
3325
- text: `当前模型(${mainModel?.name ?? mainModel?.id ?? "未知"})不支持识图,且视觉桥已在设置中关闭:图片将原样发送、可能被忽略。`,
3326
- });
3327
- }
3328
- else {
3329
- const visionModels = findVisionModels(this.session.modelRuntime);
3330
- // Preferred model from settings ("provider/id") — validated to exist
3331
- // and actually accept images; falls back to the first auto-detected.
3332
- let chosen = visionModels[0] ?? null;
3333
- const pref = this.settings.visionBridgeModel;
3334
- if (pref) {
3335
- const spec = this.resolveReviewModel(pref);
3336
- if (spec) {
3337
- const pm = this.session.modelRuntime.getModel(spec.provider, spec.id);
3338
- if (pm?.input?.includes("image")) {
3339
- chosen = {
3340
- provider: spec.provider,
3341
- id: spec.id,
3342
- label: `${pm.name ?? pm.id} (${spec.provider})`,
3343
- };
3344
- }
3345
- }
3346
- }
3347
- if (!chosen) {
3348
- this.emit({
3349
- type: "notice",
3350
- level: "warning",
3351
- text: `当前模型(${mainModel?.name ?? mainModel?.id ?? "未知"})不支持识图,且未找到可用的视觉模型:图片将原样发送、可能被忽略。在模型配置里添加任意支持图片的模型(如 qwen-vl、GLM-4V、Gemini)即可自动启用视觉桥转写。`,
3352
- });
3353
- }
3354
- else {
3355
- // Batch hash so re-sending identical images (edit & re-ask) reuses
3356
- // the transcript instead of re-burning tokens on the vision API.
3357
- // The active transcription prompt is part of the key: changing
3358
- // the custom prompt must invalidate cached transcripts made with
3359
- // the old prompt.
3360
- const batchHash = bridgedImages
3361
- .map((b) => `${b.att.name ?? "img"}:${b.raw.slice(0, 48)}`)
3362
- .join("|") +
3363
- "::" +
3364
- buildVisionBridgePrompt(this.settings.visionBridgePromptMode, this.settings.visionBridgePrompt);
3365
- let transcript = this.visionBridgeCache.get(batchHash);
3366
- if (transcript === undefined) {
3367
- this.emit({
3368
- type: "notice",
3369
- level: "info",
3370
- text: `当前模型不支持识图,正在用视觉桥(${chosen.label})转写 ${bridgedImages.length} 张图片…`,
3371
- });
3372
- try {
3373
- const chosenModel = this.session.modelRuntime.getModel(chosen.provider, chosen.id);
3374
- transcript = await transcribeImages(this.session.modelRuntime, bridgedImages.map((b) => ({
3375
- data: b.raw,
3376
- mimeType: b.mimeType,
3377
- name: b.att.name,
3378
- })), {
3379
- model: chosenModel ?? undefined,
3380
- systemPrompt: buildVisionBridgePrompt(this.settings.visionBridgePromptMode, this.settings.visionBridgePrompt),
3381
- });
3382
- this.visionBridgeCache.set(batchHash, transcript);
3383
- this.emit({
3384
- type: "notice",
3385
- level: "info",
3386
- text: `✅ 图片已由视觉桥转写完成(${chosen.label})`,
3387
- });
3388
- }
3389
- catch (err) {
3390
- transcript = "";
3391
- this.emit({
3392
- type: "notice",
3393
- level: "error",
3394
- text: `图片转写失败(${chosen.label}):${err.message}。图片将原样发送、可能被忽略。`,
3395
- });
3396
- }
3397
- }
3398
- for (const b of bridgedImages)
3399
- bridgeTranscripts.set(b.idx, transcript ?? "");
3400
- }
3401
- }
3402
- }
3403
- /** Cap for reading a file in "lines" mode (selected slice is inlined). */
3404
- const MAX_LINES_READ_BYTES = 2 * 1024 * 1024;
3405
- for (const [idx, att] of attachments.entries()) {
3406
- // Raw pasted/dropped/uploaded image — no workspace path involved (the
3407
- // browser downscales client-side; this guard only prevents abuse).
3408
- if (att.imageData) {
3409
- const raw = att.imageData.replace(/^data:[^;]*;base64,/, "");
3410
- const mimeType = att.mimeType?.startsWith("image/") ? att.mimeType : "image/png";
3411
- const bytes = Buffer.byteLength(raw, "base64");
3412
- const MAX_PASTED_IMAGE_BYTES = 2 * 1024 * 1024;
3413
- if (bytes === 0) {
3414
- this.emit({
3415
- type: "notice",
3416
- level: "error",
3417
- text: `图片数据为空,已跳过`,
3418
- });
3419
- continue;
3420
- }
3421
- if (bytes > MAX_PASTED_IMAGE_BYTES) {
3422
- this.emit({
3423
- type: "notice",
3424
- level: "warning",
3425
- text: `图片过大已跳过(>2MB):${att.name ?? "粘贴图片"}`,
3426
- });
3427
- continue;
3428
- }
3429
- const transcript = bridgeTranscripts.get(idx);
3430
- if (transcript) {
3431
- // Bridged: the text-only main model can't see images, so it gets the
3432
- // vision model's transcript as text evidence; the image block is
3433
- // kept so the card still shows the original thumbnail.
3434
- out.push({
3435
- message: {
3436
- customType: "file",
3437
- content: [
3438
- {
3439
- type: "text",
3440
- text: `\n<vision-bridge>\n${transcript}\n</vision-bridge>`,
3441
- },
3442
- { type: "image", data: raw, mimeType },
3443
- ],
3444
- display: true,
3445
- details: {
3446
- name: att.name ?? "image.png",
3447
- path: undefined,
3448
- mode: "bridged",
3449
- size: bytes,
3450
- },
3451
- },
3452
- });
3453
- continue;
3454
- }
3455
- out.push({
3456
- message: {
3457
- customType: "file",
3458
- content: [{ type: "image", data: raw, mimeType }],
3459
- display: true,
3460
- details: {
3461
- name: att.name ?? "image.png",
3462
- // No workspace path — the card renders without the path line.
3463
- path: undefined,
3464
- mode: "image",
3465
- size: bytes,
3466
- },
3467
- },
3468
- });
3469
- continue;
3470
- }
3471
- // Raw uploaded file (base64) — no workspace path involved. The bytes are
3472
- // persisted under <dataDir>/uploads/<clientId>/ so the model can read
3473
- // them on demand with its read tool (absolute path, no traversal guard
3474
- // needed — the path is server-generated). Small text uploads are inlined
3475
- // so the model sees them immediately; everything else becomes a path
3476
- // reference.
3477
- if (att.fileData) {
3478
- const buf = Buffer.from(att.fileData, "base64");
3479
- const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
3480
- if (buf.length === 0) {
3481
- this.emit({
3482
- type: "notice",
3483
- level: "error",
3484
- text: `文件数据为空,已跳过`,
3485
- });
3486
- continue;
3487
- }
3488
- if (buf.length > MAX_UPLOAD_BYTES) {
3489
- this.emit({
3490
- type: "notice",
3491
- level: "warning",
3492
- text: `文件过大已跳过(>20MB):${att.name ?? "上传文件"}`,
3493
- });
3494
- continue;
3495
- }
3496
- // Uploaded files live in a GLOBAL per-user dir (not inside the project
3497
- // or the per-client session store) so browsing a repo never picks up
3498
- // uploaded junk: <home>/.pi-web/uploads/<clientId>/.
3499
- const { homedir } = await import("node:os");
3500
- const uploadsDir = join(homedir(), ".pi-web", "uploads", this.clientId);
3501
- const safeName = (att.name ?? "file")
3502
- .replace(/[\\/:*?"<>|\x00-\x1f]/g, "_")
3503
- .slice(0, 80);
3504
- const abs = join(uploadsDir, `${Date.now()}-${safeName}`);
3505
- await fs.mkdir(uploadsDir, { recursive: true });
3506
- await fs.writeFile(abs, buf);
3507
- // Wire format: forward-slash absolute path (the read tool accepts
3508
- // absolute paths; Windows uses "C:/..." — safe inside the XML-ish tag).
3509
- const wirePath = abs.split(sep).join("/");
3510
- if (buf.length <= MAX_INLINE_BYTES && looksLikeText(buf)) {
3511
- const lines = countLines(buf);
3512
- out.push({
3513
- message: {
3514
- customType: "file",
3515
- content: [
3516
- {
3517
- type: "text",
3518
- text: `\n<file path="${wirePath}">\n\`\`\`\n${decodeText(buf)}\n\`\`\`\n</file>`,
3519
- },
3520
- ],
3521
- display: true,
3522
- details: {
3523
- name: safeName,
3524
- path: wirePath,
3525
- mode: "inline",
3526
- size: buf.length,
3527
- lines,
3528
- },
3529
- },
3530
- });
3531
- }
3532
- else {
3533
- out.push({
3534
- message: {
3535
- customType: "file",
3536
- content: [
3537
- {
3538
- type: "text",
3539
- text: `<file path="${wirePath}" size="${buf.length}" />`,
3540
- },
3541
- ],
3542
- display: true,
3543
- details: {
3544
- name: safeName,
3545
- path: wirePath,
3546
- mode: "reference",
3547
- size: buf.length,
3548
- },
3549
- },
3550
- });
3551
- }
3552
- continue;
3553
- }
3554
- const abs = resolve(root, att.path);
3555
- const rawRel = relative(root, abs);
3556
- if (rawRel.startsWith("..") || rawRel.includes(`${sep}..`)) {
3557
- this.emit({
3558
- type: "notice",
3559
- level: "warning",
3560
- text: `附件路径超出工作区:${att.path}`,
3561
- });
3562
- continue;
3563
- }
3564
- // Normalize to forward slashes (relative() returns "\\" on Windows);
3565
- // <file path> and details.path must use the wire format.
3566
- const rel = rawRel.split(sep).join("/");
3567
- let stat;
3568
- try {
3569
- stat = await fs.stat(abs);
3570
- }
3571
- catch {
3572
- this.emit({
3573
- type: "notice",
3574
- level: "error",
3575
- text: `附件不存在:${att.path}`,
3576
- });
3577
- continue;
3578
- }
3579
- const name = att.path.split(/[\\/]/).pop() ?? att.path;
3580
- // Folders can't be inlined — always a path reference the model browses
3581
- // on demand with its own tools (ls/read).
3582
- if (stat.isDirectory()) {
3583
- out.push({
3584
- message: {
3585
- customType: "file",
3586
- content: [{ type: "text", text: `<folder path="${rel}" />` }],
3587
- display: true,
3588
- details: {
3589
- name,
3590
- path: rel,
3591
- mode: "reference",
3592
- type: "folder",
3593
- },
3594
- },
3595
- });
3596
- continue;
3597
- }
3598
- if (!stat.isFile()) {
3599
- this.emit({
3600
- type: "notice",
3601
- level: "warning",
3602
- text: `跳过非文件附件:${att.path}`,
3603
- });
3604
- continue;
3605
- }
3606
- const ext = extname(att.path).toLowerCase();
3607
- if (IMAGE_EXT.has(ext) && ext !== ".svg") {
3608
- const pathImg = pathImageData.get(idx);
3609
- const transcript = bridgeTranscripts.get(idx);
3610
- if (transcript) {
3611
- // Text-only main model: the vision bridge transcribed this image —
3612
- // the model gets the transcript as text evidence (+ thumbnail).
3613
- out.push({
3614
- message: {
3615
- customType: "file",
3616
- content: [
3617
- {
3618
- type: "text",
3619
- text: `
3620
- <vision-bridge>
3621
- ${transcript}
3622
- </vision-bridge>`,
3623
- },
3624
- ...(pathImg
3625
- ? ([{
3626
- type: "image",
3627
- data: pathImg.raw,
3628
- mimeType: pathImg.mimeType,
3629
- }])
3630
- : []),
3631
- ],
3632
- display: true,
3633
- details: {
3634
- name,
3635
- path: rel,
3636
- mode: "bridged",
3637
- size: stat.size,
3638
- },
3639
- },
3640
- });
3641
- continue;
3642
- }
3643
- if (pathImg) {
3644
- // Vision-capable main model (or bridge failed): send the raw image
3645
- // content straight from the pre-read bytes.
3646
- out.push({
3647
- message: {
3648
- customType: "file",
3649
- content: [
3650
- {
3651
- type: "image",
3652
- data: pathImg.raw,
3653
- mimeType: pathImg.mimeType,
3654
- },
3655
- ],
3656
- display: true,
3657
- details: { name, path: rel, mode: "image", size: stat.size },
3658
- },
3659
- });
3660
- continue;
3661
- }
3662
- // Pre-read failed (unsupported sniff / too large): fall back to the
3663
- // legacy inline-cap behavior.
3664
- if (stat.size > MAX_ATTACHMENT_BYTES) {
3665
- this.emit({
3666
- type: "notice",
3667
- level: "warning",
3668
- text: `图片附件过大已跳过(>200KB):${att.path}`,
3669
- });
3670
- continue;
3671
- }
3672
- const data = await fs.readFile(abs, "base64");
3673
- out.push({
3674
- message: {
3675
- customType: "file",
3676
- content: [
3677
- { type: "image", data, mimeType: MIME[ext] ?? "image/png" },
3678
- ],
3679
- display: true,
3680
- details: { name, path: rel, mode: "image", size: stat.size },
3681
- },
3682
- });
3683
- continue;
3684
- }
3685
- const makeReference = () => ({
3686
- message: {
3687
- customType: "file",
3688
- content: [
3689
- {
3690
- type: "text",
3691
- text: `<file path="${rel}" size="${stat.size}" />`,
3692
- },
3693
- ],
3694
- display: true,
3695
- details: { name, path: rel, mode: "reference", size: stat.size },
3696
- },
3697
- });
3698
- const makeInline = (buf) => {
3699
- const lines = countLines(buf);
3700
- return {
3701
- message: {
3702
- customType: "file",
3703
- content: [
3704
- {
3705
- type: "text",
3706
- text: `\n<file path="${rel}">\n\`\`\`\n${decodeText(buf)}\n\`\`\`\n</file>`,
3707
- },
3708
- ],
3709
- display: true,
3710
- details: {
3711
- name,
3712
- path: rel,
3713
- mode: "inline",
3714
- size: stat.size,
3715
- lines,
3716
- },
3717
- },
3718
- };
3719
- };
3720
- // Reference mode is always honored and never reads the file.
3721
- if (att.mode === "reference") {
3722
- out.push(makeReference());
3723
- continue;
3724
- }
3725
- // Line-range mode: inline only the selected 1-based inclusive range.
3726
- // Reading is capped so a huge file can't exhaust memory even though
3727
- // the selected slice is small.
3728
- if (att.mode === "lines") {
3729
- const range = att.lines;
3730
- if (!range || range.start < 1 || range.end < range.start) {
3731
- this.emit({
3732
- type: "notice",
3733
- level: "warning",
3734
- text: `行范围无效,已改为仅引用:${att.path}`,
3735
- });
3736
- out.push(makeReference());
3737
- continue;
3738
- }
3739
- if (stat.size > MAX_LINES_READ_BYTES) {
3740
- this.emit({
3741
- type: "notice",
3742
- level: "warning",
3743
- text: `文件过大,已改为仅引用:${att.path}`,
3744
- });
3745
- out.push(makeReference());
3746
- continue;
3747
- }
3748
- const buf = await fs.readFile(abs);
3749
- if (buf.includes(0)) {
3750
- this.emit({
3751
- type: "notice",
3752
- level: "warning",
3753
- text: `二进制文件已改为仅引用:${att.path}`,
3754
- });
3755
- out.push(makeReference());
3756
- continue;
3757
- }
3758
- const parts = decodeText(buf).split("\n");
3759
- // A trailing newline yields an empty phantom line — drop it so line
3760
- // numbers match the preview panel.
3761
- if (parts.length > 0 && parts[parts.length - 1] === "")
3762
- parts.pop();
3763
- const start = Math.min(range.start, parts.length);
3764
- const end = Math.min(range.end, parts.length);
3765
- if (start < 1 || end < start) {
3766
- this.emit({
3767
- type: "notice",
3768
- level: "warning",
3769
- text: `选中行超出文件范围,已改为仅引用:${att.path}`,
3770
- });
3771
- out.push(makeReference());
3772
- continue;
3773
- }
3774
- const selected = parts.slice(start - 1, end).join("\n");
3775
- out.push({
3776
- message: {
3777
- customType: "file",
3778
- content: [
3779
- {
3780
- type: "text",
3781
- text: `\n<file path="${rel}" lines="${start}-${end}">\n\`\`\`\n${selected}\n\`\`\`\n</file>`,
3782
- },
3783
- ],
3784
- display: true,
3785
- details: {
3786
- name,
3787
- path: rel,
3788
- mode: "lines",
3789
- size: stat.size,
3790
- lines: end - start + 1,
3791
- startLine: start,
3792
- endLine: end,
3793
- },
3794
- },
3795
- });
3796
- continue;
3797
- }
3798
- // Forced inline has a hard cap to protect the model context.
3799
- if (att.mode === "inline") {
3800
- if (stat.size > MAX_INLINE_BYTES) {
3801
- this.emit({
3802
- type: "notice",
3803
- level: "warning",
3804
- text: `文件过大,已改为仅引用:${att.path}`,
3805
- });
3806
- out.push(makeReference());
3807
- continue;
3808
- }
3809
- const buf = await fs.readFile(abs);
3810
- if (buf.includes(0)) {
3811
- this.emit({
3812
- type: "notice",
3813
- level: "warning",
3814
- text: `二进制文件已改为仅引用:${att.path}`,
3815
- });
3816
- out.push(makeReference());
3817
- continue;
3818
- }
3819
- out.push(makeInline(buf));
3820
- continue;
1421
+ */
1422
+ queue = false) {
1423
+ try {
1424
+ const s = this.session;
1425
+ // Native slash commands (see NATIVE_COMMANDS) are executed here and
1426
+ // never reach the SDK. Extension / skill / template commands fall
1427
+ // through — AgentSession.prompt() handles those itself.
1428
+ const slash = parseSlash(text);
1429
+ if (slash && (await this.slash.exec(slash.name, slash.args))) {
1430
+ this.flushSnapshot();
1431
+ return;
3821
1432
  }
3822
- // Auto: small files inline, large files reference by path.
3823
- if (stat.size > MAX_INLINE_BYTES) {
3824
- out.push(makeReference());
3825
- continue;
1433
+ // Native commands above are pure config tweaks (no tokens) — allow them
1434
+ // even while quiesced. Everything that reaches the SDK is NEW work and
1435
+ // is refused until admission reopens.
1436
+ if (this.quiesceBlocked())
1437
+ return;
1438
+ // Attach files as independent nextTurn context messages (asides) so the
1439
+ // user message stays clean; they render as separate attachment cards.
1440
+ const asides = await buildAttachmentMessages({
1441
+ cwd: this.cwd,
1442
+ clientId: this.clientId,
1443
+ emit: (msg) => this.emit(msg),
1444
+ settings: this.settingsSvc.current,
1445
+ session: this.session,
1446
+ }, attachments);
1447
+ for (const aside of asides) {
1448
+ await s.sendCustomMessage(aside.message, { deliverAs: "nextTurn" });
3826
1449
  }
3827
- const buf = await fs.readFile(abs);
3828
- if (buf.includes(0)) {
3829
- this.emit({
3830
- type: "notice",
3831
- level: "warning",
3832
- text: `二进制文件已跳过(仅引用路径):${att.path}`,
1450
+ if (s.isStreaming) {
1451
+ // queue=true (补充 button) → followUp: the message is delivered only
1452
+ // after the whole run finishes — the agent finishes what it started,
1453
+ // then responds to the queued message. queue=false/undefined
1454
+ // (plain Enter) → steer: interrupts the current run — the message
1455
+ // is delivered right after the current assistant turn settles
1456
+ // (remaining planned tool calls are skipped) and the agent
1457
+ // immediately responds to it. This is the pi CLI
1458
+ // Enter-during-streaming semantic (docs/usage: Enter queues a
1459
+ // steering message); followUp would wait for the whole run
1460
+ // to finish, which users perceive as ordinary queueing.
1461
+ await s.prompt(text, {
1462
+ streamingBehavior: queue ? "followUp" : "steer",
3833
1463
  });
3834
- out.push(makeReference());
3835
- continue;
3836
1464
  }
3837
- out.push(makeInline(buf));
1465
+ else {
1466
+ await s.prompt(text);
1467
+ }
1468
+ }
1469
+ catch (err) {
1470
+ this.emit({
1471
+ type: "notice",
1472
+ level: "error",
1473
+ text: `提示发送失败:${err.message}`,
1474
+ });
1475
+ }
1476
+ // Name the conversation after its first user prompt.
1477
+ const conv = this.conv;
1478
+ if (conv.title === DEFAULT_CONV_TITLE && text.trim()) {
1479
+ const trimmed = text.trim().replace(/\s+/g, " ");
1480
+ conv.title = trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
1481
+ this.emitConversations();
3838
1482
  }
3839
- return out;
1483
+ // The active conversation has been continued since it was opened — it
1484
+ // must not be dismissed when the user switches away. (Also bumps the
1485
+ // per-project "most recently active" order used by set_cwd.)
1486
+ conv.promptedSinceActive = true;
1487
+ conv.lastActiveAt = Date.now();
1488
+ this.flushSnapshot();
3840
1489
  }
1490
+ /**
1491
+ * Turn attached files into custom-message payloads.
1492
+ *
1493
+ * Text files are size-aware: small files are inlined into the message so the
1494
+ * model sees them immediately; large files are passed as a <file path="...">
1495
+ * reference and the model reads them on demand with its read tool (which has
1496
+ * built-in truncation). Images are always passed as image content. Mode
1497
+ * "lines" inlines only a 1-based inclusive line range of the file. Raw
1498
+ * pasted/dropped/uploaded images (attachment.imageData) skip the workspace
1499
+ * path entirely and go straight to the model as image content. Raw uploaded
1500
+ * files (attachment.fileData) are persisted under <dataDir>/uploads/ and
1501
+ * attached as absolute-path references (small text ones are inlined).
1502
+ */
3841
1503
  /**
3842
1504
  * Hard-abort the running agent (Stop button / global 中断). Tries
3843
1505
  * session.abort() first; if the run is not idle within
@@ -3852,112 +1514,17 @@ ${transcript}
3852
1514
  await this.interruptRun(this.conv, "已停止");
3853
1515
  this.flushSnapshot();
3854
1516
  }
3855
- /** After a bash tool run, wait briefly for background servers to bind,
3856
- * then diff the listening-port snapshot against the pre-run one and
3857
- * remember anything new — those are servers the agent left running. */
3858
- async trackBackgroundServers() {
3859
- const before = this.bashListenBefore;
3860
- this.bashListenBefore = null;
3861
- if (!before)
3862
- return;
3863
- await new Promise((r) => setTimeout(r, 1500));
3864
- const after = await snapshotListeningPorts();
3865
- let added = false;
3866
- for (const [port, pid] of after) {
3867
- if (!before.has(port) && !this.bgServers.has(port)) {
3868
- this.bgServers.set(port, { pid, since: Date.now() });
3869
- added = true;
3870
- // Best-effort process name so the panel shows something readable.
3871
- void lookupProcessName(pid).then((name) => {
3872
- const cur = this.bgServers.get(port);
3873
- if (cur && cur.pid === pid && name) {
3874
- cur.name = name;
3875
- this.emitBgServers();
3876
- }
3877
- });
3878
- this.emit({
3879
- type: "notice",
3880
- level: "info",
3881
- text: `检测到 AI 启动的后台服务:端口 ${port}(pid ${pid})——可在顶栏「后台任务」里单独停止或全部关闭`,
3882
- });
3883
- }
3884
- }
3885
- if (added)
3886
- this.emitBgServers();
3887
- }
3888
- /** The current background-server list, oldest first. */
3889
- bgServerList() {
3890
- return [...this.bgServers.entries()]
3891
- .map(([port, v]) => ({
3892
- port,
3893
- pid: v.pid,
3894
- since: v.since,
3895
- ...(v.name ? { name: v.name } : {}),
3896
- }))
3897
- .sort((a, b) => a.since - b.since);
3898
- }
3899
- /** Push the current background-task list to every connected socket. */
3900
- emitBgServers() {
3901
- this.emit({ type: "bg_servers", servers: this.bgServerList() });
3902
- }
3903
- /** Re-snapshot listening ports and drop tracked entries that are no longer
3904
- * listening — the process exited on its own, so it must leave the panel.
3905
- * Port AND pid must both match: a port reused by an unrelated process is
3906
- * not our server anymore. Silent (the list just updates). */
3907
- async refreshBgServers() {
3908
- if (this.disposed || this.bgServers.size === 0)
3909
- return;
3910
- const now = await snapshotListeningPorts();
3911
- let changed = false;
3912
- for (const [port, v] of [...this.bgServers]) {
3913
- if (now.get(port) !== v.pid) {
3914
- this.bgServers.delete(port);
3915
- changed = true;
3916
- }
3917
- }
3918
- if (changed)
3919
- this.emitBgServers();
3920
- }
3921
1517
  /** Re-push the current list on request (panel opened); prunes dead entries first. */
3922
1518
  async listBgServers() {
3923
- await this.refreshBgServers();
3924
- this.emitBgServers();
1519
+ await this.bg.listAndPush();
3925
1520
  }
3926
1521
  /** Kill ONE background server (by port); returns whether anything was killed. */
3927
1522
  async killBackgroundServer(port) {
3928
- const entry = this.bgServers.get(port);
3929
- if (!entry) {
3930
- this.emit({
3931
- type: "notice",
3932
- level: "info",
3933
- text: `端口 ${port} 不在后台任务列表中`,
3934
- });
3935
- this.flushSnapshot();
3936
- return false;
3937
- }
3938
- killPidTree(entry.pid);
3939
- this.bgServers.delete(port);
3940
- this.emitBgServers();
3941
- this.emit({
3942
- type: "notice",
3943
- level: "info",
3944
- text: `已停止后台任务:端口 ${port}(pid ${entry.pid})`,
3945
- });
3946
- this.flushSnapshot();
3947
- return true;
1523
+ return this.bg.killOne(port);
3948
1524
  }
3949
1525
  /** Kill every background server the agent started; returns the freed ports. */
3950
1526
  async killAllBackgroundServers() {
3951
- if (this.bgServers.size === 0)
3952
- return [];
3953
- const killed = [];
3954
- for (const [port, { pid }] of [...this.bgServers]) {
3955
- killPidTree(pid);
3956
- killed.push(String(port));
3957
- }
3958
- this.bgServers.clear();
3959
- this.emitBgServers();
3960
- return killed;
1527
+ return this.bg.killAll();
3961
1528
  }
3962
1529
  /** Kill only the running bash command(s) — the agent run itself continues
3963
1530
  * (the bash tool returns an aborted error and the model moves on). Uses
@@ -4131,7 +1698,7 @@ ${transcript}
4131
1698
  this.removeConversation(displaced.id);
4132
1699
  await this.bindSession();
4133
1700
  this.emitConversations();
4134
- this.emitGoalStatus();
1701
+ this.goalSvc.emitGoalStatus();
4135
1702
  this.pushTerminals();
4136
1703
  // The new runtime re-discovered skills/templates — refresh the catalog
4137
1704
  // so the picker stops showing the previous runtime's list.
@@ -4209,7 +1776,7 @@ ${transcript}
4209
1776
  this.conv.lastActiveAt = Date.now();
4210
1777
  this.webUi.refresh();
4211
1778
  this.emitConversations();
4212
- this.emitGoalStatus();
1779
+ this.goalSvc.emitGoalStatus();
4213
1780
  this.pushTerminals();
4214
1781
  // The switched-to conversation has its own runtime (own resource cache).
4215
1782
  void this.pushSlashCommands();
@@ -4247,11 +1814,20 @@ ${transcript}
4247
1814
  });
4248
1815
  }
4249
1816
  /** List persisted sessions for this client, newest first. */
1817
+ /** The client asked for the session list at least once (lazy loading) —
1818
+ * background refreshes only re-push when this is true, so a mobile
1819
+ * client that never opened the panel never pays the disk scan. */
1820
+ sessionsRequested = false;
4250
1821
  /** Push the persisted session list to the client (client-requested). */
4251
1822
  async refreshSessions() {
1823
+ this.sessionsRequested = true;
4252
1824
  await this.pushSessions();
4253
1825
  }
4254
1826
  async pushSessions() {
1827
+ if (!this.sessionsRequested)
1828
+ return;
1829
+ if (!this.sessionsRequested)
1830
+ return;
4255
1831
  try {
4256
1832
  // Sessions live in the SDK default per-project dir
4257
1833
  // (<agentDir>/sessions/--<cwd>--/), the same files the pi CLI/TUI
@@ -4268,7 +1844,9 @@ ${transcript}
4268
1844
  source: "web",
4269
1845
  });
4270
1846
  }
4271
- const sorted = [...sessions.values()].sort((a, b) => b.modified - a.modified);
1847
+ const sorted = [...sessions.values()]
1848
+ .sort((a, b) => b.modified - a.modified)
1849
+ .slice(0, 200); // newest first — the panel shows recent history
4272
1850
  this.emit({ type: "sessions", sessions: sorted });
4273
1851
  }
4274
1852
  catch {
@@ -4354,310 +1932,87 @@ ${transcript}
4354
1932
  this.emit({
4355
1933
  type: "notice",
4356
1934
  level: "error",
4357
- text: "找不到要编辑的消息(可能已被压缩或不在当前分支)",
4358
- });
4359
- this.flushSnapshot();
4360
- return;
4361
- }
4362
- try {
4363
- const result = await this.runtime.fork(entryId);
4364
- if (result.cancelled) {
4365
- this.emit({
4366
- type: "notice",
4367
- level: "info",
4368
- text: "已取消编辑重问",
4369
- });
4370
- this.flushSnapshot();
4371
- return;
4372
- }
4373
- await this.bindSession();
4374
- await this.prompt(trimmed);
4375
- this.emit({
4376
- type: "notice",
4377
- level: "info",
4378
- text: "已从该问题重新提问(原对话保留在会话列表中)",
4379
- });
4380
- }
4381
- catch (err) {
4382
- this.emit({
4383
- type: "notice",
4384
- level: "error",
4385
- text: `编辑重问失败:${err.message}`,
4386
- });
4387
- }
4388
- this.flushSnapshot();
4389
- }
4390
- /**
4391
- * Push the recent-project list (persisted per client, merged with every cwd
4392
- * that has persisted sessions in this client's session store — so workspaces
4393
- * opened before the recent-list feature existed still show up).
4394
- */
4395
- async pushProjects() {
4396
- try {
4397
- const saved = this.stateStore.get(this.clientId);
4398
- const map = new Map();
4399
- for (const p of saved.projects)
4400
- map.set(p.path, p.lastUsed);
4401
- const all = await SessionManager.listAll();
4402
- for (const s of all) {
4403
- if (s.cwd) {
4404
- const t = s.modified.getTime();
4405
- const prev = map.get(s.cwd);
4406
- if (prev === undefined || t > prev)
4407
- map.set(s.cwd, t);
4408
- }
4409
- }
4410
- // Only keep directories that still exist — a deleted/unmounted workspace
4411
- // is useless in the picker.
4412
- const projects = [...map.entries()]
4413
- .filter(([path]) => existsSync(path))
4414
- .map(([path, lastUsed]) => ({ path, lastUsed }))
4415
- .sort((a, b) => b.lastUsed - a.lastUsed)
4416
- .slice(0, 20);
4417
- this.emit({ type: "projects", projects });
4418
- }
4419
- catch {
4420
- this.emit({ type: "projects", projects: [] });
4421
- }
4422
- }
4423
- /** List a workspace directory (relative to the configured cwd). */
4424
- async listFiles(relPath) {
4425
- try {
4426
- const { resolve, sep, relative } = await import("node:path");
4427
- const root = resolve(this.cwd);
4428
- const target = relPath ? resolve(root, relPath) : root;
4429
- const rawRel = relative(root, target);
4430
- if (rawRel.startsWith("..") || rawRel.includes(`${sep}..`)) {
4431
- this.emit({
4432
- type: "notice",
4433
- level: "warning",
4434
- text: `路径超出工作区:${relPath ?? ""}`,
4435
- });
4436
- return;
4437
- }
4438
- // Normalize to forward slashes: the wire protocol and the frontend
4439
- // always use "/", but relative() returns "\\" on Windows.
4440
- const rel = rawRel.split(sep).join("/");
4441
- const { entries, truncated, error } = await readDirForUI(target, rel);
4442
- // Watch the listed directory (only after a successful read — a missing
4443
- // dir throws above and must not create a watcher on a phantom path).
4444
- this.watchDir(target, rel);
4445
- if (error) {
4446
- // Windows-only: unreadable system dirs degrade to an empty list
4447
- // with a warning instead of a hard error — the panel stays usable.
4448
- this.emit({
4449
- type: "notice",
4450
- level: "warning",
4451
- text: `目录不可读:${error}`,
4452
- });
4453
- }
4454
- this.emit({
4455
- type: "files",
4456
- path: rel === "" ? "" : rel,
4457
- parent: rel === ""
4458
- ? null
4459
- : rel.includes("/")
4460
- ? rel.slice(0, rel.lastIndexOf("/"))
4461
- : "",
4462
- entries,
4463
- truncated,
4464
- });
4465
- }
4466
- catch (err) {
4467
- this.emit({
4468
- type: "notice",
4469
- level: "error",
4470
- text: `读取目录失败:${err.message}`,
4471
- });
4472
- }
4473
- }
4474
- /** Watch a directory for changes so the file panel refreshes instantly
4475
- * instead of waiting for the 10s poll. Watches the directory exactly as
4476
- * listed (one level); navigating re-watches the new target. fs.watch is
4477
- * unavailable on some platforms/filesystems — failures silently fall back
4478
- * to the poll. */
4479
- watchDir(absPath, rel) {
4480
- if (this.disposed || this.watchPath === rel)
4481
- return;
4482
- this.unwatchDir();
4483
- this.watchPath = rel;
4484
- try {
4485
- // persistent: false — the watcher must not keep the process alive.
4486
- this.fsWatcher = watch(absPath, { persistent: false }, () => {
4487
- // Burst events (npm install, git ops, editor save→rename) are
4488
- // debounced into a single refresh.
4489
- if (this.watchTimer)
4490
- return;
4491
- this.watchTimer = setTimeout(() => {
4492
- this.watchTimer = null;
4493
- this.emit({ type: "file_changed", path: this.watchPath ?? "" });
4494
- }, 400);
4495
- });
4496
- this.fsWatcher.on("error", () => {
4497
- // Directory deleted / unsupported fs — stop watching; the poll (or
4498
- // the next navigation) restores things.
4499
- this.unwatchDir();
4500
- });
4501
- }
4502
- catch {
4503
- // fs.watch unsupported (some network mounts, containers) — poll covers it.
4504
- this.fsWatcher = null;
4505
- this.watchPath = null;
4506
- }
4507
- }
4508
- unwatchDir() {
4509
- if (this.watchTimer) {
4510
- clearTimeout(this.watchTimer);
4511
- this.watchTimer = null;
4512
- }
4513
- if (this.fsWatcher) {
4514
- try {
4515
- this.fsWatcher.close();
4516
- }
4517
- catch {
4518
- // already closed
4519
- }
4520
- this.fsWatcher = null;
4521
- }
4522
- this.watchPath = null;
4523
- }
4524
- /** Read a workspace file for the preview panel (size-capped, binary-safe). */
4525
- async readFile(relPath) {
4526
- try {
4527
- const fs = await import("node:fs/promises");
4528
- const root = resolve(this.cwd);
4529
- const wp = workspacePath(root, relPath);
4530
- if (!wp) {
4531
- this.emit({
4532
- type: "notice",
4533
- level: "warning",
4534
- text: `路径超出工作区:${relPath}`,
4535
- });
4536
- return;
4537
- }
4538
- const { abs, rel } = wp;
4539
- const stat = await fs.stat(abs);
4540
- if (!stat.isFile()) {
4541
- this.emit({
4542
- type: "notice",
4543
- level: "warning",
4544
- text: `不是文件:${relPath}`,
4545
- });
4546
- return;
4547
- }
4548
- const name = relPath.split(/[\\/]/).pop() ?? relPath;
4549
- const kind = previewKind(name);
4550
- // Media previews stream over the /api/file HTTP endpoint, so only
4551
- // metadata is sent here — the raw bytes never touch the socket.
4552
- if (kind === "image" || kind === "video") {
4553
- this.emit({
4554
- type: "file_content",
4555
- path: rel,
4556
- name,
4557
- text: "",
4558
- truncated: false,
4559
- binary: true,
4560
- kind,
4561
- lines: 0,
4562
- size: stat.size,
4563
- });
4564
- return;
4565
- }
4566
- // Everything else: read a capped prefix and sniff the content.
4567
- // Anything that looks like text previews as text regardless of its
4568
- // extension (jsonl, .log.1, weird suffixes, …); binary content gets
4569
- // a hex dump of the first few KB instead of being refused.
4570
- const handle = await fs.open(abs, "r");
4571
- try {
4572
- const buf = Buffer.alloc(Math.min(stat.size, MAX_PREVIEW_BYTES));
4573
- const { bytesRead } = await handle.read(buf, 0, buf.length, 0);
4574
- const data = buf.subarray(0, bytesRead);
4575
- if (looksLikeText(data)) {
4576
- this.emit({
4577
- type: "file_content",
4578
- path: rel,
4579
- name,
4580
- text: decodeText(data),
4581
- truncated: bytesRead < stat.size,
4582
- binary: false,
4583
- kind: "text",
4584
- lines: countLines(data),
4585
- size: stat.size,
4586
- });
4587
- }
4588
- else {
4589
- this.emit({
4590
- type: "file_content",
4591
- path: rel,
4592
- name,
4593
- text: hexDump(data),
4594
- truncated: bytesRead < stat.size,
4595
- binary: true,
4596
- kind: kind === "text" ? "text" : "none",
4597
- lines: 0,
4598
- size: stat.size,
4599
- });
4600
- }
4601
- }
4602
- finally {
4603
- await handle.close();
4604
- }
4605
- }
4606
- catch (err) {
4607
- this.emit({
4608
- type: "notice",
4609
- level: "error",
4610
- text: `读取文件失败:${err.message}`,
4611
- });
4612
- }
4613
- }
4614
- /** Save text from the file preview panel within the active workspace. */
4615
- async writeFile(relPath, text) {
4616
- try {
4617
- const root = resolve(this.cwd);
4618
- const wp = workspacePath(root, relPath);
4619
- if (!wp) {
4620
- this.emit({
4621
- type: "notice",
4622
- level: "warning",
4623
- text: `路径超出工作区:${relPath}`,
4624
- });
4625
- return;
4626
- }
4627
- if (Buffer.byteLength(text, "utf8") > 2 * 1024 * 1024) {
4628
- this.emit({
4629
- type: "notice",
4630
- level: "warning",
4631
- text: "文件内容过大,无法保存(上限 2MB)",
4632
- });
4633
- return;
4634
- }
4635
- const stat = statSync(wp.abs);
4636
- if (!stat.isFile()) {
1935
+ text: "找不到要编辑的消息(可能已被压缩或不在当前分支)",
1936
+ });
1937
+ this.flushSnapshot();
1938
+ return;
1939
+ }
1940
+ try {
1941
+ const result = await this.runtime.fork(entryId);
1942
+ if (result.cancelled) {
4637
1943
  this.emit({
4638
1944
  type: "notice",
4639
- level: "warning",
4640
- text: `不是文件:${relPath}`,
1945
+ level: "info",
1946
+ text: "已取消编辑重问",
4641
1947
  });
1948
+ this.flushSnapshot();
4642
1949
  return;
4643
1950
  }
4644
- writeFileSync(wp.abs, text, "utf8");
1951
+ await this.bindSession();
1952
+ await this.prompt(trimmed);
4645
1953
  this.emit({
4646
1954
  type: "notice",
4647
1955
  level: "info",
4648
- text: `已保存:${wp.rel}`,
1956
+ text: "已从该问题重新提问(原对话保留在会话列表中)",
4649
1957
  });
4650
- // Re-read through the same path as the preview request so the client
4651
- // gets the canonical content, line count and file size after saving.
4652
- await this.readFile(wp.rel);
4653
1958
  }
4654
1959
  catch (err) {
4655
1960
  this.emit({
4656
1961
  type: "notice",
4657
1962
  level: "error",
4658
- text: `保存文件失败:${err.message}`,
1963
+ text: `编辑重问失败:${err.message}`,
4659
1964
  });
4660
1965
  }
1966
+ this.flushSnapshot();
1967
+ }
1968
+ /**
1969
+ * Push the recent-project list (persisted per client, merged with every cwd
1970
+ * that has persisted sessions in this client's session store — so workspaces
1971
+ * opened before the recent-list feature existed still show up).
1972
+ */
1973
+ async pushProjects() {
1974
+ try {
1975
+ const saved = this.stateStore.get(this.clientId);
1976
+ const map = new Map();
1977
+ for (const p of saved.projects)
1978
+ map.set(p.path, p.lastUsed);
1979
+ const all = await SessionManager.listAll();
1980
+ for (const s of all) {
1981
+ if (s.cwd) {
1982
+ const t = s.modified.getTime();
1983
+ const prev = map.get(s.cwd);
1984
+ if (prev === undefined || t > prev)
1985
+ map.set(s.cwd, t);
1986
+ }
1987
+ }
1988
+ // Only keep directories that still exist — a deleted/unmounted workspace
1989
+ // is useless in the picker.
1990
+ const projects = [...map.entries()]
1991
+ .filter(([path]) => existsSync(path))
1992
+ .map(([path, lastUsed]) => ({ path, lastUsed }))
1993
+ .sort((a, b) => b.lastUsed - a.lastUsed)
1994
+ .slice(0, 20);
1995
+ this.emit({ type: "projects", projects });
1996
+ }
1997
+ catch {
1998
+ this.emit({ type: "projects", projects: [] });
1999
+ }
2000
+ }
2001
+ /** List a workspace directory (relative to the configured cwd). */
2002
+ async listFiles(relPath) {
2003
+ return this.files.listFiles(relPath);
2004
+ }
2005
+ /** SCM 只读查询(结构化 JSON,reqId 匹配)。 */
2006
+ async scmQuery(kind, reqId, arg) {
2007
+ return this.files.scmQuery(kind, reqId, arg);
2008
+ }
2009
+ /** Read a workspace file for the preview panel (size-capped, binary-safe). */
2010
+ async readFile(relPath) {
2011
+ return this.files.readFile(relPath);
2012
+ }
2013
+ /** Save text from the file preview panel within the active workspace. */
2014
+ async writeFile(relPath, text) {
2015
+ return this.files.writeFile(relPath, text);
4661
2016
  }
4662
2017
  async cycleModel() {
4663
2018
  try {
@@ -4677,78 +2032,12 @@ ${transcript}
4677
2032
  * directory, and return prefix matches (dirs first, capped).
4678
2033
  */
4679
2034
  async completePath(input) {
4680
- const empty = () => this.emit({ type: "path_completions", completions: [] });
4681
- try {
4682
- const fs = await import("node:fs/promises");
4683
- const { resolve, sep, isAbsolute } = await import("node:path");
4684
- const { homedir } = await import("node:os");
4685
- const home = homedir();
4686
- // Expand ~ and relative inputs to an absolute path. Windows users type
4687
- // backslashes (P:\agent) and ~\ — handle both separator styles.
4688
- let expanded = input.trim();
4689
- if (expanded === "") {
4690
- empty();
4691
- return;
4692
- }
4693
- if (expanded === "~" || expanded === "~\\") {
4694
- expanded = home;
4695
- }
4696
- else if (expanded.startsWith("~/") || expanded.startsWith("~\\")) {
4697
- expanded = home + sep + expanded.slice(2);
4698
- }
4699
- else if (!isAbsolute(expanded)) {
4700
- expanded = resolve(this.cwd, expanded);
4701
- }
4702
- // Split into parent dir + prefix on the LAST separator of either style
4703
- // (Windows accepts both / and \, so P:\agent/de must work too).
4704
- const lastSlash = Math.max(expanded.lastIndexOf("/"), expanded.lastIndexOf("\\"));
4705
- const dirPart = lastSlash >= 0 ? expanded.slice(0, lastSlash + 1) : "";
4706
- const prefix = lastSlash >= 0 ? expanded.slice(lastSlash + 1) : expanded;
4707
- const dirents = await fs
4708
- .readdir(dirPart, { withFileTypes: true })
4709
- .catch(() => null);
4710
- if (!dirents) {
4711
- empty();
4712
- return;
4713
- }
4714
- const completions = dirents
4715
- .filter((d) => d.name.startsWith(prefix) && !ignoredEntries().has(d.name))
4716
- .map((d) => ({
4717
- name: d.name,
4718
- // Windows users type backslashes — normalize the completion to the
4719
- // wire format ("/") so the picked path round-trips cleanly.
4720
- path: IS_WIN32
4721
- ? join(dirPart, d.name).split(sep).join("/")
4722
- : dirPart + d.name,
4723
- type: (d.isDirectory() ? "dir" : "file"),
4724
- }))
4725
- .sort((a, b) => {
4726
- const aHidden = a.name.startsWith(".");
4727
- const bHidden = b.name.startsWith(".");
4728
- if (aHidden !== bHidden)
4729
- return aHidden ? 1 : -1;
4730
- if (a.type !== b.type)
4731
- return a.type === "dir" ? -1 : 1;
4732
- return a.name.localeCompare(b.name);
4733
- })
4734
- .slice(0, 30);
4735
- this.emit({ type: "path_completions", completions });
4736
- }
4737
- catch {
4738
- empty();
4739
- }
2035
+ return this.files.completePath(input);
4740
2036
  }
4741
- /**
4742
- * Switch the agent's working directory by switching the ACTIVE conversation
4743
- * to the target project's own most recently active conversation (creating a
4744
- * fresh one that resumes that project's most recent session on first
4745
- * visit). Conversations of other projects keep running untouched in their
4746
- * own per-project lists — nothing is rebuilt, so titles/cwds never leak
4747
- * between projects.
4748
- */
4749
2037
  async setCwd(newCwd) {
4750
2038
  try {
4751
2039
  const { resolve } = await import("node:path");
2040
+ this.files.unwatchGit(); // stale repo's watcher must not fire across projects
4752
2041
  const fs = await import("node:fs/promises");
4753
2042
  const abs = resolve(newCwd);
4754
2043
  const st = await fs.stat(abs);
@@ -4813,7 +2102,7 @@ ${transcript}
4813
2102
  void this.pushProjects();
4814
2103
  this.webUi.refresh();
4815
2104
  this.emitConversations();
4816
- this.emitGoalStatus();
2105
+ this.goalSvc.emitGoalStatus();
4817
2106
  // Skills / prompt templates are project-bound — refresh the catalog.
4818
2107
  void this.pushSlashCommands();
4819
2108
  this.emit({
@@ -4860,526 +2149,18 @@ ${transcript}
4860
2149
  // ---------------------------------------------------------------------------
4861
2150
  // Goal / review
4862
2151
  // ---------------------------------------------------------------------------
4863
- /** Push the active conversation's goal status to the client (the goal bar
4864
- * UI). Conversations without an active goal reflect the client's remembered
4865
- * defaults; an existing goal keeps its own review settings. */
4866
- emitGoalStatus() {
4867
- const goal = this.goal;
4868
- if (!goal.goal && !goal.reviewing && !goal.wizard.active) {
4869
- goal.reviewModel = this.goalReviewPrefs.reviewModel;
4870
- goal.maxRounds = this.goalReviewPrefs.maxRounds;
4871
- goal.locked = this.goalReviewPrefs.locked;
4872
- }
4873
- this.emit({ type: "goal_status", status: { ...goal } });
4874
- }
4875
- /**
4876
- * Set (or clear) the active goal. `goal === ""` clears it. The goal is
4877
- * applied to the CURRENT active conversation of this project; reviews check
4878
- * whatever run finishes next (agent_end).
4879
- */
2152
+ /** Goal family delegates to GoalService (see goal-service.ts). */
4880
2153
  async setGoal(goalText, opts) {
4881
- const text = (goalText ?? "").trim();
4882
- if (!text) {
4883
- await this.clearGoal();
4884
- return;
4885
- }
4886
- // A goal is scoped to the conversation that is active when it is set.
4887
- // This prevents an agent_end from a newly-created/switched conversation
4888
- // from consuming the previous conversation's goal.
4889
- const goalConversationId = this.activeId;
4890
- this.conv.goalGeneration += 1;
4891
- this.goal.reviewing = false;
4892
- this.goal.conversationId = goalConversationId;
4893
- this.goal.goal = text;
4894
- // Model & rounds preference semantics ("全局记忆"):
4895
- // - reviewModel undefined → keep the remembered choice; empty → main model.
4896
- // - maxRounds 0 = unlimited (default); >0 = finite cap (clamped to 50).
4897
- if (opts?.reviewModel !== undefined)
4898
- this.goal.reviewModel = opts.reviewModel || null;
4899
- if (typeof opts?.maxRounds === "number") {
4900
- const mr = Math.round(opts.maxRounds);
4901
- this.goal.maxRounds = mr >= 1 ? Math.min(mr, 50) : 0;
4902
- }
4903
- if (opts?.locked !== undefined)
4904
- this.goal.locked = opts.locked;
4905
- this.goalReviewPrefs = {
4906
- reviewModel: this.goal.reviewModel,
4907
- maxRounds: this.goal.maxRounds,
4908
- locked: this.goal.locked,
4909
- };
4910
- // Persist the chosen preferences so they survive reload.
4911
- this.stateStore.saveGoalPrefs(this.clientId, {
4912
- reviewModel: this.goal.reviewModel,
4913
- maxRounds: this.goal.maxRounds,
4914
- locked: this.goal.locked,
4915
- });
4916
- // Reset the loop for a freshly-set goal (single-shot goals start at 0).
4917
- this.goal.round = 0;
4918
- this.goal.reviewing = false;
4919
- this.goal.verdict = "pending";
4920
- this.goal.feedback = undefined;
4921
- this.goal.wizard.active = false;
4922
- this.goal.wizard.status = "";
4923
- this.goal.status = "目标已设,等待生成…";
4924
- this.emitGoalStatus();
4925
- this.emit({
4926
- type: "notice",
4927
- level: "info",
4928
- text: `🎯 已设目标:${text.slice(0, 80)}${text.length > 80 ? "…" : ""}`,
4929
- });
4930
- // Auto-start generation right after setting the goal (unless this setGoal is
4931
- // the wizard's internal one, which kicks off itself). This makes the direct
4932
- // goal-bar path behave like the AI-提炼 path: set a target → agent begins.
4933
- if (opts?.autoStart !== false) {
4934
- try {
4935
- const s = this.conv.session;
4936
- await s.sendUserMessage(`【目标已设定】\n\n${text}\n\n请现在开始实现这个目标。`, { deliverAs: s.isStreaming ? "steer" : "followUp" });
4937
- }
4938
- catch {
4939
- // Best-effort; the user can still prompt manually.
4940
- }
4941
- this.flushSnapshot();
4942
- }
2154
+ return this.goalSvc.setGoal(goalText, opts);
4943
2155
  }
4944
- /**
4945
- * Collaborative target wizard. Turns a raw user requirement into a refined
4946
- * goal by spinning up an ISOLATED wizard session (own fresh ModelRuntime +
4947
- * in-memory session, so its model choice is its own) that questions the user
4948
- * via `goal_ask` (multiple-choice + free-text, bridged to the browser through
4949
- * the existing select/input dialog), converging on a goal, then auto-sets it.
4950
- * Mutually exclusive with the review loop of the same conversation.
4951
- */
4952
2156
  async startGoalWizard(text, opts) {
4953
- if (this.quiesceBlocked())
4954
- return;
4955
- const draft = (text ?? "").trim();
4956
- if (!draft)
4957
- return;
4958
- // The wizard and its progress cards belong to the conversation that
4959
- // launched it. If the user switches away, do not later set a goal on the
4960
- // new active conversation while the wizard is still finishing.
4961
- const wizardConversationId = this.activeId;
4962
- const wizardConversation = this.conv;
4963
- if (wizardConversation.wizardRunning || this.wizardOwnerId !== null) {
4964
- this.emit({
4965
- type: "notice",
4966
- level: "warning",
4967
- text: "已有目标调研进行中,请等它完成…",
4968
- });
4969
- return;
4970
- }
4971
- if (wizardConversation.goal.reviewing) {
4972
- this.emit({
4973
- type: "notice",
4974
- level: "warning",
4975
- text: "正在审查中,无法开始目标调研,请稍等…",
4976
- });
4977
- return;
4978
- }
4979
- // Questions are NOT capped (调研不限制) — the wizard converges on its own;
4980
- // the idle- and total-timeouts are the only guards. maxSteps is purely a
4981
- // soft UI indicator, not a hard stop.
4982
- const maxSteps = 20;
4983
- wizardConversation.wizardRunning = true;
4984
- this.wizardOwnerId = wizardConversationId;
4985
- this.wizardCancelled = false;
4986
- this.wizardAbort = new AbortController();
4987
- this.wizardSession = null;
4988
- wizardConversation.goal.wizard.active = true;
4989
- wizardConversation.goal.wizard.draft = draft;
4990
- wizardConversation.goal.wizard.model = opts?.wizardModel ?? null;
4991
- // Remember the model choice (and persist rounds/lock) — global memory.
4992
- if (opts?.wizardModel !== undefined && opts.wizardModel !== null)
4993
- wizardConversation.goal.reviewModel = opts.wizardModel || null;
4994
- if (typeof opts?.maxRounds === "number") {
4995
- const mr = Math.round(opts.maxRounds);
4996
- wizardConversation.goal.maxRounds = mr >= 1 ? Math.min(mr, 50) : 0;
4997
- }
4998
- if (opts?.locked !== undefined)
4999
- wizardConversation.goal.locked = opts.locked;
5000
- this.goalReviewPrefs = {
5001
- reviewModel: wizardConversation.goal.reviewModel,
5002
- maxRounds: wizardConversation.goal.maxRounds,
5003
- locked: wizardConversation.goal.locked,
5004
- };
5005
- this.stateStore.saveGoalPrefs(this.clientId, {
5006
- reviewModel: wizardConversation.goal.reviewModel,
5007
- maxRounds: wizardConversation.goal.maxRounds,
5008
- locked: wizardConversation.goal.locked,
5009
- });
5010
- wizardConversation.goal.wizard.step = 0;
5011
- wizardConversation.goal.wizard.maxSteps = maxSteps;
5012
- wizardConversation.goal.wizard.status = "调研中…";
5013
- wizardConversation.goal.status = "目标调研中…";
5014
- this.emitGoalStatus();
5015
- // Idle-timeout: cancel the wizard if no question is answered within the
5016
- // window (a stale dialog with no user response must not run forever). A
5017
- // fresh timer is armed for each question; cleared once the run ends.
5018
- const ac = this.wizardAbort;
5019
- let idleTimer = null;
5020
- const armIdle = () => {
5021
- if (idleTimer)
5022
- clearTimeout(idleTimer);
5023
- idleTimer = setTimeout(() => {
5024
- if (!ac.signal.aborted) {
5025
- this.wizardCancelled = true;
5026
- ac.abort(new Error("目标调研超时(等待回答过久)"));
5027
- }
5028
- }, ClientSession.WIZARD_IDLE_TIMEOUT_MS);
5029
- idleTimer.unref?.();
5030
- };
5031
- const clearIdle = () => {
5032
- if (idleTimer) {
5033
- clearTimeout(idleTimer);
5034
- idleTimer = null;
5035
- }
5036
- };
5037
- armIdle();
5038
- // Total-duration guard: hard cap on the whole wizard session (model
5039
- // latency / unexpected loops must not run forever).
5040
- const totalTimer = setTimeout(() => {
5041
- if (!ac.signal.aborted) {
5042
- this.wizardCancelled = true;
5043
- ac.abort(new Error("目标调研超过总时长上限"));
5044
- }
5045
- }, ClientSession.WIZARD_MAX_TOTAL_MS);
5046
- totalTimer.unref?.();
5047
- this.emit({
5048
- type: "notice",
5049
- level: "info",
5050
- text: `🔍 正在围绕需求展开调研:${draft.slice(0, 60)}${draft.length > 60 ? "…" : ""}`,
5051
- });
5052
- // The main conversation to show wizard progress cards in.
5053
- const mainSession = wizardConversation.session;
5054
- let refinedGoal = "";
5055
- try {
5056
- const wmSpec = opts?.wizardModel
5057
- ? this.resolveReviewModel(opts.wizardModel)
5058
- : null; // reuse the honest "provider/id" parser
5059
- const services = await createAgentSessionServices({
5060
- cwd: wizardConversation.cwd,
5061
- agentDir: this.agentDir,
5062
- modelRuntime: await ModelRuntime.create({
5063
- authPath: join(this.agentDir, "auth.json"),
5064
- modelsPath: join(this.agentDir, "models.json"),
5065
- }),
5066
- });
5067
- let model;
5068
- if (wmSpec)
5069
- model = services.modelRuntime.getModel(wmSpec.provider, wmSpec.id);
5070
- if (!model) {
5071
- const mainModel = mainSession.model;
5072
- if (mainModel?.provider && mainModel.id)
5073
- model = services.modelRuntime.getModel(mainModel.provider, mainModel.id);
5074
- }
5075
- // The wizard asks the user questions via this tool; each call bridges one
5076
- // select/input dialog to the browser and returns the user's answer.
5077
- let qStep = 0;
5078
- const goalAsk = defineTool({
5079
- name: "goal_ask",
5080
- label: "Ask the user",
5081
- description: "Ask the user ONE question at a time to scope down the goal. Provide a clear question and 2-4 concise options; or ask an open question. Returns the user's chosen answer.",
5082
- parameters: Type.Object({
5083
- question: Type.String({ description: "The question to ask" }),
5084
- options: Type.Optional(Type.Array(Type.String())),
5085
- }),
5086
- // ONE question at a time. Sequential execution prevents the agent from
5087
- // firing parallel goal_ask calls whose dialogs would overwrite each other
5088
- // in the single browser modal (leaving earlier ones deadlocked — the
5089
- // reported "调研卡住").
5090
- executionMode: "sequential",
5091
- execute: async (_id, params, _sig, _onUpdate, ctx) => {
5092
- qStep += 1;
5093
- if (qStep > maxSteps) {
5094
- return {
5095
- content: [
5096
- {
5097
- type: "text",
5098
- text: "(达到最大提问数,请直接给出收敛后的目标文本作为最终答案)",
5099
- },
5100
- ],
5101
- details: {},
5102
- };
5103
- }
5104
- // Show the question in the main flow BEFORE blocking on the dialog, so
5105
- // the user sees the wizard working even before answering.
5106
- wizardConversation.goal.wizard.step = qStep;
5107
- wizardConversation.goal.wizard.status = `调研中:请回答第 ${qStep} 题`;
5108
- this.emitGoalStatus();
5109
- try {
5110
- armIdle();
5111
- const isChoice = !!(params.options && params.options.length > 0);
5112
- await this.pushWizardCard(mainSession, `🔍 第 ${qStep} 题:${params.question}${isChoice ? `【${params.options.join(" / ")}】` : ""}`, { question: params.question });
5113
- // Resolve the pending dialog as cancelled if the wizard is aborted.
5114
- let aborted = false;
5115
- const onAbort = () => {
5116
- aborted = true;
5117
- };
5118
- ac.signal.addEventListener("abort", onAbort, { once: true });
5119
- const choose = isChoice
5120
- ? ctx.ui.select(`🔍 第 ${qStep} 题:${params.question}`, params.options)
5121
- : ctx.ui.input(`🔍 第 ${qStep} 题:${params.question}`);
5122
- const ans = (await choose);
5123
- ac.signal.removeEventListener("abort", onAbort);
5124
- if (aborted || ac.signal.aborted) {
5125
- return {
5126
- content: [
5127
- {
5128
- type: "text",
5129
- text: "(调研已取消,请不要继续提问,直接结束对话)",
5130
- },
5131
- ],
5132
- details: {},
5133
- };
5134
- }
5135
- if (ans === undefined || ans === null || ans === false || ans === "") {
5136
- return {
5137
- content: [
5138
- {
5139
- type: "text",
5140
- text: "(用户已取消调研,请直接给出你当前收敛的目标文本作为最终答案)",
5141
- },
5142
- ],
5143
- details: {},
5144
- };
5145
- }
5146
- // Record the answer in the flow too (instant append, main session idle).
5147
- await this.pushWizardCard(mainSession, `↳ 您的回答:${ans}`, { question: params.question, answer: String(ans) });
5148
- return {
5149
- content: [{ type: "text", text: `用户回答:${ans}` }],
5150
- details: {},
5151
- };
5152
- }
5153
- catch (err) {
5154
- return {
5155
- content: [
5156
- {
5157
- type: "text",
5158
- text: ac.signal.aborted
5159
- ? "(调研已取消,请不要继续提问,直接结束对话)"
5160
- : `提问失败:${err.message}`,
5161
- },
5162
- ],
5163
- details: {},
5164
- };
5165
- }
5166
- },
5167
- });
5168
- const srv = await createAgentSessionFromServices({
5169
- services,
5170
- sessionManager: SessionManager.inMemory(this.cwd),
5171
- customTools: [goalAsk],
5172
- ...(model ? { model } : {}),
5173
- });
5174
- const wizard = srv.session;
5175
- this.wizardSession = wizard;
5176
- await wizard.bindExtensions({ mode: "rpc", uiContext: this.webUi });
5177
- // Cancel watcher: when the user ✗s / idle-timeout fires, truly stop the
5178
- // wizard's agent run (not just mark it).
5179
- if (!ac.signal.aborted) {
5180
- ac.signal.addEventListener("abort", () => {
5181
- void wizard.abort().catch(() => { });
5182
- // Close the unanswered browser dialog(s) the wizard may have up.
5183
- this.webUi.cancelPendingDialogs();
5184
- }, { once: true });
5185
- }
5186
- await wizard.prompt(wizardPrompt(draft));
5187
- refinedGoal = wizard.getLastAssistantText()?.trim() ?? "";
5188
- // The wizard is prompted to emit "GOAL: <text>". Parse past the marker;
5189
- // if it didn't follow, strip a leading preamble line and keep the rest.
5190
- const goalMatch = refinedGoal.match(/GOAL\s*[::]\s*([\s\S]*)/i);
5191
- if (goalMatch) {
5192
- refinedGoal = goalMatch[1].trim();
5193
- }
5194
- else {
5195
- const lines = refinedGoal.split("\n").filter((l) => l.trim());
5196
- if (lines.length > 1 && !/[。.!??]\s*$/.test(lines[0])) {
5197
- // First line looks like preamble (no sentence-ending punctuation).
5198
- refinedGoal = lines.slice(1).join(" ").trim();
5199
- }
5200
- }
5201
- await srv.session.dispose();
5202
- }
5203
- catch (err) {
5204
- this.emit({
5205
- type: "notice",
5206
- level: "error",
5207
- text: `目标调研失败:${err.message}`,
5208
- });
5209
- }
5210
- finally {
5211
- clearIdle();
5212
- clearTimeout(totalTimer);
5213
- wizardConversation.wizardRunning = false;
5214
- if (this.wizardOwnerId === wizardConversationId)
5215
- this.wizardOwnerId = null;
5216
- wizardConversation.goal.wizard.active = false;
5217
- wizardConversation.goal.wizard.step = 0;
5218
- wizardConversation.goal.wizard.status = "";
5219
- this.wizardSession = null;
5220
- this.emitGoalStatus();
5221
- }
5222
- // Aborted externally (✗ / clear_goal / idle-timeout): do NOT set a goal.
5223
- if (ac.signal.aborted || this.wizardCancelled) {
5224
- this.emit({
5225
- type: "notice",
5226
- level: "info",
5227
- text: `目标调研已取消${ac.signal.reason ? `:${String(ac.signal.reason?.message ?? ac.signal.reason)}` : ""}`,
5228
- });
5229
- this.wizardAbort = null;
5230
- return;
5231
- }
5232
- if (!refinedGoal.trim()) {
5233
- this.emit({
5234
- type: "notice",
5235
- level: "warning",
5236
- text: "调研未产出有效目标,请重试",
5237
- });
5238
- return;
5239
- }
5240
- if (this.activeId !== wizardConversationId) {
5241
- this.emit({
5242
- type: "notice",
5243
- level: "info",
5244
- text: "已切换对话,目标调研结果已丢弃",
5245
- });
5246
- return;
5247
- }
5248
- // Auto-set the refined goal. The wizard workflow implies "set a goal and
5249
- // work until it passes", so default LOCKED=true unless the user explicitly
5250
- // turned the lock off (a lock lets the review loop keep revising to pass;
5251
- // without it the review is single-shot).
5252
- const wantLocked = opts?.locked === undefined ? true : opts.locked;
5253
- await this.setGoal(refinedGoal, {
5254
- reviewModel: wizardConversation.goal.reviewModel ?? undefined,
5255
- maxRounds: opts?.maxRounds,
5256
- locked: wantLocked,
5257
- // The wizard kicks off generation itself below — avoid a double kick.
5258
- autoStart: false,
5259
- });
5260
- const g2 = wizardConversation.goal;
5261
- this.wizardCancelled = false;
5262
- this.wizardAbort = null;
5263
- this.emit({
5264
- type: "notice",
5265
- level: "info",
5266
- text: `🎯 调研完成,目标已设为:${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""}`,
5267
- });
5268
- // Kick the main agent into generating right away (no manual "开始吧").
5269
- // The kick-off is a user message so it appears in the flow and triggers a
5270
- // normal turn; the finishing agent_end then runs the review loop.
5271
- try {
5272
- await mainSession.sendUserMessage(`【目标已设定】\n\n${g2.goal}\n\n请现在开始实现这个目标。`, { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
5273
- }
5274
- catch {
5275
- // Generation kick-off is best-effort; the user can still prompt manually.
5276
- }
2157
+ return this.goalSvc.startGoalWizard(text, opts);
5277
2158
  }
5278
- /** Persist goal/review preference defaults (model, rounds cap, locked) without
5279
- * touching the active goal — so changes in the goal bar are remembered across
5280
- * reloads. maxRounds 0 = unlimited. Emits goal_status so the UI stays synced. */
5281
2159
  async setGoalPrefs(opts) {
5282
- if (opts?.reviewModel !== undefined)
5283
- this.goal.reviewModel = opts.reviewModel || null;
5284
- if (typeof opts?.maxRounds === "number") {
5285
- const mr = Math.round(opts.maxRounds);
5286
- this.goal.maxRounds = mr >= 1 ? Math.min(mr, 50) : 0;
5287
- }
5288
- if (opts?.locked !== undefined)
5289
- this.goal.locked = opts.locked;
5290
- this.goalReviewPrefs = {
5291
- reviewModel: this.goal.reviewModel,
5292
- maxRounds: this.goal.maxRounds,
5293
- locked: this.goal.locked,
5294
- };
5295
- this.stateStore.saveGoalPrefs(this.clientId, {
5296
- reviewModel: this.goal.reviewModel,
5297
- maxRounds: this.goal.maxRounds,
5298
- locked: this.goal.locked,
5299
- });
5300
- this.emitGoalStatus();
2160
+ return this.goalSvc.setGoalPrefs(opts);
5301
2161
  }
5302
- /** Clear the active goal (cancels the review loop AND aborts a running
5303
- * goal wizard — truly terminating its in-flight dialog + agent run). */
5304
2162
  async clearGoal() {
5305
- this.conv.goalGeneration += 1;
5306
- this.goal.reviewing = false;
5307
- this.goal.conversationId = null;
5308
- this.goal.goal = null;
5309
- this.goal.reviewing = false;
5310
- this.goal.verdict = "pending";
5311
- this.goal.feedback = undefined;
5312
- this.goal.wizard.active = false;
5313
- this.goal.wizard.status = "";
5314
- this.goal.status = "";
5315
- this.emitGoalStatus();
5316
- // Abort a running wizard for real (✗ in the goal bar while scoping).
5317
- if (this.wizardOwnerId === this.activeId) {
5318
- this.wizardCancelled = true;
5319
- this.webUi.cancelPendingDialogs();
5320
- this.wizardAbort?.abort();
5321
- const ws2 = this.wizardSession;
5322
- this.wizardSession = null;
5323
- if (ws2) {
5324
- await ws2.abort().catch(() => { });
5325
- ws2.dispose();
5326
- }
5327
- this.wizardAbort = null;
5328
- }
5329
- }
5330
- /** Build a "provider/id" or null for the reviewer model, validating it exists. */
5331
- resolveReviewModel(spec) {
5332
- if (!spec)
5333
- return null;
5334
- const slash = spec.indexOf("/");
5335
- if (slash <= 0 || slash === spec.length - 1)
5336
- return null;
5337
- return { provider: spec.slice(0, slash), id: spec.slice(slash + 1), spec };
5338
- }
5339
- /**
5340
- * The whitelisted reviewer plan — tell the reviewer what to decide and how
5341
- * to report, regardless of which model it runs on.
5342
- */
5343
- reviewerPrompt(goal, round, maxRounds, output, gitDiff, customPrompt = "") {
5344
- return [
5345
- `You are a strict, independent goal-reviewer. Your ONLY job is to judge whether the agent's work fully satisfies the stated goal, by checking the agent's final output and, when present, its git diff.`, // eslint-disable-line max-len
5346
- ``,
5347
- `# Goal`, // eslint-disable-line no-regex-spaces
5348
- goal,
5349
- ``,
5350
- `# Agent's final output`, // eslint-disable-line no-regex-spaces
5351
- output.length > 0 ? output : "(the agent produced no text — inspect the diff)", // eslint-disable-line max-len
5352
- ``,
5353
- `# Git diff (if any)`, // eslint-disable-line no-regex-spaces
5354
- gitDiff.length > 0 ? gitDiff : "(no staged/committed changes detected)", // eslint-disable-line max-len
5355
- ``,
5356
- `This is review round ${round}${maxRounds > 0 ? ` of up to ${maxRounds}` : " (no round cap — keep revising until it passes)"}.`, // eslint-disable-line max-len
5357
- ...(customPrompt.trim()
5358
- ? [``, `# Additional reviewer instructions`, customPrompt.trim()]
5359
- : []),
5360
- ``,
5361
- `Decide: does the work satisfy the goal? If yes, respond with ONLY a JSON object with this exact shape (no markdown fences, no extra text):`, // eslint-disable-line max-len
5362
- `{"verdict":"pass","feedback":"<one short sentence: what was satisfied>"}`, // eslint-disable-line max-len
5363
- `If NO, respond with ONLY: {"verdict":"fail","feedback":"<concise, actionable list of what the agent must fix to satisfy the goal>"}`, // eslint-disable-line max-len
5364
- `The feedback for a fail must be specific enough that the agent can act on it directly.`, // eslint-disable-line max-len
5365
- ].join("\n");
5366
- }
5367
- /** Insert a wizard progress card into the MAIN conversation flow and render it
5368
- * IMMEDIATELY (the main session is idle while the wizard runs in its own
5369
- * session, so — unlike nextTurn, which queues until the next user prompt —
5370
- * sending without a delivery option appends + persists + emits at once). */
5371
- async pushWizardCard(sess, text, details) {
5372
- try {
5373
- await sess.sendCustomMessage({
5374
- customType: "goal-wizard",
5375
- content: [{ type: "text", text }],
5376
- display: true,
5377
- details: { type: "goal-wizard", ...details },
5378
- });
5379
- }
5380
- catch {
5381
- // Non-fatal
5382
- }
2163
+ return this.goalSvc.clearGoal();
5383
2164
  }
5384
2165
  /** Run a git diff (unstaged + staged) in a conversation's workspace, or
5385
2166
  * "" when not a repo. */
@@ -5394,228 +2175,6 @@ ${transcript}
5394
2175
  return "";
5395
2176
  }
5396
2177
  }
5397
- /**
5398
- * The review loop: build an ISOLATED reviewer session (own fresh
5399
- * AgentSession + own ModelRuntime so the reviewer truly runs on a different
5400
- * model without touching the main session), ask it to judge the goal, then:
5401
- * - pass → set status "已通过", insert a verdict card, end the loop;
5402
- * - fail → inject the feedback as a user message into the main session
5403
- * to steer a revision; the next agent_end re-reviews with the
5404
- * same round budget.
5405
- * Guarded per conversation so separate conversations can review concurrently.
5406
- */
5407
- isCurrentGoalReview(conv, goalGeneration, reviewGeneration) {
5408
- return (!this.disposed &&
5409
- this.convs.get(conv.id) === conv &&
5410
- conv.goal.conversationId === conv.id &&
5411
- conv.goalGeneration === goalGeneration &&
5412
- conv.goalReviewGeneration === reviewGeneration &&
5413
- !!conv.goal.goal);
5414
- }
5415
- /** Drop the result of a review that became stale while it was awaiting the
5416
- * reviewer model (most commonly because the user switched conversations). */
5417
- discardStaleGoalReview(conv, goalGeneration, reviewGeneration) {
5418
- if (conv.goalReviewGeneration !== reviewGeneration)
5419
- return;
5420
- if (conv.goalGeneration === goalGeneration &&
5421
- conv.goal.conversationId === conv.id) {
5422
- conv.goal.reviewing = false;
5423
- conv.goal.status = "审查已中止,目标已更新或取消";
5424
- this.emitGoalStatus();
5425
- }
5426
- }
5427
- async runGoalReview(conv) {
5428
- // The review is bound to the conversation that just ran. Capture both the
5429
- // owner and a generation so a later switch/set/clear cannot let an old,
5430
- // asynchronous reviewer mutate the new conversation's goal state.
5431
- const mainConv = this.convs.get(conv.id) ?? conv;
5432
- const mainSession = mainConv.session;
5433
- const g = conv.goal;
5434
- if (!g.goal ||
5435
- g.conversationId !== conv.id ||
5436
- g.reviewing ||
5437
- conv.wizardRunning ||
5438
- this.disposed)
5439
- return;
5440
- const goalGeneration = conv.goalGeneration;
5441
- const reviewGeneration = ++conv.goalReviewGeneration;
5442
- // Narrowed copy — TS control-flow can't narrow `g.goal` (a mutable shared
5443
- // object field) through the entire async body, so capture it here.
5444
- const goalText = g.goal;
5445
- // Capture review-only settings for this run. Changing settings while a
5446
- // review is in flight affects the next review, never this one.
5447
- const reviewPrompt = this.settings.reviewPrompt;
5448
- const reviewDisabledSkills = new Set(this.settings.reviewDisabledSkills);
5449
- // Cap rounds: single-shot (locked=false) always exactly one review.
5450
- // For locked goals, maxRounds 0 = unlimited (keep revising until pass).
5451
- const budget = g.locked ? (g.maxRounds > 0 ? g.maxRounds : Infinity) : 1;
5452
- if (g.locked && g.maxRounds > 0 && g.round >= budget) {
5453
- g.status = `已达最大轮数(${budget}),停止审查`;
5454
- g.reviewing = false;
5455
- this.emitGoalStatus();
5456
- return;
5457
- }
5458
- g.reviewing = true;
5459
- g.round += 1;
5460
- g.verdict = "pending";
5461
- g.feedback = undefined;
5462
- g.status = `审查中(第 ${g.round} 轮)…`;
5463
- this.emitGoalStatus();
5464
- // Collect the review inputs.
5465
- let finalText = "";
5466
- try {
5467
- finalText = mainSession.getLastAssistantText() ?? "";
5468
- }
5469
- catch {
5470
- finalText = "";
5471
- }
5472
- const diff = await this.gitDiff(mainConv.cwd);
5473
- if (!this.isCurrentGoalReview(conv, goalGeneration, reviewGeneration)) {
5474
- this.discardStaleGoalReview(conv, goalGeneration, reviewGeneration);
5475
- return;
5476
- }
5477
- let reviewerVerdict = "fail";
5478
- let reviewerFeedback = "(审查无法完成)";
5479
- try {
5480
- const rmSpec = this.resolveReviewModel(g.reviewModel);
5481
- const services = await createAgentSessionServices({
5482
- cwd: mainConv.cwd,
5483
- agentDir: this.agentDir,
5484
- // The reviewer has its own skill allow/deny list. It deliberately does
5485
- // not reuse the main session's disabledSkills setting.
5486
- resourceLoaderOptions: {
5487
- skillsOverride: (res) => ({
5488
- ...res,
5489
- skills: res.skills.filter((s) => !reviewDisabledSkills.has(s.name)),
5490
- }),
5491
- },
5492
- // A FRESH ModelRuntime for the reviewer — isolated from the shared
5493
- // one used by the main conversations, so its model choice is its own.
5494
- modelRuntime: await ModelRuntime.create({
5495
- authPath: join(this.agentDir, "auth.json"),
5496
- modelsPath: join(this.agentDir, "models.json"),
5497
- }),
5498
- });
5499
- // Model resolution: explicit reviewer model, else the main session's
5500
- // current model (so a goal works even when no reviewer model is given).
5501
- let model;
5502
- if (rmSpec) {
5503
- model = services.modelRuntime.getModel(rmSpec.provider, rmSpec.id);
5504
- }
5505
- if (!model) {
5506
- const mainModel = mainSession.model;
5507
- if (mainModel?.provider && mainModel.id) {
5508
- model = services.modelRuntime.getModel(mainModel.provider, mainModel.id);
5509
- }
5510
- }
5511
- const srv = await createAgentSessionFromServices({
5512
- services,
5513
- sessionManager: SessionManager.inMemory(mainConv.cwd),
5514
- ...(model ? { model } : {}),
5515
- });
5516
- const reviewCap = g.locked && g.maxRounds > 0 ? g.maxRounds : 0; // 0 = no cap
5517
- const reviewer = srv.session;
5518
- await reviewer.prompt(this.reviewerPrompt(goalText, g.round, reviewCap, finalText, diff, reviewPrompt));
5519
- // Parse the reviewer's final output (expected to be a JSON object).
5520
- const raw = reviewer.getLastAssistantText() ?? "";
5521
- const m = raw.match(/\{\s*"verdict"\s*:\s*"(pass|fail)"[^}]*\}/);
5522
- if (m) {
5523
- reviewerVerdict = m[1];
5524
- const fm = raw.match(/"feedback"\s*:\s*"([^"]*)"/);
5525
- reviewerFeedback = fm?.[1] ?? "";
5526
- }
5527
- else {
5528
- // No JSON — assume fail with the raw output as feedback.
5529
- reviewerVerdict = "fail";
5530
- reviewerFeedback = raw.slice(0, 2000);
5531
- }
5532
- await srv.session.dispose();
5533
- }
5534
- catch (err) {
5535
- reviewerVerdict = "fail";
5536
- reviewerFeedback = `审查过程中出错:${err.message}`;
5537
- }
5538
- // The user may have switched chats or replaced/cleared the goal while the
5539
- // isolated reviewer was running. Never apply a stale verdict or inject it
5540
- // into the old session after that point.
5541
- if (!this.isCurrentGoalReview(conv, goalGeneration, reviewGeneration)) {
5542
- this.discardStaleGoalReview(conv, goalGeneration, reviewGeneration);
5543
- return;
5544
- }
5545
- g.reviewing = false;
5546
- g.verdict = reviewerVerdict;
5547
- g.feedback = reviewerFeedback;
5548
- const round = g.round;
5549
- // Display cap: 0 means "unlimited" (keep revising until pass).
5550
- const budgetForCard = g.locked ? (Number.isFinite(budget) ? budget : 0) : 1;
5551
- const verdict = reviewerVerdict;
5552
- const feedback = reviewerFeedback;
5553
- /** Format "round/cap" for user-facing strings; cap 0 → 不限. */
5554
- const capFmt = (cap) => cap > 0 ? `第 ${round}/${cap} 轮` : `第 ${round} 轮(不限)`;
5555
- if (verdict === "pass") {
5556
- g.status = "✅ 已通过目标审查";
5557
- this.emit({ type: "notice", level: "info", text: "✅ 目标已通过审查" });
5558
- g.conversationId = null;
5559
- g.goal = null; // a passed goal is done and cleared
5560
- this.emitGoalStatus();
5561
- // Pass = the review result goes straight into the conversation as an
5562
- // ordinary user message (NO separate goal-review card). It both tells the
5563
- // USER the outcome and hands the main agent back out of "goal mode", so a
5564
- // follow-up instruction like "发布" is a normal request — not a confirm echo.
5565
- try {
5566
- await mainSession.sendUserMessage(`✅ 目标已达成并通过审查(第 ${round} 轮)。\n\n目标:${goalText}\n\n${feedback}\n\n(目标模式已解除,接下来按你的普通指令响应。)`, { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
5567
- }
5568
- catch {
5569
- // Best-effort.
5570
- }
5571
- this.flushSnapshot();
5572
- return;
5573
- }
5574
- // Failure: if rounds remain, steer a revision; else report the loop done.
5575
- // For unlimited (budget=0) isLastRound is always false → keeps revising.
5576
- const isLastRound = !g.locked ? true : g.maxRounds > 0 && g.round >= g.maxRounds;
5577
- if (!isLastRound) {
5578
- g.status = `本轮不通过,正在把意见交给 agent 修改(${capFmt(budgetForCard)})…`;
5579
- this.emit({
5580
- type: "notice",
5581
- level: "warning",
5582
- text: `目标审查第 ${g.round}/${budgetForCard > 0 ? budgetForCard : "不限"} 轮未通过,把意见交给 agent 修改…`,
5583
- });
5584
- // Inject the reviewer's feedback into the main session to revise (this IS
5585
- // the fail review result, as an ordinary user message — no separate card).
5586
- try {
5587
- const steerText = `【目标审查:第 ${g.round}/${budgetForCard > 0 ? budgetForCard : "不限"} 轮未通过】\n\n目标:${goalText}\n\n` +
5588
- `审查意见:${feedback}\n\n请根据以上意见修改你的成果,使其完全满足目标。`;
5589
- await mainSession.sendUserMessage(steerText, {
5590
- deliverAs: mainSession.isStreaming ? "steer" : "followUp",
5591
- });
5592
- }
5593
- catch (err) {
5594
- g.status = `意见注入失败:${err.message}`;
5595
- }
5596
- this.emitGoalStatus();
5597
- this.flushSnapshot();
5598
- return;
5599
- }
5600
- // Rounds exhausted (finite cap reached / single-shot failed). Deliver the
5601
- // fail result as an ordinary user message (no separate card), like the pass
5602
- // and revise paths — the review result always lands in the conversation.
5603
- g.status =
5604
- g.locked && g.maxRounds > 0
5605
- ? `已达最大轮数(${g.maxRounds}),目标仍未通过`
5606
- : `目标未通过(${capFmt(budgetForCard)})`;
5607
- try {
5608
- await mainSession.sendUserMessage(`❌ 目标未通过审查(第 ${round}/${budgetForCard > 0 ? budgetForCard : "不限"} 轮)。\n\n目标:${goalText}\n\n审查意见:${feedback}`, { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
5609
- }
5610
- catch {
5611
- // Best-effort.
5612
- }
5613
- this.emit({ type: "notice", level: "warning", text: "目标未通过审查(已达最大轮数)" });
5614
- g.conversationId = null;
5615
- g.goal = null; // loop exhausted — clear the active goal
5616
- this.emitGoalStatus();
5617
- this.flushSnapshot();
5618
- }
5619
2178
  /** Switch to a specific model by "provider/id" (e.g. "anthropic/claude-sonnet-5"). */
5620
2179
  async setModel(modelId) {
5621
2180
  try {
@@ -5701,12 +2260,10 @@ ${transcript}
5701
2260
  clearInterval(this.widgetsTimer);
5702
2261
  this.widgetsTimer = null;
5703
2262
  }
5704
- this.unwatchDir();
2263
+ this.files.unwatchDir();
2264
+ this.files.unwatchGit();
5705
2265
  this.webUi.dispose();
5706
- if (this.bgTimer) {
5707
- clearInterval(this.bgTimer);
5708
- this.bgTimer = null;
5709
- }
2266
+ this.bg.stop();
5710
2267
  for (const conv of this.convs.values()) {
5711
2268
  this.clearAllToolWatchdogs(conv);
5712
2269
  conv.unsubscribe?.();