pi-web-ui 0.94.1 → 0.96.0

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 (100) hide show
  1. package/CHANGELOG.md +91 -2
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +910 -237
  5. package/dist/server/agent-service.js +2655 -231
  6. package/dist/server/approval-rules.js +596 -0
  7. package/dist/server/attachment-store.js +129 -0
  8. package/dist/server/attachments.js +92 -174
  9. package/dist/server/bg-servers.js +45 -7
  10. package/dist/server/claim-files-tool.js +4 -8
  11. package/dist/server/claim-store.js +4 -2
  12. package/dist/server/client-state.js +67 -14
  13. package/dist/server/compact-context-tool.js +126 -0
  14. package/dist/server/composer-drafts.js +9 -0
  15. package/dist/server/context-budget.js +317 -0
  16. package/dist/server/control-socket.js +57 -28
  17. package/dist/server/conversation-read-tool.js +7 -17
  18. package/dist/server/dangling-tools.js +229 -0
  19. package/dist/server/delegate-task.js +25 -28
  20. package/dist/server/dsh/dsh-agent-service.js +69 -66
  21. package/dist/server/edit-soft-tool.js +84 -16
  22. package/dist/server/eval-tool.js +588 -0
  23. package/dist/server/file-archives.js +16 -5
  24. package/dist/server/files-service.js +65 -18
  25. package/dist/server/goal-service.js +371 -77
  26. package/dist/server/hashline-engine.js +703 -0
  27. package/dist/server/host-guard.js +94 -0
  28. package/dist/server/host-metrics.js +26 -2
  29. package/dist/server/i18n.js +3 -3
  30. package/dist/server/index.js +456 -80
  31. package/dist/server/lsp-tool.js +1371 -0
  32. package/dist/server/mcp-bridge.js +47 -3
  33. package/dist/server/model-admin.js +112 -32
  34. package/dist/server/office-parse.js +375 -0
  35. package/dist/server/patch-tool.js +85 -0
  36. package/dist/server/permission-preset.js +25 -0
  37. package/dist/server/plan-manager.js +114 -0
  38. package/dist/server/plugin-api-catalog.js +296 -0
  39. package/dist/server/plugin-catalog-sync.js +36 -19
  40. package/dist/server/plugin-catalog.js +11 -4
  41. package/dist/server/plugin-facilities.js +92 -21
  42. package/dist/server/plugin-install-spec.js +196 -0
  43. package/dist/server/plugin-installer.js +73 -0
  44. package/dist/server/plugin-llm.js +71 -65
  45. package/dist/server/plugin-manifest-validate.js +305 -0
  46. package/dist/server/plugin-project.js +117 -13
  47. package/dist/server/plugin-tool-guard.js +120 -0
  48. package/dist/server/plugin-updater.js +115 -12
  49. package/dist/server/plugins.js +950 -205
  50. package/dist/server/present-files-tool.js +9 -12
  51. package/dist/server/process-utils.js +16 -6
  52. package/dist/server/prompt-composer.js +9 -0
  53. package/dist/server/protocol-version.js +1 -1
  54. package/dist/server/read-tool.js +69 -30
  55. package/dist/server/resolve-global-sdk.js +30 -16
  56. package/dist/server/schedule-agent-tool.js +12 -15
  57. package/dist/server/scheduler-tasks.js +6 -0
  58. package/dist/server/sdk-origin.js +18 -2
  59. package/dist/server/serialize.js +111 -14
  60. package/dist/server/settings-service.js +112 -3
  61. package/dist/server/skill-tool.js +5 -7
  62. package/dist/server/subagent-templates.js +51 -0
  63. package/dist/server/subagents.js +83 -66
  64. package/dist/server/terminals.js +248 -81
  65. package/dist/server/tool-approval.js +84 -0
  66. package/dist/server/tool-manager.js +237 -12
  67. package/dist/server/tool-overrides.js +59 -0
  68. package/dist/server/update-check.js +28 -1
  69. package/dist/server/uploads.js +17 -2
  70. package/dist/server/wait-subscription-scan.js +18 -21
  71. package/dist/server/workspace-snapshot.js +113 -0
  72. package/dist/server/ws-client-id.js +29 -0
  73. package/dist/server/ws-pending-queue.js +60 -0
  74. package/extensions/webui.ts +60 -2
  75. package/package.json +5 -3
  76. package/plugin-sdk/README.md +25 -0
  77. package/plugin-sdk/index.d.ts +31 -38
  78. package/plugin-sdk/index.mjs +35 -19
  79. package/plugins/catalog.json +40 -0
  80. package/themes/aetheris.css +457 -0
  81. package/themes/ayu-light.css +6 -6
  82. package/themes/catppuccin-latte.css +6 -6
  83. package/themes/claude-code-dark.css +144 -0
  84. package/themes/codex.css +6 -6
  85. package/themes/everforest-light.css +6 -6
  86. package/themes/geist.css +6 -6
  87. package/themes/gruvbox-light.css +6 -6
  88. package/themes/kanagawa-lotus.css +6 -6
  89. package/themes/rose-pine-dawn.css +6 -6
  90. package/themes/solarized-light.css +6 -6
  91. package/themes/vs-code-dark.css +146 -0
  92. package/themes/zhupi-dark.css +658 -0
  93. package/themes/zhupi.css +711 -0
  94. package/web/dist/assets/{TerminalPanel-MVoxpJOA.js → TerminalPanel-DDChcYOh.js} +1 -1
  95. package/web/dist/assets/index-Do9RgJC3.js +366 -0
  96. package/web/dist/assets/index-DryOsILO.css +1 -0
  97. package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
  98. package/web/dist/index.html +3 -3
  99. package/web/dist/assets/index-B3S9MxnN.css +0 -1
  100. package/web/dist/assets/index-DVLrHI2E.js +0 -364
@@ -12,11 +12,111 @@
12
12
  * 结构化子集 GoalConversation 传入(真实 Conversation 满足该结构),会话创建/对话框
13
13
  * 取消/git diff 等宿主能力走回调,便于独立测试。UI 文案直接中文(服务端 notice 约定)。
14
14
  */
15
+ import { mkdirSync, rmSync } from "node:fs";
15
16
  import { join } from "node:path";
16
17
  import { Type } from "typebox";
17
18
  import { createAgentSessionFromServices, createAgentSessionServices, defineTool, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
18
- import { bilingual, pick } from "./i18n.js";
19
+ import { pick } from "./i18n.js";
19
20
  import { parseModelSpec } from "./attachments.js";
21
+ /**
22
+ * 自主模式完成标记(与注入对话的【目标…】约定严格一致):
23
+ * - 【目标已达成】/【目标完成】/【目标达成】——必须带全角括号;
24
+ * - GOAL 后必须跟至少一个分隔符(冒号/下划线/空白)且 COMPLETED/PASSED 为整词。
25
+ * 刻意不收裸子串(如「目标已达成」不带括号):模型在计划、复述目标或假设句里
26
+ * 也会写出这些字样("如果测试全绿则目标已达成"),裸匹配会把中间轮误判成 pass。
27
+ */
28
+ const GOAL_COMPLETION_RE = /【目标(?:已)?(?:达成|完成)】|(?<![A-Za-z])GOAL(?:[::_]|\s+)+(?:IS\s+)?(?:COMPLETED|PASSED)(?![A-Za-z])/i;
29
+ /** 自主轮次完成信号判定(纯函数,供 runGoalReview 与单测共用)。 */
30
+ export function isGoalCompletionSignal(finalText) {
31
+ return GOAL_COMPLETION_RE.test(finalText);
32
+ }
33
+ /** 提取 raw 中第一个括号平衡的 {...} 子串(字符串字面量内的引号/转义/花括号不参与配对)。 */
34
+ function firstBalancedJsonObject(raw) {
35
+ const start = raw.indexOf("{");
36
+ if (start < 0)
37
+ return undefined;
38
+ let depth = 0;
39
+ let inString = false;
40
+ let escaped = false;
41
+ for (let i = start; i < raw.length; i++) {
42
+ const ch = raw[i];
43
+ if (inString) {
44
+ if (escaped)
45
+ escaped = false;
46
+ else if (ch === "\\")
47
+ escaped = true;
48
+ else if (ch === '"')
49
+ inString = false;
50
+ continue;
51
+ }
52
+ if (ch === '"')
53
+ inString = true;
54
+ else if (ch === "{")
55
+ depth++;
56
+ else if (ch === "}") {
57
+ depth--;
58
+ if (depth === 0)
59
+ return raw.slice(start, i + 1);
60
+ }
61
+ }
62
+ return undefined;
63
+ }
64
+ /**
65
+ * 解析审查模型的 verdict 输出(纯函数)。优先取第一个平衡 {...} 做 JSON.parse:
66
+ * 模型常包 markdown 围栏或前后闲话,feedback 里也可能有 \" 转义与嵌套引号,
67
+ * 这些由 JSON 语义天然处理;整体解析失败(单引号/尾逗号等)再退回旧的宽松
68
+ * 正则逐字段抠。两者都失败返回 undefined,调用方按「无 JSON」处理。
69
+ */
70
+ export function parseReviewerVerdict(raw) {
71
+ const json = firstBalancedJsonObject(raw);
72
+ if (json !== undefined) {
73
+ try {
74
+ const value = JSON.parse(json);
75
+ if (value && typeof value === "object" && !Array.isArray(value)) {
76
+ if (value.verdict === "pass" || value.verdict === "fail") {
77
+ return { verdict: value.verdict, feedback: typeof value.feedback === "string" ? value.feedback : "" };
78
+ }
79
+ }
80
+ }
81
+ catch {
82
+ // 不是合法 JSON(围栏残留/单引号/尾逗号)→ 落到正则兜底
83
+ }
84
+ }
85
+ const m = raw.match(/\{\s*"verdict"\s*:\s*"(pass|fail)"[^}]*\}/);
86
+ if (m) {
87
+ const fm = raw.match(/"feedback"\s*:\s*"([^"]*)"/);
88
+ return { verdict: m[1], feedback: fm?.[1] ?? "" };
89
+ }
90
+ return undefined;
91
+ }
92
+ /** diff 正文进审查 prompt 的截断上限(完整规模信息走 [diff-meta] 尾段)。 */
93
+ export const GIT_DIFF_CAP = 60_000;
94
+ /**
95
+ * 由 git 原始输出构造「变更指纹」(纯函数,供 AgentService.gitDiff 与单测共用)。
96
+ * - diff 正文非空 → 截断正文 + [diff-meta] 尾段(完整字符数 + 排序后的 status
97
+ * 指纹)。尾段永不参与截断:大 diff 两轮的前 60_000 字符可能完全相同(改动
98
+ * 落在截断线之后),只比截断正文会把持续推进误判成停滞;对内容变化敏感的
99
+ * 完整字符数让 prevDiff 等值比较能区分「真没变」与「变了但被截断」。
100
+ * - diff 正文为空 → 排序后的 `git status --porcelain` 指纹(未跟踪文件不进
101
+ * diff,却是新工作区最常见的实际进展);两段都空(返回 "")才算真停滞。
102
+ * - status 输出为空/拍不到 → 对应段省略,退化为旧版纯 diff 行为。
103
+ */
104
+ export function buildDiffFingerprint(diffOut, statusOut) {
105
+ let status = "";
106
+ if (statusOut.trim() !== "") {
107
+ // porcelain 不承诺输出有序,显式排序保证指纹逐轮稳定可比。
108
+ status = statusOut
109
+ .split("\n")
110
+ .filter((line) => line.trim() !== "")
111
+ .sort()
112
+ .join("\n")
113
+ .slice(0, 20_000);
114
+ }
115
+ if (diffOut.trim() === "")
116
+ return status;
117
+ const meta = `\n[diff-meta] chars=${diffOut.length}${status ? `\n[git-status]\n${status}` : ""}`;
118
+ return diffOut.slice(0, GIT_DIFF_CAP) + meta;
119
+ }
20
120
  /** System prompt for the goal-wizard session. The wizard asks the user a few
21
121
  * questions (via its goal_ask tool) to scope a raw requirement into a precise,
22
122
  * reviewable goal, then emits ONLY the final goal text as its last message. */
@@ -27,8 +127,11 @@ function wizardPrompt(draft) {
27
127
  `# User's raw requirement`, // eslint-disable-line no-regex-spaces
28
128
  draft,
29
129
  ``,
30
- `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
31
- `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
130
+ `Use your goal_ask tool to ask the user focused questions to pin down the essential, ambiguous details.`,
131
+ `Convergence guidelines:`,
132
+ `- Ask ONE question at a time, strictly 1 to 3 questions total: 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
133
+ `- Prefer multiple-choice with 2-4 mutually exclusive options and place your recommended choice FIRST.`,
134
+ `- In each option, concisely explain the impact or tradeoff. Use open questions only for things that genuinely need free text.`, // eslint-disable-line max-len
32
135
  `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
33
136
  `GOAL: <one concrete, verifiable sentence describing the deliverable and its acceptance criteria>`, // eslint-disable-line max-len
34
137
  `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
@@ -122,6 +225,12 @@ export class GoalService {
122
225
  * Set (or clear) the active goal. `goal === ""` clears it. The goal is
123
226
  * applied to the CURRENT active conversation of this project; reviews check
124
227
  * whatever run finishes next (agent_end).
228
+ *
229
+ * `opts.targetConvId` retargets the write to a specific conversation — used by
230
+ * the goal wizard, which runs in the background while the user may have
231
+ * switched away: the refined goal must land in the conversation that LAUNCHED
232
+ * the survey (issue #292), not in whatever conversation happens to be active
233
+ * and not be thrown away.
125
234
  */
126
235
  async setGoal(goalText, opts) {
127
236
  const text = (goalText ?? "").trim();
@@ -138,11 +247,23 @@ export class GoalService {
138
247
  });
139
248
  return;
140
249
  }
141
- // A goal is scoped to the conversation that is active when it is set.
142
- // This prevents an agent_end from a newly-created/switched conversation
250
+ // A goal is scoped to the conversation it is set on (default: the active
251
+ // one). This prevents an agent_end from a newly-created/switched conversation
143
252
  // from consuming the previous conversation's goal.
144
- const conv = this.host.activeConv();
145
- const goalConversationId = this.host.activeConvId();
253
+ const targetConv = opts?.targetConvId ? this.host.getConv(opts.targetConvId) : undefined;
254
+ if (opts?.targetConvId && !targetConv) {
255
+ // The targeted conversation is gone (closed / disposed) — refuse loudly
256
+ // instead of silently landing the goal somewhere else.
257
+ this.host.emit({
258
+ type: "notice",
259
+ level: "warning",
260
+ text: `目标未设置:发起目标调研的对话已关闭。`,
261
+ textEn: `Goal not set: the conversation that started the survey is gone.`,
262
+ });
263
+ return;
264
+ }
265
+ const conv = targetConv ?? this.host.activeConv();
266
+ const goalConversationId = conv.id;
146
267
  conv.goalGeneration += 1;
147
268
  const goal = conv.goal;
148
269
  goal.reviewing = false;
@@ -171,6 +292,10 @@ export class GoalService {
171
292
  locked: goal.locked,
172
293
  });
173
294
  // Reset the loop for a freshly-set goal (single-shot goals start at 0).
295
+ conv.stagnantRounds = 0;
296
+ conv.lastDiff = undefined;
297
+ conv.lastErrorSnippet = undefined;
298
+ conv.sameErrorRounds = 0;
174
299
  goal.round = 0;
175
300
  goal.reviewing = false;
176
301
  goal.verdict = "pending";
@@ -232,6 +357,9 @@ export class GoalService {
232
357
  // new active conversation while the wizard is still finishing.
233
358
  const wizardConversationId = this.host.activeConvId();
234
359
  const wizardConversation = this.host.activeConv();
360
+ // Human-readable name for notices that must say WHICH conversation the survey
361
+ // belongs to (issue #292: the user is expected to switch away mid-survey).
362
+ const wizardConversationTitle = wizardConversation.title;
235
363
  if (wizardConversation.wizardRunning || this.wizardOwnerId !== null) {
236
364
  this.host.emit({
237
365
  type: "notice",
@@ -329,7 +457,16 @@ export class GoalService {
329
457
  });
330
458
  // The main conversation to show wizard progress cards in.
331
459
  const mainSession = wizardConversation.session;
460
+ // The raw draft gets its own read-only card BEFORE the first question, so the
461
+ // flow starts from a visible anchor. If the survey is interrupted (idle/total
462
+ // timeout, ✗, or the user switching away and never coming back), the original
463
+ // requirement is still readable and copyable in THIS conversation instead of
464
+ // having to be retyped (issue #292).
465
+ await this.pushWizardCard(mainSession, pick(this.lang(), `🎯 原始目标草案:${draft}`, `🎯 Initial goal draft: ${draft}`, "goal.wizard.draft.card", {
466
+ draft,
467
+ }), { draft });
332
468
  let refinedGoal = "";
469
+ let goalEphemeralDir;
333
470
  try {
334
471
  const wmSpec = opts?.wizardModel ? this.resolveReviewModel(opts.wizardModel) : null; // reuse the honest "provider/id" parser
335
472
  const services = await createAgentSessionServices({
@@ -354,10 +491,14 @@ export class GoalService {
354
491
  const goalAsk = defineTool({
355
492
  name: "goal_ask",
356
493
  label: "Ask the user",
357
- description: bilingual("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.", "一次只向用户提一个问题,以明确目标范围。给出清晰的问题和 2-4 个简洁选项;或提开放式问题。返回用户选择的答案。"),
494
+ description: "Ask the user ONE focused question at a time to scope down the goal. " +
495
+ "Provide 2-4 mutually exclusive options with the recommended option first, " +
496
+ "briefly noting its impact or tradeoff; or ask an open question. Returns the user's chosen answer.",
358
497
  parameters: Type.Object({
359
- question: Type.String({ description: bilingual("The question to ask", "要问的问题") }),
360
- options: Type.Optional(Type.Array(Type.String())),
498
+ question: Type.String({ description: "The question to ask" }),
499
+ options: Type.Optional(Type.Array(Type.String(), {
500
+ description: "2-4 mutually exclusive options (recommended option first)",
501
+ })),
361
502
  }),
362
503
  // ONE question at a time. Sequential execution prevents the agent from
363
504
  // firing parallel goal_ask calls whose dialogs would overwrite each other
@@ -463,9 +604,16 @@ export class GoalService {
463
604
  }
464
605
  },
465
606
  });
607
+ const sm = SessionManager.inMemory(this.host.cwd());
608
+ goalEphemeralDir = join(services.agentDir, "goal-sessions", `wizard-${Date.now()}`);
609
+ try {
610
+ mkdirSync(goalEphemeralDir, { recursive: true });
611
+ sm.sessionDir = goalEphemeralDir;
612
+ }
613
+ catch { }
466
614
  const srv = await createAgentSessionFromServices({
467
615
  services,
468
- sessionManager: SessionManager.inMemory(this.host.cwd()),
616
+ sessionManager: sm,
469
617
  customTools: [goalAsk],
470
618
  ...(model ? { model } : {}),
471
619
  });
@@ -517,15 +665,25 @@ export class GoalService {
517
665
  wgoal.wizard.status = "";
518
666
  wgoal.wizard.statusEn = "";
519
667
  this.wizardSession = null;
668
+ if (goalEphemeralDir) {
669
+ try {
670
+ rmSync(goalEphemeralDir, { recursive: true, force: true });
671
+ }
672
+ catch { }
673
+ }
520
674
  this.emitGoalStatus();
521
675
  }
522
- // Aborted externally (✗ / clear_goal / idle-timeout): do NOT set a goal.
676
+ // Aborted externally (✗ / clear_goal / idle-timeout): do NOT set a goal. The raw
677
+ // draft card stays in the launching conversation's flow, so the user can read
678
+ // it back (and retry) instead of having to retype it (issue #292).
523
679
  if (ac.signal.aborted || this.wizardCancelled) {
524
680
  this.host.emit({
525
681
  type: "notice",
526
682
  level: "info",
527
- text: `目标调研已取消${ac.signal.reason ? `:${String(ac.signal.reason?.message ?? ac.signal.reason)}` : ""}`,
528
- textEn: `Goal survey cancelled${ac.signal.reason ? `: ${String(ac.signal.reason?.message ?? ac.signal.reason)}` : ""}`,
683
+ text: `目标调研已取消${ac.signal.reason ? `:${String(ac.signal.reason?.message ?? ac.signal.reason)}` : ""}` +
684
+ `。原始目标草案已保留在会话「${wizardConversationTitle}」的消息流中,可复制后重新发起。`,
685
+ textEn: `Goal survey cancelled${ac.signal.reason ? `: ${String(ac.signal.reason?.message ?? ac.signal.reason)}` : ""}` +
686
+ `. The initial goal draft is preserved in the conversation "${wizardConversationTitle}" — copy it and start over.`,
529
687
  });
530
688
  this.wizardAbort = null;
531
689
  return;
@@ -534,20 +692,26 @@ export class GoalService {
534
692
  this.host.emit({
535
693
  type: "notice",
536
694
  level: "warning",
537
- text: "调研未产出有效目标,请重试",
538
- textEn: "The survey produced no usable goal — retry",
695
+ text: `调研未产出有效目标,请重试(原始目标草案在会话「${wizardConversationTitle}」的消息流中)`,
696
+ textEn: `The survey produced no usable goal — retry (the initial draft is in the conversation "${wizardConversationTitle}")`,
539
697
  });
540
698
  return;
541
699
  }
542
- if (this.host.activeConvId() !== wizardConversationId) {
700
+ // The survey belongs to the conversation that launched it. The user is EXPECTED
701
+ // to switch away while the questions are being answered (check code, read docs),
702
+ // so "active ≠ launcher" is the normal case, not an error: land the refined goal
703
+ // on the launcher instead of discarding the work (issue #292).
704
+ const targetConv = this.host.getConv(wizardConversationId);
705
+ if (!targetConv) {
543
706
  this.host.emit({
544
707
  type: "notice",
545
- level: "info",
546
- text: "已切换对话,目标调研结果已丢弃",
547
- textEn: "Switched conversations; the survey result was discarded",
708
+ level: "warning",
709
+ text: `目标调研完成,但发起会话「${wizardConversationTitle}」已关闭,结果未应用(可在新会话里重新发起)。`,
710
+ textEn: `The survey finished, but the conversation that started it ("${wizardConversationTitle}") is gone — the result was not applied. Start a new survey.`,
548
711
  });
549
712
  return;
550
713
  }
714
+ const switchedAway = this.host.activeConvId() !== wizardConversationId;
551
715
  // Auto-set the refined goal. The wizard workflow implies "set a goal and
552
716
  // work until it passes", so default LOCKED=true unless the user explicitly
553
717
  // turned the lock off (a lock lets the review loop keep revising to pass;
@@ -557,6 +721,9 @@ export class GoalService {
557
721
  reviewModel: wgoal.reviewModel ?? undefined,
558
722
  maxRounds: opts?.maxRounds,
559
723
  locked: wantLocked,
724
+ // Land the goal on the conversation that launched the survey, even if the
725
+ // user is looking at another one right now (issue #292).
726
+ targetConvId: wizardConversationId,
560
727
  // The wizard kicks off generation itself below — avoid a double kick.
561
728
  autoStart: false,
562
729
  });
@@ -565,8 +732,12 @@ export class GoalService {
565
732
  this.host.emit({
566
733
  type: "notice",
567
734
  level: "info",
568
- text: `🎯 调研完成,目标已设为:${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""}`,
569
- textEn: `🎯 Survey done, goal set: ${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""}`,
735
+ text: switchedAway
736
+ ? `🎯 会话「${wizardConversationTitle}」目标调研完成,目标已设为:${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""}(已切回该会话开始生成)`
737
+ : `🎯 调研完成,目标已设为:${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""}`,
738
+ textEn: switchedAway
739
+ ? `🎯 Survey done in "${wizardConversationTitle}", goal set: ${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""} (switch back to that conversation to watch it generate)`
740
+ : `🎯 Survey done, goal set: ${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""}`,
570
741
  });
571
742
  // Kick the main agent into generating right away (no manual "开始吧").
572
743
  // The kick-off is a user message so it appears in the flow and triggers a
@@ -613,6 +784,10 @@ export class GoalService {
613
784
  async clearGoal() {
614
785
  const conv = this.host.activeConv();
615
786
  conv.goalGeneration += 1;
787
+ conv.stagnantRounds = 0;
788
+ conv.lastDiff = undefined;
789
+ conv.lastErrorSnippet = undefined;
790
+ conv.sameErrorRounds = 0;
616
791
  const goal = conv.goal;
617
792
  goal.reviewing = false;
618
793
  goal.conversationId = null;
@@ -652,6 +827,10 @@ export class GoalService {
652
827
  if (aborted) {
653
828
  if (g.goal && g.conversationId === conv.id) {
654
829
  conv.goalGeneration += 1;
830
+ conv.stagnantRounds = 0;
831
+ conv.lastDiff = undefined;
832
+ conv.lastErrorSnippet = undefined;
833
+ conv.sameErrorRounds = 0;
655
834
  g.conversationId = null;
656
835
  g.goal = null;
657
836
  g.reviewing = false;
@@ -670,9 +849,12 @@ export class GoalService {
670
849
  // Goal review hook: after the run finished normally, if a goal is
671
850
  // active (and it belonged to the ACTIVE conversation) and we're not
672
851
  // already mid-review, spawn the isolated reviewer.
852
+ // Only run when verdict is pending — failed/blocked terminal goals must not
853
+ // re-trigger reviews on subsequent unrelated conversational turns.
673
854
  if (g.goal &&
674
855
  g.conversationId === conv.id &&
675
856
  !g.reviewing &&
857
+ g.verdict === "pending" &&
676
858
  !conv.wizardRunning &&
677
859
  !this.host.isDisposed() &&
678
860
  this.goalEnabled()) {
@@ -684,6 +866,38 @@ export class GoalService {
684
866
  resolveReviewModel(spec) {
685
867
  return parseModelSpec(spec);
686
868
  }
869
+ /** 提取会话最近产生的错误特征,用于停滞与相同报错检测。 */
870
+ extractErrorSnippet(session, text) {
871
+ try {
872
+ const messages = session.agent?.state?.messages;
873
+ if (Array.isArray(messages)) {
874
+ for (let i = messages.length - 1; i >= 0 && i >= messages.length - 6; i--) {
875
+ const m = messages[i];
876
+ if (m.role === "toolResult" && m.isError) {
877
+ const errText = m.content
878
+ ?.map((c) => (c.type === "text" ? c.text : ""))
879
+ .join(" ")
880
+ .trim();
881
+ if (errText)
882
+ return errText.slice(0, 300);
883
+ }
884
+ if (m.role === "bashExecution" && m.exitCode && m.exitCode !== 0) {
885
+ const snippet = m.output?.trim().slice(-300);
886
+ if (snippet)
887
+ return `bash exit ${m.exitCode}: ${snippet}`;
888
+ }
889
+ }
890
+ }
891
+ }
892
+ catch {
893
+ // Ignore
894
+ }
895
+ const errMatch = text.match(/(?:(?:Error|Exception|Fail|Fatal):[^\n]+)/i);
896
+ if (errMatch) {
897
+ return errMatch[0].trim().slice(0, 300);
898
+ }
899
+ return undefined;
900
+ }
687
901
  /**
688
902
  * The whitelisted reviewer plan — tell the reviewer what to decide and how
689
903
  * to report, regardless of which model it runs on.
@@ -798,65 +1012,121 @@ export class GoalService {
798
1012
  }
799
1013
  let reviewerVerdict = "fail";
800
1014
  let reviewerFeedback = pick(this.lang(), "(审查无法完成)", "(The review could not be completed)", "goal.review.incomplete");
801
- try {
802
- const rmSpec = this.resolveReviewModel(g.reviewModel);
803
- const services = await createAgentSessionServices({
804
- cwd: mainConv.cwd,
805
- agentDir: this.host.agentDir,
806
- // The reviewer has its own skill allow/deny list. It deliberately does
807
- // not reuse the main session's disabledSkills setting.
808
- resourceLoaderOptions: {
809
- skillsOverride: (res) => ({
810
- ...res,
811
- skills: res.skills.filter((s) => !reviewDisabledSkills.has(s.name)),
812
- }),
813
- },
814
- // A FRESH ModelRuntime for the reviewer — isolated from the shared
815
- // one used by the main conversations, so its model choice is its own.
816
- modelRuntime: await ModelRuntime.create({
817
- authPath: join(this.host.agentDir, "auth.json"),
818
- modelsPath: join(this.host.agentDir, "models.json"),
819
- }),
820
- });
821
- // Model resolution: explicit reviewer model, else the main session's
822
- // current model (so a goal works even when no reviewer model is given).
823
- let model;
824
- if (rmSpec) {
825
- model = services.modelRuntime.getModel(rmSpec.provider, rmSpec.id);
826
- }
827
- if (!model) {
828
- const mainModel = mainSession.model;
829
- if (mainModel?.provider && mainModel.id) {
830
- model = services.modelRuntime.getModel(mainModel.provider, mainModel.id);
831
- }
1015
+ // 停滞与错误检测分析(DSH 风格防死循环与停滞检测):
1016
+ const currentError = this.extractErrorSnippet(mainSession, finalText);
1017
+ const prevError = conv.lastErrorSnippet;
1018
+ if (currentError &&
1019
+ prevError &&
1020
+ (currentError === prevError || currentError.includes(prevError) || prevError.includes(currentError))) {
1021
+ conv.sameErrorRounds = (conv.sameErrorRounds ?? 0) + 1;
1022
+ }
1023
+ else {
1024
+ conv.sameErrorRounds = currentError ? 1 : 0;
1025
+ }
1026
+ conv.lastErrorSnippet = currentError;
1027
+ const trimmedDiff = diff.trim();
1028
+ const prevDiff = conv.lastDiff;
1029
+ const isNoDiffChange = trimmedDiff === "" || (prevDiff !== undefined && trimmedDiff === prevDiff);
1030
+ if (isNoDiffChange) {
1031
+ conv.stagnantRounds = (conv.stagnantRounds ?? 0) + 1;
1032
+ }
1033
+ else {
1034
+ conv.stagnantRounds = 0;
1035
+ }
1036
+ conv.lastDiff = trimmedDiff;
1037
+ const isAutonomous = !g.reviewModel;
1038
+ if (isAutonomous) {
1039
+ // DSH 风格自主轮次驱动(免拉起独立审查会话,省 token + 零启动延迟):
1040
+ // 检查模型自身是否在输出中表明目标已达成(只认约定标记,见 GOAL_COMPLETION_RE)
1041
+ const isCompleted = isGoalCompletionSignal(finalText);
1042
+ if (isCompleted) {
1043
+ reviewerVerdict = "pass";
1044
+ reviewerFeedback = pick(this.lang(), "模型自主验证:目标已达成", "Model autonomous evaluation: Goal completed", "goal.autonomous.pass");
832
1045
  }
833
- const srv = await createAgentSessionFromServices({
834
- services,
835
- sessionManager: SessionManager.inMemory(mainConv.cwd),
836
- ...(model ? { model } : {}),
837
- });
838
- const reviewCap = g.locked && g.maxRounds > 0 ? g.maxRounds : 0; // 0 = no cap
839
- const reviewer = srv.session;
840
- await reviewer.prompt(this.reviewerPrompt(goalText, g.round, reviewCap, finalText, diff, reviewPrompt));
841
- // Parse the reviewer's final output (expected to be a JSON object).
842
- const raw = reviewer.getLastAssistantText() ?? "";
843
- const m = raw.match(/\{\s*"verdict"\s*:\s*"(pass|fail)"[^}]*\}/);
844
- if (m) {
845
- reviewerVerdict = m[1];
846
- const fm = raw.match(/"feedback"\s*:\s*"([^"]*)"/);
847
- reviewerFeedback = fm?.[1] ?? "";
1046
+ else if ((conv.sameErrorRounds ?? 0) >= 2 || (conv.stagnantRounds ?? 0) >= 2) {
1047
+ // 触发防死循环与停滞熔断(Blocked)
1048
+ reviewerVerdict = "blocked";
1049
+ const blockedReason = (conv.sameErrorRounds ?? 0) >= 2
1050
+ ? `连续 ${conv.sameErrorRounds} 轮出现相同错误:${currentError}`
1051
+ : `连续 ${conv.stagnantRounds} 轮未检测到有效文件修改或实质进展`;
1052
+ const blockedReasonEn = (conv.sameErrorRounds ?? 0) >= 2
1053
+ ? `Identical error across ${conv.sameErrorRounds} consecutive rounds: ${currentError}`
1054
+ : `No effective file modifications or progress across ${conv.stagnantRounds} consecutive rounds`;
1055
+ reviewerFeedback = pick(this.lang(), `【目标防死循环保护:执行受阻(Blocked)】\n\n` +
1056
+ `• 停滞原因:${blockedReason}\n` +
1057
+ `• 当前轮次:第 ${g.round} 轮\n` +
1058
+ `• 诊断分析:智能体在自主推进中连续轮次未产生有效进展或反复遭遇相同错误,已自动熔断以防止无谓消耗 token。\n` +
1059
+ `• 建议措施:请检查相关代码、工具权限或手动调整提示词,排查阻碍后再继续。`, `[Goal Infinite-Loop Protection: Blocked]\n\n` +
1060
+ `• Cause: ${blockedReasonEn}\n` +
1061
+ `• Current round: Round ${g.round}\n` +
1062
+ `• Diagnosis: Agent made no progress or encountered identical errors across consecutive rounds. Circuit breaker tripped to prevent token waste.\n` +
1063
+ `• Recommendation: Please check code, tool permissions, or refine prompt before proceeding.`, "goal.review.blocked", { blockedReason, blockedReasonEn, round: g.round });
848
1064
  }
849
1065
  else {
850
- // No JSON — assume fail with the raw output as feedback.
851
1066
  reviewerVerdict = "fail";
852
- reviewerFeedback = raw.slice(0, 2000);
1067
+ reviewerFeedback = pick(this.lang(), "目标尚未完成,自主推进下一轮迭代验证。", "Goal not yet completed; continuing to next iteration.", "goal.autonomous.continue");
853
1068
  }
854
- await srv.session.dispose();
855
1069
  }
856
- catch (err) {
857
- const reviewErrMsg = err.message;
858
- reviewerVerdict = "fail";
859
- reviewerFeedback = pick(this.lang(), `审查过程中出错:${reviewErrMsg}`, `Error during review: ${reviewErrMsg}`, "goal.review.error", { reviewErrMsg: reviewErrMsg });
1070
+ else {
1071
+ try {
1072
+ const rmSpec = this.resolveReviewModel(g.reviewModel);
1073
+ const services = await createAgentSessionServices({
1074
+ cwd: mainConv.cwd,
1075
+ agentDir: this.host.agentDir,
1076
+ // The reviewer has its own skill allow/deny list. It deliberately does
1077
+ // not reuse the main session's disabledSkills setting.
1078
+ resourceLoaderOptions: {
1079
+ skillsOverride: (res) => ({
1080
+ ...res,
1081
+ skills: res.skills.filter((s) => !reviewDisabledSkills.has(s.name)),
1082
+ }),
1083
+ },
1084
+ // A FRESH ModelRuntime for the reviewer — isolated from the shared
1085
+ // one used by the main conversations, so its model choice is its own.
1086
+ modelRuntime: await ModelRuntime.create({
1087
+ authPath: join(this.host.agentDir, "auth.json"),
1088
+ modelsPath: join(this.host.agentDir, "models.json"),
1089
+ }),
1090
+ });
1091
+ // Model resolution: explicit reviewer model, else the main session's
1092
+ // current model (so a goal works even when no reviewer model is given).
1093
+ let model;
1094
+ if (rmSpec) {
1095
+ model = services.modelRuntime.getModel(rmSpec.provider, rmSpec.id);
1096
+ }
1097
+ if (!model) {
1098
+ const mainModel = mainSession.model;
1099
+ if (mainModel?.provider && mainModel.id) {
1100
+ model = services.modelRuntime.getModel(mainModel.provider, mainModel.id);
1101
+ }
1102
+ }
1103
+ const srv = await createAgentSessionFromServices({
1104
+ services,
1105
+ sessionManager: SessionManager.inMemory(mainConv.cwd),
1106
+ ...(model ? { model } : {}),
1107
+ });
1108
+ const reviewCap = g.locked && g.maxRounds > 0 ? g.maxRounds : 0; // 0 = no cap
1109
+ const reviewer = srv.session;
1110
+ await reviewer.prompt(this.reviewerPrompt(goalText, g.round, reviewCap, finalText, diff, reviewPrompt));
1111
+ // Parse the reviewer's final output (expected to be a JSON object).
1112
+ const raw = reviewer.getLastAssistantText() ?? "";
1113
+ const parsed = parseReviewerVerdict(raw);
1114
+ if (parsed) {
1115
+ reviewerVerdict = parsed.verdict;
1116
+ reviewerFeedback = parsed.feedback;
1117
+ }
1118
+ else {
1119
+ // No JSON — assume fail with the raw output as feedback.
1120
+ reviewerVerdict = "fail";
1121
+ reviewerFeedback = raw.slice(0, 2000);
1122
+ }
1123
+ await srv.session.dispose();
1124
+ }
1125
+ catch (err) {
1126
+ const reviewErrMsg = err.message;
1127
+ reviewerVerdict = "fail";
1128
+ reviewerFeedback = pick(this.lang(), `审查过程中出错:${reviewErrMsg}`, `Error during review: ${reviewErrMsg}`, "goal.review.error", { reviewErrMsg: reviewErrMsg });
1129
+ }
860
1130
  }
861
1131
  // The user may have switched chats or replaced/cleared the goal while the
862
1132
  // isolated reviewer was running. Never apply a stale verdict or inject it
@@ -897,12 +1167,37 @@ export class GoalService {
897
1167
  this.host.flushSnapshot();
898
1168
  return;
899
1169
  }
1170
+ if (verdict === "blocked") {
1171
+ g.status = "⚠️ 目标受阻(停滞熔断)";
1172
+ g.statusEn = "⚠️ Goal blocked (stagnation circuit break)";
1173
+ this.host.emit({
1174
+ type: "notice",
1175
+ level: "warning",
1176
+ text: "⚠️ 目标执行受阻:检测到停滞或相同报错,已自动暂停",
1177
+ textEn: "⚠️ Goal execution blocked: stagnation or repeated error detected, auto-loop paused",
1178
+ });
1179
+ try {
1180
+ const blockedText = pick(this.lang(), `⚠️ 目标执行受阻(第 ${round} 轮已触发停滞熔断)。\n\n目标:${goalText}\n\n${feedback}\n\n(目标模式已暂停,请在排查问题后重新设定目标或手动继续。)`, `⚠️ Goal execution blocked (stagnation circuit breaker at round ${round}).\n\nGoal: ${goalText}\n\n${feedback}\n\n(Goal mode paused — please investigate and reset goal or continue manually.)`, "goal.review.blocked_msg", { round, goalText, feedback });
1181
+ await mainSession.sendUserMessage(blockedText, {
1182
+ deliverAs: mainSession.isStreaming ? "steer" : "followUp",
1183
+ });
1184
+ }
1185
+ catch {
1186
+ // Best-effort.
1187
+ }
1188
+ g.reviewing = false;
1189
+ this.emitGoalStatus();
1190
+ this.host.flushSnapshot();
1191
+ return;
1192
+ }
900
1193
  // Failure: if rounds remain, steer a revision; else report the loop done.
901
1194
  // For unlimited (budget=0) isLastRound is always false → keeps revising.
902
1195
  const isLastRound = !g.locked ? true : g.maxRounds > 0 && g.round >= g.maxRounds;
903
1196
  if (!isLastRound) {
904
1197
  g.status = `本轮不通过,正在把意见交给 agent 修改(${roundsZh})…`;
905
1198
  g.statusEn = `Round failed, sending feedback to the agent (${roundsEn})…`;
1199
+ // 注入修改意见后保持 verdict 为 pending,让 agent 下一次答完后继续下一轮审查
1200
+ g.verdict = "pending";
906
1201
  this.host.emit({
907
1202
  type: "notice",
908
1203
  level: "warning",
@@ -954,8 +1249,7 @@ export class GoalService {
954
1249
  text: "目标未通过审查(已达最大轮数)",
955
1250
  textEn: "Goal failed review (max rounds reached)",
956
1251
  });
957
- g.conversationId = null;
958
- g.goal = null; // loop exhausted — clear the active goal
1252
+ g.reviewing = false; // 审查结束:保留 g.goal 与 g.conversationId,让用户看到未通过的目标,不丢弃目标文本
959
1253
  this.emitGoalStatus();
960
1254
  this.host.flushSnapshot();
961
1255
  }