pi-web-ui 0.27.1 → 0.28.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.
@@ -909,6 +909,8 @@ class ClientStateStore {
909
909
  visionBridgeModel: s?.settings?.visionBridgeModel ?? null,
910
910
  visionBridgePromptMode: s?.settings?.visionBridgePromptMode === "replace" ? "replace" : "append",
911
911
  visionBridgePrompt: s?.settings?.visionBridgePrompt ?? "",
912
+ reviewPrompt: s?.settings?.reviewPrompt ?? "",
913
+ reviewDisabledSkills: s?.settings?.reviewDisabledSkills ?? [],
912
914
  };
913
915
  }
914
916
  /** Persist the client's settings-panel state (partial merge). */
@@ -927,12 +929,19 @@ class ClientStateStore {
927
929
  cur.visionBridgePromptMode ??
928
930
  "append",
929
931
  visionBridgePrompt: settings.visionBridgePrompt ?? cur.visionBridgePrompt ?? "",
932
+ reviewPrompt: settings.reviewPrompt ?? cur.reviewPrompt ?? "",
933
+ reviewDisabledSkills: settings.reviewDisabledSkills ?? cur.reviewDisabledSkills ?? [],
930
934
  };
931
935
  this.save();
932
936
  }
933
937
  /** Named settings presets for a client (empty if never saved). */
934
938
  getPresets(clientId) {
935
- return this.load()[clientId]?.presets ?? [];
939
+ return (this.load()[clientId]?.presets ?? []).map((p) => ({
940
+ ...p,
941
+ // Older client-state files predate review settings.
942
+ reviewPrompt: p.reviewPrompt ?? "",
943
+ reviewDisabledSkills: p.reviewDisabledSkills ?? [],
944
+ }));
936
945
  }
937
946
  /** Persist the client's named settings presets. */
938
947
  savePresets(clientId, presets) {
@@ -955,6 +964,102 @@ const TOOL_WATCHDOG_TIMEOUT_MS = (() => {
955
964
  * runtime alive; conversations of other projects keep their own lists). */
956
965
  const MAX_OPEN_CONVERSATIONS = 8;
957
966
  const DEFAULT_CONV_TITLE = "新对话";
967
+ /** Build the agent-facing persistent terminal tools for one conversation. */
968
+ export function makePersistentTerminalTools(terminals, cwd) {
969
+ const result = (text, details = {}) => ({ content: [{ type: "text", text }], details });
970
+ const failIf = (error) => {
971
+ if (error)
972
+ throw new Error(error);
973
+ };
974
+ return [
975
+ defineTool({
976
+ name: "terminal_create",
977
+ label: "Create terminal",
978
+ description: "Create a named persistent interactive PTY in the current workspace. Use terminal_input or terminal_key to interact with it and terminal_read to inspect incremental output.",
979
+ promptSnippet: "create persistent interactive PTY terminals",
980
+ parameters: Type.Object({
981
+ terminalId: Type.String({ description: "Stable terminal name" }),
982
+ cwd: Type.Optional(Type.String({ description: "Workspace-relative directory" })),
983
+ cols: Type.Optional(Type.Integer({ minimum: 2, maximum: 500 })),
984
+ rows: Type.Optional(Type.Integer({ minimum: 2, maximum: 200 })),
985
+ }),
986
+ execute: async (_id, p) => {
987
+ const info = terminals.create(p.terminalId, p.cwd ?? cwd, p.cols ?? 120, p.rows ?? 40, cwd, p.terminalId);
988
+ if (!info)
989
+ throw new Error(`创建终端失败:${p.terminalId}`);
990
+ return result(`终端已创建:${JSON.stringify(info)}`, info);
991
+ },
992
+ }),
993
+ defineTool({
994
+ name: "terminal_list",
995
+ label: "List terminals",
996
+ description: "List all persistent PTY terminals owned by this conversation.",
997
+ promptSnippet: "list persistent terminals",
998
+ parameters: Type.Object({}),
999
+ execute: async () => result(JSON.stringify(terminals.list()), terminals.list()),
1000
+ }),
1001
+ defineTool({
1002
+ name: "terminal_close",
1003
+ label: "Close terminal",
1004
+ description: "Close a persistent PTY and terminate its process tree.",
1005
+ parameters: Type.Object({ terminalId: Type.String() }),
1006
+ execute: async (_id, p) => {
1007
+ if (!terminals.has(p.terminalId))
1008
+ throw new Error(`终端不存在:${p.terminalId}`);
1009
+ terminals.kill(p.terminalId);
1010
+ return result(`终端已关闭:${p.terminalId}`);
1011
+ },
1012
+ }),
1013
+ defineTool({
1014
+ name: "terminal_input",
1015
+ label: "Send terminal input",
1016
+ description: "Send arbitrary text to a persistent PTY. Include newline when a command should be submitted.",
1017
+ parameters: Type.Object({ terminalId: Type.String(), data: Type.String() }),
1018
+ execute: async (_id, p) => {
1019
+ failIf(terminals.inputChecked(p.terminalId, p.data));
1020
+ return result(`已发送 ${p.data.length} 个字符到 ${p.terminalId}`);
1021
+ },
1022
+ }),
1023
+ defineTool({
1024
+ name: "terminal_key",
1025
+ label: "Send terminal key",
1026
+ description: "Send Enter, Tab, arrows, function keys, or Ctrl/Alt combinations to a persistent PTY.",
1027
+ parameters: Type.Object({
1028
+ terminalId: Type.String(),
1029
+ key: Type.String({ description: "Enter, Tab, ArrowUp, c, etc." }),
1030
+ modifiers: Type.Optional(Type.Object({
1031
+ ctrl: Type.Optional(Type.Boolean()),
1032
+ alt: Type.Optional(Type.Boolean()),
1033
+ shift: Type.Optional(Type.Boolean()),
1034
+ })),
1035
+ }),
1036
+ execute: async (_id, p) => {
1037
+ failIf(terminals.key(p.terminalId, p.key, p.modifiers));
1038
+ return result(`已发送按键 ${p.key} 到 ${p.terminalId}`);
1039
+ },
1040
+ }),
1041
+ defineTool({
1042
+ name: "terminal_read",
1043
+ label: "Read terminal output",
1044
+ description: "Read incremental output from a persistent PTY. Keep the returned cursor and pass it on the next read; optionally wait for new output or process exit.",
1045
+ parameters: Type.Object({
1046
+ terminalId: Type.String(),
1047
+ cursor: Type.Optional(Type.Integer({ minimum: 0 })),
1048
+ maxBytes: Type.Optional(Type.Integer({ minimum: 1, maximum: 100000 })),
1049
+ waitMs: Type.Optional(Type.Integer({ minimum: 0, maximum: 120000 })),
1050
+ }),
1051
+ execute: async (_id, p, signal) => {
1052
+ const cursor = p.cursor ?? 0;
1053
+ if (p.waitMs)
1054
+ await terminals.waitForOutput(p.terminalId, cursor, p.waitMs, signal);
1055
+ const read = terminals.read(p.terminalId, cursor, p.maxBytes ?? 20000);
1056
+ if (!read)
1057
+ throw new Error(`终端不存在:${p.terminalId}`);
1058
+ return result(JSON.stringify(read), read);
1059
+ },
1060
+ }),
1061
+ ];
1062
+ }
958
1063
  /** First user text in a session, truncated for the conversation list. */
959
1064
  function conversationTitle(session) {
960
1065
  try {
@@ -1014,30 +1119,19 @@ export class ClientSession {
1014
1119
  // injects its feedback back into the main session to revise. All goal
1015
1120
  // mutation goes through setGoal/clearGoal so UI state stays consistent.
1016
1121
  // -----------------------------------------------------------------------
1017
- goal = {
1018
- goal: null,
1122
+ /** Defaults remembered for newly-created conversations. Each conversation
1123
+ * receives its own GoalStatus, so reviews can run concurrently. */
1124
+ goalReviewPrefs = {
1019
1125
  reviewModel: null,
1020
- maxRounds: 0, // 0 = unlimited (keep revising until the goal passes)
1126
+ maxRounds: 0,
1021
1127
  locked: true,
1022
- reviewing: false,
1023
- round: 0,
1024
- status: "",
1025
- verdict: "pending",
1026
- wizard: {
1027
- active: false,
1028
- draft: "",
1029
- model: null,
1030
- step: 0,
1031
- maxSteps: 6,
1032
- status: "",
1033
- },
1034
1128
  };
1035
- /** Guard: only one review may run at a time (agent_end fires per turn and
1036
- * review is async). */
1037
- goalReviewing = false;
1038
- /** Guard: the goal wizard and the review loop are mutually exclusive — a
1039
- * wizard in flight stops review triggers (and vice versa). */
1040
- goalWizardRunning = false;
1129
+ /** Goal state exposed by the goal bar for the ACTIVE conversation. */
1130
+ get goal() {
1131
+ return this.conv.goal;
1132
+ }
1133
+ /** The browser has one dialog at a time, so wizard UI plumbing remains
1134
+ * client-wide; review execution itself is per conversation. */
1041
1135
  /** Settings-panel state (system prompt + disabled skills/extensions). The
1042
1136
  * resource-loader overrides in makeRuntimeFactory() read this at every
1043
1137
  * reload(), so session.reload() applies changes to the running runtime. */
@@ -1059,6 +1153,8 @@ export class ClientSession {
1059
1153
  /** The wizard's AgentSession while it runs — lets clearGoal truly terminate it
1060
1154
  * (abort the run), not just flip a flag. */
1061
1155
  wizardSession = null;
1156
+ /** Conversation that owns the one browser wizard currently in flight. */
1157
+ wizardOwnerId = null;
1062
1158
  /** True when the wizard was cancelled externally (✗ / clear_goal / timeout) —
1063
1159
  * startGoalWizard reads this after the run to avoid setting a goal. */
1064
1160
  wizardCancelled = false;
@@ -1106,8 +1202,46 @@ export class ClientSession {
1106
1202
  get session() {
1107
1203
  return this.conv.session;
1108
1204
  }
1109
- /** PTY terminals for this client (killed when the last socket detaches). */
1110
- terminals = new TerminalManager((msg) => this.emit(msg));
1205
+ /** PTYs are owned by individual conversations; this getter targets the active one
1206
+ * for compatibility with the existing terminal-panel dispatch path. */
1207
+ get terminals() {
1208
+ return this.conv.terminals;
1209
+ }
1210
+ getTerminalManager(conversationId) {
1211
+ return (conversationId ? this.convs.get(conversationId) : this.conv)?.terminals;
1212
+ }
1213
+ getTerminalCwd(conversationId) {
1214
+ return (conversationId ? this.convs.get(conversationId) : this.conv)?.cwd ?? this.cwd;
1215
+ }
1216
+ makeTerminalManager(conversationId, cwd) {
1217
+ return new TerminalManager((msg) => this.emitTerminal(conversationId, msg), cwd);
1218
+ }
1219
+ emitTerminal(conversationId, msg) {
1220
+ // Background conversations keep collecting output in their own PTY buffer.
1221
+ // Do not stream it into the active xterm; push the retained window on switch.
1222
+ if (msg.type === "terminal_output" && conversationId !== this.activeId)
1223
+ return;
1224
+ if (msg.type === "terminal_output" || msg.type === "terminal_exit" || msg.type === "terminal_list") {
1225
+ this.emit({ ...msg, conversationId });
1226
+ return;
1227
+ }
1228
+ this.emit(msg);
1229
+ }
1230
+ pushTerminals(conversation = this.conv) {
1231
+ this.emit({
1232
+ type: "terminal_list",
1233
+ conversationId: conversation.id,
1234
+ terminals: conversation.terminals.list(),
1235
+ });
1236
+ for (const output of conversation.terminals.replay()) {
1237
+ this.emit({
1238
+ type: "terminal_output",
1239
+ conversationId: conversation.id,
1240
+ terminalId: output.terminalId,
1241
+ data: output.data,
1242
+ });
1243
+ }
1244
+ }
1111
1245
  /**
1112
1246
  * Vision-bridge transcript cache (batch hash → text). A re-sent / re-asked
1113
1247
  * prompt with the same images skips the vision API call entirely — editing
@@ -1181,11 +1315,15 @@ export class ClientSession {
1181
1315
  // Restore last-used goal/review preferences so model & rounds survive reload.
1182
1316
  const gPrefs = stateStore.getGoalPrefs(clientId);
1183
1317
  if (gPrefs) {
1184
- cs.goal.reviewModel = gPrefs.reviewModel;
1185
- cs.goal.maxRounds = gPrefs.maxRounds;
1186
- cs.goal.locked = gPrefs.locked;
1318
+ cs.goalReviewPrefs = {
1319
+ reviewModel: gPrefs.reviewModel,
1320
+ maxRounds: gPrefs.maxRounds,
1321
+ locked: gPrefs.locked,
1322
+ };
1187
1323
  }
1188
- const runtime = await createAgentSessionRuntime(cs.makeRuntimeFactory(), {
1324
+ const conversationId = cs.nextConversationId();
1325
+ const terminals = cs.makeTerminalManager(conversationId, cwd);
1326
+ const runtime = await createAgentSessionRuntime(cs.makeRuntimeFactory(terminals), {
1189
1327
  cwd,
1190
1328
  agentDir,
1191
1329
  // Resume the most recent session for this project — the SDK default
@@ -1196,7 +1334,7 @@ export class ClientSession {
1196
1334
  // First conversation = the resumed session; it also seeds the shared
1197
1335
  // ModelRuntime that every later conversation reuses.
1198
1336
  cs.sharedModelRuntime = runtime.services.modelRuntime;
1199
- const conv = cs.makeConversation(runtime);
1337
+ const conv = cs.makeConversation(runtime, conversationId, terminals);
1200
1338
  cs.convs.set(conv.id, conv);
1201
1339
  cs.activeId = conv.id;
1202
1340
  for (const d of runtime.diagnostics) {
@@ -1216,7 +1354,7 @@ export class ClientSession {
1216
1354
  * (the model choice is client-wide), so later conversations reuse the
1217
1355
  * instance created with the first one.
1218
1356
  */
1219
- makeRuntimeFactory() {
1357
+ makeRuntimeFactory(terminals) {
1220
1358
  return async ({ cwd: effectiveCwd, sessionManager }) => {
1221
1359
  const services = await createAgentSessionServices({
1222
1360
  cwd: effectiveCwd,
@@ -1273,6 +1411,7 @@ export class ClientSession {
1273
1411
  // abortBash() 只杀这些命令,agent run 与对话继续。
1274
1412
  customTools: [
1275
1413
  makeKillableBashTool(effectiveCwd, this.bashKills),
1414
+ ...makePersistentTerminalTools(terminals, effectiveCwd),
1276
1415
  ],
1277
1416
  })),
1278
1417
  services,
@@ -1280,10 +1419,37 @@ export class ClientSession {
1280
1419
  };
1281
1420
  };
1282
1421
  }
1422
+ /** Create independent goal state for one conversation. Preferences are
1423
+ * client-wide defaults, while goal text/review progress is not shared. */
1424
+ makeGoalStatus() {
1425
+ return {
1426
+ conversationId: null,
1427
+ goal: null,
1428
+ reviewModel: this.goalReviewPrefs.reviewModel,
1429
+ maxRounds: this.goalReviewPrefs.maxRounds,
1430
+ locked: this.goalReviewPrefs.locked,
1431
+ reviewing: false,
1432
+ round: 0,
1433
+ status: "",
1434
+ verdict: "pending",
1435
+ wizard: {
1436
+ active: false,
1437
+ draft: "",
1438
+ model: null,
1439
+ step: 0,
1440
+ maxSteps: 6,
1441
+ status: "",
1442
+ },
1443
+ };
1444
+ }
1445
+ /** Allocate a stable conversation id before constructing its runtime/tools. */
1446
+ nextConversationId() {
1447
+ return `c${++this.convSeq}`;
1448
+ }
1283
1449
  /** Wrap a fresh runtime as a new conversation record. */
1284
- makeConversation(runtime) {
1450
+ makeConversation(runtime, id, terminals) {
1285
1451
  return {
1286
- id: `c${++this.convSeq}`,
1452
+ id,
1287
1453
  title: conversationTitle(runtime.session),
1288
1454
  runtime,
1289
1455
  session: runtime.session,
@@ -1294,6 +1460,11 @@ export class ClientSession {
1294
1460
  listed: false,
1295
1461
  promptedSinceActive: false,
1296
1462
  lastActiveAt: Date.now(),
1463
+ goal: this.makeGoalStatus(),
1464
+ goalGeneration: 0,
1465
+ goalReviewGeneration: 0,
1466
+ wizardRunning: false,
1467
+ terminals,
1297
1468
  msgIds: new Map(),
1298
1469
  nextMsgId: 1,
1299
1470
  userSeqByTs: new Map(),
@@ -1336,14 +1507,15 @@ export class ClientSession {
1336
1507
  // Reconnect: push the background-task list — it must survive reconnects
1337
1508
  // and outlive the conversation that started the tasks.
1338
1509
  this.emitBgServers();
1510
+ // PTYs are conversation-owned and survive a socket reconnect.
1511
+ this.pushTerminals();
1339
1512
  }
1340
1513
  detachSink(send) {
1341
1514
  this.sinks.delete(send);
1342
- // No sockets left for this client kill its terminals so processes don't
1343
- // survive a closed tab / dropped connection.
1515
+ // PTYs intentionally survive a socket drop: they are owned by the
1516
+ // conversation and can be inspected after reconnecting. Only conversation
1517
+ // disposal or server shutdown kills them.
1344
1518
  if (this.sinks.size === 0) {
1345
- this.terminals.killAll();
1346
- // No sockets → nobody to refresh; drop the dir watcher too.
1347
1519
  this.unwatchDir();
1348
1520
  }
1349
1521
  }
@@ -1510,7 +1682,7 @@ export class ClientSession {
1510
1682
  // (new chat + first message, completed turns, compaction, etc.).
1511
1683
  case "agent_end": {
1512
1684
  this.scheduleSessionsRefresh();
1513
- const g = this.goal;
1685
+ const g = conv.goal;
1514
1686
  // Manual interrupt (Stop button / abort): the last assistant message
1515
1687
  // carries stopReason "aborted". A half-finished run should NOT be
1516
1688
  // reviewed (it would fail and inject a revision, only to be stopped
@@ -1521,12 +1693,14 @@ export class ClientSession {
1521
1693
  return a.role === "assistant" && a.stopReason === "aborted";
1522
1694
  });
1523
1695
  if (aborted) {
1524
- if (g.goal) {
1525
- this.goal.goal = null;
1526
- this.goal.reviewing = false;
1527
- this.goal.verdict = "pending";
1528
- this.goal.feedback = undefined;
1529
- this.goal.status = "已手动停止,目标审查已中止";
1696
+ if (g.goal && g.conversationId === conv.id) {
1697
+ conv.goalGeneration += 1;
1698
+ g.conversationId = null;
1699
+ g.goal = null;
1700
+ g.reviewing = false;
1701
+ g.verdict = "pending";
1702
+ g.feedback = undefined;
1703
+ g.status = "已手动停止,目标审查已中止";
1530
1704
  this.emitGoalStatus();
1531
1705
  this.emit({
1532
1706
  type: "notice",
@@ -1540,9 +1714,9 @@ export class ClientSession {
1540
1714
  // active (and it belonged to the ACTIVE conversation) and we're not
1541
1715
  // already mid-review, spawn the isolated reviewer.
1542
1716
  if (g.goal &&
1717
+ g.conversationId === conv.id &&
1543
1718
  !g.reviewing &&
1544
- !this.goalWizardRunning &&
1545
- conv.id === this.activeId &&
1719
+ !conv.wizardRunning &&
1546
1720
  !this.disposed) {
1547
1721
  void this.runGoalReview(conv);
1548
1722
  }
@@ -2148,6 +2322,178 @@ export class ClientSession {
2148
2322
  });
2149
2323
  this.emit({ type: "models_config", providers: list });
2150
2324
  }
2325
+ /** Numeric metadata value (NaN/string "unknown" → undefined). */
2326
+ static numMeta(v) {
2327
+ return typeof v === "number" && Number.isFinite(v) ? v : undefined;
2328
+ }
2329
+ static boolMeta(v) {
2330
+ return typeof v === "boolean" ? v : undefined;
2331
+ }
2332
+ static strArrMeta(v) {
2333
+ return Array.isArray(v)
2334
+ ? v.filter((x) => typeof x === "string")
2335
+ : undefined;
2336
+ }
2337
+ /** Best-effort extraction of model metadata from an OpenAI-compatible
2338
+ * /models `data[]` item. Most endpoints only return `{ id }` — the extra
2339
+ * fields (context_window / max_model_len / modalities / supports_vision /
2340
+ * reasoning / display_name) come from vLLM and other extended
2341
+ * implementations, and are filled into the form when present. */
2342
+ static parseOpenAiModel(m) {
2343
+ const r = (m ?? {});
2344
+ const id = typeof r.id === "string" ? r.id : "";
2345
+ const name = (typeof r.name === "string" && r.name.trim() ? r.name : undefined) ??
2346
+ (typeof r.display_name === "string" && r.display_name.trim()
2347
+ ? r.display_name
2348
+ : undefined);
2349
+ const modalities = ClientSession.strArrMeta(r.modalities) ??
2350
+ ClientSession.strArrMeta(r.input_modalities);
2351
+ const vision = modalities?.includes("image") === true ||
2352
+ ClientSession.boolMeta(r.supports_vision) === true ||
2353
+ ClientSession.boolMeta(r.vision) === true ||
2354
+ ClientSession.strArrMeta(r.input)?.includes("image") === true;
2355
+ const reasoning = ClientSession.boolMeta(r.reasoning) === true ||
2356
+ ClientSession.boolMeta(r.supports_reasoning) === true ||
2357
+ modalities?.includes("reasoning") === true;
2358
+ const contextWindow = ClientSession.numMeta(r.context_window) ??
2359
+ ClientSession.numMeta(r.context_length) ??
2360
+ ClientSession.numMeta(r.max_model_len) ??
2361
+ ClientSession.numMeta(r.max_context_length);
2362
+ const maxTokens = ClientSession.numMeta(r.max_tokens) ??
2363
+ ClientSession.numMeta(r.max_output_tokens) ??
2364
+ ClientSession.numMeta(r.max_completion_tokens);
2365
+ return {
2366
+ id,
2367
+ ...(name ? { name } : {}),
2368
+ ...(reasoning ? { reasoning: true } : {}),
2369
+ ...(vision ? { input: ["text", "image"] } : {}),
2370
+ ...(contextWindow ? { contextWindow } : {}),
2371
+ ...(maxTokens ? { maxTokens } : {}),
2372
+ };
2373
+ }
2374
+ /** google-generative-ai /models shape:
2375
+ * { models: [{ name: "models/gemini-flash", displayName, inputTokenLimit,
2376
+ * outputTokenLimit, supportedGenerationMethods }] } */
2377
+ static parseGoogleModel(m) {
2378
+ const r = (m ?? {});
2379
+ const rawName = typeof r.name === "string" ? r.name : "";
2380
+ const id = rawName.replace(/^models\//, "");
2381
+ const displayName = typeof r.displayName === "string" ? r.displayName : undefined;
2382
+ return {
2383
+ id,
2384
+ ...(displayName && displayName !== id ? { name: displayName } : {}),
2385
+ ...(ClientSession.numMeta(r.inputTokenLimit)
2386
+ ? { contextWindow: ClientSession.numMeta(r.inputTokenLimit) }
2387
+ : {}),
2388
+ ...(ClientSession.numMeta(r.outputTokenLimit)
2389
+ ? { maxTokens: ClientSession.numMeta(r.outputTokenLimit) }
2390
+ : {}),
2391
+ };
2392
+ }
2393
+ /** Probe a custom provider's OpenAI-compatible /models endpoint (server-side
2394
+ * because the baseUrl is often a LAN/loopback host the browser can't reach
2395
+ * cross-origin) and return the advertised models. reqId is echoed back
2396
+ * in fetch_models_result so the UI can match concurrent requests. */
2397
+ async fetchModelsList(reqId, baseUrl, apiKey, authHeader, api) {
2398
+ const emitError = (error) => this.emit({ type: "fetch_models_result", reqId, ok: false, error });
2399
+ const base = (baseUrl ?? "").trim().replace(/\/+$/, "");
2400
+ if (!base)
2401
+ return emitError("请先填写 baseUrl");
2402
+ let url;
2403
+ try {
2404
+ url = new URL(base);
2405
+ }
2406
+ catch {
2407
+ return emitError(`baseUrl 无效:${base}`);
2408
+ }
2409
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
2410
+ return emitError("baseUrl 仅支持 http/https");
2411
+ }
2412
+ const headers = {};
2413
+ // Per-api auth conventions (mirror pi's built-in provider configs):
2414
+ // openai-*: Authorization: Bearer <key>
2415
+ // anthropic: x-api-key + anthropic-version
2416
+ // google: x-goog-api-key
2417
+ // authHeader=false → no auth header at all (custom gateways).
2418
+ if (apiKey?.trim() && authHeader !== false) {
2419
+ const key = apiKey.trim();
2420
+ if (api === "anthropic-messages") {
2421
+ headers["x-api-key"] = key;
2422
+ headers["anthropic-version"] = "2023-06-01";
2423
+ }
2424
+ else if (api === "google-generative-ai") {
2425
+ headers["x-goog-api-key"] = key;
2426
+ }
2427
+ else {
2428
+ headers["Authorization"] = `Bearer ${key}`;
2429
+ }
2430
+ }
2431
+ const tryFetch = async (u) => {
2432
+ const ac = new AbortController();
2433
+ const timer = setTimeout(() => ac.abort(), 15000);
2434
+ try {
2435
+ return await fetch(u, { headers, signal: ac.signal });
2436
+ }
2437
+ catch (err) {
2438
+ if (err.name === "AbortError") {
2439
+ emitError("请求超时(15 秒)");
2440
+ }
2441
+ else {
2442
+ emitError(`请求失败:${err.message}`);
2443
+ }
2444
+ return null;
2445
+ }
2446
+ finally {
2447
+ clearTimeout(timer);
2448
+ }
2449
+ };
2450
+ let res = await tryFetch(`${base}/models`);
2451
+ // BaseUrls that omit the /v1 prefix (e.g. https://api.openai.com) 404 on
2452
+ // the bare path — retry under /v1.
2453
+ if (res && res.status === 404 && !/\/v\d+[a-z-]*$/.test(base)) {
2454
+ res = await tryFetch(`${base}/v1/models`);
2455
+ }
2456
+ if (!res)
2457
+ return;
2458
+ if (!res.ok) {
2459
+ let detail = "";
2460
+ try {
2461
+ detail = (await res.text()).slice(0, 200);
2462
+ }
2463
+ catch {
2464
+ // response body already consumed / not text — ignore
2465
+ }
2466
+ return emitError(`接口返回 HTTP ${res.status}${detail ? `:${detail}` : ""}`);
2467
+ }
2468
+ let models = [];
2469
+ try {
2470
+ const json = (await res.json());
2471
+ const data = Array.isArray(json.data) ? json.data : null;
2472
+ if (data) {
2473
+ // OpenAI-compatible: { data: [{ id, context_window, modalities, … }] }
2474
+ models = data
2475
+ .map((m) => ClientSession.parseOpenAiModel(m))
2476
+ .filter((m) => m.id);
2477
+ }
2478
+ else if (Array.isArray(json.models)) {
2479
+ // Google: { models: [{ name: "models/…", displayName, … }] }
2480
+ models = json.models
2481
+ .map((m) => ClientSession.parseGoogleModel(m))
2482
+ .filter((m) => m.id);
2483
+ }
2484
+ }
2485
+ catch {
2486
+ return emitError("响应不是有效的 JSON");
2487
+ }
2488
+ // Dedupe by id (keep the first, most complete entry) and sort by id.
2489
+ const seen = new Set();
2490
+ models = models
2491
+ .filter((m) => (seen.has(m.id) ? false : (seen.add(m.id), true)))
2492
+ .sort((a, b) => a.id.localeCompare(b.id));
2493
+ if (models.length === 0)
2494
+ return emitError("接口未返回任何模型");
2495
+ this.emit({ type: "fetch_models_result", reqId, ok: true, models });
2496
+ }
2151
2497
  /** Upsert one provider into models.json and hot-reload the model runtime. */
2152
2498
  async saveModelConfig(providerId, config) {
2153
2499
  const pid = providerId.trim();
@@ -2531,6 +2877,7 @@ export class ClientSession {
2531
2877
  * settings change. */
2532
2878
  pushSettings() {
2533
2879
  const disabledSkills = new Set(this.settings.disabledSkills);
2880
+ const reviewDisabledSkills = new Set(this.settings.reviewDisabledSkills);
2534
2881
  const disabledExts = new Set(this.settings.disabledExtensions);
2535
2882
  try {
2536
2883
  // Refresh the cache with the CURRENTLY loaded set (post-filter).
@@ -2577,6 +2924,9 @@ export class ClientSession {
2577
2924
  const skills = [...this.knownSkills.values()]
2578
2925
  .map((s) => ({ ...s, enabled: !disabledSkills.has(s.name) }))
2579
2926
  .sort((a, b) => a.name.localeCompare(b.name));
2927
+ const reviewSkills = [...this.knownSkills.values()]
2928
+ .map((s) => ({ ...s, enabled: !reviewDisabledSkills.has(s.name) }))
2929
+ .sort((a, b) => a.name.localeCompare(b.name));
2580
2930
  const extensions = [...this.knownExtensions.values()]
2581
2931
  .map((e) => ({ ...e, enabled: !disabledExts.has(e.id) }))
2582
2932
  .sort((a, b) => a.name.localeCompare(b.name));
@@ -2589,6 +2939,8 @@ export class ClientSession {
2589
2939
  visionBridgeModel: this.settings.visionBridgeModel,
2590
2940
  visionBridgePromptMode: this.settings.visionBridgePromptMode,
2591
2941
  visionBridgePrompt: this.settings.visionBridgePrompt,
2942
+ reviewPrompt: this.settings.reviewPrompt,
2943
+ reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
2592
2944
  // The built-in prompts, so the replace-mode editors can prefill the
2593
2945
  // text they would otherwise replace (empty until the resource-loader
2594
2946
  // has run once for the system prompt).
@@ -2598,6 +2950,7 @@ export class ClientSession {
2598
2950
  disabledSkills: [...this.settings.disabledSkills],
2599
2951
  disabledExtensions: [...this.settings.disabledExtensions],
2600
2952
  skills,
2953
+ reviewSkills,
2601
2954
  extensions,
2602
2955
  presets: this.presets.map((p) => ({ ...p })),
2603
2956
  },
@@ -2646,6 +2999,12 @@ export class ClientSession {
2646
2999
  if (partial.visionBridgePrompt !== undefined) {
2647
3000
  this.settings.visionBridgePrompt = partial.visionBridgePrompt;
2648
3001
  }
3002
+ if (partial.reviewPrompt !== undefined) {
3003
+ this.settings.reviewPrompt = partial.reviewPrompt;
3004
+ }
3005
+ if (partial.reviewDisabledSkills !== undefined) {
3006
+ this.settings.reviewDisabledSkills = partial.reviewDisabledSkills;
3007
+ }
2649
3008
  this.stateStore.saveSettings(this.clientId, this.settings);
2650
3009
  this.pushSettings();
2651
3010
  if (needsReload)
@@ -2664,6 +3023,8 @@ export class ClientSession {
2664
3023
  customSystemPrompt: this.settings.customSystemPrompt,
2665
3024
  disabledSkills: [...this.settings.disabledSkills],
2666
3025
  disabledExtensions: [...this.settings.disabledExtensions],
3026
+ reviewPrompt: this.settings.reviewPrompt,
3027
+ reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
2667
3028
  };
2668
3029
  const existing = this.presets.findIndex((p) => p.name === n);
2669
3030
  if (existing >= 0)
@@ -2685,6 +3046,10 @@ export class ClientSession {
2685
3046
  customSystemPrompt: p.customSystemPrompt,
2686
3047
  disabledSkills: [...p.disabledSkills],
2687
3048
  disabledExtensions: [...p.disabledExtensions],
3049
+ reviewPrompt: p.reviewPrompt ?? this.settings.reviewPrompt,
3050
+ reviewDisabledSkills: [
3051
+ ...(p.reviewDisabledSkills ?? this.settings.reviewDisabledSkills),
3052
+ ],
2688
3053
  // Presets don't capture vision-bridge prefs — keep the current ones.
2689
3054
  visionBridgeEnabled: this.settings.visionBridgeEnabled,
2690
3055
  visionBridgeModel: this.settings.visionBridgeModel,
@@ -3685,7 +4050,7 @@ ${transcript}
3685
4050
  this.clearAllToolWatchdogs(conv);
3686
4051
  conv.toolStartTimes.clear();
3687
4052
  await conv.runtime.dispose();
3688
- const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(), {
4053
+ const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(conv.terminals), {
3689
4054
  cwd: conv.cwd,
3690
4055
  agentDir: this.agentDir,
3691
4056
  sessionManager: SessionManager.continueRecent(conv.cwd),
@@ -3715,7 +4080,7 @@ ${transcript}
3715
4080
  // this branch normally can't exist — kept as a safety net).
3716
4081
  const isBlank = (c) => {
3717
4082
  try {
3718
- return c.session.getSessionStats().totalMessages === 0;
4083
+ return c.session.getSessionStats().totalMessages === 0 && c.terminals.list().length === 0;
3719
4084
  }
3720
4085
  catch {
3721
4086
  // session being replaced — treat as used so we don't switch onto it
@@ -3752,18 +4117,22 @@ ${transcript}
3752
4117
  // conversation stays valid during the (async) runtime creation.
3753
4118
  const displaced = this.displaceActive();
3754
4119
  try {
3755
- const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(), {
4120
+ const conversationId = this.nextConversationId();
4121
+ const terminals = this.makeTerminalManager(conversationId, this.cwd);
4122
+ const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals), {
3756
4123
  cwd: this.cwd,
3757
4124
  agentDir: this.agentDir,
3758
4125
  sessionManager: SessionManager.create(this.cwd),
3759
4126
  });
3760
- const conv = this.makeConversation(runtime);
4127
+ const conv = this.makeConversation(runtime, conversationId, terminals);
3761
4128
  this.convs.set(conv.id, conv);
3762
4129
  this.activeId = conv.id;
3763
4130
  if (displaced)
3764
4131
  this.removeConversation(displaced.id);
3765
4132
  await this.bindSession();
3766
4133
  this.emitConversations();
4134
+ this.emitGoalStatus();
4135
+ this.pushTerminals();
3767
4136
  // The new runtime re-discovered skills/templates — refresh the catalog
3768
4137
  // so the picker stops showing the previous runtime's list.
3769
4138
  void this.pushSlashCommands();
@@ -3783,16 +4152,30 @@ ${transcript}
3783
4152
  *
3784
4153
  * - still streaming → it becomes a background run: ensure it is listed;
3785
4154
  * - idle + listed + continued → keep it (the user did continue it);
4155
+ * - any retained terminal state → keep it listed until the terminals are closed;
3786
4156
  * - idle + listed + opened-but-not-continued, or never listed at all → the
3787
4157
  * caller must drop it (returns it so removal happens only after the
3788
4158
  * active conversation has been switched away).
3789
4159
  */
3790
4160
  displaceActive() {
3791
4161
  const conv = this.conv;
4162
+ // An isolated reviewer can keep working while the main session is idle;
4163
+ // retain that conversation so its review is not disposed when the user
4164
+ // switches away without sending another prompt.
4165
+ if (conv.goal.reviewing || conv.wizardRunning) {
4166
+ conv.listed = true;
4167
+ return null;
4168
+ }
3792
4169
  if (conv.session.isStreaming) {
3793
4170
  conv.listed = true;
3794
4171
  return null;
3795
4172
  }
4173
+ // Terminal state is a reason to keep an otherwise idle conversation alive:
4174
+ // switching chats must not kill a PTY the user or agent may still need.
4175
+ if (conv.terminals.list().length > 0) {
4176
+ conv.listed = true;
4177
+ return null;
4178
+ }
3796
4179
  if (conv.listed && conv.promptedSinceActive)
3797
4180
  return null;
3798
4181
  return conv;
@@ -3806,6 +4189,7 @@ ${transcript}
3806
4189
  return;
3807
4190
  this.convs.delete(id);
3808
4191
  this.clearAllToolWatchdogs(conv);
4192
+ conv.terminals.killAll();
3809
4193
  conv.unsubscribe?.();
3810
4194
  conv.unsubscribe = undefined;
3811
4195
  void conv.runtime.dispose().catch(() => { });
@@ -3825,6 +4209,8 @@ ${transcript}
3825
4209
  this.conv.lastActiveAt = Date.now();
3826
4210
  this.webUi.refresh();
3827
4211
  this.emitConversations();
4212
+ this.emitGoalStatus();
4213
+ this.pushTerminals();
3828
4214
  // The switched-to conversation has its own runtime (own resource cache).
3829
4215
  void this.pushSlashCommands();
3830
4216
  this.flushSnapshot();
@@ -4399,12 +4785,14 @@ ${transcript}
4399
4785
  }
4400
4786
  else {
4401
4787
  // First visit to this project: resume its most recent session.
4402
- const newRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(), {
4788
+ const conversationId = this.nextConversationId();
4789
+ const terminals = this.makeTerminalManager(conversationId, abs);
4790
+ const newRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals), {
4403
4791
  cwd: abs,
4404
4792
  agentDir: this.agentDir,
4405
4793
  sessionManager: SessionManager.continueRecent(abs),
4406
4794
  });
4407
- const conv = this.makeConversation(newRuntime);
4795
+ const conv = this.makeConversation(newRuntime, conversationId, terminals);
4408
4796
  this.convs.set(conv.id, conv);
4409
4797
  this.activeId = conv.id;
4410
4798
  if (displaced)
@@ -4416,6 +4804,7 @@ ${transcript}
4416
4804
  }
4417
4805
  await this.bindSession();
4418
4806
  }
4807
+ this.pushTerminals();
4419
4808
  this.conv.promptedSinceActive = false;
4420
4809
  this.conv.lastActiveAt = Date.now();
4421
4810
  this.cwd = abs;
@@ -4424,6 +4813,7 @@ ${transcript}
4424
4813
  void this.pushProjects();
4425
4814
  this.webUi.refresh();
4426
4815
  this.emitConversations();
4816
+ this.emitGoalStatus();
4427
4817
  // Skills / prompt templates are project-bound — refresh the catalog.
4428
4818
  void this.pushSlashCommands();
4429
4819
  this.emit({
@@ -4470,9 +4860,17 @@ ${transcript}
4470
4860
  // ---------------------------------------------------------------------------
4471
4861
  // Goal / review
4472
4862
  // ---------------------------------------------------------------------------
4473
- /** Push the current goal status to the client (the goal bar UI). */
4863
+ /** Push the active conversation's goal status to the client (the goal bar
4864
+ * UI). Conversations without an active goal reflect the client's remembered
4865
+ * defaults; an existing goal keeps its own review settings. */
4474
4866
  emitGoalStatus() {
4475
- this.emit({ type: "goal_status", status: { ...this.goal } });
4867
+ const goal = this.goal;
4868
+ if (!goal.goal && !goal.reviewing && !goal.wizard.active) {
4869
+ goal.reviewModel = this.goalReviewPrefs.reviewModel;
4870
+ goal.maxRounds = this.goalReviewPrefs.maxRounds;
4871
+ goal.locked = this.goalReviewPrefs.locked;
4872
+ }
4873
+ this.emit({ type: "goal_status", status: { ...goal } });
4476
4874
  }
4477
4875
  /**
4478
4876
  * Set (or clear) the active goal. `goal === ""` clears it. The goal is
@@ -4485,6 +4883,13 @@ ${transcript}
4485
4883
  await this.clearGoal();
4486
4884
  return;
4487
4885
  }
4886
+ // A goal is scoped to the conversation that is active when it is set.
4887
+ // This prevents an agent_end from a newly-created/switched conversation
4888
+ // from consuming the previous conversation's goal.
4889
+ const goalConversationId = this.activeId;
4890
+ this.conv.goalGeneration += 1;
4891
+ this.goal.reviewing = false;
4892
+ this.goal.conversationId = goalConversationId;
4488
4893
  this.goal.goal = text;
4489
4894
  // Model & rounds preference semantics ("全局记忆"):
4490
4895
  // - reviewModel undefined → keep the remembered choice; empty → main model.
@@ -4497,6 +4902,11 @@ ${transcript}
4497
4902
  }
4498
4903
  if (opts?.locked !== undefined)
4499
4904
  this.goal.locked = opts.locked;
4905
+ this.goalReviewPrefs = {
4906
+ reviewModel: this.goal.reviewModel,
4907
+ maxRounds: this.goal.maxRounds,
4908
+ locked: this.goal.locked,
4909
+ };
4500
4910
  // Persist the chosen preferences so they survive reload.
4501
4911
  this.stateStore.saveGoalPrefs(this.clientId, {
4502
4912
  reviewModel: this.goal.reviewModel,
@@ -4537,7 +4947,7 @@ ${transcript}
4537
4947
  * in-memory session, so its model choice is its own) that questions the user
4538
4948
  * via `goal_ask` (multiple-choice + free-text, bridged to the browser through
4539
4949
  * the existing select/input dialog), converging on a goal, then auto-sets it.
4540
- * Mutually exclusive with the review loop.
4950
+ * Mutually exclusive with the review loop of the same conversation.
4541
4951
  */
4542
4952
  async startGoalWizard(text, opts) {
4543
4953
  if (this.quiesceBlocked())
@@ -4545,7 +4955,12 @@ ${transcript}
4545
4955
  const draft = (text ?? "").trim();
4546
4956
  if (!draft)
4547
4957
  return;
4548
- if (this.goalWizardRunning) {
4958
+ // The wizard and its progress cards belong to the conversation that
4959
+ // launched it. If the user switches away, do not later set a goal on the
4960
+ // new active conversation while the wizard is still finishing.
4961
+ const wizardConversationId = this.activeId;
4962
+ const wizardConversation = this.conv;
4963
+ if (wizardConversation.wizardRunning || this.wizardOwnerId !== null) {
4549
4964
  this.emit({
4550
4965
  type: "notice",
4551
4966
  level: "warning",
@@ -4553,7 +4968,7 @@ ${transcript}
4553
4968
  });
4554
4969
  return;
4555
4970
  }
4556
- if (this.goalReviewing) {
4971
+ if (wizardConversation.goal.reviewing) {
4557
4972
  this.emit({
4558
4973
  type: "notice",
4559
4974
  level: "warning",
@@ -4565,25 +4980,37 @@ ${transcript}
4565
4980
  // the idle- and total-timeouts are the only guards. maxSteps is purely a
4566
4981
  // soft UI indicator, not a hard stop.
4567
4982
  const maxSteps = 20;
4568
- this.goalWizardRunning = true;
4983
+ wizardConversation.wizardRunning = true;
4984
+ this.wizardOwnerId = wizardConversationId;
4569
4985
  this.wizardCancelled = false;
4570
4986
  this.wizardAbort = new AbortController();
4571
4987
  this.wizardSession = null;
4572
- this.goal.wizard.active = true;
4573
- this.goal.wizard.draft = draft;
4574
- this.goal.wizard.model = opts?.wizardModel ?? null;
4988
+ wizardConversation.goal.wizard.active = true;
4989
+ wizardConversation.goal.wizard.draft = draft;
4990
+ wizardConversation.goal.wizard.model = opts?.wizardModel ?? null;
4575
4991
  // Remember the model choice (and persist rounds/lock) — global memory.
4576
4992
  if (opts?.wizardModel !== undefined && opts.wizardModel !== null)
4577
- this.goal.reviewModel = opts.wizardModel || null;
4993
+ wizardConversation.goal.reviewModel = opts.wizardModel || null;
4994
+ if (typeof opts?.maxRounds === "number") {
4995
+ const mr = Math.round(opts.maxRounds);
4996
+ wizardConversation.goal.maxRounds = mr >= 1 ? Math.min(mr, 50) : 0;
4997
+ }
4998
+ if (opts?.locked !== undefined)
4999
+ wizardConversation.goal.locked = opts.locked;
5000
+ this.goalReviewPrefs = {
5001
+ reviewModel: wizardConversation.goal.reviewModel,
5002
+ maxRounds: wizardConversation.goal.maxRounds,
5003
+ locked: wizardConversation.goal.locked,
5004
+ };
4578
5005
  this.stateStore.saveGoalPrefs(this.clientId, {
4579
- reviewModel: this.goal.reviewModel,
4580
- maxRounds: this.goal.maxRounds,
4581
- locked: this.goal.locked,
5006
+ reviewModel: wizardConversation.goal.reviewModel,
5007
+ maxRounds: wizardConversation.goal.maxRounds,
5008
+ locked: wizardConversation.goal.locked,
4582
5009
  });
4583
- this.goal.wizard.step = 0;
4584
- this.goal.wizard.maxSteps = maxSteps;
4585
- this.goal.wizard.status = "调研中…";
4586
- this.goal.status = "目标调研中…";
5010
+ wizardConversation.goal.wizard.step = 0;
5011
+ wizardConversation.goal.wizard.maxSteps = maxSteps;
5012
+ wizardConversation.goal.wizard.status = "调研中…";
5013
+ wizardConversation.goal.status = "目标调研中…";
4587
5014
  this.emitGoalStatus();
4588
5015
  // Idle-timeout: cancel the wizard if no question is answered within the
4589
5016
  // window (a stale dialog with no user response must not run forever). A
@@ -4623,21 +5050,14 @@ ${transcript}
4623
5050
  text: `🔍 正在围绕需求展开调研:${draft.slice(0, 60)}${draft.length > 60 ? "…" : ""}`,
4624
5051
  });
4625
5052
  // The main conversation to show wizard progress cards in.
4626
- let mainSession = this.session;
4627
- try {
4628
- const conv = this.conv;
4629
- mainSession = conv.session;
4630
- }
4631
- catch {
4632
- // no active conversation yet
4633
- }
5053
+ const mainSession = wizardConversation.session;
4634
5054
  let refinedGoal = "";
4635
5055
  try {
4636
5056
  const wmSpec = opts?.wizardModel
4637
5057
  ? this.resolveReviewModel(opts.wizardModel)
4638
5058
  : null; // reuse the honest "provider/id" parser
4639
5059
  const services = await createAgentSessionServices({
4640
- cwd: this.cwd,
5060
+ cwd: wizardConversation.cwd,
4641
5061
  agentDir: this.agentDir,
4642
5062
  modelRuntime: await ModelRuntime.create({
4643
5063
  authPath: join(this.agentDir, "auth.json"),
@@ -4683,8 +5103,8 @@ ${transcript}
4683
5103
  }
4684
5104
  // Show the question in the main flow BEFORE blocking on the dialog, so
4685
5105
  // the user sees the wizard working even before answering.
4686
- this.goal.wizard.step = qStep;
4687
- this.goal.wizard.status = `调研中:请回答第 ${qStep} 题`;
5106
+ wizardConversation.goal.wizard.step = qStep;
5107
+ wizardConversation.goal.wizard.status = `调研中:请回答第 ${qStep} 题`;
4688
5108
  this.emitGoalStatus();
4689
5109
  try {
4690
5110
  armIdle();
@@ -4790,10 +5210,12 @@ ${transcript}
4790
5210
  finally {
4791
5211
  clearIdle();
4792
5212
  clearTimeout(totalTimer);
4793
- this.goalWizardRunning = false;
4794
- this.goal.wizard.active = false;
4795
- this.goal.wizard.step = 0;
4796
- this.goal.wizard.status = "";
5213
+ wizardConversation.wizardRunning = false;
5214
+ if (this.wizardOwnerId === wizardConversationId)
5215
+ this.wizardOwnerId = null;
5216
+ wizardConversation.goal.wizard.active = false;
5217
+ wizardConversation.goal.wizard.step = 0;
5218
+ wizardConversation.goal.wizard.status = "";
4797
5219
  this.wizardSession = null;
4798
5220
  this.emitGoalStatus();
4799
5221
  }
@@ -4815,19 +5237,27 @@ ${transcript}
4815
5237
  });
4816
5238
  return;
4817
5239
  }
5240
+ if (this.activeId !== wizardConversationId) {
5241
+ this.emit({
5242
+ type: "notice",
5243
+ level: "info",
5244
+ text: "已切换对话,目标调研结果已丢弃",
5245
+ });
5246
+ return;
5247
+ }
4818
5248
  // Auto-set the refined goal. The wizard workflow implies "set a goal and
4819
5249
  // work until it passes", so default LOCKED=true unless the user explicitly
4820
5250
  // turned the lock off (a lock lets the review loop keep revising to pass;
4821
5251
  // without it the review is single-shot).
4822
5252
  const wantLocked = opts?.locked === undefined ? true : opts.locked;
4823
5253
  await this.setGoal(refinedGoal, {
4824
- reviewModel: this.goal.reviewModel ?? undefined,
5254
+ reviewModel: wizardConversation.goal.reviewModel ?? undefined,
4825
5255
  maxRounds: opts?.maxRounds,
4826
5256
  locked: wantLocked,
4827
5257
  // The wizard kicks off generation itself below — avoid a double kick.
4828
5258
  autoStart: false,
4829
5259
  });
4830
- const g2 = this.goal;
5260
+ const g2 = wizardConversation.goal;
4831
5261
  this.wizardCancelled = false;
4832
5262
  this.wizardAbort = null;
4833
5263
  this.emit({
@@ -4857,6 +5287,11 @@ ${transcript}
4857
5287
  }
4858
5288
  if (opts?.locked !== undefined)
4859
5289
  this.goal.locked = opts.locked;
5290
+ this.goalReviewPrefs = {
5291
+ reviewModel: this.goal.reviewModel,
5292
+ maxRounds: this.goal.maxRounds,
5293
+ locked: this.goal.locked,
5294
+ };
4860
5295
  this.stateStore.saveGoalPrefs(this.clientId, {
4861
5296
  reviewModel: this.goal.reviewModel,
4862
5297
  maxRounds: this.goal.maxRounds,
@@ -4867,6 +5302,9 @@ ${transcript}
4867
5302
  /** Clear the active goal (cancels the review loop AND aborts a running
4868
5303
  * goal wizard — truly terminating its in-flight dialog + agent run). */
4869
5304
  async clearGoal() {
5305
+ this.conv.goalGeneration += 1;
5306
+ this.goal.reviewing = false;
5307
+ this.goal.conversationId = null;
4870
5308
  this.goal.goal = null;
4871
5309
  this.goal.reviewing = false;
4872
5310
  this.goal.verdict = "pending";
@@ -4876,7 +5314,7 @@ ${transcript}
4876
5314
  this.goal.status = "";
4877
5315
  this.emitGoalStatus();
4878
5316
  // Abort a running wizard for real (✗ in the goal bar while scoping).
4879
- if (this.goalWizardRunning || this.wizardAbort || this.wizardSession) {
5317
+ if (this.wizardOwnerId === this.activeId) {
4880
5318
  this.wizardCancelled = true;
4881
5319
  this.webUi.cancelPendingDialogs();
4882
5320
  this.wizardAbort?.abort();
@@ -4902,7 +5340,7 @@ ${transcript}
4902
5340
  * The whitelisted reviewer plan — tell the reviewer what to decide and how
4903
5341
  * to report, regardless of which model it runs on.
4904
5342
  */
4905
- reviewerPrompt(goal, round, maxRounds, output, gitDiff) {
5343
+ reviewerPrompt(goal, round, maxRounds, output, gitDiff, customPrompt = "") {
4906
5344
  return [
4907
5345
  `You are a strict, independent goal-reviewer. Your ONLY job is to judge whether the agent's work fully satisfies the stated goal, by checking the agent's final output and, when present, its git diff.`, // eslint-disable-line max-len
4908
5346
  ``,
@@ -4916,6 +5354,9 @@ ${transcript}
4916
5354
  gitDiff.length > 0 ? gitDiff : "(no staged/committed changes detected)", // eslint-disable-line max-len
4917
5355
  ``,
4918
5356
  `This is review round ${round}${maxRounds > 0 ? ` of up to ${maxRounds}` : " (no round cap — keep revising until it passes)"}.`, // eslint-disable-line max-len
5357
+ ...(customPrompt.trim()
5358
+ ? [``, `# Additional reviewer instructions`, customPrompt.trim()]
5359
+ : []),
4919
5360
  ``,
4920
5361
  `Decide: does the work satisfy the goal? If yes, respond with ONLY a JSON object with this exact shape (no markdown fences, no extra text):`, // eslint-disable-line max-len
4921
5362
  `{"verdict":"pass","feedback":"<one short sentence: what was satisfied>"}`, // eslint-disable-line max-len
@@ -4940,10 +5381,11 @@ ${transcript}
4940
5381
  // Non-fatal
4941
5382
  }
4942
5383
  }
4943
- /** Run a git diff (unstaged + staged) in the workspace, or "" when not a repo. */
4944
- async gitDiff() {
5384
+ /** Run a git diff (unstaged + staged) in a conversation's workspace, or
5385
+ * "" when not a repo. */
5386
+ async gitDiff(cwd) {
4945
5387
  try {
4946
- const { code, out } = await this.runAsync("git", ["diff", "HEAD"], 10_000, this.cwd);
5388
+ const { code, out } = await this.runAsync("git", ["diff", "HEAD"], 10_000, cwd);
4947
5389
  if (code !== 0)
4948
5390
  return "";
4949
5391
  return out.slice(0, 60_000);
@@ -4960,33 +5402,59 @@ ${transcript}
4960
5402
  * - fail → inject the feedback as a user message into the main session
4961
5403
  * to steer a revision; the next agent_end re-reviews with the
4962
5404
  * same round budget.
4963
- * Guarded so it never runs two reviews concurrently.
5405
+ * Guarded per conversation so separate conversations can review concurrently.
4964
5406
  */
5407
+ isCurrentGoalReview(conv, goalGeneration, reviewGeneration) {
5408
+ return (!this.disposed &&
5409
+ this.convs.get(conv.id) === conv &&
5410
+ conv.goal.conversationId === conv.id &&
5411
+ conv.goalGeneration === goalGeneration &&
5412
+ conv.goalReviewGeneration === reviewGeneration &&
5413
+ !!conv.goal.goal);
5414
+ }
5415
+ /** Drop the result of a review that became stale while it was awaiting the
5416
+ * reviewer model (most commonly because the user switched conversations). */
5417
+ discardStaleGoalReview(conv, goalGeneration, reviewGeneration) {
5418
+ if (conv.goalReviewGeneration !== reviewGeneration)
5419
+ return;
5420
+ if (conv.goalGeneration === goalGeneration &&
5421
+ conv.goal.conversationId === conv.id) {
5422
+ conv.goal.reviewing = false;
5423
+ conv.goal.status = "审查已中止,目标已更新或取消";
5424
+ this.emitGoalStatus();
5425
+ }
5426
+ }
4965
5427
  async runGoalReview(conv) {
4966
- // The review is bound to the conversation that just ran but the user may
4967
- // have switched to another conversation meanwhile. Reviews only make sense
4968
- // for the conversation that generated output, so track it locally.
5428
+ // The review is bound to the conversation that just ran. Capture both the
5429
+ // owner and a generation so a later switch/set/clear cannot let an old,
5430
+ // asynchronous reviewer mutate the new conversation's goal state.
4969
5431
  const mainConv = this.convs.get(conv.id) ?? conv;
4970
5432
  const mainSession = mainConv.session;
4971
- const g = this.goal;
5433
+ const g = conv.goal;
4972
5434
  if (!g.goal ||
4973
- this.goalReviewing ||
4974
- this.goalWizardRunning ||
5435
+ g.conversationId !== conv.id ||
5436
+ g.reviewing ||
5437
+ conv.wizardRunning ||
4975
5438
  this.disposed)
4976
5439
  return;
5440
+ const goalGeneration = conv.goalGeneration;
5441
+ const reviewGeneration = ++conv.goalReviewGeneration;
4977
5442
  // Narrowed copy — TS control-flow can't narrow `g.goal` (a mutable shared
4978
5443
  // object field) through the entire async body, so capture it here.
4979
5444
  const goalText = g.goal;
5445
+ // Capture review-only settings for this run. Changing settings while a
5446
+ // review is in flight affects the next review, never this one.
5447
+ const reviewPrompt = this.settings.reviewPrompt;
5448
+ const reviewDisabledSkills = new Set(this.settings.reviewDisabledSkills);
4980
5449
  // Cap rounds: single-shot (locked=false) always exactly one review.
4981
5450
  // For locked goals, maxRounds 0 = unlimited (keep revising until pass).
4982
5451
  const budget = g.locked ? (g.maxRounds > 0 ? g.maxRounds : Infinity) : 1;
4983
5452
  if (g.locked && g.maxRounds > 0 && g.round >= budget) {
4984
- this.goal.status = `已达最大轮数(${budget}),停止审查`;
4985
- this.goal.reviewing = false;
5453
+ g.status = `已达最大轮数(${budget}),停止审查`;
5454
+ g.reviewing = false;
4986
5455
  this.emitGoalStatus();
4987
5456
  return;
4988
5457
  }
4989
- this.goalReviewing = true;
4990
5458
  g.reviewing = true;
4991
5459
  g.round += 1;
4992
5460
  g.verdict = "pending";
@@ -5001,14 +5469,26 @@ ${transcript}
5001
5469
  catch {
5002
5470
  finalText = "";
5003
5471
  }
5004
- const diff = await this.gitDiff();
5472
+ const diff = await this.gitDiff(mainConv.cwd);
5473
+ if (!this.isCurrentGoalReview(conv, goalGeneration, reviewGeneration)) {
5474
+ this.discardStaleGoalReview(conv, goalGeneration, reviewGeneration);
5475
+ return;
5476
+ }
5005
5477
  let reviewerVerdict = "fail";
5006
5478
  let reviewerFeedback = "(审查无法完成)";
5007
5479
  try {
5008
5480
  const rmSpec = this.resolveReviewModel(g.reviewModel);
5009
5481
  const services = await createAgentSessionServices({
5010
- cwd: this.cwd,
5482
+ cwd: mainConv.cwd,
5011
5483
  agentDir: this.agentDir,
5484
+ // The reviewer has its own skill allow/deny list. It deliberately does
5485
+ // not reuse the main session's disabledSkills setting.
5486
+ resourceLoaderOptions: {
5487
+ skillsOverride: (res) => ({
5488
+ ...res,
5489
+ skills: res.skills.filter((s) => !reviewDisabledSkills.has(s.name)),
5490
+ }),
5491
+ },
5012
5492
  // A FRESH ModelRuntime for the reviewer — isolated from the shared
5013
5493
  // one used by the main conversations, so its model choice is its own.
5014
5494
  modelRuntime: await ModelRuntime.create({
@@ -5030,12 +5510,12 @@ ${transcript}
5030
5510
  }
5031
5511
  const srv = await createAgentSessionFromServices({
5032
5512
  services,
5033
- sessionManager: SessionManager.inMemory(this.cwd),
5513
+ sessionManager: SessionManager.inMemory(mainConv.cwd),
5034
5514
  ...(model ? { model } : {}),
5035
5515
  });
5036
5516
  const reviewCap = g.locked && g.maxRounds > 0 ? g.maxRounds : 0; // 0 = no cap
5037
5517
  const reviewer = srv.session;
5038
- await reviewer.prompt(this.reviewerPrompt(goalText, g.round, reviewCap, finalText, diff));
5518
+ await reviewer.prompt(this.reviewerPrompt(goalText, g.round, reviewCap, finalText, diff, reviewPrompt));
5039
5519
  // Parse the reviewer's final output (expected to be a JSON object).
5040
5520
  const raw = reviewer.getLastAssistantText() ?? "";
5041
5521
  const m = raw.match(/\{\s*"verdict"\s*:\s*"(pass|fail)"[^}]*\}/);
@@ -5055,7 +5535,13 @@ ${transcript}
5055
5535
  reviewerVerdict = "fail";
5056
5536
  reviewerFeedback = `审查过程中出错:${err.message}`;
5057
5537
  }
5058
- this.goalReviewing = false;
5538
+ // The user may have switched chats or replaced/cleared the goal while the
5539
+ // isolated reviewer was running. Never apply a stale verdict or inject it
5540
+ // into the old session after that point.
5541
+ if (!this.isCurrentGoalReview(conv, goalGeneration, reviewGeneration)) {
5542
+ this.discardStaleGoalReview(conv, goalGeneration, reviewGeneration);
5543
+ return;
5544
+ }
5059
5545
  g.reviewing = false;
5060
5546
  g.verdict = reviewerVerdict;
5061
5547
  g.feedback = reviewerFeedback;
@@ -5069,6 +5555,7 @@ ${transcript}
5069
5555
  if (verdict === "pass") {
5070
5556
  g.status = "✅ 已通过目标审查";
5071
5557
  this.emit({ type: "notice", level: "info", text: "✅ 目标已通过审查" });
5558
+ g.conversationId = null;
5072
5559
  g.goal = null; // a passed goal is done and cleared
5073
5560
  this.emitGoalStatus();
5074
5561
  // Pass = the review result goes straight into the conversation as an
@@ -5124,6 +5611,7 @@ ${transcript}
5124
5611
  // Best-effort.
5125
5612
  }
5126
5613
  this.emit({ type: "notice", level: "warning", text: "目标未通过审查(已达最大轮数)" });
5614
+ g.conversationId = null;
5127
5615
  g.goal = null; // loop exhausted — clear the active goal
5128
5616
  this.emitGoalStatus();
5129
5617
  this.flushSnapshot();
@@ -5199,7 +5687,8 @@ ${transcript}
5199
5687
  }
5200
5688
  async dispose() {
5201
5689
  this.disposed = true;
5202
- this.terminals.killAll();
5690
+ for (const conv of this.convs.values())
5691
+ conv.terminals.killAll();
5203
5692
  if (this.snapshotTimer) {
5204
5693
  clearTimeout(this.snapshotTimer);
5205
5694
  this.snapshotTimer = null;