pi-web-ui 0.96.1 → 0.97.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +48 -2
  2. package/README.md +26 -0
  3. package/README.zh-CN.md +25 -0
  4. package/bin/pi-web-ui.mjs +11 -2
  5. package/dist/server/agent-service.js +1195 -120
  6. package/dist/server/client-state.js +89 -1
  7. package/dist/server/compacted-history.js +146 -0
  8. package/dist/server/delegate-mode.js +149 -0
  9. package/dist/server/delegate-task.js +10 -10
  10. package/dist/server/dsh/dsh-agent-service.js +35 -2
  11. package/dist/server/dsh/dsh-client.js +3 -1
  12. package/dist/server/eval-tool.js +3 -1
  13. package/dist/server/goal-review-gate.js +263 -0
  14. package/dist/server/goal-service.js +1225 -445
  15. package/dist/server/index.js +35 -1
  16. package/dist/server/lsp-tool.js +13 -10
  17. package/dist/server/markers/builtins/todo.js +0 -2
  18. package/dist/server/mcp-bridge.js +13 -0
  19. package/dist/server/patch-tool.js +1 -0
  20. package/dist/server/patch-turn-end-boundary.js +77 -0
  21. package/dist/server/permission-preset.js +44 -0
  22. package/dist/server/plan-mode.js +376 -0
  23. package/dist/server/plugin-facilities.js +36 -3
  24. package/dist/server/plugins.js +6 -3
  25. package/dist/server/protocol-version.js +1 -1
  26. package/dist/server/serialize.js +11 -0
  27. package/dist/server/settings-service.js +18 -6
  28. package/dist/server/subagents.js +360 -408
  29. package/dist/server/text-sniff.js +15 -9
  30. package/dist/server/tool-manager.js +48 -4
  31. package/dist/server/update-check.js +11 -3
  32. package/dist/server/wait-subscription-scan.js +5 -1
  33. package/package.json +3 -3
  34. package/themes/zhupi-dark.css +5 -30
  35. package/themes/zhupi.css +5 -30
  36. package/web/dist/assets/{TerminalPanel-O3GyFIGG.js → TerminalPanel-CITBqQrb.js} +1 -1
  37. package/web/dist/assets/index-DwToli0J.css +1 -0
  38. package/web/dist/assets/index-oI7UKOqJ.js +368 -0
  39. package/web/dist/index.html +2 -2
  40. package/web/dist/assets/index-B7Zp23Vc.css +0 -1
  41. package/web/dist/assets/index-ByfoaDwp.js +0 -366
@@ -14,9 +14,11 @@
14
14
  // provider so built-in model lists follow the official pi.dev catalog
15
15
  // wholesale (no union merge / no stale built-in leftovers).
16
16
  import "./patch-remote-catalog.js";
17
+ import "./patch-turn-end-boundary.js";
17
18
  import { spawn } from "node:child_process";
18
19
  import { createHash, randomUUID } from "node:crypto";
19
20
  import { createRequire } from "node:module";
21
+ import * as fsPromises from "node:fs/promises";
20
22
  import { appendFileSync, existsSync, readFileSync, renameSync, rmSync, statSync, mkdirSync, watch, writeFileSync, } from "node:fs";
21
23
  import { homedir } from "node:os";
22
24
  import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
@@ -34,7 +36,7 @@ import { removeQueuedByIndexOrText } from "./queue-utils.js";
34
36
  import { syncPluginToolsIntoSession } from "./plugins.js";
35
37
  import { denialText, } from "./plugin-tool-guard.js";
36
38
  import { SettingsService } from "./settings-service.js";
37
- import { GoalService, buildDiffFingerprint } from "./goal-service.js";
39
+ import { GoalService, buildDiffFingerprint, extractErrorSnippetFromSession, lastToolNameOfSession, } from "./goal-service.js";
38
40
  import { MarkerService } from "./marker-service.js";
39
41
  import { SlashCommandsService, parseSlash } from "./slash-commands.js";
40
42
  import { ModelAdminService } from "./model-admin.js";
@@ -49,8 +51,13 @@ import { createWorkspaceSnapshot, restoreWorkspaceSnapshot } from "./workspace-s
49
51
  import { isPathInsideRoot } from "./approval-rules.js";
50
52
  import { approvalSuppressionReason, checkDangerousToolCall, isApprovalPolicyEmpty, pluginApprovalCategory, } from "./tool-approval.js";
51
53
  import { PlanManager } from "./plan-manager.js";
54
+ import { buildPlanModePrompt, planModeDenial, planModeNoticeText, PLAN_MODE_BLOCKED_TOOL_NAMES } from "./plan-mode.js";
55
+ import { DELEGATION_SYSTEM_PROMPT, delegationDenial, delegateNoticeText } from "./delegate-mode.js";
56
+ import { goalReviewDenial, shouldDeferPromptForReview } from "./goal-review-gate.js";
57
+ import { readPlanModeFromSession } from "./permission-preset.js";
58
+ import { readDelegateModeFromSession } from "./permission-preset.js";
52
59
  import { applyHeadTail, makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, } from "./terminals.js";
53
- import { applyAgentToolsGating, ASK_USER_QUESTION_TOOL_NAME, BROWSER_PAGE_TOOL_NAME, effectiveDisabledAgentTools, isAgentToolEnabled, isTerminalGuidanceOn, MARKERS_LIST_TOOL_NAME, PI_AGENT_PRESETS, PI_PERMISSION_OPTIONS, PLAN_UPDATE_TOOL_NAME, PRESENT_FILES_TOOL_NAME, presetAllowsPluginTools, presetHasQuestionnaire, presetShowsSkillCatalog, } from "./tool-manager.js";
60
+ import { applyAgentToolsGating, ASK_USER_QUESTION_TOOL_NAME, BROWSER_PAGE_TOOL_NAME, effectiveDisabledAgentTools, isAgentToolEnabled, isTerminalGuidanceOn, localizedName, MARKERS_LIST_TOOL_NAME, PI_AGENT_PRESETS, PI_PERMISSION_OPTIONS, PLAN_UPDATE_TOOL_NAME, PRESENT_FILES_TOOL_NAME, presetAllowsPluginTools, presetHasQuestionnaire, presetShowsSkillCatalog, } from "./tool-manager.js";
54
61
  import { WebUIContext } from "./webui-context.js";
55
62
  import { DEFAULT_COMPACTION_RESERVE_TOKENS, effectiveSoftCap, softCapToReserve } from "./soft-cap.js";
56
63
  import { pruneContextHierarchically } from "./context-budget.js";
@@ -75,6 +82,7 @@ import { extractTouches, formatTouchesCompact, intersectTouches } from "./conver
75
82
  import { ClaimStore, matchClaims, mergeTouchSidecar, readTouchSidecar, removeTouchSidecar } from "./claim-store.js";
76
83
  import { makeClaimFilesTool } from "./claim-files-tool.js";
77
84
  import { makeSkillTool } from "./skill-tool.js";
85
+ import { getCompactedMessages } from "./compacted-history.js";
78
86
  import { makeScheduleTools } from "./schedule-agent-tool.js";
79
87
  import { makePatchTool } from "./patch-tool.js";
80
88
  import { makeLspTool } from "./lsp-tool.js";
@@ -266,6 +274,85 @@ export function makeAdaptiveBashTool(killable, terminalBacked, useTerminal) {
266
274
  },
267
275
  };
268
276
  }
277
+ /**
278
+ * 计划模式闸门装饰(通用):套在任何工具定义外层,只在**执行前**判定一次。
279
+ * 与权限沙箱/插件守卫平行且更靠外 —— 计划模式是会话级硬约束,不给任何
280
+ * 「记住同类/全部允许」的口子(允许了就等于放开了实施)。
281
+ * 关闭时零开销(返回原定义,不包 execute)。
282
+ */
283
+ export function withPlanModeGate(def, planMode, getLang) {
284
+ return {
285
+ ...def,
286
+ execute: (async (...args) => {
287
+ if (planMode()) {
288
+ const params = args[1];
289
+ const denied = planModeDenial(def.name, params);
290
+ if (denied) {
291
+ const reason = pick(getLang(), denied.reason, denied.reasonEn);
292
+ const err = new Error(reason);
293
+ err.details = {
294
+ guardDenied: true,
295
+ planModeDenied: true,
296
+ kind: denied.kind,
297
+ };
298
+ throw err;
299
+ }
300
+ }
301
+ return def.execute(...args);
302
+ }),
303
+ };
304
+ }
305
+ /**
306
+ * 目标审查回合闸门(server/goal-review-gate.ts):主对话在 `awaitingVerdict`
307
+ * 置位期间是审查者 —— 写类工具、非常规 bash、派发类工具、向用户提问一律拒。
308
+ * 形状与 withPlanModeGate / withDelegationGate 同构,串在最外层(理由最贴合
309
+ * 此刻:跟模型说「这是审查回合」比「这是计划/审查者模式」更有用)。闸门无状态
310
+ * (每次执行只读 `awaitingVerdict`),verdict 落定即自动恢复,不存在钉死。
311
+ */
312
+ export function withGoalReviewGate(def, reviewTurn, getLang) {
313
+ return {
314
+ ...def,
315
+ execute: (async (...args) => {
316
+ if (reviewTurn()) {
317
+ const params = args[1];
318
+ const denied = goalReviewDenial(def.name, params);
319
+ if (denied) {
320
+ return {
321
+ content: [{ type: "text", text: pick(getLang(), denied.reason, denied.reasonEn) }],
322
+ details: { guardDenied: true, goalReviewDenied: true, kind: denied.kind },
323
+ isError: true,
324
+ };
325
+ }
326
+ }
327
+ return def.execute(...args);
328
+ }),
329
+ };
330
+ }
331
+ /**
332
+ * 审查者模式闸门(server/delegate-mode.ts):主对话只审阅不施工 ——
333
+ * 写类工具、非常规 bash、派发类工具一律拒。形状与 withPlanModeGate 同构,
334
+ * 串在它**外层**(两者都开时,先问计划模式还是审查者模式都不影响结果:
335
+ * 都是拒,只是理由不同)。
336
+ */
337
+ export function withDelegationGate(def, delegateMode, getLang) {
338
+ return {
339
+ ...def,
340
+ execute: (async (...args) => {
341
+ if (delegateMode()) {
342
+ const params = args[1];
343
+ const denied = delegationDenial(def.name, params);
344
+ if (denied) {
345
+ return {
346
+ content: [{ type: "text", text: pick(getLang(), denied.reason, denied.reasonEn) }],
347
+ details: { guardDenied: true, delegateDenied: true, kind: denied.kind },
348
+ isError: true,
349
+ };
350
+ }
351
+ }
352
+ return def.execute(...args);
353
+ }),
354
+ };
355
+ }
269
356
  /**
270
357
  * 给已接管工具(bash/read)包上拦截守卫与人机协同审批:
271
358
  * 1. pre guard 命中 deny → 直接阻断;
@@ -666,7 +753,11 @@ export function makePlanUpdateTool(planManager, getActiveConvId, emit, flushSnap
666
753
  steps: Type.Array(Type.Object({
667
754
  id: Type.String({ description: "Unique step ID, e.g. '1', 'step-1'" }),
668
755
  title: Type.String({ description: "Short step title" }),
669
- status: Type.Optional(Type.Union([Type.Literal("pending"), Type.Literal("in_progress"), Type.Literal("done"), Type.Literal("failed")], { description: "Step status: pending | in_progress | done | failed (default: pending)" })),
756
+ status: Type.Optional(Type.Unsafe({
757
+ type: "string",
758
+ enum: ["pending", "in_progress", "done", "failed"],
759
+ description: "Step status: pending | in_progress | done | failed (default: pending)",
760
+ })),
670
761
  description: Type.Optional(Type.String({
671
762
  description: "Optional detailed description, acceptance criteria, file touch list, or rollback note.",
672
763
  })),
@@ -718,6 +809,7 @@ function wrapBashToolWithPermission(tool, getPermission, getLang) {
718
809
  function makeMarkersListTool(getActiveId, markerSvc) {
719
810
  return {
720
811
  name: MARKERS_LIST_TOOL_NAME,
812
+ promptSnippet: "list current inline-marker tasks (read-only — writes go through [[todo:...]] inline markers)",
721
813
  label: "List marker state",
722
814
  description: "Read-only query of inline marker state. All WRITE operations must use inline markers ([[todo:new:...]] etc.) in the reply body — never this tool.",
723
815
  parameters: Type.Object({
@@ -757,52 +849,50 @@ export function makeAskUserQuestionTool(clientSession,
757
849
  /** 本 runtime 所属会话:提问跟着对话走,快照只把当前对话的问卷推给客户端。 */
758
850
  ownerId) {
759
851
  const QuestionOptionSchema = Type.Object({
760
- label: Type.String({ description: "Display label for the option (1-5 words)" }),
852
+ label: Type.String({ description: "Display label (1-5 words)" }),
761
853
  description: Type.Optional(Type.String({
762
- description: "One short sentence explaining the impact or tradeoff if selected.",
854
+ description: "Short explanation of impact/tradeoff",
763
855
  })),
764
856
  preview: Type.Optional(Type.String({
765
- description: "Optional preview rendered below when this option is selected (markdown or HTML — use for mockups/code/config).",
857
+ description: "Optional preview (markdown/HTML/code)",
766
858
  })),
767
859
  });
768
860
  const QuestionSchema = Type.Object({
769
- id: Type.String({ description: "Unique identifier for this question (snake_case)" }),
770
- question: Type.String({ description: "The full question text to display (markdown/HTML ok)" }),
771
- detail: Type.Optional(Type.String({ description: "Optional detail/context shown under the question" })),
772
- header: Type.Optional(Type.String({ description: "Optional short header for this question" })),
861
+ id: Type.String({ description: "Unique identifier (snake_case)" }),
862
+ question: Type.String({ description: "Question text (markdown/HTML ok)" }),
863
+ detail: Type.Optional(Type.String({ description: "Optional context under question" })),
864
+ header: Type.Optional(Type.String({ description: "Optional short header" })),
773
865
  options: Type.Optional(Type.Array(QuestionOptionSchema, {
774
- description: "2-4 mutually exclusive choices. Put the recommended option first when there is a clear default.",
866
+ description: "2-4 choices (recommended option first)",
775
867
  minItems: 2,
776
868
  maxItems: 4,
777
869
  })),
778
- multiSelect: Type.Optional(Type.Boolean({ description: "Allow selecting multiple options (default: false)" })),
870
+ multiSelect: Type.Optional(Type.Boolean({ description: "Allow multiple choices (default: false)" })),
779
871
  dependsOn: Type.Optional(Type.Object({
780
- questionId: Type.String({ description: "ID of the prior question this question depends on" }),
872
+ questionId: Type.String({ description: "Prior question ID" }),
781
873
  value: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())], {
782
- description: "Show only when the prior answer equals this value (or is in the array). Omit to show whenever answered.",
874
+ description: "Show when prior answer matches",
783
875
  })),
784
876
  })),
785
877
  optionsMap: Type.Optional(Type.Record(Type.String(), Type.Array(QuestionOptionSchema), {
786
- description: "Dynamic options based on prior question's chosen value: e.g. { 'React': [opts...], 'Vue': [opts...] }",
878
+ description: "Dynamic options keyed by prior answer",
787
879
  })),
788
880
  });
789
881
  return {
790
882
  name: "ask_user_question",
791
883
  label: "Ask the user",
792
- description: "Ask the user focused questions to clarify ambiguous requirements (clarify the task, confirm decisions, get preferences). " +
793
- "Strictly ask 1 to 3 questions per call (prefer 1, max 3); provide 2 to 4 mutually exclusive options with the " +
794
- "recommended option first, and explain impact/tradeoff in each option description. " +
795
- "Each question renders a browser dialog with markdown/HTML rich text; options may carry a `preview`. Submit or cancel to resume.",
884
+ description: "Ask the user 1-3 focused questions to clarify requirements, confirm decisions, or choose options. " +
885
+ "Provide 2-4 choices (recommended first) with concise tradeoffs. " +
886
+ "Renders a rich browser dialog; resumes on submit or cancel.",
796
887
  promptSnippet: "ask the user 1-3 focused questions with recommended options and tradeoffs to clarify requirements",
797
888
  promptGuidelines: [
798
- "When requirements are ambiguous, use ask_user_question to clarify instead of guessing: " +
799
- "ask 1 to 3 focused questions (prefer 1, max 3), provide 2-4 mutually exclusive options with the " +
800
- "recommended option first, and explain impact/tradeoff in description",
801
- "A cancelled question comes back as a tool error — respect it and continue without re-asking immediately",
889
+ "When requirements are ambiguous, use ask_user_question to clarify: " +
890
+ "strictly 1 to 3 focused questions (prefer 1), 2-4 choices with recommended option first, and explain impact/tradeoff concisely",
891
+ "A cancelled question comes back as a tool error — respect it without immediately re-asking",
802
892
  ],
803
893
  parameters: Type.Object({
804
894
  questions: Type.Array(QuestionSchema, {
805
- description: "Questions to ask the user (strictly 1 to 3 questions; prefer 1).",
895
+ description: "1-3 questions to ask (prefer 1)",
806
896
  minItems: 1,
807
897
  maxItems: 3,
808
898
  }),
@@ -1361,6 +1451,238 @@ export function isInsideSessionsDir(agentDir, targetPath) {
1361
1451
  roots.push(resolve(extra));
1362
1452
  return roots.some((root) => abs.startsWith(root + sep));
1363
1453
  }
1454
+ /**
1455
+ * 从会话文件首行读取 cwd(轻量探测,最多读取 2KB,绝不完整解析整个 jsonl 消息历史)。
1456
+ */
1457
+ async function readCwdFromSessionHeader(filePath) {
1458
+ let handle;
1459
+ try {
1460
+ handle = await fsPromises.open(filePath, "r");
1461
+ const buf = Buffer.alloc(2048);
1462
+ const { bytesRead } = await handle.read(buf, 0, 2048, 0);
1463
+ const text = buf.toString("utf8", 0, bytesRead);
1464
+ const nl = text.indexOf("\n");
1465
+ const firstLine = nl !== -1 ? text.slice(0, nl) : text;
1466
+ if (!firstLine.trim())
1467
+ return null;
1468
+ const parsed = JSON.parse(firstLine);
1469
+ return typeof parsed.cwd === "string" && parsed.cwd.trim() ? parsed.cwd : null;
1470
+ }
1471
+ catch {
1472
+ return null;
1473
+ }
1474
+ finally {
1475
+ if (handle)
1476
+ await handle.close().catch(() => { });
1477
+ }
1478
+ }
1479
+ /**
1480
+ * 快速轻量地从 SDK 会话目录中发现项目路径(仅读取会话文件的第一行头部获取 cwd,
1481
+ * 绝不使用 SessionManager.listAll 遍历解析全部会话文本,避免大历史时阻塞数秒到数十秒)。
1482
+ */
1483
+ async function discoverRecentProjectsFromDisk(sessionRoots, limit = 30) {
1484
+ const projects = new Map();
1485
+ for (const root of sessionRoots) {
1486
+ if (projects.size >= limit)
1487
+ break;
1488
+ try {
1489
+ if (!existsSync(root))
1490
+ continue;
1491
+ const entries = await fsPromises.readdir(root, { withFileTypes: true });
1492
+ // 1. 扁平布局(PI_CODING_AGENT_SESSION_DIR):根目录下直接是 .jsonl
1493
+ const flatFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
1494
+ if (flatFiles.length > 0) {
1495
+ const fileStats = await Promise.all(flatFiles.map(async (f) => {
1496
+ const fp = join(root, f.name);
1497
+ try {
1498
+ const st = await fsPromises.stat(fp);
1499
+ return { path: fp, mtime: st.mtimeMs };
1500
+ }
1501
+ catch {
1502
+ return null;
1503
+ }
1504
+ }));
1505
+ fileStats.sort((a, b) => (b?.mtime ?? 0) - (a?.mtime ?? 0));
1506
+ for (const s of fileStats) {
1507
+ if (!s || projects.size >= limit)
1508
+ break;
1509
+ const cwd = await readCwdFromSessionHeader(s.path);
1510
+ if (cwd) {
1511
+ const key = normalizePathKey(cwd);
1512
+ if (!projects.has(key)) {
1513
+ try {
1514
+ if (existsSync(cwd))
1515
+ projects.set(key, { path: cwd, lastUsed: s.mtime });
1516
+ }
1517
+ catch { }
1518
+ }
1519
+ }
1520
+ }
1521
+ }
1522
+ // 2. 默认子目录布局(<agentDir>/sessions/--<cwd>--/):每 cwd 一个子目录
1523
+ const subdirs = entries.filter((e) => e.isDirectory() || e.isSymbolicLink()).map((e) => join(root, e.name));
1524
+ if (subdirs.length > 0 && projects.size < limit) {
1525
+ const CHUNK = 64;
1526
+ const dirStats = [];
1527
+ for (let i = 0; i < subdirs.length; i += CHUNK) {
1528
+ const chunk = subdirs.slice(i, i + CHUNK);
1529
+ const stats = await Promise.all(chunk.map(async (dir) => {
1530
+ try {
1531
+ const st = await fsPromises.stat(dir);
1532
+ return { path: dir, mtime: st.mtimeMs };
1533
+ }
1534
+ catch {
1535
+ return null;
1536
+ }
1537
+ }));
1538
+ for (const s of stats)
1539
+ if (s)
1540
+ dirStats.push(s);
1541
+ }
1542
+ dirStats.sort((a, b) => b.mtime - a.mtime);
1543
+ for (let i = 0; i < dirStats.length; i += 16) {
1544
+ if (projects.size >= limit)
1545
+ break;
1546
+ const batch = dirStats.slice(i, i + 16);
1547
+ const batchRes = await Promise.all(batch.map(async (d) => {
1548
+ try {
1549
+ const files = await fsPromises.readdir(d.path);
1550
+ const jsonls = files.filter((f) => f.endsWith(".jsonl"));
1551
+ if (jsonls.length === 0)
1552
+ return null;
1553
+ let newestFile = "";
1554
+ let newestMtime = 0;
1555
+ for (const f of jsonls) {
1556
+ const fp = join(d.path, f);
1557
+ try {
1558
+ const st = await fsPromises.stat(fp);
1559
+ if (st.mtimeMs > newestMtime) {
1560
+ newestMtime = st.mtimeMs;
1561
+ newestFile = fp;
1562
+ }
1563
+ }
1564
+ catch { }
1565
+ }
1566
+ if (!newestFile)
1567
+ return null;
1568
+ const cwd = await readCwdFromSessionHeader(newestFile);
1569
+ if (!cwd)
1570
+ return null;
1571
+ return { path: cwd, lastUsed: newestMtime };
1572
+ }
1573
+ catch {
1574
+ return null;
1575
+ }
1576
+ }));
1577
+ for (const r of batchRes) {
1578
+ if (!r)
1579
+ continue;
1580
+ const key = normalizePathKey(r.path);
1581
+ if (!projects.has(key)) {
1582
+ try {
1583
+ if (existsSync(r.path)) {
1584
+ projects.set(key, r);
1585
+ }
1586
+ }
1587
+ catch { }
1588
+ }
1589
+ }
1590
+ }
1591
+ }
1592
+ }
1593
+ catch {
1594
+ /* best effort */
1595
+ }
1596
+ }
1597
+ return [...projects.values()].sort((a, b) => b.lastUsed - a.lastUsed);
1598
+ }
1599
+ /**
1600
+ * 快速解析单个会话文件(提取 id/cwd/name/parentSessionPath/firstMessage/messageCount/modified/allMessagesText)。
1601
+ * 仅用于会话列表发现与搜索,避免调用 SDK 重型的全量树解析。
1602
+ */
1603
+ async function parseSessionInfoFast(filePath, fileMtime) {
1604
+ try {
1605
+ const content = await fsPromises.readFile(filePath, "utf8");
1606
+ const lines = content.split("\n");
1607
+ let id = "";
1608
+ let cwd = "";
1609
+ let name;
1610
+ let parentSessionPath;
1611
+ let firstMessage = "";
1612
+ let messageCount = 0;
1613
+ let lastActivityTime = 0;
1614
+ let headerTime = 0;
1615
+ const allMessages = [];
1616
+ for (const line of lines) {
1617
+ if (!line.trim())
1618
+ continue;
1619
+ let entry;
1620
+ try {
1621
+ entry = JSON.parse(line);
1622
+ }
1623
+ catch {
1624
+ continue;
1625
+ }
1626
+ const rec = entry;
1627
+ if (!rec)
1628
+ continue;
1629
+ if (rec.type === "session") {
1630
+ id = typeof rec.id === "string" ? rec.id : "";
1631
+ cwd = typeof rec.cwd === "string" ? rec.cwd : "";
1632
+ if (typeof rec.timestamp === "string")
1633
+ headerTime = new Date(rec.timestamp).getTime();
1634
+ if (typeof rec.parentSession === "string")
1635
+ parentSessionPath = rec.parentSession;
1636
+ }
1637
+ else if (rec.type === "session_info") {
1638
+ if (typeof rec.name === "string" && rec.name.trim())
1639
+ name = rec.name.trim();
1640
+ }
1641
+ else if (rec.type === "message") {
1642
+ messageCount++;
1643
+ const msg = rec.message;
1644
+ if (msg) {
1645
+ if (typeof msg.timestamp === "number")
1646
+ lastActivityTime = Math.max(lastActivityTime, msg.timestamp);
1647
+ let text = "";
1648
+ if (typeof msg.content === "string") {
1649
+ text = msg.content;
1650
+ }
1651
+ else if (Array.isArray(msg.content)) {
1652
+ for (const part of msg.content) {
1653
+ if (part && typeof part.text === "string") {
1654
+ text += (text ? " " : "") + part.text;
1655
+ }
1656
+ }
1657
+ }
1658
+ if (text) {
1659
+ if (!firstMessage && msg.role === "user")
1660
+ firstMessage = text;
1661
+ allMessages.push(text);
1662
+ }
1663
+ }
1664
+ }
1665
+ }
1666
+ if (!id)
1667
+ return null;
1668
+ const modified = lastActivityTime > 0 ? lastActivityTime : headerTime > 0 ? headerTime : fileMtime;
1669
+ return {
1670
+ path: filePath,
1671
+ id,
1672
+ cwd,
1673
+ name,
1674
+ parentSessionPath,
1675
+ created: new Date(headerTime || fileMtime),
1676
+ modified: new Date(modified),
1677
+ messageCount,
1678
+ firstMessage: firstMessage || "(no messages)",
1679
+ allMessagesText: allMessages.join(" "),
1680
+ };
1681
+ }
1682
+ catch {
1683
+ return null;
1684
+ }
1685
+ }
1364
1686
  /** 会话当前模型的 "provider/id"(无模型时 null;软上限按模型覆盖用,issue #229)。 */
1365
1687
  function modelKeyOf(session) {
1366
1688
  const m = session?.model;
@@ -1470,6 +1792,9 @@ export class ClientSession {
1470
1792
  * top bar applies to every chat, not just the one that set it. Seeded by
1471
1793
  * the first conversation and reused by later ones. */
1472
1794
  sharedModelRuntime;
1795
+ /** 目标模式「委托执行」(Plan A)的「本轮结束」等待者:convId → 回调集合。
1796
+ * agent_end 到达时唤醒(事件驱动,绝不轮询);对话被移出时按 gone 收。 */
1797
+ turnEndWaiters = new Map();
1473
1798
  // -----------------------------------------------------------------------
1474
1799
  // Goal / review / wizard —— 自包含模块,见 goal-service.ts。每个对话有独立
1475
1800
  // 的 GoalStatus,审查可并发;宿主回调在构造函数里接入。
@@ -1631,7 +1956,7 @@ export class ClientSession {
1631
1956
  * `model`(可选):"provider/id",显式指定子代理模型。不传时由调用方决定是否
1632
1957
  * 回退到模板模型 / 设置面板默认模型;null = 跟随主对话当前模型(默认行为,
1633
1958
  * runtime 重建时会继承共享 ModelRuntime 的当前默认)。 */
1634
- async spawnSubagentConversation(prompt, type, cwd, apply, model, parentId, persist) {
1959
+ async spawnSubagentConversation(prompt, type, cwd, apply, model, parentId, persist, title) {
1635
1960
  // 数量上限先行:每个子代理都是完整 runtime + TerminalManager,无上限时一次
1636
1961
  // 并行派发几十个会把服务进程拖垮。持久化普通对话则受项目会话上限限制。
1637
1962
  if (persist) {
@@ -1690,7 +2015,7 @@ export class ClientSession {
1690
2015
  // syncPluginTools 不回补,模板热改不影响已运行的子代理(与 prompt/技能一致)。
1691
2016
  conv.subagentBarsPluginTools = !!apply && apply.enabledExtensions.length > 0;
1692
2017
  conv.listed = true;
1693
- conv.title = subagentTitle(prompt);
2018
+ conv.title = title ?? subagentTitle(prompt);
1694
2019
  this.convs.set(conv.id, conv);
1695
2020
  // 子代理会话同样订阅 SDK 事件:否则 onEvent 永不触发,点开查看时没有
1696
2021
  // message_delta 流式增量、快照也不刷新,只能靠切走切回时的 flushSnapshot
@@ -1778,8 +2103,9 @@ export class ClientSession {
1778
2103
  });
1779
2104
  }
1780
2105
  }
1781
- // 触发回合(后台执行;失败转识为通知)。
1782
- void conv.session.sendUserMessage(prompt).catch((err) => {
2106
+ // 触发回合(后台执行;失败转识为通知)。把 Promise 挂在对话上:目标模式的
2107
+ // 「等本回合结束」要先等它落定,才能区分「还没开跑」与「已跑完」。
2108
+ conv.kickoff = conv.session.sendUserMessage(prompt).catch((err) => {
1783
2109
  this.emit({
1784
2110
  type: "notice",
1785
2111
  level: "error",
@@ -1877,6 +2203,92 @@ export class ClientSession {
1877
2203
  }
1878
2204
  return {};
1879
2205
  }
2206
+ /**
2207
+ * 事件驱动地等某对话「当前回合结束」(目标模式委托执行用)。
2208
+ * 若当前并未在跑则立即返回既有结局;超时返回 "timeout";对话已消失返回 "gone"。
2209
+ * 不轮询:靠 agent_end 的 notifyTurnEnd / removeConversation 唤醒。
2210
+ */
2211
+ async waitConversationTurnEnd(convId, timeoutMs) {
2212
+ const isStreaming = (c) => {
2213
+ try {
2214
+ return c.session.isStreaming;
2215
+ }
2216
+ catch {
2217
+ return false;
2218
+ }
2219
+ };
2220
+ let conv = this.convs.get(convId);
2221
+ if (!conv)
2222
+ return Promise.resolve("gone");
2223
+ // spawn 的首回合是 fire-and-forget 投递的:先等它落定,否则「还没开跑」会被
2224
+ // 误判成「已跑完」(空取样 → 下一轮派活撞在一起)。
2225
+ if (conv.kickoff) {
2226
+ await conv.kickoff.catch(() => { });
2227
+ conv = this.convs.get(convId);
2228
+ if (!conv)
2229
+ return "gone";
2230
+ }
2231
+ // 极短的启动宽限:投递刚返回时 isStreaming 可能还差一拍。只在这里等,
2232
+ // 一旦开跑就交给事件驱动(agent_end),不是长轮询。
2233
+ if (!isStreaming(conv)) {
2234
+ const graceEnd = Date.now() + Math.min(3000, Math.max(0, timeoutMs));
2235
+ while (!isStreaming(conv) && Date.now() < graceEnd) {
2236
+ await new Promise((resolve) => setTimeout(resolve, 50));
2237
+ const again = this.convs.get(convId);
2238
+ if (!again)
2239
+ return "gone";
2240
+ conv = again;
2241
+ }
2242
+ }
2243
+ if (!isStreaming(conv))
2244
+ return this.roleOutcomeOf(conv);
2245
+ return new Promise((resolve) => {
2246
+ const set = this.turnEndWaiters.get(convId) ?? new Set();
2247
+ this.turnEndWaiters.set(convId, set);
2248
+ let settled = false;
2249
+ let timer;
2250
+ const finish = (o) => {
2251
+ if (settled)
2252
+ return;
2253
+ settled = true;
2254
+ if (timer)
2255
+ clearTimeout(timer);
2256
+ set.delete(finish);
2257
+ if (set.size === 0)
2258
+ this.turnEndWaiters.delete(convId);
2259
+ resolve(o);
2260
+ };
2261
+ timer = setTimeout(() => finish("timeout"), Math.max(1000, timeoutMs));
2262
+ timer.unref?.();
2263
+ set.add(finish);
2264
+ // 注册后复检:agent_end 可能恰好在 isStreaming 检查与注册之间到达。
2265
+ const still = this.convs.get(convId);
2266
+ if (!still) {
2267
+ finish("gone");
2268
+ return;
2269
+ }
2270
+ if (!isStreaming(still))
2271
+ finish(this.roleOutcomeOf(still));
2272
+ });
2273
+ }
2274
+ /** 对话结局 → 角色轮结局(报错 > 中止 > 正常)。 */
2275
+ roleOutcomeOf(conv) {
2276
+ const { error, canceled } = this.subagentRunOutcome(conv);
2277
+ if (error)
2278
+ return "error";
2279
+ if (canceled)
2280
+ return "canceled";
2281
+ return "done";
2282
+ }
2283
+ /** 某对话回合结束 → 唤醒它的等待者(agent_end / 中止路径都调)。 */
2284
+ notifyTurnEnd(conv) {
2285
+ const set = this.turnEndWaiters.get(conv.id);
2286
+ if (!set || set.size === 0)
2287
+ return;
2288
+ const outcome = this.roleOutcomeOf(conv);
2289
+ for (const fn of set)
2290
+ fn(outcome);
2291
+ }
1880
2292
  emitTerminal(conversationId, msg) {
1881
2293
  // Background conversations keep collecting output in their own PTY buffer.
1882
2294
  // Do not stream it into the active xterm; push the retained window on switch.
@@ -2661,7 +3073,11 @@ export class ClientSession {
2661
3073
  },
2662
3074
  applyRetryOverrides: () => this.applyRetryOverrides(),
2663
3075
  applyCompactionOverrides: () => this.applyCompactionOverrides(),
2664
- applyToolGating: () => this.applyToolGating(this.session, this.conv?.agentPreset),
3076
+ applyToolGating: () => {
3077
+ for (const conv of this.convs.values()) {
3078
+ this.applyToolGating(conv.session, conv.agentPreset);
3079
+ }
3080
+ },
2665
3081
  promptSnapshot: () => this.promptSnapshot(),
2666
3082
  getMarkerState: () => ({
2667
3083
  markersEnabled: this.markerSvc.current.markersEnabled,
@@ -2669,6 +3085,10 @@ export class ClientSession {
2669
3085
  markers: this.markerSvc.listForUi(),
2670
3086
  }),
2671
3087
  getApprovalPolicy: () => this.approvalPolicyState(),
3088
+ // 目标模式总开关关闭 → 在飞的目标/调研立即停(幂等;无在飞目标时无声)。
3089
+ onGoalModeDisabled: () => {
3090
+ void this.goalSvc.stopAllGoals();
3091
+ },
2672
3092
  }, this.subagentTemplates, this.approvalRules);
2673
3093
  this.goalSvc = new GoalService({
2674
3094
  clientId,
@@ -2687,8 +3107,122 @@ export class ClientSession {
2687
3107
  activeConv: () => this.conv,
2688
3108
  getConv: (id) => this.convs.get(id),
2689
3109
  cwd: () => this.cwd,
2690
- reviewSettings: () => this.settingsSvc.reviewPrefs,
2691
3110
  gitDiff: (dir) => this.gitDiff(dir),
3111
+ // 任务计划看板(#389 目标与计划联动):审查者核验步骤看板,向导产物注入看板
3112
+ getPlan: (convId) => this.planManager.getPlan(convId),
3113
+ describePlan: (convId) => this.planManager.describePlan(convId),
3114
+ setPlan: (convId, steps) => {
3115
+ this.updatePlan(steps, undefined, convId);
3116
+ },
3117
+ // 工作区 git 可用性(目标模式停滞判定的可信信号开关):非仓库目录下
3118
+ // git diff 恒为空,取样会跳过停滞计数,纯问答类目标不再被误熔断。
3119
+ isGitRepo: async (cwd) => {
3120
+ try {
3121
+ const r = await this.runAsync("git", ["rev-parse", "--is-inside-work-tree"], 5_000, cwd || this.cwd);
3122
+ return r.code === 0;
3123
+ }
3124
+ catch {
3125
+ return false;
3126
+ }
3127
+ },
3128
+ // ---- 目标模式 2.0 的角色对话桥(唯一审查/执行路径)----
3129
+ // 复用子代理通道(同一套模板/模型/思考强度/配额/左栏展示),但角色对话**落盘**
3130
+ // (persist=true):转录进历史、服务重启后仍可打开回看。
3131
+ // 代价:落盘对话 isSubagent=false → 占「每项目 8 个普通对话」名额之一
3132
+ // (spawnSubagentConversation 满员时抛错 → GoalService 降级回 self 并提示),
3133
+ // 且左栏不再有「子代理」徽标 —— 故给它一个带前缀的标题保持可辨识。
3134
+ spawnRoleAgent: async ({ role, prompt, cwd, model, parentId, title }) => {
3135
+ const baseCwd = cwd || (parentId ? this.convs.get(parentId)?.cwd : undefined) || this.cwd;
3136
+ const convId = await this.spawnSubagentConversation(prompt, role === "executor" ? "goal-executor" : "goal-reviewer", baseCwd, undefined, model ?? null, parentId, true, title);
3137
+ if (title) {
3138
+ const conv = this.convs.get(convId);
3139
+ if (conv)
3140
+ conv.title = title;
3141
+ }
3142
+ return convId;
3143
+ },
3144
+ waitRoleAgent: (convId, timeoutMs) => this.waitConversationTurnEnd(convId, timeoutMs),
3145
+ sendRoleAgent: async (convId, message, deliverAs) => {
3146
+ const conv = this.convs.get(convId);
3147
+ if (!conv?.session)
3148
+ return false;
3149
+ await conv.session.sendUserMessage(message, deliverAs ? { deliverAs } : conv.session.isStreaming ? { deliverAs: "steer" } : undefined);
3150
+ return true;
3151
+ },
3152
+ readRoleAgent: (convId) => {
3153
+ const conv = this.convs.get(convId);
3154
+ if (!conv?.session)
3155
+ return undefined;
3156
+ let text = "";
3157
+ try {
3158
+ text = conv.session.getLastAssistantText() ?? "";
3159
+ }
3160
+ catch {
3161
+ text = "";
3162
+ }
3163
+ // 执行者 vitals(目标条实时进度 + 用量累计用;轮次边界才读一次,
3164
+ // getSessionStats 会遍历转写,不在高频路径上调)。
3165
+ let streaming;
3166
+ let lastTool;
3167
+ let usage;
3168
+ try {
3169
+ streaming = conv.session.isStreaming;
3170
+ }
3171
+ catch {
3172
+ streaming = undefined;
3173
+ }
3174
+ try {
3175
+ lastTool = lastToolNameOfSession(conv.session);
3176
+ }
3177
+ catch {
3178
+ lastTool = undefined;
3179
+ }
3180
+ try {
3181
+ const t = conv.session.getSessionStats()?.tokens;
3182
+ if (t && typeof t.input === "number" && typeof t.output === "number") {
3183
+ usage = { input: t.input, output: t.output };
3184
+ }
3185
+ }
3186
+ catch {
3187
+ usage = undefined;
3188
+ }
3189
+ return {
3190
+ text,
3191
+ errorSnippet: extractErrorSnippetFromSession(conv.session, text),
3192
+ streaming,
3193
+ lastTool,
3194
+ usage,
3195
+ };
3196
+ },
3197
+ stopRoleAgent: async (convId) => {
3198
+ const conv = this.convs.get(convId);
3199
+ if (!conv)
3200
+ return;
3201
+ try {
3202
+ if (conv.session.isStreaming || !conv.session.isIdle) {
3203
+ await this.interruptRun(conv, pick(this.getLang(), "目标模式停止角色对话", "Goal mode stopped a role conversation", "agent.role.stop"));
3204
+ }
3205
+ }
3206
+ catch {
3207
+ // best-effort
3208
+ }
3209
+ },
3210
+ dismissRoleAgent: async (convId) => {
3211
+ const conv = this.convs.get(convId);
3212
+ if (!conv)
3213
+ return;
3214
+ // 用户正看着这个角色对话时不要把他弹走(对话留着,用户可自行关闭)。
3215
+ if (convId === this.activeId)
3216
+ return;
3217
+ try {
3218
+ await this.dismissConversation(convId, true, true);
3219
+ }
3220
+ catch {
3221
+ // best-effort
3222
+ }
3223
+ },
3224
+ hasConv: (convId) => this.convs.has(convId),
3225
+ roleDeadlineMs: () => this.getBaseToolWatchdogTimeoutMs(),
2692
3226
  });
2693
3227
  this.modelAdmin = new ModelAdminService({
2694
3228
  agentDir,
@@ -2829,6 +3363,21 @@ export class ClientSession {
2829
3363
  out.push(pickTemplatePrompt(apply, this.getLang()));
2830
3364
  }
2831
3365
  // 主会话自定义「追加」已并入组合模板的 {{append}} 覆盖,不再在此注入。
3366
+ // 计划模式:只规划不实施 —— 硬闸门(server/plan-mode.ts 拒写类/旁路
3367
+ // 工具与非常规 bash)之外再补一段软约束,每轮重建提示词时读当前
3368
+ // 会话状态,切换后下一次 reload 生效。提示词正文可在设置面板改
3369
+ // (追加/替换内置默认,见 buildPlanModePrompt)。
3370
+ // ⚠️ 这一段与平台无关(曾在 win32 分支里被误嵌套,导致非 Windows
3371
+ // 上计划模式的软约束从来没进过提示词,只有硬闸门在挡)。
3372
+ if (this.planModeOf(ownerId)) {
3373
+ const planPrefs = this.settingsSvc.current;
3374
+ out.push(buildPlanModePrompt(planPrefs.planModePromptMode, planPrefs.planModePrompt));
3375
+ }
3376
+ // 审查者模式(自动委派):本对话只审阅,活由服务端派给常驻执行对话。
3377
+ // 与计划模式**互斥优先级**:计划模式开着时以计划模式为准,不派活。
3378
+ if (this.delegateModeOf(ownerId) && !this.planModeOf(ownerId)) {
3379
+ out.push(DELEGATION_SYSTEM_PROMPT);
3380
+ }
2832
3381
  if (process.platform === "win32") {
2833
3382
  // Windows 专属 persona:bash 工具跑 Git Bash 且无默认超时、终端
2834
3383
  // 是交互式 TTY——注入约束避免 heredoc/交互/长驻命令挂死整个会话;
@@ -2994,6 +3543,8 @@ export class ClientSession {
2994
3543
  // 覆盖 SDK 内置 bash(customTools 按 name 覆盖)。双实现分流:
2995
3544
  // 「默认 bash 覆盖」开关(terminalBash)关 → 原生 SDK bash(纯进程、不开终端);
2996
3545
  // 开 → 终端接管 bash(persist 决定一次性/持久,可静默自动转后台)。
3546
+ // 整列工具统一过计划模式闸门(只规划不实施,写类/旁路工具执行前拒),
3547
+ // read/write/edit 三处覆盖在会话建好后另注入(见 installToolOverrides)。
2997
3548
  customTools: [
2998
3549
  // P1-5:bash/read 包插件拦截(pre 拒/问即拦、post 脱敏补上下文;
2999
3550
  // 未注入 toolGuard 时 withToolGuard 原样返回,零开销)。
@@ -3026,6 +3577,18 @@ export class ClientSession {
3026
3577
  "workspace-write-never", () => this.getLang()),
3027
3578
  ...makePersistentTerminalTools(terminals, effectiveCwd, () => this.getLang(), {
3028
3579
  checkSafety: (cmd) => {
3580
+ // 计划模式:终端只放行只读命令(常驻终端也走这条,与 bash 覆盖同口径)。
3581
+ if (this.planModeOf(ownerId)) {
3582
+ const denied = planModeDenial("bash", { command: cmd });
3583
+ if (denied)
3584
+ return { blocked: true, reason: denied.reason };
3585
+ }
3586
+ // 目标审查回合:常驻终端同样只放只读命令(审查者跑测试可以,改文件不行)。
3587
+ if (this.goalReviewTurnOf(ownerId)) {
3588
+ const denied = goalReviewDenial("bash", { command: cmd });
3589
+ if (denied)
3590
+ return { blocked: true, reason: denied.reason };
3591
+ }
3029
3592
  const perm = (ownerId ? this.convs.get(ownerId)?.permissionPreset : undefined) ??
3030
3593
  this.settingsSvc.current.defaultPermissionPreset ??
3031
3594
  "workspace-write-never";
@@ -3046,9 +3609,10 @@ export class ClientSession {
3046
3609
  // 扩展加载顺序无关,直接塞进来会静默顶掉第三方扩展注册的同名工具(见
3047
3610
  // tool-overrides.ts);它们改在会话建好后由 installToolOverrides 注入。
3048
3611
  // 不覆盖内置 edit 的独立宽松编辑工具(缩进不敏感匹配;开关看设置;带权限沙箱拦截与人机协同)。
3049
- wrapEditSoftToolWithPermission(makeEditSoftTool(effectiveCwd, () => this.getLang()), effectiveCwd, () => (ownerId ? this.convs.get(ownerId)?.permissionPreset : undefined) ??
3612
+ // 目标审查闸门包在最外层:审查回合的理由最贴合此刻(跟模型说「这是审查回合」)。
3613
+ withGoalReviewGate(withDelegationGate(withPlanModeGate(wrapEditSoftToolWithPermission(makeEditSoftTool(effectiveCwd, () => this.getLang()), effectiveCwd, () => (ownerId ? this.convs.get(ownerId)?.permissionPreset : undefined) ??
3050
3614
  this.settingsSvc.current.defaultPermissionPreset ??
3051
- "workspace-write-never", () => this.roots, () => this.getLang(), (toolCallId, toolName, params, reason, reasonEn, convId, category) => this.askApproval(toolCallId, toolName, params, reason, reasonEn, convId, category), () => ownerId, () => this.approvalRules.list()),
3615
+ "workspace-write-never", () => this.roots, () => this.getLang(), (toolCallId, toolName, params, reason, reasonEn, convId, category) => this.askApproval(toolCallId, toolName, params, reason, reasonEn, convId, category), () => ownerId, () => this.approvalRules.list()), () => this.planModeOf(ownerId), () => this.getLang()), () => this.delegateModeOf(ownerId), () => this.getLang()), () => this.goalReviewTurnOf(ownerId), () => this.getLang()),
3052
3616
  // 插件注册的 AI 工具(创建时刻的实时快照,已按 disabledPluginTools 过滤;
3053
3617
  // 后续注册经 refreshPluginTools 动态补入已有会话)。
3054
3618
  // 子代理模板带非空扩展白名单时不注入:插件/MCP 工具没有 SDK extensionKey
@@ -3133,7 +3697,11 @@ export class ClientSession {
3133
3697
  makePatchTool({ cwd: effectiveCwd, ownerId }),
3134
3698
  // 原生语言服务器工具(lsp,定义跳转/引用/悬停/诊断)。
3135
3699
  makeLspTool({ cwd: effectiveCwd, ownerId }),
3136
- ],
3700
+ ].map((t) =>
3701
+ // 目标审查闸门在最外层(理由最贴合此刻);插件工具也在这个数组里,
3702
+ // 创建时注册的同样被闸门覆盖(后续动态补入的走 syncPluginTools,与
3703
+ // 计划/审查者闸门同口径不在覆盖面,见 goal-review-gate.ts 头注)。
3704
+ withGoalReviewGate(withDelegationGate(withPlanModeGate(t, () => this.planModeOf(ownerId), () => this.getLang()), () => this.delegateModeOf(ownerId), () => this.getLang()), () => this.goalReviewTurnOf(ownerId), () => this.getLang())),
3137
3705
  });
3138
3706
  // 桥接工具归属锚点:SDK 会话对象在本 runtime 生命周期内稳定,过户只搬对话
3139
3707
  // 不改它(见 ClientSession.findConversationHome)。
@@ -3175,6 +3743,8 @@ export class ClientSession {
3175
3743
  permissionPreset: readPermissionFromSession(runtime.session.sessionManager) ??
3176
3744
  this.settingsSvc.current.defaultPermissionPreset ??
3177
3745
  "workspace-write-never",
3746
+ planMode: readPlanModeFromSession(runtime.session.sessionManager) === true,
3747
+ delegateMode: readDelegateModeFromSession(runtime.session.sessionManager) === true,
3178
3748
  // A brand-new conversation is not yet LISTED — it enters the running
3179
3749
  // list only when it is displaced to the background while still
3180
3750
  // streaming (its runtime is what `listed` protects). A blank chat is
@@ -3188,7 +3758,6 @@ export class ClientSession {
3188
3758
  stallNoticed: false,
3189
3759
  goal: this.makeGoalStatus(),
3190
3760
  goalGeneration: 0,
3191
- goalReviewGeneration: 0,
3192
3761
  wizardRunning: false,
3193
3762
  deltaSeq: 0,
3194
3763
  terminals,
@@ -3598,6 +4167,9 @@ export class ClientSession {
3598
4167
  const modelId = curModel ? `${curModel.provider}/${curModel.id}` : undefined;
3599
4168
  return {
3600
4169
  conversationId: target.id,
4170
+ sessionId: target.session.sessionId,
4171
+ sessionFile: target.session.sessionFile,
4172
+ sessionDir: target.session.sessionManager?.getSessionDir?.(),
3601
4173
  title: target.title,
3602
4174
  model: modelId,
3603
4175
  at: target.lastActiveAt,
@@ -3885,6 +4457,11 @@ export class ClientSession {
3885
4457
  // Any SDK event proves the run is alive — feeds the stall watchdog below.
3886
4458
  conv.lastSdkEventAt = Date.now();
3887
4459
  conv.stallNoticed = false;
4460
+ // 模型可能在 pi-web-ui 之外被换(SDK 从会话历史恢复、/model 命令等),
4461
+ // 而软上限 reserve 是按注入那一刻的窗口换算的固定值——模型一变必须重算,
4462
+ // 否则大窗口算出的 reserve 泄漏到小窗口模型上,触发点被压成负数,
4463
+ // 上下文刚过几万 token 就反复触发压缩。热路径只做一次字符串比对。
4464
+ this.reapplySoftCapIfModelChanged(conv);
3888
4465
  switch (event.type) {
3889
4466
  case "bash_execution_update": {
3890
4467
  if (event.id) {
@@ -4141,6 +4718,8 @@ export class ClientSession {
4141
4718
  return a.role === "assistant" && a.stopReason === "aborted";
4142
4719
  });
4143
4720
  if (aborted) {
4721
+ // 角色轮等待者先唤醒(中止也算「本轮结束」),再让 GoalService 作废目标。
4722
+ this.notifyTurnEnd(conv);
4144
4723
  const stopNotice = this.goalSvc.onAgentEnd(conv, true);
4145
4724
  if (stopNotice) {
4146
4725
  this.emit({ type: "notice", level: "warning", text: stopNotice.text, textEn: stopNotice.textEn });
@@ -4169,6 +4748,9 @@ export class ClientSession {
4169
4748
  this.emitConversations();
4170
4749
  }
4171
4750
  // Goal review hook lives in GoalService.onAgentEnd(conv, false).
4751
+ // 先唤醒角色轮等待者:委托执行(Plan A)下服务端正阻塞在「等主对话给 verdict」
4752
+ // 或「等对话空闲」上,而 onAgentEnd 随后会把它需要的 verdict 交回。
4753
+ this.notifyTurnEnd(conv);
4172
4754
  this.goalSvc.onAgentEnd(conv, false);
4173
4755
  // Deferred settings reload: settings (system prompt / skills /
4174
4756
  // extensions) changed while the run was streaming — applying now
@@ -4596,11 +5178,17 @@ export class ClientSession {
4596
5178
  pendingQuestion: this.pendingQuestionForSnapshot(),
4597
5179
  pendingApproval: this.pendingApprovalForSnapshot(),
4598
5180
  plan: this.planManager.getPlan(this.activeId),
5181
+ // 计划模式是**会话级**开关:快照恒给布尔(不用 undefined),否则
5182
+ // snapshot_delta 里 key 缺席 → 前端 spread 合并会残留上一对话的 true。
5183
+ planMode: conv?.planMode === true,
5184
+ // 审查者模式同样是**会话级**:布尔恒给(理由同 planMode),另带执行对话 id。
5185
+ delegateMode: conv?.delegateMode === true,
5186
+ delegateConvId: conv?.delegateConvId ?? null,
4599
5187
  subagentHandoffs: this.subagentHandoffs.length > 0 ? [...this.subagentHandoffs] : undefined,
4600
5188
  agentPreset: conv
4601
5189
  ? {
4602
5190
  id: conv.agentPreset ?? "standard",
4603
- name: PI_AGENT_PRESETS.find((p) => p.id === conv.agentPreset)?.name ?? "全功能",
5191
+ name: localizedName(PI_AGENT_PRESETS.find((p) => p.id === conv.agentPreset), this.getLang(), "全功能"),
4604
5192
  locked: conv.presetLocked || !this.isBlankConversation(conv),
4605
5193
  }
4606
5194
  : null,
@@ -6018,24 +6606,50 @@ export class ClientSession {
6018
6606
  * 合并视图,SDK 每次自动压缩检查前都重读 getCompactionSettings(),
6019
6607
  * 无需 reload;软上限关闭时回填 SDK 默认 reserve(不让旧覆盖泄漏)。
6020
6608
  * 窗口未知(会话未就绪/无模型)的会话跳过——创建/就绪/换模型路径
6021
- * 会重放(见各 applyRetryOverrides 调用点)。 */
6609
+ * 会重放(见各 applyRetryOverrides 调用点);此外 onEvent 按模型
6610
+ * key 变化兜底重放(reapplySoftCapIfModelChanged),保证会话模型
6611
+ * 在 pi-web-ui 之外被换(如 SDK 从会话历史恢复)后覆盖不泄漏。 */
6022
6612
  applyCompactionOverrides() {
6023
6613
  const s = this.settingsSvc.current;
6024
6614
  for (const c of this.convs.values()) {
6025
6615
  try {
6026
- const modelId = modelKeyOf(c.session);
6027
- const contextWindow = contextWindowOf(c.session);
6028
- const cap = effectiveSoftCap(s.softCapTokens, s.softCapByModel, modelId);
6029
- const reserve = softCapToReserve(contextWindow, cap);
6030
- c.session.settingsManager.applyOverrides({
6031
- compaction: { reserveTokens: reserve ?? DEFAULT_COMPACTION_RESERVE_TOKENS },
6032
- });
6616
+ this.applyCompactionOverrideForConv(c, s);
6033
6617
  }
6034
6618
  catch {
6035
6619
  // 会话未就绪或已释放 → 其 runtime 创建时统一注入。
6036
6620
  }
6037
6621
  }
6038
6622
  }
6623
+ /** 单会话换算并注入软上限 reserve(applyCompactionOverrides 的循环体;
6624
+ * reapplySoftCapIfModelChanged 单会话重放也复用)。抛错交调用方处置。 */
6625
+ applyCompactionOverrideForConv(c, s) {
6626
+ const modelId = modelKeyOf(c.session);
6627
+ const contextWindow = contextWindowOf(c.session);
6628
+ const cap = effectiveSoftCap(s.softCapTokens, s.softCapByModel, modelId);
6629
+ const reserve = softCapToReserve(contextWindow, cap);
6630
+ c.session.settingsManager.applyOverrides({
6631
+ compaction: { reserveTokens: reserve ?? DEFAULT_COMPACTION_RESERVE_TOKENS },
6632
+ });
6633
+ }
6634
+ /** 会话模型变化时重放该会话的软上限覆盖。SDK 恢复历史会话的模型
6635
+ * (findInitialModel)与 /model 命令都不经过 pi-web-ui 的 setModel
6636
+ * 路径,注入过的 reserve 会停留在旧模型窗口的换算值上——例如默认
6637
+ * 模型(1M 窗口)算出 748576,泄漏到 500K 窗口的会话后压缩触发点
6638
+ * 变成负数,上下文几万 token 就被反复压缩。onEvent 每个事件比对
6639
+ * 一次 model key(O(1) 字符串比较),变化才重放。 */
6640
+ reapplySoftCapIfModelChanged(conv) {
6641
+ try {
6642
+ const key = modelKeyOf(conv.session);
6643
+ if (conv.lastModelKey === key)
6644
+ return;
6645
+ conv.lastModelKey = key;
6646
+ if (key)
6647
+ this.applyCompactionOverrideForConv(conv, this.settingsSvc.current);
6648
+ }
6649
+ catch {
6650
+ // 会话未就绪或已释放 → 留给创建/换模型路径与后续事件重试。
6651
+ }
6652
+ }
6039
6653
  /** 当前活动对话的生效软上限(快照底栏标记线用;null = 关闭/未知)。 */
6040
6654
  activeSoftCap(contextWindow) {
6041
6655
  try {
@@ -6151,6 +6765,12 @@ export class ClientSession {
6151
6765
  return c.agentPreset;
6152
6766
  return undefined;
6153
6767
  }
6768
+ convOfSession(session) {
6769
+ for (const c of this.convs.values())
6770
+ if (c.session === session)
6771
+ return c;
6772
+ return undefined;
6773
+ }
6154
6774
  /**
6155
6775
  * read / write / edit 三处覆盖的注入规格:基底 = 扩展注册的同名工具优先,否则 SDK 内置实现
6156
6776
  * (见 tool-overrides.ts —— 这三处覆盖不能塞进创建时的 `customTools`,那会静默顶掉扩展的
@@ -6177,8 +6797,10 @@ export class ClientSession {
6177
6797
  getRules: () => this.approvalRules.list(),
6178
6798
  };
6179
6799
  // 写/编的权限沙箱包装:同一个函数,有扩展同名工具时把它的定义当基底(末参)。
6180
- const composeWrite = (base) => wrapWriteToolWithPermission(cwd, currentPermission, () => this.roots, () => this.getLang(), approve, () => ownerId, () => this.approvalRules.list(), base);
6181
- const composeEdit = (base) => wrapEditToolWithPermission(cwd, currentPermission, () => this.roots, () => this.getLang(), approve, () => ownerId, () => this.approvalRules.list(), base);
6800
+ // 目标审查闸门包在**最外层**(权限沙箱之前):审查回合只读核实,写类直接拒。
6801
+ const planGate = (def) => withGoalReviewGate(withDelegationGate(withPlanModeGate(def, () => this.planModeOf(ownerId), () => this.getLang()), () => this.delegateModeOf(ownerId), () => this.getLang()), () => this.goalReviewTurnOf(ownerId), () => this.getLang());
6802
+ const composeWrite = (base) => planGate(wrapWriteToolWithPermission(cwd, currentPermission, () => this.roots, () => this.getLang(), approve, () => ownerId, () => this.approvalRules.list(), base));
6803
+ const composeEdit = (base) => planGate(wrapEditToolWithPermission(cwd, currentPermission, () => this.roots, () => this.getLang(), approve, () => ownerId, () => this.approvalRules.list(), base));
6182
6804
  return [
6183
6805
  {
6184
6806
  name: "read",
@@ -6197,8 +6819,20 @@ export class ClientSession {
6197
6819
  * session.reload() 与新会话创建都会把 custom 工具加回活跃集,
6198
6820
  * 所以这两条路径之后都要重放本方法(见 reloadSession/创建处)。 */
6199
6821
  applyToolGating(session, preset) {
6200
- const targetPreset = preset ?? this.presetOfSession(session) ?? this.settingsSvc.current.defaultAgentPreset ?? "standard";
6201
- applyAgentToolsGating(session, effectiveDisabledAgentTools(this.settingsSvc.current), targetPreset);
6822
+ const conv = this.convOfSession(session);
6823
+ const targetPreset = preset ??
6824
+ conv?.agentPreset ??
6825
+ this.presetOfSession(session) ??
6826
+ this.settingsSvc.current.defaultAgentPreset ??
6827
+ "standard";
6828
+ const isPlanMode = conv?.planMode === true;
6829
+ const disabled = new Set(effectiveDisabledAgentTools(this.settingsSvc.current));
6830
+ if (isPlanMode) {
6831
+ for (const toolName of PLAN_MODE_BLOCKED_TOOL_NAMES) {
6832
+ disabled.add(toolName);
6833
+ }
6834
+ }
6835
+ applyAgentToolsGating(session, [...disabled], targetPreset);
6202
6836
  this.syncPluginTools(session, targetPreset);
6203
6837
  this.sessionStatsCache = null;
6204
6838
  this.cachedBaseTokens = null;
@@ -6269,8 +6903,8 @@ export class ClientSession {
6269
6903
  this.emit({
6270
6904
  type: "notice",
6271
6905
  level: "info",
6272
- text: `已切换为「${hit.name}」预设`,
6273
- textEn: `Switched to preset "${hit.name}"`,
6906
+ text: `已切换为「${localizedName(hit, this.getLang())}」预设`,
6907
+ textEn: `Switched to preset "${localizedName(hit, this.getLang())}"`,
6274
6908
  });
6275
6909
  this.pushSettings();
6276
6910
  this.flushSnapshot();
@@ -6328,10 +6962,207 @@ export class ClientSession {
6328
6962
  this.emit({
6329
6963
  type: "notice",
6330
6964
  level: "info",
6331
- text: `当前会话权限已切换为「${hit.name}」`,
6332
- textEn: `Current session permission switched to "${hit.name}"`,
6965
+ text: `当前会话权限已切换为「${localizedName(hit, this.getLang())}」`,
6966
+ textEn: `Current session permission switched to "${localizedName(hit, this.getLang())}"`,
6967
+ });
6968
+ this.flushSnapshot();
6969
+ }
6970
+ /** 该会话是否处于计划模式(ownerId 缺省 = 当前对话)。 */
6971
+ planModeOf(ownerId) {
6972
+ const id = ownerId ?? this.activeId;
6973
+ return this.convs.get(id)?.planMode === true;
6974
+ }
6975
+ /** 审查者模式(自动委派)是否对该 runtime 所属会话开着。 */
6976
+ delegateModeOf(ownerId) {
6977
+ const id = ownerId ?? this.activeId;
6978
+ return this.convs.get(id)?.delegateMode === true;
6979
+ }
6980
+ /** 该会话是否正在跑目标审查回合(awaitingVerdict 置位):是则写类 / 派发类工具
6981
+ * 走目标审查闸门(server/goal-review-gate.ts)。ownerId 恒为会话 id(创建时传入),
6982
+ * 缺省才回退活动对话 —— 与 planModeOf / delegateModeOf 同口径。 */
6983
+ goalReviewTurnOf(ownerId) {
6984
+ const id = ownerId ?? this.activeId;
6985
+ return this.convs.get(id)?.awaitingVerdict != null;
6986
+ }
6987
+ /**
6988
+ * 切换审查者模式(会话级,默认关)。
6989
+ * 开启后:① 本对话只审阅(写类/派发类工具硬闸门 + 提示词段);② 用户发的每条
6990
+ * prompt 由服务端转给一个**常驻落盘执行对话**执行。计划模式优先(两者同开不派活)。
6991
+ */
6992
+ async setDelegateMode(enabled, conversationId) {
6993
+ const target = conversationId ? this.convs.get(conversationId) : this.conv;
6994
+ if (!target)
6995
+ return;
6996
+ if (target.delegateMode === enabled) {
6997
+ this.flushSnapshot();
6998
+ return;
6999
+ }
7000
+ target.delegateMode = enabled;
7001
+ if (!enabled)
7002
+ target.delegateConvId = null; // 关闭后不再指向旧执行对话
7003
+ try {
7004
+ // 与 plan/mode 同口径写进转录:切会话/重载能回放(布尔),执行对话 id 不落盘。
7005
+ const sm = target.session.sessionManager;
7006
+ sm?.appendCustomEntry?.("delegate/mode", { enabled });
7007
+ }
7008
+ catch {
7009
+ // best effort for in-memory sessions
7010
+ }
7011
+ const notice = delegateNoticeText(enabled);
7012
+ this.emit({ type: "notice", level: "info", text: notice.text, textEn: notice.textEn });
7013
+ this.flushSnapshot();
7014
+ // 提示词段随之增减(下一轮生效);失败不阻断(硬闸门仍拦得住)。
7015
+ try {
7016
+ await target.session.reload?.();
7017
+ }
7018
+ catch {
7019
+ // best effort
7020
+ }
7021
+ }
7022
+ /**
7023
+ * 自动路由:把本轮用户输入转给常驻执行对话执行(审查者模式的干活路径)。
7024
+ * 返回 true = 已接管(调用方不要再跑主会话的模型);false = 没收走(走正常路径)。
7025
+ *
7026
+ * 与计划模式互斥:计划模式开着时**不派活**(那边已把 spawn/旁路工具全拒,
7027
+ * 再自动派活就是死锁),由计划模式那一轮自己在主对话里出计划。
7028
+ */
7029
+ async dispatchToDelegate(conv, text) {
7030
+ if (conv.delegateMode !== true)
7031
+ return false;
7032
+ if (conv.planMode === true)
7033
+ return false; // 计划模式优先
7034
+ if (this.quiesceBlocked())
7035
+ return false;
7036
+ const trimmed = text.trim();
7037
+ if (!trimmed)
7038
+ return false;
7039
+ let execId = conv.delegateConvId;
7040
+ if (execId && !this.convs.get(execId)?.session)
7041
+ execId = null;
7042
+ // 首轮:第一条 prompt 直接交给 spawnSubagentConversation(它会跑起这一轮)——
7043
+ // **不要再 sendUserMessage 一次**,否则执行对话正在处理中,第二次投递会被
7044
+ // SDK 拒("Agent is already processing a prompt")。之后每轮才走追加投递。
7045
+ const firstTurn = !execId;
7046
+ if (firstTurn) {
7047
+ try {
7048
+ // 常驻执行对话:落盘普通对话(进历史、可续聊),与目标模式的执行对话同一通道。
7049
+ // 满员(项目 8 个普通对话)→ 抛错 → 下面提示用户,不会静默把活留在主对话。
7050
+ execId = await this.spawnSubagentConversation(trimmed, "delegate-executor", conv.cwd ?? this.cwd, undefined, null, conv.id, true, pick(this.getLang(), "委派执行", "Delegate executor"));
7051
+ const execConv = this.convs.get(execId);
7052
+ if (execConv)
7053
+ execConv.title = pick(this.getLang(), "委派执行", "Delegate executor");
7054
+ conv.delegateConvId = execId;
7055
+ }
7056
+ catch (err) {
7057
+ const msg = err instanceof Error ? err.message : String(err);
7058
+ this.emit({
7059
+ type: "notice",
7060
+ level: "error",
7061
+ text: `审查者模式:无法创建执行对话(${msg})。请关闭审查者模式,或先释放一些对话名额。`,
7062
+ textEn: `Reviewer mode: could not create the executor conversation (${msg}). Turn reviewer mode off, or free up some conversation slots first.`,
7063
+ });
7064
+ this.flushSnapshot();
7065
+ return false;
7066
+ }
7067
+ }
7068
+ else {
7069
+ const execConv = this.convs.get(execId);
7070
+ if (!execConv?.session)
7071
+ return false;
7072
+ try {
7073
+ await execConv.session.sendUserMessage(trimmed, execConv.session.isStreaming ? { deliverAs: "steer" } : undefined);
7074
+ }
7075
+ catch (err) {
7076
+ const msg = err instanceof Error ? err.message : String(err);
7077
+ this.emit({
7078
+ type: "notice",
7079
+ level: "error",
7080
+ text: `审查者模式:派活失败(${msg})`,
7081
+ textEn: `Reviewer mode: dispatch failed (${msg})`,
7082
+ });
7083
+ this.flushSnapshot();
7084
+ return false;
7085
+ }
7086
+ }
7087
+ this.emit({
7088
+ type: "notice",
7089
+ level: "info",
7090
+ text: `🔎 已派给执行对话:${trimmed.slice(0, 120)}${trimmed.length > 120 ? "…" : ""}(本对话只审阅)`,
7091
+ textEn: `🔎 Dispatched to the executor conversation: ${trimmed.slice(0, 120)}${trimmed.length > 120 ? "…" : ""} (this conversation reviews only)`,
6333
7092
  });
6334
7093
  this.flushSnapshot();
7094
+ // 执行对话跑完 → 回报主对话(只通知,不自动验收:验收由人/主对话下一轮做)。
7095
+ void this.notifyWhenDelegateFinishes(conv, execId);
7096
+ return true;
7097
+ }
7098
+ /** 等常驻执行对话这一轮结束,回主对话一条通知(带它的最后一条回复摘要)。 */
7099
+ async notifyWhenDelegateFinishes(conv, execId) {
7100
+ try {
7101
+ await this.waitConversationTurnEnd(execId, 30 * 60 * 1000);
7102
+ }
7103
+ catch {
7104
+ // 超时/取消:仍给一条「执行对话还没结束」的弱提示,避免主对话永远静默。
7105
+ }
7106
+ const execConv = this.convs.get(execId);
7107
+ if (!execConv)
7108
+ return;
7109
+ const last = [...execConv.session.agent.state.messages]
7110
+ .reverse()
7111
+ .find((m) => m.role === "assistant");
7112
+ const summary = typeof last?.content === "string" ? last.content : JSON.stringify(last?.content ?? "").slice(0, 400);
7113
+ this.emit({
7114
+ type: "notice",
7115
+ level: "info",
7116
+ text: `✅ 执行对话已交付,请审阅:${summary.slice(0, 300)}${summary.length > 300 ? "…" : ""}(左栏「委派执行」可打开看全文)`,
7117
+ textEn: `✅ The executor conversation delivered — review it: ${summary.slice(0, 300)}${summary.length > 300 ? "…" : ""} (open “Delegate executor” in the sidebar for the full transcript)`,
7118
+ });
7119
+ this.flushSnapshot();
7120
+ }
7121
+ /**
7122
+ * 切换计划模式(会话级,只规划不实施)。
7123
+ * 软约束(系统提示词段)+ 硬闸门(写类/旁路工具与非常规 bash 直接拒)双管:
7124
+ * 状态随会话转录落盘(切会话/重载恢复),并即时提示用户当前口径。
7125
+ */
7126
+ async setPlanMode(enabled, conversationId) {
7127
+ const target = conversationId ? this.convs.get(conversationId) : this.conv;
7128
+ if (!target)
7129
+ return;
7130
+ if (target.planMode === enabled) {
7131
+ this.flushSnapshot();
7132
+ return;
7133
+ }
7134
+ target.planMode = enabled;
7135
+ try {
7136
+ // 持久会话:写入转录日志,切会话/重载后回放恢复(与 permission/preset 同口径)。
7137
+ const sm = target.session.sessionManager;
7138
+ sm?.appendCustomEntry?.("plan/mode", { enabled });
7139
+ }
7140
+ catch {
7141
+ // best effort for in-memory sessions
7142
+ }
7143
+ // 动态应用工具门控:开启计划模式时剔除写类工具,关闭时恢复写类工具
7144
+ this.applyToolGating(target.session);
7145
+ // 计划模式有会话准入校验(空白对话也能开,但快照要立刻反映按钮态)。
7146
+ const notice = planModeNoticeText(enabled);
7147
+ this.emit({ type: "notice", level: "info", text: notice.text, textEn: notice.textEn });
7148
+ this.flushSnapshot();
7149
+ // 提示词段随之增减:重建资源让下一轮就带上/去掉计划模式约束。
7150
+ void this.reloadPromptForPlanMode(target);
7151
+ }
7152
+ /**
7153
+ * 提示词重建:切换计划模式后需要让**下一轮**带上/去掉计划模式段
7154
+ * (appendSystemPromptOverride 在资源加载重放时取值)。会话空闲时即时
7155
+ * 重建,用户下一句就生效;重建失败不阻断(硬闸门仍然拦得住)。
7156
+ */
7157
+ async reloadPromptForPlanMode(conv) {
7158
+ try {
7159
+ await conv.session.reload?.();
7160
+ // reload() 重新加载扩展时可能会将工具加回活跃集,重跑一次门控确保写工具被持续剔除
7161
+ this.applyToolGating(conv.session);
7162
+ }
7163
+ catch {
7164
+ // best effort:某些引擎/临时态不支持 reload
7165
+ }
6335
7166
  }
6336
7167
  /** 设置新会话默认权限预设。 */
6337
7168
  async setDefaultPermissionPreset(preset) {
@@ -6350,7 +7181,7 @@ export class ClientSession {
6350
7181
  this.refreshPermission();
6351
7182
  this.flushSnapshot();
6352
7183
  }
6353
- /** 当前启用的插件 AI 工具定义(provider 快照按 disabledPluginTools 过滤;
7184
+ /** 当前启用的插件 AI 工具定义(provider 快照按 disabledPlugins 与 disabledPluginTools 过滤;
6354
7185
  * 未知/已卸载插件的禁用条目保留但不影响现有工具)。
6355
7186
  * 预设是第二层门控(见 tool-manager.ts 语义总表):非 standard 预设下插件工具
6356
7187
  * 一律不可用(读写未知,保守处理),此时返回空表,调用方负责从会话移除。 */
@@ -6358,7 +7189,14 @@ export class ClientSession {
6358
7189
  if (!presetAllowsPluginTools(preset))
6359
7190
  return [];
6360
7191
  const off = new Set(normalizeDisabledPluginTools(this.settingsSvc.current.disabledPluginTools));
6361
- return (this.pluginToolsProvider?.() ?? []).filter((t) => !off.has(t.name)).map(pluginToolToDefinition);
7192
+ const disabledPlugins = new Set(this.settingsSvc.current.disabledPlugins ?? []);
7193
+ return (this.pluginToolsProvider?.() ?? [])
7194
+ .filter((t) => {
7195
+ if (t.pluginId && disabledPlugins.has(t.pluginId))
7196
+ return false;
7197
+ return !off.has(t.name);
7198
+ })
7199
+ .map(pluginToolToDefinition);
6362
7200
  }
6363
7201
  /** 把插件 AI 工具同步进一个已存在的会话(新增/更新/移除;禁用工具同步移除)。
6364
7202
  * 实际 diff 逻辑在 plugins.ts 的 syncPluginToolsIntoSession(可单测)。
@@ -6634,6 +7472,18 @@ export class ClientSession {
6634
7472
  * after the current turn settles, skipping remaining planned tool calls.
6635
7473
  */
6636
7474
  queue = false) {
7475
+ const trimmedText = (text ?? "").trim();
7476
+ const hasAttachments = Boolean(attachments && attachments.length > 0);
7477
+ if (!trimmedText && !hasAttachments) {
7478
+ this.emit({
7479
+ type: "notice",
7480
+ level: "warning",
7481
+ text: "发送已忽略:提示词为空且未附带文件或上下文引用。",
7482
+ textEn: "Prompt ignored: text is empty and no attachments were provided.",
7483
+ });
7484
+ this.flushSnapshot();
7485
+ return;
7486
+ }
6637
7487
  // Captured at the START (before any await): the conversation being
6638
7488
  // addressed by this prompt. See the naming block below — a concurrent
6639
7489
  // switch/new_chat while prompt() is in flight must never target a
@@ -6641,6 +7491,54 @@ export class ClientSession {
6641
7491
  const conv = this.conv;
6642
7492
  const promptAc = new AbortController();
6643
7493
  conv.activePromptAc = promptAc;
7494
+ if (conv.planMode === true) {
7495
+ this.applyToolGating(conv.session);
7496
+ }
7497
+ // 审查者模式(自动委派):本对话只审阅 —— 用户这条 prompt 直接转给常驻执行
7498
+ // 对话执行,主会话这一轮**不跑模型**。接在 promptAc 之后、draft 清理之前:
7499
+ // 转走的内容不该把主对话的草稿也清掉(用户可能还想在主对话里追一句)。
7500
+ // 计划模式优先(那边直接返回 false)。
7501
+ if (await this.dispatchToDelegate(conv, text)) {
7502
+ conv.presetLocked = true;
7503
+ return;
7504
+ }
7505
+ // 目标审查回合进行中:纯文本插话顺延到 verdict 落定后(steer 进去会污染
7506
+ // verdict,一次插话烧掉整个目标;followUp 由 SDK 排在整轮结束后才送达,
7507
+ // 本来就安全所以只拦 steer)。斜杠命令直通(原生配置类不进模型);带附件 /
7508
+ // 图片的不顺延(附件引用只在发送瞬间有效)→ 响亮拒绝,草稿保留在输入框。
7509
+ // 判定抽成纯函数(server/goal-review-gate.ts),单测见 goal-review-gate.test.ts。
7510
+ if (shouldDeferPromptForReview({
7511
+ queue,
7512
+ text,
7513
+ hasAttachments: !!attachments && attachments.length > 0,
7514
+ awaitingVerdict: conv?.awaitingVerdict != null,
7515
+ })) {
7516
+ const gc = conv;
7517
+ (gc.deferredPrompts ??= []).push(text);
7518
+ this.emit({
7519
+ type: "notice",
7520
+ level: "info",
7521
+ text: "审查回合进行中,你的消息已排队(审查结束后自动发送,不会打断审查)。",
7522
+ textEn: "A review round is running; your message is queued and will be sent automatically when it ends (without interrupting the review).",
7523
+ });
7524
+ this.flushSnapshot();
7525
+ return;
7526
+ }
7527
+ if (!queue &&
7528
+ !text.trim().startsWith("/") &&
7529
+ !!attachments &&
7530
+ attachments.length > 0 &&
7531
+ conv?.awaitingVerdict != null) {
7532
+ // 带附件的插话不顺延也不 steer:直接拒绝,用户稍后重发(草稿还在输入框)。
7533
+ this.emit({
7534
+ type: "notice",
7535
+ level: "warning",
7536
+ text: "审查回合进行中:带附件/图片的消息请等审查结束后再发(文本草稿已保留)。",
7537
+ textEn: "A review round is running; please resend messages with attachments/images after it ends (your draft is kept).",
7538
+ });
7539
+ this.flushSnapshot();
7540
+ return;
7541
+ }
6644
7542
  // 输入框内容被消费(发送/斜杠执行)→ 清掉该会话存过的草稿(best-effort)。
6645
7543
  // 快捷短语发送(不碰输入框)同样清:客户端发送成功后会把当前草稿重存回来。
6646
7544
  // clear() 同时记录 clear 时间戳水位:清掉之后才 landing 的旧 draft_update
@@ -6942,6 +7840,12 @@ export class ClientSession {
6942
7840
  // 立刻要有它(此刻还在流式输出,不能等 agent_end 的防抖刷新)。
6943
7841
  this.emitConversations();
6944
7842
  }
7843
+ // 置换守卫:从这一刻起进入异步前置(附件构建 + 影子快照)——在它们完成前
7844
+ // 对话既不 streaming 也没有新消息落盘,displaceActive() 会把它误判成
7845
+ // 空闲对话并销毁 runtime,导致投递中的消息被静默丢弃。放在所有同步校验 /
7846
+ // 原生斜杠命令拦截之后:原生命令(/new, /cwd 等)不进投递流程且可能
7847
+ // 立即切换会话,若提前置位会被 displaceActive 误判为有消息投递而报错通知。
7848
+ conv.promptInFlight = true;
6945
7849
  // Attach files as independent nextTurn context messages (asides) so the
6946
7850
  // user message stays clean; they render as separate attachment cards.
6947
7851
  const asides = await buildAttachmentMessages({
@@ -6982,6 +7886,16 @@ export class ClientSession {
6982
7886
  return;
6983
7887
  }
6984
7888
  conv.activePromptAc = undefined;
7889
+ // 前置阶段(附件 / 影子快照)期间用户可能已经切走或新建了对话:消息仍然
7890
+ // 落在本对话里,指给用户看,免得「发出去的消息不见了」。
7891
+ if (conv.id !== this.activeId) {
7892
+ this.emit({
7893
+ type: "notice",
7894
+ level: "info",
7895
+ text: `消息已投递到「${conv.title}」——你刚切换了对话,它不在当前对话里`,
7896
+ textEn: `Message delivered to "${conv.title}" — you switched conversations before it landed`,
7897
+ });
7898
+ }
6985
7899
  if (s.isStreaming) {
6986
7900
  // queue=true (补充 button) → followUp: the message is delivered only
6987
7901
  // after the whole run finishes — the agent finishes what it started,
@@ -7030,6 +7944,11 @@ export class ClientSession {
7030
7944
  textEn: `Failed to send prompt: ${err.message}`,
7031
7945
  });
7032
7946
  }
7947
+ finally {
7948
+ // 无论走哪条出口(斜杠命令 / quiesce 拒绝 / 中止 / 抛错 / 正常落定),
7949
+ // 投递窗口都结束了;留着 true 会让这条对话永远不被置换。
7950
+ conv.promptInFlight = false;
7951
+ }
7033
7952
  // The active conversation (captured at prompt start — see above) has been
7034
7953
  // continued since it was opened — it must not be dismissed when the user
7035
7954
  // switches away. (Also bumps the per-project "most recently active"
@@ -7529,8 +8448,8 @@ export class ClientSession {
7529
8448
  this.emit({
7530
8449
  type: "notice",
7531
8450
  level: "warning",
7532
- text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先打开某个对话并离开(不继续对话)以移出列表`,
7533
- textEn: `This project already has the max open conversations (${MAX_OPEN_CONVERSATIONS}). Open one and leave it (without continuing) to remove it from the list.`,
8451
+ text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先移出不需要的对话(打开后离开不继续对话即移出;钉住的对话需先取消钉住)`,
8452
+ textEn: `This project already has the max open conversations (${MAX_OPEN_CONVERSATIONS}). Open one and leave it (without continuing) to remove it from the list; pinned chats must be unpinned first.`,
7534
8453
  });
7535
8454
  return false;
7536
8455
  }
@@ -7901,11 +8820,22 @@ export class ClientSession {
7901
8820
  openTerminals: conv.terminals.countBlockingLive(),
7902
8821
  listed: conv.listed,
7903
8822
  promptedSinceActive: conv.promptedSinceActive,
8823
+ pinned: conv.pinned,
8824
+ // 投递中的消息(附件构建 / 影子快照阶段):见 Conversation.promptInFlight。
8825
+ promptInFlight: Boolean(conv.promptInFlight),
7904
8826
  hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
7905
8827
  hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
7906
8828
  });
7907
8829
  if (retained) {
7908
8830
  conv.listed = true;
8831
+ if (conv.promptInFlight && !conv.session.isStreaming) {
8832
+ this.emit({
8833
+ type: "notice",
8834
+ level: "info",
8835
+ text: `「${conv.title}」的消息正在投递,已为你保留该对话`,
8836
+ textEn: `"${conv.title}" is still sending your message — kept in the running list`,
8837
+ });
8838
+ }
7909
8839
  return null;
7910
8840
  }
7911
8841
  return conv;
@@ -7917,6 +8847,20 @@ export class ClientSession {
7917
8847
  const conv = this.convs.get(id);
7918
8848
  if (!conv || id === this.activeId)
7919
8849
  return;
8850
+ // 正在投递的消息(附件构建 / 影子快照阶段)必须先打断:否则 prompt() 醒来
8851
+ // 后会把用户消息写进已销毁的 runtime,静默丢失。
8852
+ if (conv.activePromptAc) {
8853
+ conv.activePromptAc.abort();
8854
+ conv.activePromptAc = undefined;
8855
+ }
8856
+ // 角色轮等待者:对话被移出 → 等它的循环收到 gone(否则要等到超时)。
8857
+ const waiters = this.turnEndWaiters.get(id);
8858
+ if (waiters) {
8859
+ this.turnEndWaiters.delete(id);
8860
+ // 先摘表再逐个唤醒:回调里的自删不会弄脏迭代。
8861
+ for (const fn of waiters)
8862
+ fn("gone");
8863
+ }
7920
8864
  // 对话真关闭(dismiss/释放)→ 放掉它的认领。过户不走这里(对话换个会话
7921
8865
  // 继续,owner 不变,认领继续有效),所以只在此处释放。
7922
8866
  try {
@@ -8310,6 +9254,7 @@ export class ClientSession {
8310
9254
  return pq ? { hasQuestion: true, questionId: pq.id, questionTitle: pq.title } : {};
8311
9255
  })(),
8312
9256
  parentId: conv.parentId,
9257
+ ...(conv.pinned ? { pinned: true } : {}),
8313
9258
  ...(conv.forkFrom ? { forkFrom: conv.forkFrom } : {}),
8314
9259
  });
8315
9260
  }
@@ -8355,30 +9300,97 @@ export class ClientSession {
8355
9300
  * pushSessions() and searchSessions() share this fridge — opening the
8356
9301
  * panel warms it, then every keystroke inside the TTL is free.
8357
9302
  */
8358
- sessionInfosCache = null;
8359
- static SESSION_INFO_CACHE_TTL = 3000;
8360
- /** 最近项目列表缓存:pushProjects 的全量扫盘(SessionManager.listAll +
8361
- * existsSync 逐个校验)昂贵,切项目/新对话/跨客户端通知时频繁触发 ——
8362
- * TTL 内直接复用并把当前 cwd 合并进去,不反复扫盘。 */
8363
- projectsCache = null;
8364
- static PROJECTS_CACHE_TTL = 15_000;
8365
- projectsInFlight = null;
9303
+ /** 最近项目列表缓存:跨客户端共享静态缓存,TTL 窗口内直接复用。 */
9304
+ static projectsCache = null;
9305
+ static PROJECTS_CACHE_TTL = 60_000;
9306
+ static projectsInFlight = null;
9307
+ /** 会话单文件 stat 缓存(path -> { mtime, size, info }):避免对未修改的会话文件重复读取与 JSON 解析。 */
9308
+ static sessionFileCache = new Map();
9309
+ /** 会话列表缓存(按 cwdKey 隔离,30s TTL)。 */
9310
+ static sessionInfosCache = new Map();
9311
+ static sessionInfosInFlight = new Map();
9312
+ static SESSION_INFO_CACHE_TTL = 30_000;
8366
9313
  async loadSessionInfos() {
8367
9314
  const now = Date.now();
8368
- const c = this.sessionInfosCache;
8369
- if (c && c.cwd === this.cwd && now - c.at < ClientSession.SESSION_INFO_CACHE_TTL) {
9315
+ const cwdKey = normalizePathKey(this.cwd);
9316
+ const c = ClientSession.sessionInfosCache.get(cwdKey);
9317
+ if (c && now - c.at < ClientSession.SESSION_INFO_CACHE_TTL) {
8370
9318
  return c.infos;
8371
9319
  }
8372
- const infos = await SessionManager.list(this.cwd, piSessionsRoot());
8373
- this.sessionInfosCache = { cwd: this.cwd, infos, at: now };
8374
- return infos;
9320
+ const inFlight = ClientSession.sessionInfosInFlight.get(cwdKey);
9321
+ if (inFlight)
9322
+ return inFlight;
9323
+ const run = (async () => {
9324
+ try {
9325
+ const sessionDir = piSessionsRoot()
9326
+ ? resolve(piSessionsRoot())
9327
+ : SessionManager.create(this.cwd).getSessionDir();
9328
+ if (!existsSync(sessionDir))
9329
+ return [];
9330
+ const dirEntries = await fsPromises.readdir(sessionDir);
9331
+ const files = dirEntries.filter((f) => f.endsWith(".jsonl"));
9332
+ if (files.length === 0)
9333
+ return [];
9334
+ // Stat files to detect modified/new files (takes only ~8ms for 600+ files)
9335
+ const stats = await Promise.all(files.map(async (f) => {
9336
+ const fp = join(sessionDir, f);
9337
+ try {
9338
+ const st = await fsPromises.stat(fp);
9339
+ return { path: fp, mtime: st.mtimeMs, size: st.size };
9340
+ }
9341
+ catch {
9342
+ return null;
9343
+ }
9344
+ }));
9345
+ const validStats = stats.filter((s) => s !== null);
9346
+ validStats.sort((a, b) => b.mtime - a.mtime);
9347
+ const candidates = validStats.slice(0, 200);
9348
+ const results = await Promise.all(candidates.map(async (file) => {
9349
+ const cached = ClientSession.sessionFileCache.get(file.path);
9350
+ if (cached && cached.mtime === file.mtime && cached.size === file.size) {
9351
+ return cached.info;
9352
+ }
9353
+ const info = await parseSessionInfoFast(file.path, file.mtime);
9354
+ if (info) {
9355
+ ClientSession.sessionFileCache.set(file.path, {
9356
+ mtime: file.mtime,
9357
+ size: file.size,
9358
+ info,
9359
+ });
9360
+ }
9361
+ return info;
9362
+ }));
9363
+ const validInfos = results.filter((info) => info !== null);
9364
+ validInfos.sort((a, b) => b.modified.getTime() - a.modified.getTime());
9365
+ ClientSession.sessionInfosCache.set(cwdKey, { infos: validInfos, at: Date.now() });
9366
+ return validInfos;
9367
+ }
9368
+ catch {
9369
+ try {
9370
+ const fallback = await SessionManager.list(this.cwd, piSessionsRoot());
9371
+ ClientSession.sessionInfosCache.set(cwdKey, { infos: fallback, at: Date.now() });
9372
+ return fallback;
9373
+ }
9374
+ catch {
9375
+ return [];
9376
+ }
9377
+ }
9378
+ finally {
9379
+ ClientSession.sessionInfosInFlight.delete(cwdKey);
9380
+ }
9381
+ })();
9382
+ ClientSession.sessionInfosInFlight.set(cwdKey, run);
9383
+ return run;
8375
9384
  }
8376
9385
  /** Session files on disk changed (delete / new-transcript) — drop the brief
8377
9386
  * TTL fridge so the NEXT listing re-reads the directory instead of serving
8378
9387
  * the pre-mutation snapshot (delete-then-refresh commonly runs inside the
8379
9388
  * window, which would re-push the just-removed session). */
8380
- invalidateSessionInfos() {
8381
- this.sessionInfosCache = null;
9389
+ invalidateSessionInfos(filePath) {
9390
+ ClientSession.sessionInfosCache.clear();
9391
+ if (filePath) {
9392
+ ClientSession.sessionFileCache.delete(resolve(filePath));
9393
+ }
8382
9394
  }
8383
9395
  /** Push the persisted session list to the client (client-requested). */
8384
9396
  async refreshSessions() {
@@ -8482,7 +9494,7 @@ export class ClientSession {
8482
9494
  }
8483
9495
  if (holder) {
8484
9496
  // Same source the history panel uses (refreshSessions): newest first.
8485
- const infos = await SessionManager.list(this.cwd, piSessionsRoot());
9497
+ const infos = await this.loadSessionInfos();
8486
9498
  const next = infos
8487
9499
  .filter((s) => resolve(s.path) !== abs)
8488
9500
  .sort((a, b) => b.modified.getTime() - a.modified.getTime())[0];
@@ -8550,7 +9562,7 @@ export class ClientSession {
8550
9562
  // Bust the brief session-info fridge: refreshSessions() below usually
8551
9563
  // lands inside its 3s TTL and would otherwise re-serve a listing that
8552
9564
  // still contains the deleted transcript.
8553
- this.invalidateSessionInfos();
9565
+ this.invalidateSessionInfos(abs);
8554
9566
  await this.refreshSessions();
8555
9567
  }
8556
9568
  catch (err) {
@@ -8583,7 +9595,7 @@ export class ClientSession {
8583
9595
  const mgr = SessionManager.open(abs);
8584
9596
  mgr.appendSessionInfo(trimmed);
8585
9597
  this.setConversationTitleForFile(abs, trimmed);
8586
- this.invalidateSessionInfos();
9598
+ this.invalidateSessionInfos(abs);
8587
9599
  await this.refreshSessions();
8588
9600
  }
8589
9601
  catch (err) {
@@ -8595,6 +9607,55 @@ export class ClientSession {
8595
9607
  });
8596
9608
  }
8597
9609
  }
9610
+ /** 钉住 / 取消钉住某个对话(左栏右键菜单)。钉住后切走也不从「运行的对话」
9611
+ * 释放(见 shouldRetainActive 的 pinned 判据,最高优先级);取消钉住不立即
9612
+ * 释放,下一次自然置换时按常规规则处理。*/
9613
+ async setConversationPinned(id, pinned) {
9614
+ const conv = this.convs.get(id);
9615
+ if (!conv) {
9616
+ this.emit({
9617
+ type: "notice",
9618
+ level: "warning",
9619
+ text: "该对话不存在或已关闭",
9620
+ textEn: "This conversation does not exist or is already closed",
9621
+ });
9622
+ return;
9623
+ }
9624
+ // 子代理本来就永久豁免置换(displaceActive 开头即返回),钉子对它们无意义。
9625
+ if (conv.isSubagent) {
9626
+ this.emit({
9627
+ type: "notice",
9628
+ level: "info",
9629
+ text: "子代理对话始终保留在运行列表,无需钉住",
9630
+ textEn: "Subagent chats always stay in the running list — no need to pin",
9631
+ });
9632
+ return;
9633
+ }
9634
+ if (pinned === !!conv.pinned) {
9635
+ this.emitConversations();
9636
+ return;
9637
+ }
9638
+ if (pinned) {
9639
+ conv.pinned = true;
9640
+ // 立即进入运行列表(含空白对话):listed 一旦置位,shownInRunningList 即放行。
9641
+ conv.listed = true;
9642
+ }
9643
+ else {
9644
+ delete conv.pinned;
9645
+ }
9646
+ this.emitConversations();
9647
+ this.flushSnapshot();
9648
+ this.emit({
9649
+ type: "notice",
9650
+ level: "info",
9651
+ text: pinned
9652
+ ? `已钉住对话「${conv.title}」,切换其他对话不会将它移出运行列表`
9653
+ : `已取消钉住对话「${conv.title}」,下次切换离开时按常规规则处理`,
9654
+ textEn: pinned
9655
+ ? `Pinned "${conv.title}" — switching away keeps it in the running list`
9656
+ : `Unpinned "${conv.title}" — it will be handled by the usual rules on the next switch`,
9657
+ });
9658
+ }
8598
9659
  /** Rename a live conversation by id: retitle in memory AND persist a
8599
9660
  * session_info entry to its transcript so History matches immediately. */
8600
9661
  async renameConversation(id, name) {
@@ -8660,6 +9721,7 @@ export class ClientSession {
8660
9721
  // Dismiss 口径:只看“用过”的用户终端(AI bash 不钉住,见上)。
8661
9722
  openTerminals: conv.terminals.countUserBlockingLive(),
8662
9723
  listed: false,
9724
+ promptInFlight: Boolean(conv.promptInFlight),
8663
9725
  promptedSinceActive: false,
8664
9726
  hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
8665
9727
  hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
@@ -8842,6 +9904,7 @@ export class ClientSession {
8842
9904
  openTerminals: 0,
8843
9905
  listed: conv.listed,
8844
9906
  promptedSinceActive: conv.promptedSinceActive,
9907
+ promptInFlight: Boolean(conv.promptInFlight),
8845
9908
  hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
8846
9909
  hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
8847
9910
  })) {
@@ -9090,6 +10153,7 @@ export class ClientSession {
9090
10153
  // Dismiss 口径:只看“用过”的用户终端(issue #181,AI bash 不钉住)。
9091
10154
  openTerminals: conv.terminals.countUserBlockingLive(),
9092
10155
  listed: false,
10156
+ promptInFlight: Boolean(conv.promptInFlight),
9093
10157
  promptedSinceActive: false,
9094
10158
  hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
9095
10159
  hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
@@ -9234,8 +10298,8 @@ export class ClientSession {
9234
10298
  this.emit({
9235
10299
  type: "notice",
9236
10300
  level: "warning",
9237
- text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先打开某个对话并离开(不继续对话)以移出列表`,
9238
- textEn: `This project already has the max open conversations (${MAX_OPEN_CONVERSATIONS}). Open one and leave it (without continuing) to remove it from the list.`,
10301
+ text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先移出不需要的对话(打开后离开不继续对话即移出;钉住的对话需先取消钉住)`,
10302
+ textEn: `This project already has the max open conversations (${MAX_OPEN_CONVERSATIONS}). Open one and leave it (without continuing) to remove it from the list; pinned chats must be unpinned first.`,
9239
10303
  });
9240
10304
  return;
9241
10305
  }
@@ -9324,6 +10388,21 @@ export class ClientSession {
9324
10388
  // 兜底:getEntry 查整棵树
9325
10389
  return found ?? conv.session.sessionManager.getEntry(messageId) ?? null;
9326
10390
  }
10391
+ /**
10392
+ * 获取被某个压缩卡片折叠的历史消息(issue #398,按需惰性加载)。
10393
+ */
10394
+ getCompactedMessages(compactionMessageId, targetConvId) {
10395
+ const targetConv = (targetConvId ? this.convs.get(targetConvId) : this.conv) ?? this.conv;
10396
+ const sm = targetConv.session.sessionManager;
10397
+ const result = getCompactedMessages(sm, compactionMessageId, (m) => this.uiMessageKey(targetConv, m).n);
10398
+ this.emit({
10399
+ type: "compacted_messages_result",
10400
+ compactionMessageId,
10401
+ conversationId: targetConv.id,
10402
+ messages: result.messages,
10403
+ error: result.error,
10404
+ });
10405
+ }
9327
10406
  /**
9328
10407
  * Fork a NEW branch conversation from a specific historical message position.
9329
10408
  * Truncates the transcript before (or at) that message as the context of the
@@ -9420,8 +10499,8 @@ export class ClientSession {
9420
10499
  this.emit({
9421
10500
  type: "notice",
9422
10501
  level: "warning",
9423
- text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先打开某个对话并离开(不继续对话)以移出列表`,
9424
- textEn: `This project already has the max open conversations (${MAX_OPEN_CONVERSATIONS}). Open one and leave it (without continuing) to remove it from the list.`,
10502
+ text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先移出不需要的对话(打开后离开不继续对话即移出;钉住的对话需先取消钉住)`,
10503
+ textEn: `This project already has the max open conversations (${MAX_OPEN_CONVERSATIONS}). Open one and leave it (without continuing) to remove it from the list; pinned chats must be unpinned first.`,
9425
10504
  });
9426
10505
  return;
9427
10506
  }
@@ -9681,16 +10760,16 @@ export class ClientSession {
9681
10760
  */
9682
10761
  async pushProjects() {
9683
10762
  const now = Date.now();
9684
- const cached = this.projectsCache;
10763
+ const cached = ClientSession.projectsCache;
9685
10764
  // TTL 命中:直接复用(把当前 cwd 合并进去,刚 remember 的新项目也可见)。
9686
10765
  if (cached && now - cached.at < ClientSession.PROJECTS_CACHE_TTL) {
9687
10766
  this.emit({ type: "projects", projects: this.withCurrentCwd(cached.projects, now) });
9688
10767
  return;
9689
10768
  }
9690
10769
  // 已有扫描在跑:搭车等它,不要并发扫两遍盘。
9691
- if (this.projectsInFlight) {
10770
+ if (ClientSession.projectsInFlight) {
9692
10771
  try {
9693
- const projects = await this.projectsInFlight;
10772
+ const projects = await ClientSession.projectsInFlight;
9694
10773
  if (projects)
9695
10774
  this.emit({ type: "projects", projects: this.withCurrentCwd(projects, Date.now()) });
9696
10775
  }
@@ -9699,43 +10778,31 @@ export class ClientSession {
9699
10778
  }
9700
10779
  return;
9701
10780
  }
10781
+ // 执行轻量磁盘发现(仅读会话文件首行头部获取 cwd,不解析全部消息历史)
10782
+ const sessionRoots = [resolve(this.agentDir, "sessions")];
10783
+ const extra = piSessionsRoot();
10784
+ if (extra)
10785
+ sessionRoots.push(resolve(extra));
9702
10786
  const run = (async () => {
9703
10787
  try {
9704
- const saved = this.stateStore.get(this.clientId);
9705
- const removedProjects = new Set(this.stateStore.getRemovedProjects(this.clientId).map(normalizePathKey));
9706
- const map = new Map();
9707
- for (const p of saved.projects)
9708
- map.set(p.path, p.lastUsed);
9709
- const all = await SessionManager.listAll(piSessionsRoot());
9710
- for (const s of all) {
9711
- if (s.cwd) {
9712
- const t = s.modified.getTime();
9713
- const prev = map.get(s.cwd);
9714
- if (prev === undefined || t > prev)
9715
- map.set(s.cwd, t);
9716
- }
9717
- }
9718
- // Only keep directories that still exist — a deleted/unmounted workspace
9719
- // is useless in the picker. Tombstoned entries (explicitly removed by
9720
- // the user) stay hidden even though session files still mention them.
9721
- const projects = [...map.entries()]
9722
- .filter(([path]) => !removedProjects.has(normalizePathKey(path)) && existsSync(path))
9723
- .map(([path, lastUsed]) => ({ path, lastUsed }))
9724
- .sort((a, b) => b.lastUsed - a.lastUsed)
9725
- .slice(0, 20);
9726
- this.projectsCache = { at: Date.now(), projects };
9727
- this.emit({ type: "projects", projects });
9728
- return projects;
10788
+ const discovered = await discoverRecentProjectsFromDisk(sessionRoots, 30);
10789
+ this.stateStore.mergeDiscoveredProjects(discovered);
10790
+ const merged = this.stateStore.getRecentProjects(this.clientId);
10791
+ ClientSession.projectsCache = { at: Date.now(), projects: merged };
10792
+ const result = this.withCurrentCwd(merged, Date.now());
10793
+ this.emit({ type: "projects", projects: result });
10794
+ return merged;
9729
10795
  }
9730
10796
  catch {
9731
- this.emit({ type: "projects", projects: [] });
9732
- return null;
10797
+ const fallback = this.withCurrentCwd(this.stateStore.getRecentProjects(this.clientId), Date.now());
10798
+ this.emit({ type: "projects", projects: fallback });
10799
+ return fallback;
9733
10800
  }
9734
10801
  finally {
9735
- this.projectsInFlight = null;
10802
+ ClientSession.projectsInFlight = null;
9736
10803
  }
9737
10804
  })();
9738
- this.projectsInFlight = run;
10805
+ ClientSession.projectsInFlight = run;
9739
10806
  await run;
9740
10807
  }
9741
10808
  /** 缓存命中时把当前 cwd 并进去:命中则刷新 lastUsed 重排,未命中则补到首位
@@ -9756,7 +10823,7 @@ export class ClientSession {
9756
10823
  }
9757
10824
  /** 最近项目缓存失效(用户显式移除项目后,下一次推送必须重扫)。 */
9758
10825
  invalidateProjectsCache() {
9759
- this.projectsCache = null;
10826
+ ClientSession.projectsCache = null;
9760
10827
  }
9761
10828
  /** List a workspace directory (relative to the configured cwd). */
9762
10829
  async listFiles(relPath) {
@@ -10493,8 +11560,13 @@ export function checkPluginCwd(cwd) {
10493
11560
  }
10494
11561
  return { ok: true, abs };
10495
11562
  }
11563
+ /** 插件/调度伪客户端:sink 常驻(fire-and-forget 的空函数),不能按浏览器存活判断。 */
11564
+ export function isPseudoClientId(id) {
11565
+ return id.startsWith("plugin:") || id.startsWith("scheduler:");
11566
+ }
10496
11567
  export class AgentService {
10497
11568
  cwd;
11569
+ static isPseudoClientId = isPseudoClientId;
10498
11570
  /** index.ts 注入:SDK 工具执行事件的插件转发钩子,attach 时拷贝到每个新会话。 */
10499
11571
  onToolEvent = undefined;
10500
11572
  /** index.ts 注入:bash/read 插件拦截钩子,attach 时拷贝到每个新会话。 */
@@ -10808,10 +11880,16 @@ export class AgentService {
10808
11880
  continue;
10809
11881
  // issue #291:跳过无浏览器连接的残骸(非伪客户端 sinkCount=0 = 断连留存)。
10810
11882
  // 伪客户端(scheduler:/plugin:)不走浏览器,sinkCount 永远为 0,保留。
10811
- if (!AgentService.isPseudoClientId(clientId) && cs.sinkCount() === 0)
11883
+ const isPseudo = AgentService.isPseudoClientId(clientId);
11884
+ if (!isPseudo && cs.sinkCount() === 0)
10812
11885
  continue;
10813
- for (const r of cs.streamingSummariesAll())
10814
- out.push({ ...r, owner: clientId });
11886
+ for (const r of cs.streamingSummariesAll()) {
11887
+ out.push({
11888
+ ...r,
11889
+ owner: clientId,
11890
+ ...(isPseudo ? { pseudo: true } : {}),
11891
+ });
11892
+ }
10815
11893
  }
10816
11894
  return out;
10817
11895
  }
@@ -11086,10 +12164,7 @@ export class AgentService {
11086
12164
  service: toServiceInfo(launchOrigin()),
11087
12165
  };
11088
12166
  }
11089
- /** 插件/调度伪客户端:sink 常驻(fire-and-forget 的空函数),不能按浏览器存活判断。 */
11090
- static isPseudoClientId(id) {
11091
- return id.startsWith("plugin:") || id.startsWith("scheduler:");
11092
- }
12167
+ // (原 private static isPseudoClientId 已抽到模块级导出并挂为 static 属性)
11093
12168
  /**
11094
12169
  * 浏览器重启认领:给 fresh clientId 找一个可接管的断开残留会话(返回旧 id)。
11095
12170
  * 有别的在线浏览器时返回 null(issue #10 隔离优先)。
@@ -11186,7 +12261,7 @@ export class AgentService {
11186
12261
  return;
11187
12262
  }
11188
12263
  if (AgentService.isPseudoClientId(ownerId)) {
11189
- fail("定时任务/插件会话不支持过户", "Scheduler/plugin sessions cannot be taken over.");
12264
+ fail("定时任务/插件会话不支持过户 —— 报告会自动落回绑定的对话,可在后台任务面板查看进度", "Scheduler/plugin sessions cannot be taken over — reports are saved to the bound conversation automatically; check progress in the Background Tasks panel.");
11190
12265
  return;
11191
12266
  }
11192
12267
  const source = this.clients.get(ownerId);
@@ -11212,7 +12287,7 @@ export class AgentService {
11212
12287
  const movedMains = briefs.filter((b) => moveSet.has(b.id) && !b.isSubagent && !b.isEphemeral).length;
11213
12288
  const openInProject = target.takeoverBriefs().filter((b) => b.cwd === main.cwd && !b.isSubagent && !b.isEphemeral).length + movedMains;
11214
12289
  if (openInProject > MAX_OPEN_CONVERSATIONS) {
11215
- fail(`目标项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先打开某个对话并离开(不继续对话)以移出列表`, `This project already has the max open conversations (${MAX_OPEN_CONVERSATIONS}). Open one and leave it (without continuing) to remove it from the list.`);
12290
+ fail(`目标项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先移出不需要的对话(打开后离开不继续对话即移出;钉住的对话需先取消钉住)`, `This project already has the max open conversations (${MAX_OPEN_CONVERSATIONS}). Open one and leave it (without continuing) to remove it from the list; pinned chats must be unpinned first.`);
11216
12291
  return;
11217
12292
  }
11218
12293
  try {
@@ -11266,7 +12341,7 @@ export class AgentService {
11266
12341
  if (ownerId === targetId)
11267
12342
  return; // 自己的问卷走本地通道,不需要预告
11268
12343
  if (AgentService.isPseudoClientId(ownerId)) {
11269
- fail("定时任务/插件会话的问卷不支持跨页作答", "Scheduler/plugin session questions cannot be answered cross-page.");
12344
+ fail("定时任务/插件会话的问卷不支持跨页作答 —— 报告会自动落回绑定的对话,可在后台任务面板查看进度", "Scheduler/plugin session questions cannot be answered cross-page — reports are saved to the bound conversation automatically; check progress in the Background Tasks panel.");
11270
12345
  return;
11271
12346
  }
11272
12347
  const source = this.clients.get(ownerId);