pi-web-ui 0.18.0 → 0.19.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.
@@ -14,7 +14,8 @@ import { spawn } from "node:child_process";
14
14
  import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync, watch, } from "node:fs";
15
15
  import { dirname, join, relative, resolve, sep } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
- import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, getAgentDir, SessionManager, } from "@earendil-works/pi-coding-agent";
17
+ import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, defineTool, getAgentDir, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
18
+ import { Type } from "typebox";
18
19
  import { serializeMessage, serializeStreamingMessage, } from "./serialize.js";
19
20
  import { loadCommands, saveCommandsFile, TerminalManager, } from "./terminals.js";
20
21
  const SNAPSHOT_INTERVAL_MS = 60;
@@ -211,6 +212,23 @@ function looksLikeText(buf) {
211
212
  * payloads by data length (identical lengths within the same ms are far too
212
213
  * unlikely to matter).
213
214
  */
215
+ /** System prompt for the goal-wizard session. The wizard asks the user a few
216
+ * questions (via its goal_ask tool) to scope a raw requirement into a precise,
217
+ * reviewable goal, then emits ONLY the final goal text as its last message. */
218
+ function wizardPrompt(draft) {
219
+ return [
220
+ `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
221
+ ``,
222
+ `# User's raw requirement`, // eslint-disable-line no-regex-spaces
223
+ draft,
224
+ ``,
225
+ `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
226
+ `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
227
+ `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
228
+ `GOAL: <one concrete, verifiable sentence describing the deliverable and its acceptance criteria>`, // eslint-disable-line max-len
229
+ `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
230
+ ].join("\n");
231
+ }
214
232
  function contentFingerprint(m) {
215
233
  const content = m.content;
216
234
  if (!Array.isArray(content) || content.length === 0)
@@ -405,6 +423,15 @@ export class WebUIContext {
405
423
  this.emit({ type: "dialog_closed", id });
406
424
  }
407
425
  }
426
+ /** Close every pending dialog as cancelled (used when a goal wizard aborts —
427
+ * its unanswered browser dialogs must vanish, not linger). */
428
+ cancelPendingDialogs() {
429
+ for (const [id, resolve] of this.pendingDialogs) {
430
+ this.pendingDialogs.delete(id);
431
+ resolve(null);
432
+ this.emit({ type: "dialog_closed", id });
433
+ }
434
+ }
408
435
  // -- inert TUI-only affordances ------------------------------------------
409
436
  onTerminalInput = () => () => { };
410
437
  setWorkingMessage = () => { };
@@ -639,6 +666,28 @@ class ClientStateStore {
639
666
  ].slice(0, 30);
640
667
  this.save();
641
668
  }
669
+ /** Last-used goal/review prefs for a client, or undefined if never set. */
670
+ getGoalPrefs(clientId) {
671
+ const s = this.load()[clientId];
672
+ if (!s?.goalPrefs)
673
+ return undefined;
674
+ return {
675
+ reviewModel: s.goalPrefs.reviewModel ?? null,
676
+ maxRounds: s.goalPrefs.maxRounds ?? 0,
677
+ locked: s.goalPrefs.locked ?? true,
678
+ };
679
+ }
680
+ /** Persist the client's goal/review preferences (model choice, rounds, lock). */
681
+ saveGoalPrefs(clientId, prefs) {
682
+ const all = this.load();
683
+ const state = (all[clientId] ??= { projects: [] });
684
+ state.goalPrefs = {
685
+ reviewModel: prefs?.reviewModel ?? null,
686
+ maxRounds: prefs?.maxRounds ?? 0,
687
+ locked: prefs?.locked ?? true,
688
+ };
689
+ this.save();
690
+ }
642
691
  }
643
692
  /** Cap on simultaneously open conversations of ONE project (each keeps a full
644
693
  * runtime alive; conversations of other projects keep their own lists). */
@@ -694,6 +743,51 @@ export class ClientSession {
694
743
  * top bar applies to every chat, not just the one that set it. Seeded by
695
744
  * the first conversation and reused by later ones. */
696
745
  sharedModelRuntime;
746
+ // -----------------------------------------------------------------------
747
+ // Goal / review state. When a goal is active, every finished agent run
748
+ // (agent_end) is checked by an ISOLATED reviewer agent; a failing review
749
+ // injects its feedback back into the main session to revise. All goal
750
+ // mutation goes through setGoal/clearGoal so UI state stays consistent.
751
+ // -----------------------------------------------------------------------
752
+ goal = {
753
+ goal: null,
754
+ reviewModel: null,
755
+ maxRounds: 0, // 0 = unlimited (keep revising until the goal passes)
756
+ locked: true,
757
+ reviewing: false,
758
+ round: 0,
759
+ status: "",
760
+ verdict: "pending",
761
+ wizard: {
762
+ active: false,
763
+ draft: "",
764
+ model: null,
765
+ step: 0,
766
+ maxSteps: 6,
767
+ status: "",
768
+ },
769
+ };
770
+ /** Guard: only one review may run at a time (agent_end fires per turn and
771
+ * review is async). */
772
+ goalReviewing = false;
773
+ /** Guard: the goal wizard and the review loop are mutually exclusive — a
774
+ * wizard in flight stops review triggers (and vice versa). */
775
+ goalWizardRunning = false;
776
+ /** Aborts the currently-running goal wizard (user clicked ✗ / timed out). Drives
777
+ * the in-flight goal_ask dialog to resolve as cancelled and (via the run
778
+ * signal) stops the wizard session's agent run. Recreated per wizard. */
779
+ wizardAbort = null;
780
+ /** The wizard's AgentSession while it runs — lets clearGoal truly terminate it
781
+ * (abort the run), not just flip a flag. */
782
+ wizardSession = null;
783
+ /** True when the wizard was cancelled externally (✗ / clear_goal / timeout) —
784
+ * startGoalWizard reads this after the run to avoid setting a goal. */
785
+ wizardCancelled = false;
786
+ /** Idle-timeout for the wizard: if no answer arrives within this window (a
787
+ * dialog is up but the user doesn't respond), the wizard is auto-cancelled. */
788
+ static WIZARD_IDLE_TIMEOUT_MS = 5 * 60_000;
789
+ /** Absolute deadline for the whole wizard session (model latency guard). */
790
+ static WIZARD_MAX_TOTAL_MS = 20 * 60_000;
697
791
  /** The active conversation (all session operations target it). */
698
792
  get conv() {
699
793
  const conv = this.convs.get(this.activeId);
@@ -745,6 +839,13 @@ export class ClientSession {
745
839
  static async create(clientId, cwd, stateStore) {
746
840
  const agentDir = process.env.PI_CODING_AGENT_DIR ?? getAgentDir();
747
841
  const cs = new ClientSession(clientId, cwd, agentDir, stateStore);
842
+ // Restore last-used goal/review preferences so model & rounds survive reload.
843
+ const gPrefs = stateStore.getGoalPrefs(clientId);
844
+ if (gPrefs) {
845
+ cs.goal.reviewModel = gPrefs.reviewModel;
846
+ cs.goal.maxRounds = gPrefs.maxRounds;
847
+ cs.goal.locked = gPrefs.locked;
848
+ }
748
849
  const runtime = await createAgentSessionRuntime(cs.makeRuntimeFactory(), {
749
850
  cwd,
750
851
  agentDir,
@@ -835,6 +936,9 @@ export class ClientSession {
835
936
  // Reconnect: same for the slash-command catalog (the picker needs it even
836
937
  // before the client asks).
837
938
  void this.pushSlashCommands();
939
+ // Reconnect: push the remembered goal prefs (model choice, rounds cap,
940
+ // locked) so the goal bar restores them on reload — "全局记忆".
941
+ this.emitGoalStatus();
838
942
  }
839
943
  detachSink(send) {
840
944
  this.sinks.delete(send);
@@ -956,7 +1060,46 @@ export class ClientSession {
956
1060
  break;
957
1061
  // A run finished or a new entry was persisted — keep the session list fresh
958
1062
  // (new chat + first message, completed turns, compaction, etc.).
959
- case "agent_end":
1063
+ case "agent_end": {
1064
+ this.scheduleSessionsRefresh();
1065
+ const g = this.goal;
1066
+ // Manual interrupt (Stop button / abort): the last assistant message
1067
+ // carries stopReason "aborted". A half-finished run should NOT be
1068
+ // reviewed (it would fail and inject a revision, only to be stopped
1069
+ // again → an endless review loop). Clear the goal so the review loop
1070
+ // stops too, then let the user give a fresh instruction.
1071
+ const aborted = event.messages.some((m) => {
1072
+ const a = m;
1073
+ return a.role === "assistant" && a.stopReason === "aborted";
1074
+ });
1075
+ if (aborted) {
1076
+ if (g.goal) {
1077
+ this.goal.goal = null;
1078
+ this.goal.reviewing = false;
1079
+ this.goal.verdict = "pending";
1080
+ this.goal.feedback = undefined;
1081
+ this.goal.status = "已手动停止,目标审查已中止";
1082
+ this.emitGoalStatus();
1083
+ this.emit({
1084
+ type: "notice",
1085
+ level: "warning",
1086
+ text: "⏹ 已手动停止,目标审查已中止(想继续可重新设定目标)",
1087
+ });
1088
+ }
1089
+ break;
1090
+ }
1091
+ // Goal review hook: after the run finished normally, if a goal is
1092
+ // active (and it belonged to the ACTIVE conversation) and we're not
1093
+ // already mid-review, spawn the isolated reviewer.
1094
+ if (g.goal &&
1095
+ !g.reviewing &&
1096
+ !this.goalWizardRunning &&
1097
+ conv.id === this.activeId &&
1098
+ !this.disposed) {
1099
+ void this.runGoalReview(conv);
1100
+ }
1101
+ break;
1102
+ }
960
1103
  case "entry_appended":
961
1104
  this.scheduleSessionsRefresh();
962
1105
  break;
@@ -1122,11 +1265,12 @@ export class ClientSession {
1122
1265
  * Never throws / never crashes the server: spawn errors (ENOENT etc.)
1123
1266
  * resolve with code -1 so callers can report them as notices.
1124
1267
  */
1125
- runAsync(cmd, args, timeoutMs) {
1268
+ runAsync(cmd, args, timeoutMs, cwd) {
1126
1269
  return new Promise((resolve) => {
1127
1270
  let p;
1128
1271
  try {
1129
1272
  p = spawn(cmd, args, {
1273
+ ...(cwd ? { cwd } : {}),
1130
1274
  stdio: ["ignore", "pipe", "pipe"],
1131
1275
  // Windows: npm and friends are .cmd shims — Node can only exec
1132
1276
  // them through the shell (otherwise spawn npm → ENOENT).
@@ -3055,6 +3199,665 @@ export class ClientSession {
3055
3199
  });
3056
3200
  }
3057
3201
  }
3202
+ // ---------------------------------------------------------------------------
3203
+ // Goal / review
3204
+ // ---------------------------------------------------------------------------
3205
+ /** Push the current goal status to the client (the goal bar UI). */
3206
+ emitGoalStatus() {
3207
+ this.emit({ type: "goal_status", status: { ...this.goal } });
3208
+ }
3209
+ /**
3210
+ * Set (or clear) the active goal. `goal === ""` clears it. The goal is
3211
+ * applied to the CURRENT active conversation of this project; reviews check
3212
+ * whatever run finishes next (agent_end).
3213
+ */
3214
+ async setGoal(goalText, opts) {
3215
+ const text = (goalText ?? "").trim();
3216
+ if (!text) {
3217
+ await this.clearGoal();
3218
+ return;
3219
+ }
3220
+ this.goal.goal = text;
3221
+ // Model & rounds preference semantics ("全局记忆"):
3222
+ // - reviewModel undefined → keep the remembered choice; empty → main model.
3223
+ // - maxRounds 0 = unlimited (default); >0 = finite cap (clamped to 50).
3224
+ if (opts?.reviewModel !== undefined)
3225
+ this.goal.reviewModel = opts.reviewModel || null;
3226
+ if (typeof opts?.maxRounds === "number") {
3227
+ const mr = Math.round(opts.maxRounds);
3228
+ this.goal.maxRounds = mr >= 1 ? Math.min(mr, 50) : 0;
3229
+ }
3230
+ if (opts?.locked !== undefined)
3231
+ this.goal.locked = opts.locked;
3232
+ // Persist the chosen preferences so they survive reload.
3233
+ this.stateStore.saveGoalPrefs(this.clientId, {
3234
+ reviewModel: this.goal.reviewModel,
3235
+ maxRounds: this.goal.maxRounds,
3236
+ locked: this.goal.locked,
3237
+ });
3238
+ // Reset the loop for a freshly-set goal (single-shot goals start at 0).
3239
+ this.goal.round = 0;
3240
+ this.goal.reviewing = false;
3241
+ this.goal.verdict = "pending";
3242
+ this.goal.feedback = undefined;
3243
+ this.goal.wizard.active = false;
3244
+ this.goal.wizard.status = "";
3245
+ this.goal.status = "目标已设,等待生成…";
3246
+ this.emitGoalStatus();
3247
+ this.emit({
3248
+ type: "notice",
3249
+ level: "info",
3250
+ text: `🎯 已设目标:${text.slice(0, 80)}${text.length > 80 ? "…" : ""}`,
3251
+ });
3252
+ // Auto-start generation right after setting the goal (unless this setGoal is
3253
+ // the wizard's internal one, which kicks off itself). This makes the direct
3254
+ // goal-bar path behave like the AI-提炼 path: set a target → agent begins.
3255
+ if (opts?.autoStart !== false) {
3256
+ try {
3257
+ const s = this.conv.session;
3258
+ await s.sendUserMessage(`【目标已设定】\n\n${text}\n\n请现在开始实现这个目标。`, { deliverAs: s.isStreaming ? "steer" : "followUp" });
3259
+ }
3260
+ catch {
3261
+ // Best-effort; the user can still prompt manually.
3262
+ }
3263
+ this.flushSnapshot();
3264
+ }
3265
+ }
3266
+ /**
3267
+ * Collaborative target wizard. Turns a raw user requirement into a refined
3268
+ * goal by spinning up an ISOLATED wizard session (own fresh ModelRuntime +
3269
+ * in-memory session, so its model choice is its own) that questions the user
3270
+ * via `goal_ask` (multiple-choice + free-text, bridged to the browser through
3271
+ * the existing select/input dialog), converging on a goal, then auto-sets it.
3272
+ * Mutually exclusive with the review loop.
3273
+ */
3274
+ async startGoalWizard(text, opts) {
3275
+ const draft = (text ?? "").trim();
3276
+ if (!draft)
3277
+ return;
3278
+ if (this.goalWizardRunning) {
3279
+ this.emit({
3280
+ type: "notice",
3281
+ level: "warning",
3282
+ text: "已有目标调研进行中,请等它完成…",
3283
+ });
3284
+ return;
3285
+ }
3286
+ if (this.goalReviewing) {
3287
+ this.emit({
3288
+ type: "notice",
3289
+ level: "warning",
3290
+ text: "正在审查中,无法开始目标调研,请稍等…",
3291
+ });
3292
+ return;
3293
+ }
3294
+ // Questions are NOT capped (调研不限制) — the wizard converges on its own;
3295
+ // the idle- and total-timeouts are the only guards. maxSteps is purely a
3296
+ // soft UI indicator, not a hard stop.
3297
+ const maxSteps = 20;
3298
+ this.goalWizardRunning = true;
3299
+ this.wizardCancelled = false;
3300
+ this.wizardAbort = new AbortController();
3301
+ this.wizardSession = null;
3302
+ this.goal.wizard.active = true;
3303
+ this.goal.wizard.draft = draft;
3304
+ this.goal.wizard.model = opts?.wizardModel ?? null;
3305
+ // Remember the model choice (and persist rounds/lock) — global memory.
3306
+ if (opts?.wizardModel !== undefined && opts.wizardModel !== null)
3307
+ this.goal.reviewModel = opts.wizardModel || null;
3308
+ this.stateStore.saveGoalPrefs(this.clientId, {
3309
+ reviewModel: this.goal.reviewModel,
3310
+ maxRounds: this.goal.maxRounds,
3311
+ locked: this.goal.locked,
3312
+ });
3313
+ this.goal.wizard.step = 0;
3314
+ this.goal.wizard.maxSteps = maxSteps;
3315
+ this.goal.wizard.status = "调研中…";
3316
+ this.goal.status = "目标调研中…";
3317
+ this.emitGoalStatus();
3318
+ // Idle-timeout: cancel the wizard if no question is answered within the
3319
+ // window (a stale dialog with no user response must not run forever). A
3320
+ // fresh timer is armed for each question; cleared once the run ends.
3321
+ const ac = this.wizardAbort;
3322
+ let idleTimer = null;
3323
+ const armIdle = () => {
3324
+ if (idleTimer)
3325
+ clearTimeout(idleTimer);
3326
+ idleTimer = setTimeout(() => {
3327
+ if (!ac.signal.aborted) {
3328
+ this.wizardCancelled = true;
3329
+ ac.abort(new Error("目标调研超时(等待回答过久)"));
3330
+ }
3331
+ }, ClientSession.WIZARD_IDLE_TIMEOUT_MS);
3332
+ idleTimer.unref?.();
3333
+ };
3334
+ const clearIdle = () => {
3335
+ if (idleTimer) {
3336
+ clearTimeout(idleTimer);
3337
+ idleTimer = null;
3338
+ }
3339
+ };
3340
+ armIdle();
3341
+ // Total-duration guard: hard cap on the whole wizard session (model
3342
+ // latency / unexpected loops must not run forever).
3343
+ const totalTimer = setTimeout(() => {
3344
+ if (!ac.signal.aborted) {
3345
+ this.wizardCancelled = true;
3346
+ ac.abort(new Error("目标调研超过总时长上限"));
3347
+ }
3348
+ }, ClientSession.WIZARD_MAX_TOTAL_MS);
3349
+ totalTimer.unref?.();
3350
+ this.emit({
3351
+ type: "notice",
3352
+ level: "info",
3353
+ text: `🔍 正在围绕需求展开调研:${draft.slice(0, 60)}${draft.length > 60 ? "…" : ""}`,
3354
+ });
3355
+ // The main conversation to show wizard progress cards in.
3356
+ let mainSession = this.session;
3357
+ try {
3358
+ const conv = this.conv;
3359
+ mainSession = conv.session;
3360
+ }
3361
+ catch {
3362
+ // no active conversation yet
3363
+ }
3364
+ let refinedGoal = "";
3365
+ try {
3366
+ const wmSpec = opts?.wizardModel
3367
+ ? this.resolveReviewModel(opts.wizardModel)
3368
+ : null; // reuse the honest "provider/id" parser
3369
+ const services = await createAgentSessionServices({
3370
+ cwd: this.cwd,
3371
+ agentDir: this.agentDir,
3372
+ modelRuntime: await ModelRuntime.create({
3373
+ authPath: join(this.agentDir, "auth.json"),
3374
+ modelsPath: join(this.agentDir, "models.json"),
3375
+ }),
3376
+ });
3377
+ let model;
3378
+ if (wmSpec)
3379
+ model = services.modelRuntime.getModel(wmSpec.provider, wmSpec.id);
3380
+ if (!model) {
3381
+ const mainModel = mainSession.model;
3382
+ if (mainModel?.provider && mainModel.id)
3383
+ model = services.modelRuntime.getModel(mainModel.provider, mainModel.id);
3384
+ }
3385
+ // The wizard asks the user questions via this tool; each call bridges one
3386
+ // select/input dialog to the browser and returns the user's answer.
3387
+ let qStep = 0;
3388
+ const goalAsk = defineTool({
3389
+ name: "goal_ask",
3390
+ label: "Ask the user",
3391
+ 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.",
3392
+ parameters: Type.Object({
3393
+ question: Type.String({ description: "The question to ask" }),
3394
+ options: Type.Optional(Type.Array(Type.String())),
3395
+ }),
3396
+ // ONE question at a time. Sequential execution prevents the agent from
3397
+ // firing parallel goal_ask calls whose dialogs would overwrite each other
3398
+ // in the single browser modal (leaving earlier ones deadlocked — the
3399
+ // reported "调研卡住").
3400
+ executionMode: "sequential",
3401
+ execute: async (_id, params, _sig, _onUpdate, ctx) => {
3402
+ qStep += 1;
3403
+ if (qStep > maxSteps) {
3404
+ return {
3405
+ content: [
3406
+ {
3407
+ type: "text",
3408
+ text: "(达到最大提问数,请直接给出收敛后的目标文本作为最终答案)",
3409
+ },
3410
+ ],
3411
+ details: {},
3412
+ };
3413
+ }
3414
+ // Show the question in the main flow BEFORE blocking on the dialog, so
3415
+ // the user sees the wizard working even before answering.
3416
+ this.goal.wizard.step = qStep;
3417
+ this.goal.wizard.status = `调研中:请回答第 ${qStep} 题`;
3418
+ this.emitGoalStatus();
3419
+ try {
3420
+ armIdle();
3421
+ const isChoice = !!(params.options && params.options.length > 0);
3422
+ await this.pushWizardCard(mainSession, `🔍 第 ${qStep} 题:${params.question}${isChoice ? `【${params.options.join(" / ")}】` : ""}`, { question: params.question });
3423
+ // Resolve the pending dialog as cancelled if the wizard is aborted.
3424
+ let aborted = false;
3425
+ const onAbort = () => {
3426
+ aborted = true;
3427
+ };
3428
+ ac.signal.addEventListener("abort", onAbort, { once: true });
3429
+ const choose = isChoice
3430
+ ? ctx.ui.select(`🔍 第 ${qStep} 题:${params.question}`, params.options)
3431
+ : ctx.ui.input(`🔍 第 ${qStep} 题:${params.question}`);
3432
+ const ans = (await choose);
3433
+ ac.signal.removeEventListener("abort", onAbort);
3434
+ if (aborted || ac.signal.aborted) {
3435
+ return {
3436
+ content: [
3437
+ {
3438
+ type: "text",
3439
+ text: "(调研已取消,请不要继续提问,直接结束对话)",
3440
+ },
3441
+ ],
3442
+ details: {},
3443
+ };
3444
+ }
3445
+ if (ans === undefined || ans === null || ans === false || ans === "") {
3446
+ return {
3447
+ content: [
3448
+ {
3449
+ type: "text",
3450
+ text: "(用户已取消调研,请直接给出你当前收敛的目标文本作为最终答案)",
3451
+ },
3452
+ ],
3453
+ details: {},
3454
+ };
3455
+ }
3456
+ // Record the answer in the flow too (instant append, main session idle).
3457
+ await this.pushWizardCard(mainSession, `↳ 您的回答:${ans}`, { question: params.question, answer: String(ans) });
3458
+ return {
3459
+ content: [{ type: "text", text: `用户回答:${ans}` }],
3460
+ details: {},
3461
+ };
3462
+ }
3463
+ catch (err) {
3464
+ return {
3465
+ content: [
3466
+ {
3467
+ type: "text",
3468
+ text: ac.signal.aborted
3469
+ ? "(调研已取消,请不要继续提问,直接结束对话)"
3470
+ : `提问失败:${err.message}`,
3471
+ },
3472
+ ],
3473
+ details: {},
3474
+ };
3475
+ }
3476
+ },
3477
+ });
3478
+ const srv = await createAgentSessionFromServices({
3479
+ services,
3480
+ sessionManager: SessionManager.inMemory(this.cwd),
3481
+ customTools: [goalAsk],
3482
+ ...(model ? { model } : {}),
3483
+ });
3484
+ const wizard = srv.session;
3485
+ this.wizardSession = wizard;
3486
+ await wizard.bindExtensions({ mode: "rpc", uiContext: this.webUi });
3487
+ // Cancel watcher: when the user ✗s / idle-timeout fires, truly stop the
3488
+ // wizard's agent run (not just mark it).
3489
+ if (!ac.signal.aborted) {
3490
+ ac.signal.addEventListener("abort", () => {
3491
+ void wizard.abort().catch(() => { });
3492
+ // Close the unanswered browser dialog(s) the wizard may have up.
3493
+ this.webUi.cancelPendingDialogs();
3494
+ }, { once: true });
3495
+ }
3496
+ await wizard.prompt(wizardPrompt(draft));
3497
+ refinedGoal = wizard.getLastAssistantText()?.trim() ?? "";
3498
+ // The wizard is prompted to emit "GOAL: <text>". Parse past the marker;
3499
+ // if it didn't follow, strip a leading preamble line and keep the rest.
3500
+ const goalMatch = refinedGoal.match(/GOAL\s*[::]\s*([\s\S]*)/i);
3501
+ if (goalMatch) {
3502
+ refinedGoal = goalMatch[1].trim();
3503
+ }
3504
+ else {
3505
+ const lines = refinedGoal.split("\n").filter((l) => l.trim());
3506
+ if (lines.length > 1 && !/[。.!??]\s*$/.test(lines[0])) {
3507
+ // First line looks like preamble (no sentence-ending punctuation).
3508
+ refinedGoal = lines.slice(1).join(" ").trim();
3509
+ }
3510
+ }
3511
+ await srv.session.dispose();
3512
+ }
3513
+ catch (err) {
3514
+ this.emit({
3515
+ type: "notice",
3516
+ level: "error",
3517
+ text: `目标调研失败:${err.message}`,
3518
+ });
3519
+ }
3520
+ finally {
3521
+ clearIdle();
3522
+ clearTimeout(totalTimer);
3523
+ this.goalWizardRunning = false;
3524
+ this.goal.wizard.active = false;
3525
+ this.goal.wizard.step = 0;
3526
+ this.goal.wizard.status = "";
3527
+ this.wizardSession = null;
3528
+ this.emitGoalStatus();
3529
+ }
3530
+ // Aborted externally (✗ / clear_goal / idle-timeout): do NOT set a goal.
3531
+ if (ac.signal.aborted || this.wizardCancelled) {
3532
+ this.emit({
3533
+ type: "notice",
3534
+ level: "info",
3535
+ text: `目标调研已取消${ac.signal.reason ? `:${String(ac.signal.reason?.message ?? ac.signal.reason)}` : ""}`,
3536
+ });
3537
+ this.wizardAbort = null;
3538
+ return;
3539
+ }
3540
+ if (!refinedGoal.trim()) {
3541
+ this.emit({
3542
+ type: "notice",
3543
+ level: "warning",
3544
+ text: "调研未产出有效目标,请重试",
3545
+ });
3546
+ return;
3547
+ }
3548
+ // Auto-set the refined goal. The wizard workflow implies "set a goal and
3549
+ // work until it passes", so default LOCKED=true unless the user explicitly
3550
+ // turned the lock off (a lock lets the review loop keep revising to pass;
3551
+ // without it the review is single-shot).
3552
+ const wantLocked = opts?.locked === undefined ? true : opts.locked;
3553
+ await this.setGoal(refinedGoal, {
3554
+ reviewModel: this.goal.reviewModel ?? undefined,
3555
+ maxRounds: opts?.maxRounds,
3556
+ locked: wantLocked,
3557
+ // The wizard kicks off generation itself below — avoid a double kick.
3558
+ autoStart: false,
3559
+ });
3560
+ const g2 = this.goal;
3561
+ this.wizardCancelled = false;
3562
+ this.wizardAbort = null;
3563
+ this.emit({
3564
+ type: "notice",
3565
+ level: "info",
3566
+ text: `🎯 调研完成,目标已设为:${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""}`,
3567
+ });
3568
+ // Kick the main agent into generating right away (no manual "开始吧").
3569
+ // The kick-off is a user message so it appears in the flow and triggers a
3570
+ // normal turn; the finishing agent_end then runs the review loop.
3571
+ try {
3572
+ await mainSession.sendUserMessage(`【目标已设定】\n\n${g2.goal}\n\n请现在开始实现这个目标。`, { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
3573
+ }
3574
+ catch {
3575
+ // Generation kick-off is best-effort; the user can still prompt manually.
3576
+ }
3577
+ }
3578
+ /** Persist goal/review preference defaults (model, rounds cap, locked) without
3579
+ * touching the active goal — so changes in the goal bar are remembered across
3580
+ * reloads. maxRounds 0 = unlimited. Emits goal_status so the UI stays synced. */
3581
+ async setGoalPrefs(opts) {
3582
+ if (opts?.reviewModel !== undefined)
3583
+ this.goal.reviewModel = opts.reviewModel || null;
3584
+ if (typeof opts?.maxRounds === "number") {
3585
+ const mr = Math.round(opts.maxRounds);
3586
+ this.goal.maxRounds = mr >= 1 ? Math.min(mr, 50) : 0;
3587
+ }
3588
+ if (opts?.locked !== undefined)
3589
+ this.goal.locked = opts.locked;
3590
+ this.stateStore.saveGoalPrefs(this.clientId, {
3591
+ reviewModel: this.goal.reviewModel,
3592
+ maxRounds: this.goal.maxRounds,
3593
+ locked: this.goal.locked,
3594
+ });
3595
+ this.emitGoalStatus();
3596
+ }
3597
+ /** Clear the active goal (cancels the review loop AND aborts a running
3598
+ * goal wizard — truly terminating its in-flight dialog + agent run). */
3599
+ async clearGoal() {
3600
+ this.goal.goal = null;
3601
+ this.goal.reviewing = false;
3602
+ this.goal.verdict = "pending";
3603
+ this.goal.feedback = undefined;
3604
+ this.goal.wizard.active = false;
3605
+ this.goal.wizard.status = "";
3606
+ this.goal.status = "";
3607
+ this.emitGoalStatus();
3608
+ // Abort a running wizard for real (✗ in the goal bar while scoping).
3609
+ if (this.goalWizardRunning || this.wizardAbort || this.wizardSession) {
3610
+ this.wizardCancelled = true;
3611
+ this.webUi.cancelPendingDialogs();
3612
+ this.wizardAbort?.abort();
3613
+ const ws2 = this.wizardSession;
3614
+ this.wizardSession = null;
3615
+ if (ws2) {
3616
+ await ws2.abort().catch(() => { });
3617
+ ws2.dispose();
3618
+ }
3619
+ this.wizardAbort = null;
3620
+ }
3621
+ }
3622
+ /** Build a "provider/id" or null for the reviewer model, validating it exists. */
3623
+ resolveReviewModel(spec) {
3624
+ if (!spec)
3625
+ return null;
3626
+ const slash = spec.indexOf("/");
3627
+ if (slash <= 0 || slash === spec.length - 1)
3628
+ return null;
3629
+ return { provider: spec.slice(0, slash), id: spec.slice(slash + 1), spec };
3630
+ }
3631
+ /**
3632
+ * The whitelisted reviewer plan — tell the reviewer what to decide and how
3633
+ * to report, regardless of which model it runs on.
3634
+ */
3635
+ reviewerPrompt(goal, round, maxRounds, output, gitDiff) {
3636
+ return [
3637
+ `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
3638
+ ``,
3639
+ `# Goal`, // eslint-disable-line no-regex-spaces
3640
+ goal,
3641
+ ``,
3642
+ `# Agent's final output`, // eslint-disable-line no-regex-spaces
3643
+ output.length > 0 ? output : "(the agent produced no text — inspect the diff)", // eslint-disable-line max-len
3644
+ ``,
3645
+ `# Git diff (if any)`, // eslint-disable-line no-regex-spaces
3646
+ gitDiff.length > 0 ? gitDiff : "(no staged/committed changes detected)", // eslint-disable-line max-len
3647
+ ``,
3648
+ `This is review round ${round}${maxRounds > 0 ? ` of up to ${maxRounds}` : " (no round cap — keep revising until it passes)"}.`, // eslint-disable-line max-len
3649
+ ``,
3650
+ `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
3651
+ `{"verdict":"pass","feedback":"<one short sentence: what was satisfied>"}`, // eslint-disable-line max-len
3652
+ `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
3653
+ `The feedback for a fail must be specific enough that the agent can act on it directly.`, // eslint-disable-line max-len
3654
+ ].join("\n");
3655
+ }
3656
+ /** Insert a wizard progress card into the MAIN conversation flow and render it
3657
+ * IMMEDIATELY (the main session is idle while the wizard runs in its own
3658
+ * session, so — unlike nextTurn, which queues until the next user prompt —
3659
+ * sending without a delivery option appends + persists + emits at once). */
3660
+ async pushWizardCard(sess, text, details) {
3661
+ try {
3662
+ await sess.sendCustomMessage({
3663
+ customType: "goal-wizard",
3664
+ content: [{ type: "text", text }],
3665
+ display: true,
3666
+ details: { type: "goal-wizard", ...details },
3667
+ });
3668
+ }
3669
+ catch {
3670
+ // Non-fatal
3671
+ }
3672
+ }
3673
+ /** Run a git diff (unstaged + staged) in the workspace, or "" when not a repo. */
3674
+ async gitDiff() {
3675
+ try {
3676
+ const { code, out } = await this.runAsync("git", ["diff", "HEAD"], 10_000, this.cwd);
3677
+ if (code !== 0)
3678
+ return "";
3679
+ return out.slice(0, 60_000);
3680
+ }
3681
+ catch {
3682
+ return "";
3683
+ }
3684
+ }
3685
+ /**
3686
+ * The review loop: build an ISOLATED reviewer session (own fresh
3687
+ * AgentSession + own ModelRuntime so the reviewer truly runs on a different
3688
+ * model without touching the main session), ask it to judge the goal, then:
3689
+ * - pass → set status "已通过", insert a verdict card, end the loop;
3690
+ * - fail → inject the feedback as a user message into the main session
3691
+ * to steer a revision; the next agent_end re-reviews with the
3692
+ * same round budget.
3693
+ * Guarded so it never runs two reviews concurrently.
3694
+ */
3695
+ async runGoalReview(conv) {
3696
+ // The review is bound to the conversation that just ran — but the user may
3697
+ // have switched to another conversation meanwhile. Reviews only make sense
3698
+ // for the conversation that generated output, so track it locally.
3699
+ const mainConv = this.convs.get(conv.id) ?? conv;
3700
+ const mainSession = mainConv.session;
3701
+ const g = this.goal;
3702
+ if (!g.goal ||
3703
+ this.goalReviewing ||
3704
+ this.goalWizardRunning ||
3705
+ this.disposed)
3706
+ return;
3707
+ // Narrowed copy — TS control-flow can't narrow `g.goal` (a mutable shared
3708
+ // object field) through the entire async body, so capture it here.
3709
+ const goalText = g.goal;
3710
+ // Cap rounds: single-shot (locked=false) always exactly one review.
3711
+ // For locked goals, maxRounds 0 = unlimited (keep revising until pass).
3712
+ const budget = g.locked ? (g.maxRounds > 0 ? g.maxRounds : Infinity) : 1;
3713
+ if (g.locked && g.maxRounds > 0 && g.round >= budget) {
3714
+ this.goal.status = `已达最大轮数(${budget}),停止审查`;
3715
+ this.goal.reviewing = false;
3716
+ this.emitGoalStatus();
3717
+ return;
3718
+ }
3719
+ this.goalReviewing = true;
3720
+ g.reviewing = true;
3721
+ g.round += 1;
3722
+ g.verdict = "pending";
3723
+ g.feedback = undefined;
3724
+ g.status = `审查中(第 ${g.round} 轮)…`;
3725
+ this.emitGoalStatus();
3726
+ // Collect the review inputs.
3727
+ let finalText = "";
3728
+ try {
3729
+ finalText = mainSession.getLastAssistantText() ?? "";
3730
+ }
3731
+ catch {
3732
+ finalText = "";
3733
+ }
3734
+ const diff = await this.gitDiff();
3735
+ let reviewerVerdict = "fail";
3736
+ let reviewerFeedback = "(审查无法完成)";
3737
+ try {
3738
+ const rmSpec = this.resolveReviewModel(g.reviewModel);
3739
+ const services = await createAgentSessionServices({
3740
+ cwd: this.cwd,
3741
+ agentDir: this.agentDir,
3742
+ // A FRESH ModelRuntime for the reviewer — isolated from the shared
3743
+ // one used by the main conversations, so its model choice is its own.
3744
+ modelRuntime: await ModelRuntime.create({
3745
+ authPath: join(this.agentDir, "auth.json"),
3746
+ modelsPath: join(this.agentDir, "models.json"),
3747
+ }),
3748
+ });
3749
+ // Model resolution: explicit reviewer model, else the main session's
3750
+ // current model (so a goal works even when no reviewer model is given).
3751
+ let model;
3752
+ if (rmSpec) {
3753
+ model = services.modelRuntime.getModel(rmSpec.provider, rmSpec.id);
3754
+ }
3755
+ if (!model) {
3756
+ const mainModel = mainSession.model;
3757
+ if (mainModel?.provider && mainModel.id) {
3758
+ model = services.modelRuntime.getModel(mainModel.provider, mainModel.id);
3759
+ }
3760
+ }
3761
+ const srv = await createAgentSessionFromServices({
3762
+ services,
3763
+ sessionManager: SessionManager.inMemory(this.cwd),
3764
+ ...(model ? { model } : {}),
3765
+ });
3766
+ const reviewCap = g.locked && g.maxRounds > 0 ? g.maxRounds : 0; // 0 = no cap
3767
+ const reviewer = srv.session;
3768
+ await reviewer.prompt(this.reviewerPrompt(goalText, g.round, reviewCap, finalText, diff));
3769
+ // Parse the reviewer's final output (expected to be a JSON object).
3770
+ const raw = reviewer.getLastAssistantText() ?? "";
3771
+ const m = raw.match(/\{\s*"verdict"\s*:\s*"(pass|fail)"[^}]*\}/);
3772
+ if (m) {
3773
+ reviewerVerdict = m[1];
3774
+ const fm = raw.match(/"feedback"\s*:\s*"([^"]*)"/);
3775
+ reviewerFeedback = fm?.[1] ?? "";
3776
+ }
3777
+ else {
3778
+ // No JSON — assume fail with the raw output as feedback.
3779
+ reviewerVerdict = "fail";
3780
+ reviewerFeedback = raw.slice(0, 2000);
3781
+ }
3782
+ await srv.session.dispose();
3783
+ }
3784
+ catch (err) {
3785
+ reviewerVerdict = "fail";
3786
+ reviewerFeedback = `审查过程中出错:${err.message}`;
3787
+ }
3788
+ this.goalReviewing = false;
3789
+ g.reviewing = false;
3790
+ g.verdict = reviewerVerdict;
3791
+ g.feedback = reviewerFeedback;
3792
+ const round = g.round;
3793
+ // Display cap: 0 means "unlimited" (keep revising until pass).
3794
+ const budgetForCard = g.locked ? (Number.isFinite(budget) ? budget : 0) : 1;
3795
+ const verdict = reviewerVerdict;
3796
+ const feedback = reviewerFeedback;
3797
+ /** Format "round/cap" for user-facing strings; cap 0 → 不限. */
3798
+ const capFmt = (cap) => cap > 0 ? `第 ${round}/${cap} 轮` : `第 ${round} 轮(不限)`;
3799
+ if (verdict === "pass") {
3800
+ g.status = "✅ 已通过目标审查";
3801
+ this.emit({ type: "notice", level: "info", text: "✅ 目标已通过审查" });
3802
+ g.goal = null; // a passed goal is done and cleared
3803
+ this.emitGoalStatus();
3804
+ // Pass = the review result goes straight into the conversation as an
3805
+ // ordinary user message (NO separate goal-review card). It both tells the
3806
+ // USER the outcome and hands the main agent back out of "goal mode", so a
3807
+ // follow-up instruction like "发布" is a normal request — not a confirm echo.
3808
+ try {
3809
+ await mainSession.sendUserMessage(`✅ 目标已达成并通过审查(第 ${round} 轮)。\n\n目标:${goalText}\n\n${feedback}\n\n(目标模式已解除,接下来按你的普通指令响应。)`, { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
3810
+ }
3811
+ catch {
3812
+ // Best-effort.
3813
+ }
3814
+ this.flushSnapshot();
3815
+ return;
3816
+ }
3817
+ // Failure: if rounds remain, steer a revision; else report the loop done.
3818
+ // For unlimited (budget=0) isLastRound is always false → keeps revising.
3819
+ const isLastRound = !g.locked ? true : g.maxRounds > 0 && g.round >= g.maxRounds;
3820
+ if (!isLastRound) {
3821
+ g.status = `本轮不通过,正在把意见交给 agent 修改(${capFmt(budgetForCard)})…`;
3822
+ this.emit({
3823
+ type: "notice",
3824
+ level: "warning",
3825
+ text: `目标审查第 ${g.round}/${budgetForCard > 0 ? budgetForCard : "不限"} 轮未通过,把意见交给 agent 修改…`,
3826
+ });
3827
+ // Inject the reviewer's feedback into the main session to revise (this IS
3828
+ // the fail review result, as an ordinary user message — no separate card).
3829
+ try {
3830
+ const steerText = `【目标审查:第 ${g.round}/${budgetForCard > 0 ? budgetForCard : "不限"} 轮未通过】\n\n目标:${goalText}\n\n` +
3831
+ `审查意见:${feedback}\n\n请根据以上意见修改你的成果,使其完全满足目标。`;
3832
+ await mainSession.sendUserMessage(steerText, {
3833
+ deliverAs: mainSession.isStreaming ? "steer" : "followUp",
3834
+ });
3835
+ }
3836
+ catch (err) {
3837
+ g.status = `意见注入失败:${err.message}`;
3838
+ }
3839
+ this.emitGoalStatus();
3840
+ this.flushSnapshot();
3841
+ return;
3842
+ }
3843
+ // Rounds exhausted (finite cap reached / single-shot failed). Deliver the
3844
+ // fail result as an ordinary user message (no separate card), like the pass
3845
+ // and revise paths — the review result always lands in the conversation.
3846
+ g.status =
3847
+ g.locked && g.maxRounds > 0
3848
+ ? `已达最大轮数(${g.maxRounds}),目标仍未通过`
3849
+ : `目标未通过(${capFmt(budgetForCard)})`;
3850
+ try {
3851
+ await mainSession.sendUserMessage(`❌ 目标未通过审查(第 ${round}/${budgetForCard > 0 ? budgetForCard : "不限"} 轮)。\n\n目标:${goalText}\n\n审查意见:${feedback}`, { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
3852
+ }
3853
+ catch {
3854
+ // Best-effort.
3855
+ }
3856
+ this.emit({ type: "notice", level: "warning", text: "目标未通过审查(已达最大轮数)" });
3857
+ g.goal = null; // loop exhausted — clear the active goal
3858
+ this.emitGoalStatus();
3859
+ this.flushSnapshot();
3860
+ }
3058
3861
  /** Switch to a specific model by "provider/id" (e.g. "anthropic/claude-sonnet-5"). */
3059
3862
  async setModel(modelId) {
3060
3863
  try {