@youweichen/pi-web-ui 0.45.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 (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/bin/pi-web-ui.mjs +1818 -0
  4. package/deploy/com.xingshuyin.pi-web-ui.plist +48 -0
  5. package/deploy/nginx-subpath.conf +88 -0
  6. package/deploy/pi-web-ui-task.xml +71 -0
  7. package/deploy/pi-web-ui.service +31 -0
  8. package/dist/server/agent-service.js +2975 -0
  9. package/dist/server/attachments.js +672 -0
  10. package/dist/server/bg-servers.js +150 -0
  11. package/dist/server/client-state.js +234 -0
  12. package/dist/server/control-socket.js +177 -0
  13. package/dist/server/ensure-bash.js +80 -0
  14. package/dist/server/files-service.js +709 -0
  15. package/dist/server/goal-service.js +869 -0
  16. package/dist/server/index.js +879 -0
  17. package/dist/server/mcp-bridge.js +268 -0
  18. package/dist/server/model-admin.js +821 -0
  19. package/dist/server/patch-node-pty.js +80 -0
  20. package/dist/server/plugin-facilities.js +299 -0
  21. package/dist/server/plugin-updater.js +226 -0
  22. package/dist/server/plugins.js +893 -0
  23. package/dist/server/process-utils.js +112 -0
  24. package/dist/server/protocol-version.js +11 -0
  25. package/dist/server/protocol.js +6 -0
  26. package/dist/server/scm.js +298 -0
  27. package/dist/server/serialize.js +172 -0
  28. package/dist/server/settings-service.js +395 -0
  29. package/dist/server/slash-commands.js +263 -0
  30. package/dist/server/terminals.js +1372 -0
  31. package/dist/server/text-sniff.js +268 -0
  32. package/dist/server/uploads.js +107 -0
  33. package/dist/server/vision-bridge.js +131 -0
  34. package/dist/server/webui-context.js +222 -0
  35. package/extensions/webui.ts +192 -0
  36. package/package.json +99 -0
  37. package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +32 -0
  38. package/web/dist/assets/TerminalPanel-D4AktsEn.js +2 -0
  39. package/web/dist/assets/abnfDiagram-VCTEODGH-Ct5StJwt.js +1 -0
  40. package/web/dist/assets/arc-pkuxEN8A.js +1 -0
  41. package/web/dist/assets/architectureDiagram-5GKGNRK7-C6P6yn4y.js +36 -0
  42. package/web/dist/assets/blockDiagram-I7D4REHJ-BmgfH1jt.js +129 -0
  43. package/web/dist/assets/c4Diagram-7LVT6UL2-CgmwTFsK.js +38 -0
  44. package/web/dist/assets/channel-0Y__6sq2.js +1 -0
  45. package/web/dist/assets/chunk-2Q5K7J3B-DX_NqAiH.js +1 -0
  46. package/web/dist/assets/chunk-5VM5RSS4-gLeuQuYv.js +15 -0
  47. package/web/dist/assets/chunk-F27PBJKO-D4WX4mhI.js +1 -0
  48. package/web/dist/assets/chunk-IMKFNOWR-DBpDxGQD.js +231 -0
  49. package/web/dist/assets/chunk-JWPE2WC7-DIQ8iZN5.js +1 -0
  50. package/web/dist/assets/chunk-POPQ4Y6H-F_YTNoGz.js +1 -0
  51. package/web/dist/assets/chunk-SVP7TREG-CmDIvX-r.js +88 -0
  52. package/web/dist/assets/chunk-TICWLB2K-BQUzHQyS.js +206 -0
  53. package/web/dist/assets/chunk-XXDRQBXY-DqR9iffH.js +1 -0
  54. package/web/dist/assets/classDiagram-ZZMXUADV-BEfGIpzu.js +1 -0
  55. package/web/dist/assets/classDiagram-v2-VYDZK3BY-BEfGIpzu.js +1 -0
  56. package/web/dist/assets/cose-bilkent-JH36ORCC-Dl67W4Dx.js +1 -0
  57. package/web/dist/assets/cynefin-OW5HDTMX-fWdqaCpX.js +178 -0
  58. package/web/dist/assets/cynefinDiagram-5FMLGOSQ-CSUAXbaP.js +62 -0
  59. package/web/dist/assets/cytoscape.esm-Bch-eiPH.js +331 -0
  60. package/web/dist/assets/dagre-GXQ25YYZ-CqUVwu1X.js +4 -0
  61. package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  62. package/web/dist/assets/diagram-S7CK7UJ4-bRce-WYV.js +30 -0
  63. package/web/dist/assets/diagram-UQ7AKVKN-CiqPtPBE.js +41 -0
  64. package/web/dist/assets/diagram-VSXAHHWV-CbEA05LX.js +3 -0
  65. package/web/dist/assets/diagram-VX7I27RA-CRcY9MD-.js +24 -0
  66. package/web/dist/assets/diagram-Z3DM3KII-DmTwvZdS.js +24 -0
  67. package/web/dist/assets/ebnfDiagram-PWID7BFC-DbwmiGVj.js +1 -0
  68. package/web/dist/assets/erDiagram-RLTQ6QDP-Cxiaf87y.js +99 -0
  69. package/web/dist/assets/flowDiagram-HODETNUW-BQiecP1I.js +168 -0
  70. package/web/dist/assets/ganttDiagram-EL5Y4UJY-CSr8RRNw.js +292 -0
  71. package/web/dist/assets/gitGraphDiagram-WWUBYQGX-C4Tdis3g.js +106 -0
  72. package/web/dist/assets/index-CniHcFxT.js +22 -0
  73. package/web/dist/assets/index-DYz2iP_B.css +10 -0
  74. package/web/dist/assets/infoDiagram-27XIBGKW-ij_5A0pY.js +2 -0
  75. package/web/dist/assets/init-Gi6I4Gst.js +1 -0
  76. package/web/dist/assets/ishikawaDiagram-5VMMS53U-C8-tm2LV.js +70 -0
  77. package/web/dist/assets/journeyDiagram-3NMN7TZE-ZJedkNel.js +139 -0
  78. package/web/dist/assets/kanban-definition-UXKFOSKX-BEuoM53n.js +89 -0
  79. package/web/dist/assets/katex-C5jXJg4s.js +257 -0
  80. package/web/dist/assets/layout-zq_9DigZ.js +1 -0
  81. package/web/dist/assets/linear-D-QXJ5Wc.js +1 -0
  82. package/web/dist/assets/markdown-CUPbftfV.js +51 -0
  83. package/web/dist/assets/mermaid.core-CCiG-bOm.js +312 -0
  84. package/web/dist/assets/mindmap-definition-YA3MSWOX-VaAisUIe.js +96 -0
  85. package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  86. package/web/dist/assets/pegDiagram-XKGWAZYB-D5vmRtkn.js +1 -0
  87. package/web/dist/assets/pieDiagram-E7YTZNPT-loD6V8fn.js +39 -0
  88. package/web/dist/assets/quadrantDiagram-AXDQQJYC-B7E4mH1-.js +7 -0
  89. package/web/dist/assets/railroadDiagram-O6MQD6OU-DTALHvDa.js +1 -0
  90. package/web/dist/assets/react-ZkKJMDQQ.js +24 -0
  91. package/web/dist/assets/requirementDiagram-BXWQKSXE-ZjJKuEcR.js +84 -0
  92. package/web/dist/assets/sankeyDiagram-P5KCCOFB-DufTudDP.js +40 -0
  93. package/web/dist/assets/sequenceDiagram-WJ2MYXX4-B-Zk4alI.js +162 -0
  94. package/web/dist/assets/sizeCapture-INFHLROL-B1pjlxXN.js +1 -0
  95. package/web/dist/assets/stateDiagram-D77RDMKH-BeWg6PIR.js +1 -0
  96. package/web/dist/assets/stateDiagram-v2-MP3YSRHH-DagWhIFJ.js +1 -0
  97. package/web/dist/assets/swimlanes-42K2YHIH-4J4jmMmO.js +1 -0
  98. package/web/dist/assets/swimlanesDiagram-VR7AAH4N-Dsi9x540.js +8 -0
  99. package/web/dist/assets/timeline-definition-24CTP7MA-ByqLfxqe.js +120 -0
  100. package/web/dist/assets/vennDiagram-4TSXK5OY-B_EqVW4R.js +34 -0
  101. package/web/dist/assets/wardleyDiagram-VM6X3IG4-Bu7sA6F8.js +78 -0
  102. package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
  103. package/web/dist/assets/xychartDiagram-S5SC5T6Z-w1KPVGKk.js +7 -0
  104. package/web/dist/favicon.svg +8 -0
  105. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  106. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  107. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  108. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  109. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  110. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
  111. package/web/dist/fonts/inter/inter-latin-400-normal.woff2 +0 -0
  112. package/web/dist/fonts/inter/inter-latin-500-normal.woff2 +0 -0
  113. package/web/dist/fonts/inter/inter-latin-600-normal.woff2 +0 -0
  114. package/web/dist/fonts/inter/inter-latin-700-normal.woff2 +0 -0
  115. package/web/dist/fonts/inter/inter-latin-800-normal.woff2 +0 -0
  116. package/web/dist/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
  117. package/web/dist/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
  118. package/web/dist/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
  119. package/web/dist/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
  120. package/web/dist/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
  121. package/web/dist/icon.ico +0 -0
  122. package/web/dist/index.html +17 -0
  123. package/web/public/favicon.svg +8 -0
  124. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  125. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  126. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  127. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  128. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  129. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
  130. package/web/public/fonts/inter/inter-latin-400-normal.woff2 +0 -0
  131. package/web/public/fonts/inter/inter-latin-500-normal.woff2 +0 -0
  132. package/web/public/fonts/inter/inter-latin-600-normal.woff2 +0 -0
  133. package/web/public/fonts/inter/inter-latin-700-normal.woff2 +0 -0
  134. package/web/public/fonts/inter/inter-latin-800-normal.woff2 +0 -0
  135. package/web/public/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
  136. package/web/public/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
  137. package/web/public/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
  138. package/web/public/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
  139. package/web/public/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
  140. package/web/public/icon.ico +0 -0
@@ -0,0 +1,869 @@
1
+ /**
2
+ * goal-service — 目标 / 审查循环 / 调研向导,从 agent-service.ts 抽出。
3
+ *
4
+ * 职责:
5
+ * - setGoal/clearGoal/setGoalPrefs:目标状态机 + 偏好「全局记忆」(client-state.json)
6
+ * - runGoalReview:agent_end 后用 ISOLATED 审查会话(独立 ModelRuntime)判定
7
+ * pass/fail,fail 时把意见作为普通 user 消息注入主会话重改
8
+ * - startGoalWizard:AI 提炼——独立调研会话经 goal_ask 工具逐题提问(对话框桥接浏览器),
9
+ * 收敛出 GOAL: 后自动设为目标并触发生成
10
+ *
11
+ * 经 GoalHost 窄接口与 ClientSession 解耦(同 settings-service 模式):对话记录按
12
+ * 结构化子集 GoalConversation 传入(真实 Conversation 满足该结构),会话创建/对话框
13
+ * 取消/git diff 等宿主能力走回调,便于独立测试。UI 文案直接中文(服务端 notice 约定)。
14
+ */
15
+ import { join } from "node:path";
16
+ import { Type } from "typebox";
17
+ import { createAgentSessionFromServices, createAgentSessionServices, defineTool, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
18
+ import { parseModelSpec } from "./attachments.js";
19
+ /** System prompt for the goal-wizard session. The wizard asks the user a few
20
+ * questions (via its goal_ask tool) to scope a raw requirement into a precise,
21
+ * reviewable goal, then emits ONLY the final goal text as its last message. */
22
+ function wizardPrompt(draft) {
23
+ return [
24
+ `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
25
+ ``,
26
+ `# User's raw requirement`, // eslint-disable-line no-regex-spaces
27
+ draft,
28
+ ``,
29
+ `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
30
+ `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
31
+ `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
32
+ `GOAL: <one concrete, verifiable sentence describing the deliverable and its acceptance criteria>`, // eslint-disable-line max-len
33
+ `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
34
+ ].join("\n");
35
+ }
36
+ export class GoalService {
37
+ host;
38
+ /** Defaults remembered for newly-created conversations. Each conversation
39
+ * receives its own GoalStatus, so reviews can run concurrently. */
40
+ prefs = {
41
+ reviewModel: null,
42
+ maxRounds: 0,
43
+ locked: true,
44
+ };
45
+ /** Aborts the currently-running goal wizard (user clicked ✗ / timed out). Drives
46
+ * the in-flight goal_ask dialog to resolve as cancelled and (via the run
47
+ * signal) stops the wizard session's agent run. Recreated per wizard. */
48
+ wizardAbort = null;
49
+ /** The wizard's AgentSession while it runs — lets clearGoal truly terminate it
50
+ * (abort the run), not just flip a flag. */
51
+ wizardSession = null;
52
+ /** Conversation that owns the one browser wizard currently in flight. */
53
+ wizardOwnerId = null;
54
+ /** True when the wizard was cancelled externally (✗ / clear_goal / timeout) —
55
+ * startGoalWizard reads this after the run to avoid setting a goal. */
56
+ wizardCancelled = false;
57
+ /** Idle-timeout for the wizard: if no answer arrives within this window (a
58
+ * dialog is up but the user doesn't respond), the wizard is auto-cancelled. */
59
+ static WIZARD_IDLE_TIMEOUT_MS = 5 * 60_000;
60
+ /** Absolute deadline for the whole wizard session (model latency guard). */
61
+ static WIZARD_MAX_TOTAL_MS = 20 * 60_000;
62
+ constructor(host) {
63
+ this.host = host;
64
+ // Restore last-used goal/review preferences so model & rounds survive reload.
65
+ const gPrefs = host.stateStore.getGoalPrefs(host.clientId);
66
+ if (gPrefs) {
67
+ this.prefs = {
68
+ reviewModel: gPrefs.reviewModel,
69
+ maxRounds: gPrefs.maxRounds,
70
+ locked: gPrefs.locked,
71
+ };
72
+ }
73
+ }
74
+ /** Remembered defaults (model choice / rounds cap / lock). */
75
+ get reviewPrefs() {
76
+ return this.prefs;
77
+ }
78
+ /** Create independent goal state for one conversation. Preferences are
79
+ * client-wide defaults, while goal text/review progress is not shared. */
80
+ makeGoalStatus() {
81
+ return {
82
+ conversationId: null,
83
+ goal: null,
84
+ reviewModel: this.prefs.reviewModel,
85
+ maxRounds: this.prefs.maxRounds,
86
+ locked: this.prefs.locked,
87
+ reviewing: false,
88
+ round: 0,
89
+ status: "",
90
+ verdict: "pending",
91
+ wizard: {
92
+ active: false,
93
+ draft: "",
94
+ model: null,
95
+ step: 0,
96
+ maxSteps: 6,
97
+ status: "",
98
+ },
99
+ };
100
+ }
101
+ /** Push the active conversation's goal status to the client (the goal bar
102
+ * restores remembered prefs when nothing is active). */
103
+ emitGoalStatus() {
104
+ const goal = this.host.activeConv().goal;
105
+ if (!goal.goal && !goal.reviewing && !goal.wizard.active) {
106
+ goal.reviewModel = this.prefs.reviewModel;
107
+ goal.maxRounds = this.prefs.maxRounds;
108
+ goal.locked = this.prefs.locked;
109
+ }
110
+ this.host.emit({ type: "goal_status", status: { ...goal } });
111
+ }
112
+ /**
113
+ * Set (or clear) the active goal. `goal === ""` clears it. The goal is
114
+ * applied to the CURRENT active conversation of this project; reviews check
115
+ * whatever run finishes next (agent_end).
116
+ */
117
+ async setGoal(goalText, opts) {
118
+ const text = (goalText ?? "").trim();
119
+ if (!text) {
120
+ await this.clearGoal();
121
+ return;
122
+ }
123
+ // A goal is scoped to the conversation that is active when it is set.
124
+ // This prevents an agent_end from a newly-created/switched conversation
125
+ // from consuming the previous conversation's goal.
126
+ const conv = this.host.activeConv();
127
+ const goalConversationId = this.host.activeConvId();
128
+ conv.goalGeneration += 1;
129
+ const goal = conv.goal;
130
+ goal.reviewing = false;
131
+ goal.conversationId = goalConversationId;
132
+ goal.goal = text;
133
+ // Model & rounds preference semantics ("全局记忆"):
134
+ // - reviewModel undefined → keep the remembered choice; empty → main model.
135
+ // - maxRounds 0 = unlimited (default); >0 = finite cap (clamped to 50).
136
+ if (opts?.reviewModel !== undefined)
137
+ goal.reviewModel = opts.reviewModel || null;
138
+ if (typeof opts?.maxRounds === "number") {
139
+ const mr = Math.round(opts.maxRounds);
140
+ goal.maxRounds = mr >= 1 ? Math.min(mr, 50) : 0;
141
+ }
142
+ if (opts?.locked !== undefined)
143
+ goal.locked = opts.locked;
144
+ this.prefs = {
145
+ reviewModel: goal.reviewModel,
146
+ maxRounds: goal.maxRounds,
147
+ locked: goal.locked,
148
+ };
149
+ // Persist the chosen preferences so they survive reload.
150
+ this.host.stateStore.saveGoalPrefs(this.host.clientId, {
151
+ reviewModel: goal.reviewModel,
152
+ maxRounds: goal.maxRounds,
153
+ locked: goal.locked,
154
+ });
155
+ // Reset the loop for a freshly-set goal (single-shot goals start at 0).
156
+ goal.round = 0;
157
+ goal.reviewing = false;
158
+ goal.verdict = "pending";
159
+ goal.feedback = undefined;
160
+ goal.wizard.active = false;
161
+ goal.wizard.status = "";
162
+ goal.status = "目标已设,等待生成…";
163
+ this.emitGoalStatus();
164
+ this.host.emit({
165
+ type: "notice",
166
+ level: "info",
167
+ text: `🎯 已设目标:${text.slice(0, 80)}${text.length > 80 ? "…" : ""}`,
168
+ });
169
+ // Auto-start generation right after setting the goal (unless this setGoal is
170
+ // the wizard's internal one, which kicks off itself). This makes the direct
171
+ // goal-bar path behave like the AI-提炼 path: set a target → agent begins.
172
+ if (opts?.autoStart !== false) {
173
+ try {
174
+ const s = conv.session;
175
+ await s.sendUserMessage(`【目标已设定】\n\n${text}\n\n请现在开始实现这个目标。`, { deliverAs: s.isStreaming ? "steer" : "followUp" });
176
+ }
177
+ catch {
178
+ // Best-effort; the user can still prompt manually.
179
+ }
180
+ this.host.flushSnapshot();
181
+ }
182
+ }
183
+ /**
184
+ * Collaborative target wizard. Turns a raw user requirement into a refined
185
+ * goal by spinning up an ISOLATED wizard session (own fresh ModelRuntime +
186
+ * in-memory session, so its model choice is its own) that questions the user
187
+ * via `goal_ask` (multiple-choice + free-text, bridged to the browser through
188
+ * the existing select/input dialog), converging on a goal, then auto-sets it.
189
+ * Mutually exclusive with the review loop of the same conversation.
190
+ */
191
+ async startGoalWizard(text, opts) {
192
+ if (this.host.quiesceBlocked())
193
+ return;
194
+ const draft = (text ?? "").trim();
195
+ if (!draft)
196
+ return;
197
+ // The wizard and its progress cards belong to the conversation that
198
+ // launched it. If the user switches away, do not later set a goal on the
199
+ // new active conversation while the wizard is still finishing.
200
+ const wizardConversationId = this.host.activeConvId();
201
+ const wizardConversation = this.host.activeConv();
202
+ if (wizardConversation.wizardRunning || this.wizardOwnerId !== null) {
203
+ this.host.emit({
204
+ type: "notice",
205
+ level: "warning",
206
+ text: "已有目标调研进行中,请等它完成…",
207
+ });
208
+ return;
209
+ }
210
+ if (wizardConversation.goal.reviewing) {
211
+ this.host.emit({
212
+ type: "notice",
213
+ level: "warning",
214
+ text: "正在审查中,无法开始目标调研,请稍等…",
215
+ });
216
+ return;
217
+ }
218
+ // Questions are NOT capped (调研不限制) — the wizard converges on its own;
219
+ // the idle- and total-timeouts are the only guards. maxSteps is purely a
220
+ // soft UI indicator, not a hard stop.
221
+ const maxSteps = 20;
222
+ wizardConversation.wizardRunning = true;
223
+ this.wizardOwnerId = wizardConversationId;
224
+ this.wizardCancelled = false;
225
+ this.wizardAbort = new AbortController();
226
+ this.wizardSession = null;
227
+ const wgoal = wizardConversation.goal;
228
+ wgoal.wizard.active = true;
229
+ wgoal.wizard.draft = draft;
230
+ wgoal.wizard.model = opts?.wizardModel ?? null;
231
+ // Remember the model choice (and persist rounds/lock) — global memory.
232
+ if (opts?.wizardModel !== undefined && opts.wizardModel !== null)
233
+ wgoal.reviewModel = opts.wizardModel || null;
234
+ if (typeof opts?.maxRounds === "number") {
235
+ const mr = Math.round(opts.maxRounds);
236
+ wgoal.maxRounds = mr >= 1 ? Math.min(mr, 50) : 0;
237
+ }
238
+ if (opts?.locked !== undefined)
239
+ wgoal.locked = opts.locked;
240
+ this.prefs = {
241
+ reviewModel: wgoal.reviewModel,
242
+ maxRounds: wgoal.maxRounds,
243
+ locked: wgoal.locked,
244
+ };
245
+ this.host.stateStore.saveGoalPrefs(this.host.clientId, {
246
+ reviewModel: wgoal.reviewModel,
247
+ maxRounds: wgoal.maxRounds,
248
+ locked: wgoal.locked,
249
+ });
250
+ wgoal.wizard.step = 0;
251
+ wgoal.wizard.maxSteps = maxSteps;
252
+ wgoal.wizard.status = "调研中…";
253
+ wgoal.status = "目标调研中…";
254
+ this.emitGoalStatus();
255
+ // Idle-timeout: cancel the wizard if no question is answered within the
256
+ // window (a stale dialog with no user response must not run forever). A
257
+ // fresh timer is armed for each question; cleared once the run ends.
258
+ const ac = this.wizardAbort;
259
+ let idleTimer = null;
260
+ const armIdle = () => {
261
+ if (idleTimer)
262
+ clearTimeout(idleTimer);
263
+ idleTimer = setTimeout(() => {
264
+ if (!ac.signal.aborted) {
265
+ this.wizardCancelled = true;
266
+ ac.abort(new Error("目标调研超时(等待回答过久)"));
267
+ }
268
+ }, GoalService.WIZARD_IDLE_TIMEOUT_MS);
269
+ idleTimer.unref?.();
270
+ };
271
+ const clearIdle = () => {
272
+ if (idleTimer) {
273
+ clearTimeout(idleTimer);
274
+ idleTimer = null;
275
+ }
276
+ };
277
+ armIdle();
278
+ // Total-duration guard: hard cap on the whole wizard session (model
279
+ // latency / unexpected loops must not run forever).
280
+ const totalTimer = setTimeout(() => {
281
+ if (!ac.signal.aborted) {
282
+ this.wizardCancelled = true;
283
+ ac.abort(new Error("目标调研超过总时长上限"));
284
+ }
285
+ }, GoalService.WIZARD_MAX_TOTAL_MS);
286
+ totalTimer.unref?.();
287
+ this.host.emit({
288
+ type: "notice",
289
+ level: "info",
290
+ text: `🔍 正在围绕需求展开调研:${draft.slice(0, 60)}${draft.length > 60 ? "…" : ""}`,
291
+ });
292
+ // The main conversation to show wizard progress cards in.
293
+ const mainSession = wizardConversation.session;
294
+ let refinedGoal = "";
295
+ try {
296
+ const wmSpec = opts?.wizardModel
297
+ ? this.resolveReviewModel(opts.wizardModel)
298
+ : null; // reuse the honest "provider/id" parser
299
+ const services = await createAgentSessionServices({
300
+ cwd: wizardConversation.cwd,
301
+ agentDir: this.host.agentDir,
302
+ modelRuntime: await ModelRuntime.create({
303
+ authPath: join(this.host.agentDir, "auth.json"),
304
+ modelsPath: join(this.host.agentDir, "models.json"),
305
+ }),
306
+ });
307
+ let model;
308
+ if (wmSpec)
309
+ model = services.modelRuntime.getModel(wmSpec.provider, wmSpec.id);
310
+ if (!model) {
311
+ const mainModel = mainSession.model;
312
+ if (mainModel?.provider && mainModel.id)
313
+ model = services.modelRuntime.getModel(mainModel.provider, mainModel.id);
314
+ }
315
+ // The wizard asks the user questions via this tool; each call bridges one
316
+ // select/input dialog to the browser and returns the user's answer.
317
+ let qStep = 0;
318
+ const goalAsk = defineTool({
319
+ name: "goal_ask",
320
+ label: "Ask the user",
321
+ 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.",
322
+ parameters: Type.Object({
323
+ question: Type.String({ description: "The question to ask" }),
324
+ options: Type.Optional(Type.Array(Type.String())),
325
+ }),
326
+ // ONE question at a time. Sequential execution prevents the agent from
327
+ // firing parallel goal_ask calls whose dialogs would overwrite each other
328
+ // in the single browser modal (leaving earlier ones deadlocked — the
329
+ // reported "调研卡住").
330
+ executionMode: "sequential",
331
+ execute: async (_id, params, _sig, _onUpdate, ctx) => {
332
+ qStep += 1;
333
+ if (qStep > maxSteps) {
334
+ return {
335
+ content: [
336
+ {
337
+ type: "text",
338
+ text: "(达到最大提问数,请直接给出收敛后的目标文本作为最终答案)",
339
+ },
340
+ ],
341
+ details: {},
342
+ };
343
+ }
344
+ // Show the question in the main flow BEFORE blocking on the dialog, so
345
+ // the user sees the wizard working even before answering.
346
+ wgoal.wizard.step = qStep;
347
+ wgoal.wizard.status = `调研中:请回答第 ${qStep} 题`;
348
+ this.emitGoalStatus();
349
+ try {
350
+ armIdle();
351
+ const isChoice = !!(params.options && params.options.length > 0);
352
+ await this.pushWizardCard(mainSession, `🔍 第 ${qStep} 题:${params.question}${isChoice ? `【${params.options.join(" / ")}】` : ""}`, { question: params.question });
353
+ // Resolve the pending dialog as cancelled if the wizard is aborted.
354
+ let aborted = false;
355
+ const onAbort = () => {
356
+ aborted = true;
357
+ };
358
+ ac.signal.addEventListener("abort", onAbort, { once: true });
359
+ const choose = isChoice
360
+ ? ctx.ui.select(`🔍 第 ${qStep} 题:${params.question}`, params.options)
361
+ : ctx.ui.input(`🔍 第 ${qStep} 题:${params.question}`);
362
+ const ans = (await choose);
363
+ ac.signal.removeEventListener("abort", onAbort);
364
+ if (aborted || ac.signal.aborted) {
365
+ return {
366
+ content: [
367
+ {
368
+ type: "text",
369
+ text: "(调研已取消,请不要继续提问,直接结束对话)",
370
+ },
371
+ ],
372
+ details: {},
373
+ };
374
+ }
375
+ if (ans === undefined || ans === null || ans === false || ans === "") {
376
+ return {
377
+ content: [
378
+ {
379
+ type: "text",
380
+ text: "(用户已取消调研,请直接给出你当前收敛的目标文本作为最终答案)",
381
+ },
382
+ ],
383
+ details: {},
384
+ };
385
+ }
386
+ // Record the answer in the flow too (instant append, main session idle).
387
+ await this.pushWizardCard(mainSession, `↳ 您的回答:${ans}`, { question: params.question, answer: String(ans) });
388
+ return {
389
+ content: [{ type: "text", text: `用户回答:${ans}` }],
390
+ details: {},
391
+ };
392
+ }
393
+ catch (err) {
394
+ return {
395
+ content: [
396
+ {
397
+ type: "text",
398
+ text: ac.signal.aborted
399
+ ? "(调研已取消,请不要继续提问,直接结束对话)"
400
+ : `提问失败:${err.message}`,
401
+ },
402
+ ],
403
+ details: {},
404
+ };
405
+ }
406
+ },
407
+ });
408
+ const srv = await createAgentSessionFromServices({
409
+ services,
410
+ sessionManager: SessionManager.inMemory(this.host.cwd()),
411
+ customTools: [goalAsk],
412
+ ...(model ? { model } : {}),
413
+ });
414
+ const wizard = srv.session;
415
+ this.wizardSession = wizard;
416
+ await wizard.bindExtensions({ mode: "rpc", uiContext: this.host.webUi });
417
+ // Cancel watcher: when the user ✗s / idle-timeout fires, truly stop the
418
+ // wizard's agent run (not just mark it).
419
+ if (!ac.signal.aborted) {
420
+ ac.signal.addEventListener("abort", () => {
421
+ void wizard.abort().catch(() => { });
422
+ // Close the unanswered browser dialog(s) the wizard may have up.
423
+ this.host.webUi.cancelPendingDialogs();
424
+ }, { once: true });
425
+ }
426
+ await wizard.prompt(wizardPrompt(draft));
427
+ refinedGoal = wizard.getLastAssistantText()?.trim() ?? "";
428
+ // The wizard is prompted to emit "GOAL: <text>". Parse past the marker;
429
+ // if it didn't follow, strip a leading preamble line and keep the rest.
430
+ const goalMatch = refinedGoal.match(/GOAL\s*[::]\s*([\s\S]*)/i);
431
+ if (goalMatch) {
432
+ refinedGoal = goalMatch[1].trim();
433
+ }
434
+ else {
435
+ const lines = refinedGoal.split("\n").filter((l) => l.trim());
436
+ if (lines.length > 1 && !/[。.!??]\s*$/.test(lines[0])) {
437
+ // First line looks like preamble (no sentence-ending punctuation).
438
+ refinedGoal = lines.slice(1).join(" ").trim();
439
+ }
440
+ }
441
+ await srv.session.dispose();
442
+ }
443
+ catch (err) {
444
+ this.host.emit({
445
+ type: "notice",
446
+ level: "error",
447
+ text: `目标调研失败:${err.message}`,
448
+ });
449
+ }
450
+ finally {
451
+ clearIdle();
452
+ clearTimeout(totalTimer);
453
+ wizardConversation.wizardRunning = false;
454
+ if (this.wizardOwnerId === wizardConversationId)
455
+ this.wizardOwnerId = null;
456
+ wgoal.wizard.active = false;
457
+ wgoal.wizard.step = 0;
458
+ wgoal.wizard.status = "";
459
+ this.wizardSession = null;
460
+ this.emitGoalStatus();
461
+ }
462
+ // Aborted externally (✗ / clear_goal / idle-timeout): do NOT set a goal.
463
+ if (ac.signal.aborted || this.wizardCancelled) {
464
+ this.host.emit({
465
+ type: "notice",
466
+ level: "info",
467
+ text: `目标调研已取消${ac.signal.reason ? `:${String(ac.signal.reason?.message ?? ac.signal.reason)}` : ""}`,
468
+ });
469
+ this.wizardAbort = null;
470
+ return;
471
+ }
472
+ if (!refinedGoal.trim()) {
473
+ this.host.emit({
474
+ type: "notice",
475
+ level: "warning",
476
+ text: "调研未产出有效目标,请重试",
477
+ });
478
+ return;
479
+ }
480
+ if (this.host.activeConvId() !== wizardConversationId) {
481
+ this.host.emit({
482
+ type: "notice",
483
+ level: "info",
484
+ text: "已切换对话,目标调研结果已丢弃",
485
+ });
486
+ return;
487
+ }
488
+ // Auto-set the refined goal. The wizard workflow implies "set a goal and
489
+ // work until it passes", so default LOCKED=true unless the user explicitly
490
+ // turned the lock off (a lock lets the review loop keep revising to pass;
491
+ // without it the review is single-shot).
492
+ const wantLocked = opts?.locked === undefined ? true : opts.locked;
493
+ await this.setGoal(refinedGoal, {
494
+ reviewModel: wgoal.reviewModel ?? undefined,
495
+ maxRounds: opts?.maxRounds,
496
+ locked: wantLocked,
497
+ // The wizard kicks off generation itself below — avoid a double kick.
498
+ autoStart: false,
499
+ });
500
+ this.wizardCancelled = false;
501
+ this.wizardAbort = null;
502
+ this.host.emit({
503
+ type: "notice",
504
+ level: "info",
505
+ text: `🎯 调研完成,目标已设为:${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""}`,
506
+ });
507
+ // Kick the main agent into generating right away (no manual "开始吧").
508
+ // The kick-off is a user message so it appears in the flow and triggers a
509
+ // normal turn; the finishing agent_end then runs the review loop.
510
+ try {
511
+ await mainSession.sendUserMessage(`【目标已设定】\n\n${wgoal.goal}\n\n请现在开始实现这个目标。`, { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
512
+ }
513
+ catch {
514
+ // Generation kick-off is best-effort; the user can still prompt manually.
515
+ }
516
+ }
517
+ /** Persist goal/review preference defaults (model, rounds cap, locked) without
518
+ * touching the active goal — so changes in the goal bar are remembered across
519
+ * reloads. maxRounds 0 = unlimited. Emits goal_status so the UI stays synced. */
520
+ async setGoalPrefs(opts) {
521
+ const goal = this.host.activeConv().goal;
522
+ if (opts?.reviewModel !== undefined)
523
+ goal.reviewModel = opts.reviewModel || null;
524
+ if (typeof opts?.maxRounds === "number") {
525
+ const mr = Math.round(opts.maxRounds);
526
+ goal.maxRounds = mr >= 1 ? Math.min(mr, 50) : 0;
527
+ }
528
+ if (opts?.locked !== undefined)
529
+ goal.locked = opts.locked;
530
+ this.prefs = {
531
+ reviewModel: goal.reviewModel,
532
+ maxRounds: goal.maxRounds,
533
+ locked: goal.locked,
534
+ };
535
+ this.host.stateStore.saveGoalPrefs(this.host.clientId, {
536
+ reviewModel: goal.reviewModel,
537
+ maxRounds: goal.maxRounds,
538
+ locked: goal.locked,
539
+ });
540
+ this.emitGoalStatus();
541
+ }
542
+ /** Clear the active goal (cancels the review loop AND aborts a running
543
+ * goal wizard — truly terminating its in-flight dialog + agent run). */
544
+ async clearGoal() {
545
+ const conv = this.host.activeConv();
546
+ conv.goalGeneration += 1;
547
+ const goal = conv.goal;
548
+ goal.reviewing = false;
549
+ goal.conversationId = null;
550
+ goal.goal = null;
551
+ goal.reviewing = false;
552
+ goal.verdict = "pending";
553
+ goal.feedback = undefined;
554
+ goal.wizard.active = false;
555
+ goal.wizard.status = "";
556
+ goal.status = "";
557
+ this.emitGoalStatus();
558
+ // Abort a running wizard for real (✗ in the goal bar while scoping).
559
+ if (this.wizardOwnerId === this.host.activeConvId()) {
560
+ this.wizardCancelled = true;
561
+ this.host.webUi.cancelPendingDialogs();
562
+ this.wizardAbort?.abort();
563
+ const ws2 = this.wizardSession;
564
+ this.wizardSession = null;
565
+ if (ws2) {
566
+ await ws2.abort().catch(() => { });
567
+ ws2.dispose();
568
+ }
569
+ this.wizardAbort = null;
570
+ }
571
+ }
572
+ /**
573
+ * agent_end hook. `aborted` = the finished run ended by manual stop; in that
574
+ * case any active goal of THIS conversation is cleared so the review loop
575
+ * stops too (a half-finished run must not be reviewed — endless loop).
576
+ * Otherwise, spawn the isolated reviewer if a goal is pending. Returns a
577
+ * notice text for the host to emit (manual-stop case), or null.
578
+ */
579
+ onAgentEnd(conv, aborted) {
580
+ const g = conv.goal;
581
+ if (aborted) {
582
+ if (g.goal && g.conversationId === conv.id) {
583
+ conv.goalGeneration += 1;
584
+ g.conversationId = null;
585
+ g.goal = null;
586
+ g.reviewing = false;
587
+ g.verdict = "pending";
588
+ g.feedback = undefined;
589
+ g.status = "已手动停止,目标审查已中止";
590
+ this.emitGoalStatus();
591
+ return "⏹ 已手动停止,目标审查已中止(想继续可重新设定目标)";
592
+ }
593
+ return null;
594
+ }
595
+ // Goal review hook: after the run finished normally, if a goal is
596
+ // active (and it belonged to the ACTIVE conversation) and we're not
597
+ // already mid-review, spawn the isolated reviewer.
598
+ if (g.goal &&
599
+ g.conversationId === conv.id &&
600
+ !g.reviewing &&
601
+ !conv.wizardRunning &&
602
+ !this.host.isDisposed()) {
603
+ void this.runGoalReview(conv);
604
+ }
605
+ return null;
606
+ }
607
+ /** Build a "provider/id" or null for the reviewer model, validating it exists. */
608
+ resolveReviewModel(spec) {
609
+ return parseModelSpec(spec);
610
+ }
611
+ /**
612
+ * The whitelisted reviewer plan — tell the reviewer what to decide and how
613
+ * to report, regardless of which model it runs on.
614
+ */
615
+ reviewerPrompt(goal, round, maxRounds, output, gitDiff, customPrompt = "") {
616
+ return [
617
+ `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
618
+ ``,
619
+ `# Goal`, // eslint-disable-line no-regex-spaces
620
+ goal,
621
+ ``,
622
+ `# Agent's final output`, // eslint-disable-line no-regex-spaces
623
+ output.length > 0 ? output : "(the agent produced no text — inspect the diff)", // eslint-disable-line max-len
624
+ ``,
625
+ `# Git diff (if any)`, // eslint-disable-line no-regex-spaces
626
+ gitDiff.length > 0 ? gitDiff : "(no staged/committed changes detected)", // eslint-disable-line max-len
627
+ ``,
628
+ `This is review round ${round}${maxRounds > 0 ? ` of up to ${maxRounds}` : " (no round cap — keep revising until it passes)"}.`, // eslint-disable-line max-len
629
+ ...(customPrompt.trim()
630
+ ? [``, `# Additional reviewer instructions`, customPrompt.trim()]
631
+ : []),
632
+ ``,
633
+ `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
634
+ `{"verdict":"pass","feedback":"<one short sentence: what was satisfied>"}`, // eslint-disable-line max-len
635
+ `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
636
+ `The feedback for a fail must be specific enough that the agent can act on it directly.`, // eslint-disable-line max-len
637
+ ].join("\n");
638
+ }
639
+ /** Insert a wizard progress card into the MAIN conversation flow and render it
640
+ * IMMEDIATELY (the main session is idle while the wizard runs in its own
641
+ * session, so — unlike nextTurn, which queues until the next user prompt —
642
+ * sending without a delivery option appends + persists + emits at once). */
643
+ async pushWizardCard(sess, text, details) {
644
+ try {
645
+ await sess.sendCustomMessage({
646
+ customType: "goal-wizard",
647
+ content: [{ type: "text", text }],
648
+ display: true,
649
+ details: { type: "goal-wizard", ...details },
650
+ });
651
+ }
652
+ catch {
653
+ // Card insertion is cosmetic — never block the question flow on it.
654
+ }
655
+ }
656
+ isCurrentGoalReview(conv, goalGeneration, reviewGeneration) {
657
+ return (!this.host.isDisposed() &&
658
+ this.host.getConv(conv.id) === conv &&
659
+ conv.goal.conversationId === conv.id &&
660
+ conv.goalGeneration === goalGeneration &&
661
+ conv.goalReviewGeneration === reviewGeneration &&
662
+ !!conv.goal.goal);
663
+ }
664
+ /** Drop the result of a review that became stale while it was awaiting the
665
+ * reviewer model (most commonly because the user switched conversations). */
666
+ discardStaleGoalReview(conv, goalGeneration, reviewGeneration) {
667
+ if (conv.goalReviewGeneration !== reviewGeneration)
668
+ return;
669
+ if (conv.goalGeneration === goalGeneration &&
670
+ conv.goal.conversationId === conv.id) {
671
+ conv.goal.reviewing = false;
672
+ conv.goal.status = "审查已中止,目标已更新或取消";
673
+ this.emitGoalStatus();
674
+ }
675
+ }
676
+ async runGoalReview(conv) {
677
+ // The review is bound to the conversation that just ran. Capture both the
678
+ // owner and a generation so a later switch/set/clear cannot let an old,
679
+ // asynchronous reviewer mutate the new conversation's goal state.
680
+ const mainConv = this.host.getConv(conv.id) ?? conv;
681
+ const mainSession = mainConv.session;
682
+ const g = conv.goal;
683
+ if (!g.goal ||
684
+ g.conversationId !== conv.id ||
685
+ g.reviewing ||
686
+ conv.wizardRunning ||
687
+ this.host.isDisposed())
688
+ return;
689
+ const goalGeneration = conv.goalGeneration;
690
+ const reviewGeneration = ++conv.goalReviewGeneration;
691
+ // Narrowed copy — TS control-flow can't narrow `g.goal` (a mutable shared
692
+ // object field) through the entire async body, so capture it here.
693
+ const goalText = g.goal;
694
+ // Capture review-only settings for this run. Changing settings while a
695
+ // review is in flight affects the next review, never this one.
696
+ const reviewPrefs = this.host.reviewSettings();
697
+ const reviewPrompt = reviewPrefs.reviewPrompt;
698
+ const reviewDisabledSkills = new Set(reviewPrefs.reviewDisabledSkills);
699
+ // Cap rounds: single-shot (locked=false) always exactly one review.
700
+ // For locked goals, maxRounds 0 = unlimited (keep revising until pass).
701
+ const budget = g.locked ? (g.maxRounds > 0 ? g.maxRounds : Infinity) : 1;
702
+ if (g.locked && g.maxRounds > 0 && g.round >= budget) {
703
+ g.status = `已达最大轮数(${budget}),停止审查`;
704
+ g.reviewing = false;
705
+ this.emitGoalStatus();
706
+ return;
707
+ }
708
+ g.reviewing = true;
709
+ g.round += 1;
710
+ g.verdict = "pending";
711
+ g.feedback = undefined;
712
+ g.status = `审查中(第 ${g.round} 轮)…`;
713
+ this.emitGoalStatus();
714
+ // Collect the review inputs.
715
+ let finalText = "";
716
+ try {
717
+ finalText = mainSession.getLastAssistantText() ?? "";
718
+ }
719
+ catch {
720
+ finalText = "";
721
+ }
722
+ const diff = await this.host.gitDiff(mainConv.cwd);
723
+ if (!this.isCurrentGoalReview(conv, goalGeneration, reviewGeneration)) {
724
+ this.discardStaleGoalReview(conv, goalGeneration, reviewGeneration);
725
+ return;
726
+ }
727
+ let reviewerVerdict = "fail";
728
+ let reviewerFeedback = "(审查无法完成)";
729
+ try {
730
+ const rmSpec = this.resolveReviewModel(g.reviewModel);
731
+ const services = await createAgentSessionServices({
732
+ cwd: mainConv.cwd,
733
+ agentDir: this.host.agentDir,
734
+ // The reviewer has its own skill allow/deny list. It deliberately does
735
+ // not reuse the main session's disabledSkills setting.
736
+ resourceLoaderOptions: {
737
+ skillsOverride: (res) => ({
738
+ ...res,
739
+ skills: res.skills.filter((s) => !reviewDisabledSkills.has(s.name)),
740
+ }),
741
+ },
742
+ // A FRESH ModelRuntime for the reviewer — isolated from the shared
743
+ // one used by the main conversations, so its model choice is its own.
744
+ modelRuntime: await ModelRuntime.create({
745
+ authPath: join(this.host.agentDir, "auth.json"),
746
+ modelsPath: join(this.host.agentDir, "models.json"),
747
+ }),
748
+ });
749
+ // Model resolution: explicit reviewer model, else the main session's
750
+ // current model (so a goal works even when no reviewer model is given).
751
+ let model;
752
+ if (rmSpec) {
753
+ model = services.modelRuntime.getModel(rmSpec.provider, rmSpec.id);
754
+ }
755
+ if (!model) {
756
+ const mainModel = mainSession.model;
757
+ if (mainModel?.provider && mainModel.id) {
758
+ model = services.modelRuntime.getModel(mainModel.provider, mainModel.id);
759
+ }
760
+ }
761
+ const srv = await createAgentSessionFromServices({
762
+ services,
763
+ sessionManager: SessionManager.inMemory(mainConv.cwd),
764
+ ...(model ? { model } : {}),
765
+ });
766
+ const reviewCap = g.locked && g.maxRounds > 0 ? g.maxRounds : 0; // 0 = no cap
767
+ const reviewer = srv.session;
768
+ await reviewer.prompt(this.reviewerPrompt(goalText, g.round, reviewCap, finalText, diff, reviewPrompt));
769
+ // Parse the reviewer's final output (expected to be a JSON object).
770
+ const raw = reviewer.getLastAssistantText() ?? "";
771
+ const m = raw.match(/\{\s*"verdict"\s*:\s*"(pass|fail)"[^}]*\}/);
772
+ if (m) {
773
+ reviewerVerdict = m[1];
774
+ const fm = raw.match(/"feedback"\s*:\s*"([^"]*)"/);
775
+ reviewerFeedback = fm?.[1] ?? "";
776
+ }
777
+ else {
778
+ // No JSON — assume fail with the raw output as feedback.
779
+ reviewerVerdict = "fail";
780
+ reviewerFeedback = raw.slice(0, 2000);
781
+ }
782
+ await srv.session.dispose();
783
+ }
784
+ catch (err) {
785
+ reviewerVerdict = "fail";
786
+ reviewerFeedback = `审查过程中出错:${err.message}`;
787
+ }
788
+ // The user may have switched chats or replaced/cleared the goal while the
789
+ // isolated reviewer was running. Never apply a stale verdict or inject it
790
+ // into the old session after that point.
791
+ if (!this.isCurrentGoalReview(conv, goalGeneration, reviewGeneration)) {
792
+ this.discardStaleGoalReview(conv, goalGeneration, reviewGeneration);
793
+ return;
794
+ }
795
+ g.reviewing = false;
796
+ g.verdict = reviewerVerdict;
797
+ g.feedback = reviewerFeedback;
798
+ const round = g.round;
799
+ // Display cap: 0 means "unlimited" (keep revising until pass).
800
+ const budgetForCard = g.locked ? (Number.isFinite(budget) ? budget : 0) : 1;
801
+ const verdict = reviewerVerdict;
802
+ const feedback = reviewerFeedback;
803
+ /** Format "round/cap" for user-facing strings; cap 0 → 不限. */
804
+ const capFmt = (cap) => cap > 0 ? `第 ${round}/${cap} 轮` : `第 ${round} 轮(不限)`;
805
+ if (verdict === "pass") {
806
+ g.status = "✅ 已通过目标审查";
807
+ this.host.emit({ type: "notice", level: "info", text: "✅ 目标已通过审查" });
808
+ g.conversationId = null;
809
+ g.goal = null; // a passed goal is done and cleared
810
+ this.emitGoalStatus();
811
+ // Pass = the review result goes straight into the conversation as an
812
+ // ordinary user message (NO separate goal-review card). It both tells the
813
+ // USER the outcome and hands the main agent back out of "goal mode", so a
814
+ // follow-up instruction like "发布" is a normal request — not a confirm echo.
815
+ try {
816
+ await mainSession.sendUserMessage(`✅ 目标已达成并通过审查(第 ${round} 轮)。\n\n目标:${goalText}\n\n${feedback}\n\n(目标模式已解除,接下来按你的普通指令响应。)`, { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
817
+ }
818
+ catch {
819
+ // Best-effort.
820
+ }
821
+ this.host.flushSnapshot();
822
+ return;
823
+ }
824
+ // Failure: if rounds remain, steer a revision; else report the loop done.
825
+ // For unlimited (budget=0) isLastRound is always false → keeps revising.
826
+ const isLastRound = !g.locked ? true : g.maxRounds > 0 && g.round >= g.maxRounds;
827
+ if (!isLastRound) {
828
+ g.status = `本轮不通过,正在把意见交给 agent 修改(${capFmt(budgetForCard)})…`;
829
+ this.host.emit({
830
+ type: "notice",
831
+ level: "warning",
832
+ text: `目标审查第 ${g.round}/${budgetForCard > 0 ? budgetForCard : "不限"} 轮未通过,把意见交给 agent 修改…`,
833
+ });
834
+ // Inject the reviewer's feedback into the main session to revise (this IS
835
+ // the fail review result, as an ordinary user message — no separate card).
836
+ try {
837
+ const steerText = `【目标审查:第 ${g.round}/${budgetForCard > 0 ? budgetForCard : "不限"} 轮未通过】\n\n目标:${goalText}\n\n` +
838
+ `审查意见:${feedback}\n\n请根据以上意见修改你的成果,使其完全满足目标。`;
839
+ await mainSession.sendUserMessage(steerText, {
840
+ deliverAs: mainSession.isStreaming ? "steer" : "followUp",
841
+ });
842
+ }
843
+ catch (err) {
844
+ g.status = `意见注入失败:${err.message}`;
845
+ }
846
+ this.emitGoalStatus();
847
+ this.host.flushSnapshot();
848
+ return;
849
+ }
850
+ // Rounds exhausted (finite cap reached / single-shot failed). Deliver the
851
+ // fail result as an ordinary user message (no separate card), like the pass
852
+ // and revise paths — the review result always lands in the conversation.
853
+ g.status =
854
+ g.locked && g.maxRounds > 0
855
+ ? `已达最大轮数(${g.maxRounds}),目标仍未通过`
856
+ : `目标未通过(${capFmt(budgetForCard)})`;
857
+ try {
858
+ await mainSession.sendUserMessage(`❌ 目标未通过审查(第 ${round}/${budgetForCard > 0 ? budgetForCard : "不限"} 轮)。\n\n目标:${goalText}\n\n审查意见:${feedback}`, { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
859
+ }
860
+ catch {
861
+ // Best-effort.
862
+ }
863
+ this.host.emit({ type: "notice", level: "warning", text: "目标未通过审查(已达最大轮数)" });
864
+ g.conversationId = null;
865
+ g.goal = null; // loop exhausted — clear the active goal
866
+ this.emitGoalStatus();
867
+ this.host.flushSnapshot();
868
+ }
869
+ }