pi-web-ui 0.94.0 → 0.95.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 (65) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +751 -206
  5. package/dist/server/agent-service.js +1869 -85
  6. package/dist/server/approval-rules.js +550 -0
  7. package/dist/server/attachment-store.js +112 -0
  8. package/dist/server/attachments.js +62 -171
  9. package/dist/server/client-state.js +4 -0
  10. package/dist/server/compact-context-tool.js +125 -0
  11. package/dist/server/context-budget.js +317 -0
  12. package/dist/server/dangling-tools.js +162 -0
  13. package/dist/server/dsh/dsh-agent-service.js +50 -60
  14. package/dist/server/edit-soft-tool.js +70 -5
  15. package/dist/server/eval-tool.js +535 -0
  16. package/dist/server/files-service.js +35 -7
  17. package/dist/server/goal-service.js +240 -68
  18. package/dist/server/hashline-engine.js +665 -0
  19. package/dist/server/host-metrics.js +26 -2
  20. package/dist/server/http-proxy.js +11 -1
  21. package/dist/server/index.js +157 -9
  22. package/dist/server/lsp-tool.js +679 -0
  23. package/dist/server/model-admin.js +20 -0
  24. package/dist/server/office-parse.js +309 -0
  25. package/dist/server/patch-tool.js +91 -0
  26. package/dist/server/plan-manager.js +104 -0
  27. package/dist/server/plugin-api-catalog.js +296 -0
  28. package/dist/server/plugin-install-spec.js +196 -0
  29. package/dist/server/plugin-installer.js +73 -0
  30. package/dist/server/plugin-manifest-validate.js +305 -0
  31. package/dist/server/plugin-project.js +47 -11
  32. package/dist/server/plugin-tool-guard.js +120 -0
  33. package/dist/server/plugins.js +745 -161
  34. package/dist/server/process-utils.js +12 -6
  35. package/dist/server/scheduler-tasks.js +6 -0
  36. package/dist/server/serialize.js +13 -3
  37. package/dist/server/settings-service.js +108 -1
  38. package/dist/server/subagents.js +41 -0
  39. package/dist/server/terminals.js +69 -14
  40. package/dist/server/tool-approval.js +84 -0
  41. package/dist/server/tool-manager.js +214 -6
  42. package/dist/server/workspace-snapshot.js +113 -0
  43. package/package.json +3 -2
  44. package/plugin-sdk/README.md +25 -0
  45. package/plugin-sdk/index.d.ts +31 -38
  46. package/plugin-sdk/index.mjs +35 -19
  47. package/plugins/catalog.json +20 -0
  48. package/themes/ayu-light.css +6 -6
  49. package/themes/catppuccin-latte.css +6 -6
  50. package/themes/claude-code-dark.css +144 -0
  51. package/themes/codex.css +6 -6
  52. package/themes/dark-teal.css +20 -6
  53. package/themes/everforest-light.css +6 -6
  54. package/themes/geist.css +6 -6
  55. package/themes/gruvbox-light.css +6 -6
  56. package/themes/kanagawa-lotus.css +6 -6
  57. package/themes/rose-pine-dawn.css +6 -6
  58. package/themes/solarized-light.css +6 -6
  59. package/themes/vs-code-dark.css +146 -0
  60. package/web/dist/assets/{TerminalPanel-B-CMQuzm.js → TerminalPanel-DlGBF2MD.js} +1 -1
  61. package/web/dist/assets/index-BXnCQL9p.js +364 -0
  62. package/web/dist/assets/index-RpIGFyi_.css +1 -0
  63. package/web/dist/index.html +2 -2
  64. package/web/dist/assets/index-18jK6al2.js +0 -364
  65. package/web/dist/assets/index-B3S9MxnN.css +0 -1
@@ -122,6 +122,12 @@ export class GoalService {
122
122
  * Set (or clear) the active goal. `goal === ""` clears it. The goal is
123
123
  * applied to the CURRENT active conversation of this project; reviews check
124
124
  * whatever run finishes next (agent_end).
125
+ *
126
+ * `opts.targetConvId` retargets the write to a specific conversation — used by
127
+ * the goal wizard, which runs in the background while the user may have
128
+ * switched away: the refined goal must land in the conversation that LAUNCHED
129
+ * the survey (issue #292), not in whatever conversation happens to be active
130
+ * and not be thrown away.
125
131
  */
126
132
  async setGoal(goalText, opts) {
127
133
  const text = (goalText ?? "").trim();
@@ -138,11 +144,23 @@ export class GoalService {
138
144
  });
139
145
  return;
140
146
  }
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
147
+ // A goal is scoped to the conversation it is set on (default: the active
148
+ // one). This prevents an agent_end from a newly-created/switched conversation
143
149
  // from consuming the previous conversation's goal.
144
- const conv = this.host.activeConv();
145
- const goalConversationId = this.host.activeConvId();
150
+ const targetConv = opts?.targetConvId ? this.host.getConv(opts.targetConvId) : undefined;
151
+ if (opts?.targetConvId && !targetConv) {
152
+ // The targeted conversation is gone (closed / disposed) — refuse loudly
153
+ // instead of silently landing the goal somewhere else.
154
+ this.host.emit({
155
+ type: "notice",
156
+ level: "warning",
157
+ text: `目标未设置:发起目标调研的对话已关闭。`,
158
+ textEn: `Goal not set: the conversation that started the survey is gone.`,
159
+ });
160
+ return;
161
+ }
162
+ const conv = targetConv ?? this.host.activeConv();
163
+ const goalConversationId = conv.id;
146
164
  conv.goalGeneration += 1;
147
165
  const goal = conv.goal;
148
166
  goal.reviewing = false;
@@ -171,6 +189,10 @@ export class GoalService {
171
189
  locked: goal.locked,
172
190
  });
173
191
  // Reset the loop for a freshly-set goal (single-shot goals start at 0).
192
+ conv.stagnantRounds = 0;
193
+ conv.lastDiff = undefined;
194
+ conv.lastErrorSnippet = undefined;
195
+ conv.sameErrorRounds = 0;
174
196
  goal.round = 0;
175
197
  goal.reviewing = false;
176
198
  goal.verdict = "pending";
@@ -232,6 +254,9 @@ export class GoalService {
232
254
  // new active conversation while the wizard is still finishing.
233
255
  const wizardConversationId = this.host.activeConvId();
234
256
  const wizardConversation = this.host.activeConv();
257
+ // Human-readable name for notices that must say WHICH conversation the survey
258
+ // belongs to (issue #292: the user is expected to switch away mid-survey).
259
+ const wizardConversationTitle = wizardConversation.title;
235
260
  if (wizardConversation.wizardRunning || this.wizardOwnerId !== null) {
236
261
  this.host.emit({
237
262
  type: "notice",
@@ -329,6 +354,14 @@ export class GoalService {
329
354
  });
330
355
  // The main conversation to show wizard progress cards in.
331
356
  const mainSession = wizardConversation.session;
357
+ // The raw draft gets its own read-only card BEFORE the first question, so the
358
+ // flow starts from a visible anchor. If the survey is interrupted (idle/total
359
+ // timeout, ✗, or the user switching away and never coming back), the original
360
+ // requirement is still readable and copyable in THIS conversation instead of
361
+ // having to be retyped (issue #292).
362
+ await this.pushWizardCard(mainSession, pick(this.lang(), `🎯 原始目标草案:${draft}`, `🎯 Initial goal draft: ${draft}`, "goal.wizard.draft.card", {
363
+ draft,
364
+ }), { draft });
332
365
  let refinedGoal = "";
333
366
  try {
334
367
  const wmSpec = opts?.wizardModel ? this.resolveReviewModel(opts.wizardModel) : null; // reuse the honest "provider/id" parser
@@ -519,13 +552,17 @@ export class GoalService {
519
552
  this.wizardSession = null;
520
553
  this.emitGoalStatus();
521
554
  }
522
- // Aborted externally (✗ / clear_goal / idle-timeout): do NOT set a goal.
555
+ // Aborted externally (✗ / clear_goal / idle-timeout): do NOT set a goal. The raw
556
+ // draft card stays in the launching conversation's flow, so the user can read
557
+ // it back (and retry) instead of having to retype it (issue #292).
523
558
  if (ac.signal.aborted || this.wizardCancelled) {
524
559
  this.host.emit({
525
560
  type: "notice",
526
561
  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)}` : ""}`,
562
+ text: `目标调研已取消${ac.signal.reason ? `:${String(ac.signal.reason?.message ?? ac.signal.reason)}` : ""}` +
563
+ `。原始目标草案已保留在会话「${wizardConversationTitle}」的消息流中,可复制后重新发起。`,
564
+ textEn: `Goal survey cancelled${ac.signal.reason ? `: ${String(ac.signal.reason?.message ?? ac.signal.reason)}` : ""}` +
565
+ `. The initial goal draft is preserved in the conversation "${wizardConversationTitle}" — copy it and start over.`,
529
566
  });
530
567
  this.wizardAbort = null;
531
568
  return;
@@ -534,20 +571,26 @@ export class GoalService {
534
571
  this.host.emit({
535
572
  type: "notice",
536
573
  level: "warning",
537
- text: "调研未产出有效目标,请重试",
538
- textEn: "The survey produced no usable goal — retry",
574
+ text: `调研未产出有效目标,请重试(原始目标草案在会话「${wizardConversationTitle}」的消息流中)`,
575
+ textEn: `The survey produced no usable goal — retry (the initial draft is in the conversation "${wizardConversationTitle}")`,
539
576
  });
540
577
  return;
541
578
  }
542
- if (this.host.activeConvId() !== wizardConversationId) {
579
+ // The survey belongs to the conversation that launched it. The user is EXPECTED
580
+ // to switch away while the questions are being answered (check code, read docs),
581
+ // so "active ≠ launcher" is the normal case, not an error: land the refined goal
582
+ // on the launcher instead of discarding the work (issue #292).
583
+ const targetConv = this.host.getConv(wizardConversationId);
584
+ if (!targetConv) {
543
585
  this.host.emit({
544
586
  type: "notice",
545
- level: "info",
546
- text: "已切换对话,目标调研结果已丢弃",
547
- textEn: "Switched conversations; the survey result was discarded",
587
+ level: "warning",
588
+ text: `目标调研完成,但发起会话「${wizardConversationTitle}」已关闭,结果未应用(可在新会话里重新发起)。`,
589
+ textEn: `The survey finished, but the conversation that started it ("${wizardConversationTitle}") is gone — the result was not applied. Start a new survey.`,
548
590
  });
549
591
  return;
550
592
  }
593
+ const switchedAway = this.host.activeConvId() !== wizardConversationId;
551
594
  // Auto-set the refined goal. The wizard workflow implies "set a goal and
552
595
  // work until it passes", so default LOCKED=true unless the user explicitly
553
596
  // turned the lock off (a lock lets the review loop keep revising to pass;
@@ -557,6 +600,9 @@ export class GoalService {
557
600
  reviewModel: wgoal.reviewModel ?? undefined,
558
601
  maxRounds: opts?.maxRounds,
559
602
  locked: wantLocked,
603
+ // Land the goal on the conversation that launched the survey, even if the
604
+ // user is looking at another one right now (issue #292).
605
+ targetConvId: wizardConversationId,
560
606
  // The wizard kicks off generation itself below — avoid a double kick.
561
607
  autoStart: false,
562
608
  });
@@ -565,8 +611,12 @@ export class GoalService {
565
611
  this.host.emit({
566
612
  type: "notice",
567
613
  level: "info",
568
- text: `🎯 调研完成,目标已设为:${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""}`,
569
- textEn: `🎯 Survey done, goal set: ${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""}`,
614
+ text: switchedAway
615
+ ? `🎯 会话「${wizardConversationTitle}」目标调研完成,目标已设为:${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""}(已切回该会话开始生成)`
616
+ : `🎯 调研完成,目标已设为:${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""}`,
617
+ textEn: switchedAway
618
+ ? `🎯 Survey done in "${wizardConversationTitle}", goal set: ${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""} (switch back to that conversation to watch it generate)`
619
+ : `🎯 Survey done, goal set: ${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""}`,
570
620
  });
571
621
  // Kick the main agent into generating right away (no manual "开始吧").
572
622
  // The kick-off is a user message so it appears in the flow and triggers a
@@ -613,6 +663,10 @@ export class GoalService {
613
663
  async clearGoal() {
614
664
  const conv = this.host.activeConv();
615
665
  conv.goalGeneration += 1;
666
+ conv.stagnantRounds = 0;
667
+ conv.lastDiff = undefined;
668
+ conv.lastErrorSnippet = undefined;
669
+ conv.sameErrorRounds = 0;
616
670
  const goal = conv.goal;
617
671
  goal.reviewing = false;
618
672
  goal.conversationId = null;
@@ -652,6 +706,10 @@ export class GoalService {
652
706
  if (aborted) {
653
707
  if (g.goal && g.conversationId === conv.id) {
654
708
  conv.goalGeneration += 1;
709
+ conv.stagnantRounds = 0;
710
+ conv.lastDiff = undefined;
711
+ conv.lastErrorSnippet = undefined;
712
+ conv.sameErrorRounds = 0;
655
713
  g.conversationId = null;
656
714
  g.goal = null;
657
715
  g.reviewing = false;
@@ -684,6 +742,38 @@ export class GoalService {
684
742
  resolveReviewModel(spec) {
685
743
  return parseModelSpec(spec);
686
744
  }
745
+ /** 提取会话最近产生的错误特征,用于停滞与相同报错检测。 */
746
+ extractErrorSnippet(session, text) {
747
+ try {
748
+ const messages = session.agent?.state?.messages;
749
+ if (Array.isArray(messages)) {
750
+ for (let i = messages.length - 1; i >= 0 && i >= messages.length - 6; i--) {
751
+ const m = messages[i];
752
+ if (m.role === "toolResult" && m.isError) {
753
+ const errText = m.content
754
+ ?.map((c) => (c.type === "text" ? c.text : ""))
755
+ .join(" ")
756
+ .trim();
757
+ if (errText)
758
+ return errText.slice(0, 300);
759
+ }
760
+ if (m.role === "bashExecution" && m.exitCode && m.exitCode !== 0) {
761
+ const snippet = m.output?.trim().slice(-300);
762
+ if (snippet)
763
+ return `bash exit ${m.exitCode}: ${snippet}`;
764
+ }
765
+ }
766
+ }
767
+ }
768
+ catch {
769
+ // Ignore
770
+ }
771
+ const errMatch = text.match(/(?:(?:Error|Exception|Fail|Fatal):[^\n]+)/i);
772
+ if (errMatch) {
773
+ return errMatch[0].trim().slice(0, 300);
774
+ }
775
+ return undefined;
776
+ }
687
777
  /**
688
778
  * The whitelisted reviewer plan — tell the reviewer what to decide and how
689
779
  * to report, regardless of which model it runs on.
@@ -798,65 +888,123 @@ export class GoalService {
798
888
  }
799
889
  let reviewerVerdict = "fail";
800
890
  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
- }
891
+ // 停滞与错误检测分析(DSH 风格防死循环与停滞检测):
892
+ const currentError = this.extractErrorSnippet(mainSession, finalText);
893
+ const prevError = conv.lastErrorSnippet;
894
+ if (currentError &&
895
+ prevError &&
896
+ (currentError === prevError || currentError.includes(prevError) || prevError.includes(currentError))) {
897
+ conv.sameErrorRounds = (conv.sameErrorRounds ?? 0) + 1;
898
+ }
899
+ else {
900
+ conv.sameErrorRounds = currentError ? 1 : 0;
901
+ }
902
+ conv.lastErrorSnippet = currentError;
903
+ const trimmedDiff = diff.trim();
904
+ const prevDiff = conv.lastDiff;
905
+ const isNoDiffChange = trimmedDiff === "" || (prevDiff !== undefined && trimmedDiff === prevDiff);
906
+ if (isNoDiffChange) {
907
+ conv.stagnantRounds = (conv.stagnantRounds ?? 0) + 1;
908
+ }
909
+ else {
910
+ conv.stagnantRounds = 0;
911
+ }
912
+ conv.lastDiff = trimmedDiff;
913
+ const isAutonomous = !g.reviewModel;
914
+ if (isAutonomous) {
915
+ // DSH 风格自主轮次驱动(免拉起独立审查会话,省 token + 零启动延迟):
916
+ // 检查模型自身是否在输出中表明目标已达成
917
+ const completionRegex = /【目标(?:已)?(?:达成|完成)】|GOAL(?:[::_]|\s+)*(?:IS\s+)?(?:COMPLETED|PASSED)|目标已达成|目标已完成/i;
918
+ const isCompleted = completionRegex.test(finalText);
919
+ if (isCompleted) {
920
+ reviewerVerdict = "pass";
921
+ reviewerFeedback = pick(this.lang(), "模型自主验证:目标已达成", "Model autonomous evaluation: Goal completed", "goal.autonomous.pass");
832
922
  }
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] ?? "";
923
+ else if ((conv.sameErrorRounds ?? 0) >= 2 || (conv.stagnantRounds ?? 0) >= 2) {
924
+ // 触发防死循环与停滞熔断(Blocked)
925
+ reviewerVerdict = "blocked";
926
+ const blockedReason = (conv.sameErrorRounds ?? 0) >= 2
927
+ ? `连续 ${conv.sameErrorRounds} 轮出现相同错误:${currentError}`
928
+ : `连续 ${conv.stagnantRounds} 轮未检测到有效文件修改或实质进展`;
929
+ const blockedReasonEn = (conv.sameErrorRounds ?? 0) >= 2
930
+ ? `Identical error across ${conv.sameErrorRounds} consecutive rounds: ${currentError}`
931
+ : `No effective file modifications or progress across ${conv.stagnantRounds} consecutive rounds`;
932
+ reviewerFeedback = pick(this.lang(), `【目标防死循环保护:执行受阻(Blocked)】\n\n` +
933
+ `• 停滞原因:${blockedReason}\n` +
934
+ `• 当前轮次:第 ${g.round} 轮\n` +
935
+ `• 诊断分析:智能体在自主推进中连续轮次未产生有效进展或反复遭遇相同错误,已自动熔断以防止无谓消耗 token。\n` +
936
+ `• 建议措施:请检查相关代码、工具权限或手动调整提示词,排查阻碍后再继续。`, `[Goal Infinite-Loop Protection: Blocked]\n\n` +
937
+ `• Cause: ${blockedReasonEn}\n` +
938
+ `• Current round: Round ${g.round}\n` +
939
+ `• Diagnosis: Agent made no progress or encountered identical errors across consecutive rounds. Circuit breaker tripped to prevent token waste.\n` +
940
+ `• Recommendation: Please check code, tool permissions, or refine prompt before proceeding.`, "goal.review.blocked", { blockedReason, blockedReasonEn, round: g.round });
848
941
  }
849
942
  else {
850
- // No JSON — assume fail with the raw output as feedback.
851
943
  reviewerVerdict = "fail";
852
- reviewerFeedback = raw.slice(0, 2000);
944
+ reviewerFeedback = pick(this.lang(), "目标尚未完成,自主推进下一轮迭代验证。", "Goal not yet completed; continuing to next iteration.", "goal.autonomous.continue");
853
945
  }
854
- await srv.session.dispose();
855
946
  }
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 });
947
+ else {
948
+ try {
949
+ const rmSpec = this.resolveReviewModel(g.reviewModel);
950
+ const services = await createAgentSessionServices({
951
+ cwd: mainConv.cwd,
952
+ agentDir: this.host.agentDir,
953
+ // The reviewer has its own skill allow/deny list. It deliberately does
954
+ // not reuse the main session's disabledSkills setting.
955
+ resourceLoaderOptions: {
956
+ skillsOverride: (res) => ({
957
+ ...res,
958
+ skills: res.skills.filter((s) => !reviewDisabledSkills.has(s.name)),
959
+ }),
960
+ },
961
+ // A FRESH ModelRuntime for the reviewer — isolated from the shared
962
+ // one used by the main conversations, so its model choice is its own.
963
+ modelRuntime: await ModelRuntime.create({
964
+ authPath: join(this.host.agentDir, "auth.json"),
965
+ modelsPath: join(this.host.agentDir, "models.json"),
966
+ }),
967
+ });
968
+ // Model resolution: explicit reviewer model, else the main session's
969
+ // current model (so a goal works even when no reviewer model is given).
970
+ let model;
971
+ if (rmSpec) {
972
+ model = services.modelRuntime.getModel(rmSpec.provider, rmSpec.id);
973
+ }
974
+ if (!model) {
975
+ const mainModel = mainSession.model;
976
+ if (mainModel?.provider && mainModel.id) {
977
+ model = services.modelRuntime.getModel(mainModel.provider, mainModel.id);
978
+ }
979
+ }
980
+ const srv = await createAgentSessionFromServices({
981
+ services,
982
+ sessionManager: SessionManager.inMemory(mainConv.cwd),
983
+ ...(model ? { model } : {}),
984
+ });
985
+ const reviewCap = g.locked && g.maxRounds > 0 ? g.maxRounds : 0; // 0 = no cap
986
+ const reviewer = srv.session;
987
+ await reviewer.prompt(this.reviewerPrompt(goalText, g.round, reviewCap, finalText, diff, reviewPrompt));
988
+ // Parse the reviewer's final output (expected to be a JSON object).
989
+ const raw = reviewer.getLastAssistantText() ?? "";
990
+ const m = raw.match(/\{\s*"verdict"\s*:\s*"(pass|fail)"[^}]*\}/);
991
+ if (m) {
992
+ reviewerVerdict = m[1];
993
+ const fm = raw.match(/"feedback"\s*:\s*"([^"]*)"/);
994
+ reviewerFeedback = fm?.[1] ?? "";
995
+ }
996
+ else {
997
+ // No JSON — assume fail with the raw output as feedback.
998
+ reviewerVerdict = "fail";
999
+ reviewerFeedback = raw.slice(0, 2000);
1000
+ }
1001
+ await srv.session.dispose();
1002
+ }
1003
+ catch (err) {
1004
+ const reviewErrMsg = err.message;
1005
+ reviewerVerdict = "fail";
1006
+ reviewerFeedback = pick(this.lang(), `审查过程中出错:${reviewErrMsg}`, `Error during review: ${reviewErrMsg}`, "goal.review.error", { reviewErrMsg: reviewErrMsg });
1007
+ }
860
1008
  }
861
1009
  // The user may have switched chats or replaced/cleared the goal while the
862
1010
  // isolated reviewer was running. Never apply a stale verdict or inject it
@@ -897,6 +1045,30 @@ export class GoalService {
897
1045
  this.host.flushSnapshot();
898
1046
  return;
899
1047
  }
1048
+ if (verdict === "blocked") {
1049
+ g.status = "⚠️ 目标受阻(停滞熔断)";
1050
+ g.statusEn = "⚠️ Goal blocked (stagnation circuit break)";
1051
+ this.host.emit({
1052
+ type: "notice",
1053
+ level: "warning",
1054
+ text: "⚠️ 目标执行受阻:检测到停滞或相同报错,已自动暂停",
1055
+ textEn: "⚠️ Goal execution blocked: stagnation or repeated error detected, auto-loop paused",
1056
+ });
1057
+ try {
1058
+ 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 });
1059
+ await mainSession.sendUserMessage(blockedText, {
1060
+ deliverAs: mainSession.isStreaming ? "steer" : "followUp",
1061
+ });
1062
+ }
1063
+ catch {
1064
+ // Best-effort.
1065
+ }
1066
+ g.conversationId = null;
1067
+ g.goal = null; // loop blocked — clear the active goal
1068
+ this.emitGoalStatus();
1069
+ this.host.flushSnapshot();
1070
+ return;
1071
+ }
900
1072
  // Failure: if rounds remain, steer a revision; else report the loop done.
901
1073
  // For unlimited (budget=0) isLastRound is always false → keeps revising.
902
1074
  const isLastRound = !g.locked ? true : g.maxRounds > 0 && g.round >= g.maxRounds;