pi-web-ui 0.96.1 → 0.98.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 (88) hide show
  1. package/CHANGELOG.md +144 -2
  2. package/README.md +29 -3
  3. package/README.zh-CN.md +28 -3
  4. package/bin/pi-web-ui.mjs +75 -25
  5. package/dist/server/agent-service.js +1829 -262
  6. package/dist/server/approval-rules.js +5 -10
  7. package/dist/server/atomic-file.js +40 -0
  8. package/dist/server/attachments.js +22 -0
  9. package/dist/server/claim-store.js +5 -11
  10. package/dist/server/client-state.js +296 -11
  11. package/dist/server/compact-context-tool.js +3 -1
  12. package/dist/server/compacted-history.js +148 -0
  13. package/dist/server/composer-drafts.js +4 -6
  14. package/dist/server/control-socket.js +0 -40
  15. package/dist/server/delegate-mode.js +143 -0
  16. package/dist/server/delegate-task.js +10 -10
  17. package/dist/server/dsh/dsh-agent-service.js +66 -31
  18. package/dist/server/dsh/dsh-client.js +48 -10
  19. package/dist/server/dsh/dsh-serialize.js +12 -10
  20. package/dist/server/dsh/dsh-sessions.js +20 -6
  21. package/dist/server/dsh/runtime/goal-rpc.mjs +14 -1
  22. package/dist/server/dsh/runtime/runtime-root.mjs +1 -1
  23. package/dist/server/ensure-bash.js +3 -5
  24. package/dist/server/eval-tool.js +11 -5
  25. package/dist/server/goal-review-gate.js +250 -0
  26. package/dist/server/goal-service.js +1272 -448
  27. package/dist/server/index.js +118 -16
  28. package/dist/server/launch-origin.js +0 -4
  29. package/dist/server/locales.js +3 -5
  30. package/dist/server/lsp-tool.js +123 -24
  31. package/dist/server/markers/builtins/notify.js +4 -2
  32. package/dist/server/markers/builtins/rename.js +0 -1
  33. package/dist/server/markers/builtins/todo.js +6 -28
  34. package/dist/server/markers/marker.js +0 -15
  35. package/dist/server/markers/registry.js +0 -3
  36. package/dist/server/markers/store.js +0 -3
  37. package/dist/server/mcp-bridge.js +13 -0
  38. package/dist/server/mcp-hot-reload.js +12 -5
  39. package/dist/server/model-admin.js +19 -18
  40. package/dist/server/model-enrich.js +0 -3
  41. package/dist/server/patch-tool.js +22 -6
  42. package/dist/server/patch-turn-end-boundary.js +77 -0
  43. package/dist/server/permission-preset.js +69 -0
  44. package/dist/server/plan-manager.js +150 -5
  45. package/dist/server/plan-mode.js +414 -0
  46. package/dist/server/plugin-api-catalog.js +2 -0
  47. package/dist/server/plugin-catalog.js +6 -6
  48. package/dist/server/plugin-dom.js +3 -4
  49. package/dist/server/plugin-facilities.js +39 -8
  50. package/dist/server/plugin-grants.js +5 -8
  51. package/dist/server/plugin-permissions.js +4 -6
  52. package/dist/server/plugin-schedule.js +4 -7
  53. package/dist/server/plugin-updater.js +151 -38
  54. package/dist/server/plugins.js +22 -5
  55. package/dist/server/prompt-composer.js +12 -3
  56. package/dist/server/protocol-version.js +1 -1
  57. package/dist/server/resolve-global-sdk.js +8 -0
  58. package/dist/server/schedule-agent-tool.js +5 -1
  59. package/dist/server/scheduler-tasks.js +46 -27
  60. package/dist/server/sdk-origin.js +27 -0
  61. package/dist/server/serialize.js +33 -9
  62. package/dist/server/settings-service.js +18 -6
  63. package/dist/server/slash-commands.js +16 -0
  64. package/dist/server/subagent-templates.js +4 -10
  65. package/dist/server/subagents.js +379 -416
  66. package/dist/server/terminals.js +11 -6
  67. package/dist/server/text-quote.js +59 -0
  68. package/dist/server/text-sniff.js +15 -9
  69. package/dist/server/tool-manager.js +60 -7
  70. package/dist/server/tool-overrides.js +24 -0
  71. package/dist/server/update-check.js +80 -28
  72. package/dist/server/wait-subscription-scan.js +5 -1
  73. package/extensions/webui.ts +15 -1
  74. package/package.json +33 -21
  75. package/plugin-sdk/client-utils.mjs +92 -0
  76. package/plugin-sdk/index.d.ts +21 -0
  77. package/plugin-sdk/index.mjs +2 -0
  78. package/plugins/catalog.json +7 -0
  79. package/themes/zhupi-dark.css +5 -30
  80. package/themes/zhupi.css +5 -30
  81. package/web/dist/assets/{TerminalPanel-O3GyFIGG.js → TerminalPanel-C88r3r3Q.js} +1 -1
  82. package/web/dist/assets/index-2jDtjHvW.css +1 -0
  83. package/web/dist/assets/index-BjdK7s1M.js +368 -0
  84. package/web/dist/index.html +2 -2
  85. package/web/dist/sw.js +2 -1
  86. package/web/public/sw.js +2 -1
  87. package/web/dist/assets/index-B7Zp23Vc.css +0 -1
  88. package/web/dist/assets/index-ByfoaDwp.js +0 -366
@@ -14,13 +14,16 @@
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";
20
- import { appendFileSync, existsSync, readFileSync, renameSync, rmSync, statSync, mkdirSync, watch, writeFileSync, } from "node:fs";
21
+ import * as fsPromises from "node:fs/promises";
22
+ import { appendFileSync, existsSync, readFileSync, 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";
23
25
  import { fileURLToPath } from "node:url";
26
+ import { writeAtomicSync } from "./atomic-file.js";
24
27
  import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashToolDefinition, createEditToolDefinition, createLocalBashOperations, createWriteToolDefinition, getAgentDir, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
25
28
  import { Type } from "typebox";
26
29
  import { BgServerTracker } from "./bg-servers.js";
@@ -34,7 +37,7 @@ import { removeQueuedByIndexOrText } from "./queue-utils.js";
34
37
  import { syncPluginToolsIntoSession } from "./plugins.js";
35
38
  import { denialText, } from "./plugin-tool-guard.js";
36
39
  import { SettingsService } from "./settings-service.js";
37
- import { GoalService, buildDiffFingerprint } from "./goal-service.js";
40
+ import { GoalService, buildDiffFingerprint, extractErrorSnippetFromSession, lastToolNameOfSession, } from "./goal-service.js";
38
41
  import { MarkerService } from "./marker-service.js";
39
42
  import { SlashCommandsService, parseSlash } from "./slash-commands.js";
40
43
  import { ModelAdminService } from "./model-admin.js";
@@ -49,8 +52,13 @@ import { createWorkspaceSnapshot, restoreWorkspaceSnapshot } from "./workspace-s
49
52
  import { isPathInsideRoot } from "./approval-rules.js";
50
53
  import { approvalSuppressionReason, checkDangerousToolCall, isApprovalPolicyEmpty, pluginApprovalCategory, } from "./tool-approval.js";
51
54
  import { PlanManager } from "./plan-manager.js";
55
+ import { buildPlanModePrompt, planModeDenial, planModeNoticeText, PLAN_MODE_BLOCKED_TOOL_NAMES } from "./plan-mode.js";
56
+ import { DELEGATION_SYSTEM_PROMPT, delegationDenial, delegateNoticeText } from "./delegate-mode.js";
57
+ import { goalReviewDenial, shouldDeferPromptForReview } from "./goal-review-gate.js";
58
+ import { readPlanModeFromSession, readPlanFromSession } from "./permission-preset.js";
59
+ import { readDelegateModeFromSession } from "./permission-preset.js";
52
60
  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";
61
+ 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
62
  import { WebUIContext } from "./webui-context.js";
55
63
  import { DEFAULT_COMPACTION_RESERVE_TOKENS, effectiveSoftCap, softCapToReserve } from "./soft-cap.js";
56
64
  import { pruneContextHierarchically } from "./context-budget.js";
@@ -59,7 +67,7 @@ import { makeEditSoftTool } from "./edit-soft-tool.js";
59
67
  // 覆盖 SDK 内置 read:路径是目录时列出目录条目(行为开关 readDirEnabled,默认开)。
60
68
  // 覆盖定义与「与扩展同名工具共存」的注入辅助分在两个文件(后者的依据见 tool-overrides.ts)。
61
69
  import { makeReadDirTool, withReadDirSupport } from "./read-tool.js";
62
- import { installToolOverrides, } from "./tool-overrides.js";
70
+ import { installToolOverrides, syncSubagentOverride, } from "./tool-overrides.js";
63
71
  // 展示文件给用户(present_files):图片/视频内联、文本开预览弹窗、本地打开按钮。
64
72
  import { makePresentFilesTool } from "./present-files-tool.js";
65
73
  // 主动压缩上下文工具(compact_context):AI 主动根据当前问题精简上下文并自主控制范围。
@@ -75,15 +83,17 @@ import { extractTouches, formatTouchesCompact, intersectTouches } from "./conver
75
83
  import { ClaimStore, matchClaims, mergeTouchSidecar, readTouchSidecar, removeTouchSidecar } from "./claim-store.js";
76
84
  import { makeClaimFilesTool } from "./claim-files-tool.js";
77
85
  import { makeSkillTool } from "./skill-tool.js";
86
+ import { getCompactedMessages } from "./compacted-history.js";
78
87
  import { makeScheduleTools } from "./schedule-agent-tool.js";
79
88
  import { makePatchTool } from "./patch-tool.js";
80
89
  import { makeLspTool } from "./lsp-tool.js";
81
90
  import { sameSessionFile } from "./scheduler-tasks.js";
82
91
  import { buildAttachmentMessages, parseModelSpec } from "./attachments.js";
92
+ import { formatQuotedPrompt, readTextQuote } from "./text-quote.js";
83
93
  import { buildVisionBridgePrompt, findVisionModels, transcribeImages } from "./vision-bridge.js";
84
94
  import { isNotRepoError, scmCommitContext } from "./scm.js";
85
95
  import { buildCommitMsgInput, buildCommitMsgPrompt, sanitizeCommitMessage } from "./scm-commitmsg.js";
86
- import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, buildToolsSchemaText, estimatePromptTokens, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
96
+ import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, buildToolsSchemaText, estimatePromptTokens, renderPromptTemplate, resolveSectionTexts, splitExtensionWrap, } from "./prompt-composer.js";
87
97
  import { launchOrigin, toServiceInfo } from "./launch-origin.js";
88
98
  import { findEntryByUiId, serializeMessage, serializeStreamingMessage, stripTransientRetryErrors, } from "./serialize.js";
89
99
  import { loadCommands, saveCommandsFile, TerminalManager } from "./terminals.js";
@@ -151,6 +161,25 @@ export class QuiesceRejectedError extends Error {
151
161
  /** 自家内联扩展名(组合模板渲染,见 prompt-composer.ts)。SDK 以其
152
162
  * "<inline:<name>>" 作为 path;扩展白名单/禁用过滤必须放行它。 */
153
163
  const INLINE_PERSONA_EXT = "<inline:pi-webui-persona>";
164
+ /** 本次 run 的 SDK 原始提示词 + 早前扩展的首尾增补(见 splitExtensionWrap)。
165
+ * 导出仅为单测(tests/unit/persona-prompt-chain.test.ts)可达。
166
+ * systemPromptOptions 就是 runner 里那个共享可变对象,forceSystemPrompt 有值即
167
+ * 说明前面有扩展替换过提示词;临时清掉再读 event.systemPrompt 就拿到原始基线
168
+ * (公开字段,不碰 SDK 内部)。没人动过时零开销,直接返回当前文本。 */
169
+ export function splitAgentStartPrompt(event) {
170
+ const current = event.systemPrompt;
171
+ const opts = event.systemPromptOptions;
172
+ const forced = opts?.forceSystemPrompt;
173
+ if (!opts || typeof forced !== "string")
174
+ return { pre: "", core: current, post: "" };
175
+ try {
176
+ opts.forceSystemPrompt = undefined;
177
+ return splitExtensionWrap(event.systemPrompt, current);
178
+ }
179
+ finally {
180
+ opts.forceSystemPrompt = forced;
181
+ }
182
+ }
154
183
  /** Pi 包文档路径(composer 的 {{pi_docs}} 自动内容用)。随安装位置解析一次。 */
155
184
  const PI_DOC_PATHS = (() => {
156
185
  try {
@@ -266,6 +295,85 @@ export function makeAdaptiveBashTool(killable, terminalBacked, useTerminal) {
266
295
  },
267
296
  };
268
297
  }
298
+ /**
299
+ * 计划模式闸门装饰(通用):套在任何工具定义外层,只在**执行前**判定一次。
300
+ * 与权限沙箱/插件守卫平行且更靠外 —— 计划模式是会话级硬约束,不给任何
301
+ * 「记住同类/全部允许」的口子(允许了就等于放开了实施)。
302
+ * 关闭时零开销(返回原定义,不包 execute)。
303
+ */
304
+ export function withPlanModeGate(def, planMode, getLang) {
305
+ return {
306
+ ...def,
307
+ execute: (async (...args) => {
308
+ if (planMode()) {
309
+ const params = args[1];
310
+ const denied = planModeDenial(def.name, params);
311
+ if (denied) {
312
+ const reason = pick(getLang(), denied.reason, denied.reasonEn);
313
+ const err = new Error(reason);
314
+ err.details = {
315
+ guardDenied: true,
316
+ planModeDenied: true,
317
+ kind: denied.kind,
318
+ };
319
+ throw err;
320
+ }
321
+ }
322
+ return def.execute(...args);
323
+ }),
324
+ };
325
+ }
326
+ /**
327
+ * 目标审查回合闸门(server/goal-review-gate.ts):主对话在 `awaitingVerdict`
328
+ * 置位期间是审查者 —— 写类工具、非常规 bash、派发类工具、向用户提问一律拒。
329
+ * 形状与 withPlanModeGate / withDelegationGate 同构,串在最外层(理由最贴合
330
+ * 此刻:跟模型说「这是审查回合」比「这是计划/审查者模式」更有用)。闸门无状态
331
+ * (每次执行只读 `awaitingVerdict`),verdict 落定即自动恢复,不存在钉死。
332
+ */
333
+ export function withGoalReviewGate(def, reviewTurn, getLang) {
334
+ return {
335
+ ...def,
336
+ execute: (async (...args) => {
337
+ if (reviewTurn()) {
338
+ const params = args[1];
339
+ const denied = goalReviewDenial(def.name, params);
340
+ if (denied) {
341
+ return {
342
+ content: [{ type: "text", text: pick(getLang(), denied.reason, denied.reasonEn) }],
343
+ details: { guardDenied: true, goalReviewDenied: true, kind: denied.kind },
344
+ isError: true,
345
+ };
346
+ }
347
+ }
348
+ return def.execute(...args);
349
+ }),
350
+ };
351
+ }
352
+ /**
353
+ * 审查者模式闸门(server/delegate-mode.ts):主对话只审阅不施工 ——
354
+ * 写类工具、非常规 bash、派发类工具一律拒。形状与 withPlanModeGate 同构,
355
+ * 串在它**外层**(两者都开时,先问计划模式还是审查者模式都不影响结果:
356
+ * 都是拒,只是理由不同)。
357
+ */
358
+ export function withDelegationGate(def, delegateMode, getLang) {
359
+ return {
360
+ ...def,
361
+ execute: (async (...args) => {
362
+ if (delegateMode()) {
363
+ const params = args[1];
364
+ const denied = delegationDenial(def.name, params);
365
+ if (denied) {
366
+ return {
367
+ content: [{ type: "text", text: pick(getLang(), denied.reason, denied.reasonEn) }],
368
+ details: { guardDenied: true, delegateDenied: true, kind: denied.kind },
369
+ isError: true,
370
+ };
371
+ }
372
+ }
373
+ return def.execute(...args);
374
+ }),
375
+ };
376
+ }
269
377
  /**
270
378
  * 给已接管工具(bash/read)包上拦截守卫与人机协同审批:
271
379
  * 1. pre guard 命中 deny → 直接阻断;
@@ -651,7 +759,7 @@ function wrapEditSoftToolWithPermission(tool, cwd, getPermission, getRoots, getL
651
759
  /**
652
760
  * 结构化任务执行计划更新工具(plan_update)— Plan Mode / Step State Machine。
653
761
  */
654
- export function makePlanUpdateTool(planManager, getActiveConvId, emit, flushSnapshot) {
762
+ export function makePlanUpdateTool(planManager, getActiveConvTarget, emit, flushSnapshot, onPersist) {
655
763
  return {
656
764
  name: PLAN_UPDATE_TOOL_NAME,
657
765
  label: "plan_update",
@@ -666,7 +774,11 @@ export function makePlanUpdateTool(planManager, getActiveConvId, emit, flushSnap
666
774
  steps: Type.Array(Type.Object({
667
775
  id: Type.String({ description: "Unique step ID, e.g. '1', 'step-1'" }),
668
776
  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)" })),
777
+ status: Type.Optional(Type.Unsafe({
778
+ type: "string",
779
+ enum: ["pending", "in_progress", "done", "failed"],
780
+ description: "Step status: pending | in_progress | done | failed (default: pending)",
781
+ })),
670
782
  description: Type.Optional(Type.String({
671
783
  description: "Optional detailed description, acceptance criteria, file touch list, or rollback note.",
672
784
  })),
@@ -674,9 +786,21 @@ export function makePlanUpdateTool(planManager, getActiveConvId, emit, flushSnap
674
786
  activeStepId: Type.Optional(Type.String({ description: "ID of the step currently being executed" })),
675
787
  }),
676
788
  execute: async (toolCallId, params) => {
677
- const convId = getActiveConvId();
789
+ const target = getActiveConvTarget();
790
+ const convId = typeof target === "string" ? target : target.id;
791
+ const sessionId = typeof target === "string" ? undefined : target.sessionId;
678
792
  const p = params;
679
- const plan = planManager.setPlan(convId, p.steps, p.activeStepId);
793
+ const plan = planManager.setPlan(convId, p.steps, p.activeStepId, sessionId);
794
+ if (typeof target !== "string" && target.sessionManager) {
795
+ try {
796
+ const sm = target.sessionManager;
797
+ sm?.appendCustomEntry?.("plan/update", { plan });
798
+ }
799
+ catch {
800
+ // 转录追加失败不影响主流程
801
+ }
802
+ }
803
+ onPersist?.(convId, plan);
680
804
  emit({
681
805
  type: "plan_updated",
682
806
  conversationId: convId,
@@ -718,6 +842,7 @@ function wrapBashToolWithPermission(tool, getPermission, getLang) {
718
842
  function makeMarkersListTool(getActiveId, markerSvc) {
719
843
  return {
720
844
  name: MARKERS_LIST_TOOL_NAME,
845
+ promptSnippet: "list current inline-marker tasks (read-only — writes go through [[todo:...]] inline markers)",
721
846
  label: "List marker state",
722
847
  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
848
  parameters: Type.Object({
@@ -757,52 +882,50 @@ export function makeAskUserQuestionTool(clientSession,
757
882
  /** 本 runtime 所属会话:提问跟着对话走,快照只把当前对话的问卷推给客户端。 */
758
883
  ownerId) {
759
884
  const QuestionOptionSchema = Type.Object({
760
- label: Type.String({ description: "Display label for the option (1-5 words)" }),
885
+ label: Type.String({ description: "Display label (1-5 words)" }),
761
886
  description: Type.Optional(Type.String({
762
- description: "One short sentence explaining the impact or tradeoff if selected.",
887
+ description: "Short explanation of impact/tradeoff",
763
888
  })),
764
889
  preview: Type.Optional(Type.String({
765
- description: "Optional preview rendered below when this option is selected (markdown or HTML — use for mockups/code/config).",
890
+ description: "Optional preview (markdown/HTML/code)",
766
891
  })),
767
892
  });
768
893
  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" })),
894
+ id: Type.String({ description: "Unique identifier (snake_case)" }),
895
+ question: Type.String({ description: "Question text (markdown/HTML ok)" }),
896
+ detail: Type.Optional(Type.String({ description: "Optional context under question" })),
897
+ header: Type.Optional(Type.String({ description: "Optional short header" })),
773
898
  options: Type.Optional(Type.Array(QuestionOptionSchema, {
774
- description: "2-4 mutually exclusive choices. Put the recommended option first when there is a clear default.",
899
+ description: "2-4 choices (recommended option first)",
775
900
  minItems: 2,
776
901
  maxItems: 4,
777
902
  })),
778
- multiSelect: Type.Optional(Type.Boolean({ description: "Allow selecting multiple options (default: false)" })),
903
+ multiSelect: Type.Optional(Type.Boolean({ description: "Allow multiple choices (default: false)" })),
779
904
  dependsOn: Type.Optional(Type.Object({
780
- questionId: Type.String({ description: "ID of the prior question this question depends on" }),
905
+ questionId: Type.String({ description: "Prior question ID" }),
781
906
  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.",
907
+ description: "Show when prior answer matches",
783
908
  })),
784
909
  })),
785
910
  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...] }",
911
+ description: "Dynamic options keyed by prior answer",
787
912
  })),
788
913
  });
789
914
  return {
790
915
  name: "ask_user_question",
791
916
  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.",
917
+ description: "Ask the user 1-3 focused questions to clarify requirements, confirm decisions, or choose options. " +
918
+ "Provide 2-4 choices (recommended first) with concise tradeoffs. " +
919
+ "Renders a rich browser dialog; resumes on submit or cancel.",
796
920
  promptSnippet: "ask the user 1-3 focused questions with recommended options and tradeoffs to clarify requirements",
797
921
  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",
922
+ "When requirements are ambiguous, use ask_user_question to clarify: " +
923
+ "strictly 1 to 3 focused questions (prefer 1), 2-4 choices with recommended option first, and explain impact/tradeoff concisely",
924
+ "A cancelled question comes back as a tool error — respect it without immediately re-asking",
802
925
  ],
803
926
  parameters: Type.Object({
804
927
  questions: Type.Array(QuestionSchema, {
805
- description: "Questions to ask the user (strictly 1 to 3 questions; prefer 1).",
928
+ description: "1-3 questions to ask (prefer 1)",
806
929
  minItems: 1,
807
930
  maxItems: 3,
808
931
  }),
@@ -1095,6 +1218,14 @@ function pluginToolToDefinition(tool) {
1095
1218
  * unlikely to matter).
1096
1219
  */
1097
1220
  function contentFingerprint(m) {
1221
+ const summary = m.summary;
1222
+ if (typeof summary === "string" && summary.length > 0) {
1223
+ let h = 5381;
1224
+ for (let i = 0; i < summary.length && i < 512; i++) {
1225
+ h = ((h << 5) + h + summary.charCodeAt(i)) >>> 0;
1226
+ }
1227
+ return `sum:${h.toString(36)}:${summary.length}`;
1228
+ }
1098
1229
  const content = m.content;
1099
1230
  if (!Array.isArray(content) || content.length === 0)
1100
1231
  return "empty";
@@ -1169,31 +1300,10 @@ function previewToolResult(result) {
1169
1300
  }
1170
1301
  }
1171
1302
  /**
1172
- * 转录整文件重写的原子落盘:先写同目录临时文件再 rename。转录是 append-only
1173
- * 的唯一事实源,compaction 收尾/清理的整文件重写若中途被打断(进程被杀/断电),
1174
- * 原地 writeFileSync 会留下截断的 JSONL;同目录 rename 是原子的,最坏情况是
1175
- * 旧文件原样保留,绝不会出现半份转录。Windows 下目标被外部占用(阅读器/杀软
1176
- * 锁)时 rename 可能 EPERM —— 退回原地写保住功能(旧行为),不因小概率锁失败丢更新。
1303
+ * 转录整文件重写的原子落盘:先写同目录临时文件再 rename。
1177
1304
  */
1178
1305
  function atomicWriteFileSync(file, data) {
1179
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
1180
- try {
1181
- writeFileSync(tmp, data);
1182
- try {
1183
- renameSync(tmp, file);
1184
- }
1185
- catch {
1186
- writeFileSync(file, data);
1187
- }
1188
- }
1189
- finally {
1190
- try {
1191
- rmSync(tmp, { force: true });
1192
- }
1193
- catch {
1194
- // 临时文件清不掉只能留给下次(同目录 .tmp,不影响转录本身)
1195
- }
1196
- }
1306
+ writeAtomicSync(file, data);
1197
1307
  }
1198
1308
  /** Cap on simultaneously open NON-subagent conversations of ONE project (each keeps a full
1199
1309
  * runtime alive; conversations of other projects keep their own lists).
@@ -1248,18 +1358,17 @@ function conversationTitle(session) {
1248
1358
  }
1249
1359
  return DEFAULT_CONV_TITLE;
1250
1360
  }
1251
- /** 全局搜索的会话匹配:大小写不敏感,命中任一项即算 ——
1252
- * 显示名、当前项目内的文件名片段、首条消息,以及完整转录文本
1253
- * (SDK 的 allMessagesText 包含每一段 user 与 assistant 消息,AI 输出也在内)。 */
1254
- function sessionMatchesSearch(q, s) {
1361
+ /** 全局搜索的会话元信息匹配:大小写不敏感,命中任一项即算 ——
1362
+ * 显示名、当前项目内的文件名片段、首条消息。
1363
+ * 转录全文匹配不在这里:全文单条可达 MB 级,不再随列表缓存常驻(issue #440),
1364
+ * 由 filterSessionsForSearch 按需加载后判定(口径不变:user 与 assistant 消息文本)。 */
1365
+ function sessionMatchesMetadata(q, s) {
1255
1366
  if (s.name && s.name.toLowerCase().includes(q))
1256
1367
  return true;
1257
1368
  if (basename(s.path).toLowerCase().includes(q))
1258
1369
  return true;
1259
1370
  if (s.firstMessage.toLowerCase().includes(q))
1260
1371
  return true;
1261
- if (s.allMessagesText.toLowerCase().includes(q))
1262
- return true;
1263
1372
  return false;
1264
1373
  }
1265
1374
  /** 抽取一条 AgentMessage 的可搜索文本(user/assistant 的 text 块;
@@ -1283,6 +1392,12 @@ function messageSearchText(m) {
1283
1392
  /** 转录全文扫描/整读的大小上限(16MB,与 readHistorySession 一致):
1284
1393
  * 超限文件同步 readFileSync 会卡事件循环数百毫秒起,搜索不值得。 */
1285
1394
  const MAX_TRANSCRIPT_SCAN_BYTES = 16 * 1024 * 1024;
1395
+ /** 转录全文进入内存/缓存的字符上限(256K 字符):全文只服务搜索的命中判定
1396
+ * (issue #440 拆分后按需加载),封顶保证单条 worst case 内存可控(缓存 256 条
1397
+ * × 256K 字符 ≈ 64MB 上界,且只在搜索活动期间存在)。
1398
+ * 影响面:超长转录在截断点之后的内容不参与「是否命中」判定;命中后的消息级
1399
+ * 定位(collectSessionAnchors)仍扫全文件,不受此上限影响。 */
1400
+ const MAX_SEARCH_TEXT_CHARS = 256 * 1024;
1286
1401
  /** 扫描一个会话转录文件,收集文本命中查询的消息锚点(role + timestamp,
1287
1402
  * 按转录顺序,最多 cap 个)。仅 user/assistant 消息参与,与搜索范围一致。
1288
1403
  * 超过大小上限的转录直接跳过(stat 先行,不整读)—— 全局搜索逐会话同步
@@ -1361,6 +1476,253 @@ export function isInsideSessionsDir(agentDir, targetPath) {
1361
1476
  roots.push(resolve(extra));
1362
1477
  return roots.some((root) => abs.startsWith(root + sep));
1363
1478
  }
1479
+ /**
1480
+ * 从会话文件首行读取 cwd(轻量探测,最多读取 2KB,绝不完整解析整个 jsonl 消息历史)。
1481
+ */
1482
+ async function readCwdFromSessionHeader(filePath) {
1483
+ let handle;
1484
+ try {
1485
+ handle = await fsPromises.open(filePath, "r");
1486
+ const buf = Buffer.alloc(2048);
1487
+ const { bytesRead } = await handle.read(buf, 0, 2048, 0);
1488
+ const text = buf.toString("utf8", 0, bytesRead);
1489
+ const nl = text.indexOf("\n");
1490
+ const firstLine = nl !== -1 ? text.slice(0, nl) : text;
1491
+ if (!firstLine.trim())
1492
+ return null;
1493
+ const parsed = JSON.parse(firstLine);
1494
+ return typeof parsed.cwd === "string" && parsed.cwd.trim() ? parsed.cwd : null;
1495
+ }
1496
+ catch {
1497
+ return null;
1498
+ }
1499
+ finally {
1500
+ if (handle)
1501
+ await handle.close().catch(() => { });
1502
+ }
1503
+ }
1504
+ /**
1505
+ * 快速轻量地从 SDK 会话目录中发现项目路径(仅读取会话文件的第一行头部获取 cwd,
1506
+ * 绝不使用 SessionManager.listAll 遍历解析全部会话文本,避免大历史时阻塞数秒到数十秒)。
1507
+ */
1508
+ async function discoverRecentProjectsFromDisk(sessionRoots, limit = 30) {
1509
+ const projects = new Map();
1510
+ for (const root of sessionRoots) {
1511
+ if (projects.size >= limit)
1512
+ break;
1513
+ try {
1514
+ // 异步探测(issue #441):会话根可能在已断连的网络盘上,同步 existsSync 会冻结事件循环。
1515
+ try {
1516
+ await fsPromises.access(root);
1517
+ }
1518
+ catch {
1519
+ continue;
1520
+ }
1521
+ const entries = await fsPromises.readdir(root, { withFileTypes: true });
1522
+ // 1. 扁平布局(PI_CODING_AGENT_SESSION_DIR):根目录下直接是 .jsonl
1523
+ const flatFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
1524
+ if (flatFiles.length > 0) {
1525
+ const fileStats = await Promise.all(flatFiles.map(async (f) => {
1526
+ const fp = join(root, f.name);
1527
+ try {
1528
+ const st = await fsPromises.stat(fp);
1529
+ return { path: fp, mtime: st.mtimeMs };
1530
+ }
1531
+ catch {
1532
+ return null;
1533
+ }
1534
+ }));
1535
+ fileStats.sort((a, b) => (b?.mtime ?? 0) - (a?.mtime ?? 0));
1536
+ for (const s of fileStats) {
1537
+ if (!s || projects.size >= limit)
1538
+ break;
1539
+ const cwd = await readCwdFromSessionHeader(s.path);
1540
+ if (cwd) {
1541
+ const key = normalizePathKey(cwd);
1542
+ if (!projects.has(key)) {
1543
+ try {
1544
+ // 异步探测(issue #441):cwd 可能在已断连的网络盘上。
1545
+ await fsPromises.access(cwd);
1546
+ projects.set(key, { path: cwd, lastUsed: s.mtime });
1547
+ }
1548
+ catch { }
1549
+ }
1550
+ }
1551
+ }
1552
+ }
1553
+ // 2. 默认子目录布局(<agentDir>/sessions/--<cwd>--/):每 cwd 一个子目录
1554
+ const subdirs = entries.filter((e) => e.isDirectory() || e.isSymbolicLink()).map((e) => join(root, e.name));
1555
+ if (subdirs.length > 0 && projects.size < limit) {
1556
+ const CHUNK = 64;
1557
+ const dirStats = [];
1558
+ for (let i = 0; i < subdirs.length; i += CHUNK) {
1559
+ const chunk = subdirs.slice(i, i + CHUNK);
1560
+ const stats = await Promise.all(chunk.map(async (dir) => {
1561
+ try {
1562
+ const st = await fsPromises.stat(dir);
1563
+ return { path: dir, mtime: st.mtimeMs };
1564
+ }
1565
+ catch {
1566
+ return null;
1567
+ }
1568
+ }));
1569
+ for (const s of stats)
1570
+ if (s)
1571
+ dirStats.push(s);
1572
+ }
1573
+ dirStats.sort((a, b) => b.mtime - a.mtime);
1574
+ for (let i = 0; i < dirStats.length; i += 16) {
1575
+ if (projects.size >= limit)
1576
+ break;
1577
+ const batch = dirStats.slice(i, i + 16);
1578
+ const batchRes = await Promise.all(batch.map(async (d) => {
1579
+ try {
1580
+ const files = await fsPromises.readdir(d.path);
1581
+ const jsonls = files.filter((f) => f.endsWith(".jsonl"));
1582
+ if (jsonls.length === 0)
1583
+ return null;
1584
+ let newestFile = "";
1585
+ let newestMtime = 0;
1586
+ for (const f of jsonls) {
1587
+ const fp = join(d.path, f);
1588
+ try {
1589
+ const st = await fsPromises.stat(fp);
1590
+ if (st.mtimeMs > newestMtime) {
1591
+ newestMtime = st.mtimeMs;
1592
+ newestFile = fp;
1593
+ }
1594
+ }
1595
+ catch { }
1596
+ }
1597
+ if (!newestFile)
1598
+ return null;
1599
+ const cwd = await readCwdFromSessionHeader(newestFile);
1600
+ if (!cwd)
1601
+ return null;
1602
+ return { path: cwd, lastUsed: newestMtime };
1603
+ }
1604
+ catch {
1605
+ return null;
1606
+ }
1607
+ }));
1608
+ for (const r of batchRes) {
1609
+ if (!r)
1610
+ continue;
1611
+ const key = normalizePathKey(r.path);
1612
+ if (!projects.has(key)) {
1613
+ try {
1614
+ // 异步探测(issue #441):cwd 可能在已断连的网络盘上。
1615
+ await fsPromises.access(r.path);
1616
+ projects.set(key, r);
1617
+ }
1618
+ catch { }
1619
+ }
1620
+ }
1621
+ }
1622
+ }
1623
+ }
1624
+ catch {
1625
+ /* best effort */
1626
+ }
1627
+ }
1628
+ return [...projects.values()].sort((a, b) => b.lastUsed - a.lastUsed);
1629
+ }
1630
+ /**
1631
+ * 快速解析单个会话文件(提取 id/cwd/name/parentSessionPath/firstMessage/messageCount/modified,
1632
+ * 以及可选的转录全文 allMessagesText)。仅用于会话列表发现与搜索,避免调用 SDK 重型的全量树解析。
1633
+ *
1634
+ * withAllMessagesText=false(默认,列表路径)时不收集转录全文,allMessagesText 返回空串 ——
1635
+ * 全文单条可达 MB 级,绝不随列表进任何缓存(issue #440);true(搜索路径)时收集全文并在
1636
+ * MAX_SEARCH_TEXT_CHARS 处截断。
1637
+ */
1638
+ async function parseSessionInfoFast(filePath, fileMtime, withAllMessagesText = false) {
1639
+ try {
1640
+ const content = await fsPromises.readFile(filePath, "utf8");
1641
+ const lines = content.split("\n");
1642
+ let id = "";
1643
+ let cwd = "";
1644
+ let name;
1645
+ let parentSessionPath;
1646
+ let firstMessage = "";
1647
+ let messageCount = 0;
1648
+ let lastActivityTime = 0;
1649
+ let headerTime = 0;
1650
+ const allMessages = withAllMessagesText ? [] : null;
1651
+ for (const line of lines) {
1652
+ if (!line.trim())
1653
+ continue;
1654
+ let entry;
1655
+ try {
1656
+ entry = JSON.parse(line);
1657
+ }
1658
+ catch {
1659
+ continue;
1660
+ }
1661
+ const rec = entry;
1662
+ if (!rec)
1663
+ continue;
1664
+ if (rec.type === "session") {
1665
+ id = typeof rec.id === "string" ? rec.id : "";
1666
+ cwd = typeof rec.cwd === "string" ? rec.cwd : "";
1667
+ if (typeof rec.timestamp === "string")
1668
+ headerTime = new Date(rec.timestamp).getTime();
1669
+ if (typeof rec.parentSession === "string")
1670
+ parentSessionPath = rec.parentSession;
1671
+ }
1672
+ else if (rec.type === "session_info") {
1673
+ if (typeof rec.name === "string" && rec.name.trim())
1674
+ name = rec.name.trim();
1675
+ }
1676
+ else if (rec.type === "message") {
1677
+ messageCount++;
1678
+ const msg = rec.message;
1679
+ if (msg) {
1680
+ if (typeof msg.timestamp === "number")
1681
+ lastActivityTime = Math.max(lastActivityTime, msg.timestamp);
1682
+ let text = "";
1683
+ if (typeof msg.content === "string") {
1684
+ text = msg.content;
1685
+ }
1686
+ else if (Array.isArray(msg.content)) {
1687
+ for (const part of msg.content) {
1688
+ if (part && typeof part.text === "string") {
1689
+ text += (text ? " " : "") + part.text;
1690
+ }
1691
+ }
1692
+ }
1693
+ if (text) {
1694
+ if (!firstMessage && msg.role === "user")
1695
+ firstMessage = text;
1696
+ allMessages?.push(text);
1697
+ }
1698
+ }
1699
+ }
1700
+ }
1701
+ if (!id)
1702
+ return null;
1703
+ const modified = lastActivityTime > 0 ? lastActivityTime : headerTime > 0 ? headerTime : fileMtime;
1704
+ let allMessagesText = "";
1705
+ if (allMessages) {
1706
+ const joined = allMessages.join(" ");
1707
+ allMessagesText = joined.length > MAX_SEARCH_TEXT_CHARS ? joined.slice(0, MAX_SEARCH_TEXT_CHARS) : joined;
1708
+ }
1709
+ return {
1710
+ path: filePath,
1711
+ id,
1712
+ cwd,
1713
+ name,
1714
+ parentSessionPath,
1715
+ created: new Date(headerTime || fileMtime),
1716
+ modified: new Date(modified),
1717
+ messageCount,
1718
+ firstMessage: firstMessage || "(no messages)",
1719
+ allMessagesText,
1720
+ };
1721
+ }
1722
+ catch {
1723
+ return null;
1724
+ }
1725
+ }
1364
1726
  /** 会话当前模型的 "provider/id"(无模型时 null;软上限按模型覆盖用,issue #229)。 */
1365
1727
  function modelKeyOf(session) {
1366
1728
  const m = session?.model;
@@ -1408,11 +1770,8 @@ export function sameCwd(a, b) {
1408
1770
  const y = String(b ?? "").trim();
1409
1771
  if (!x || !y)
1410
1772
  return false;
1411
- const norm = (s) => s.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
1412
- if (norm(x) === norm(y))
1413
- return true;
1414
1773
  try {
1415
- return norm(resolve(x)) === norm(resolve(y));
1774
+ return normalizePathKey(x) === normalizePathKey(y);
1416
1775
  }
1417
1776
  catch {
1418
1777
  return false;
@@ -1470,6 +1829,9 @@ export class ClientSession {
1470
1829
  * top bar applies to every chat, not just the one that set it. Seeded by
1471
1830
  * the first conversation and reused by later ones. */
1472
1831
  sharedModelRuntime;
1832
+ /** 目标模式「委托执行」(Plan A)的「本轮结束」等待者:convId → 回调集合。
1833
+ * agent_end 到达时唤醒(事件驱动,绝不轮询);对话被移出时按 gone 收。 */
1834
+ turnEndWaiters = new Map();
1473
1835
  // -----------------------------------------------------------------------
1474
1836
  // Goal / review / wizard —— 自包含模块,见 goal-service.ts。每个对话有独立
1475
1837
  // 的 GoalStatus,审查可并发;宿主回调在构造函数里接入。
@@ -1631,7 +1993,7 @@ export class ClientSession {
1631
1993
  * `model`(可选):"provider/id",显式指定子代理模型。不传时由调用方决定是否
1632
1994
  * 回退到模板模型 / 设置面板默认模型;null = 跟随主对话当前模型(默认行为,
1633
1995
  * runtime 重建时会继承共享 ModelRuntime 的当前默认)。 */
1634
- async spawnSubagentConversation(prompt, type, cwd, apply, model, parentId, persist) {
1996
+ async spawnSubagentConversation(prompt, type, cwd, apply, model, parentId, persist, title) {
1635
1997
  // 数量上限先行:每个子代理都是完整 runtime + TerminalManager,无上限时一次
1636
1998
  // 并行派发几十个会把服务进程拖垮。持久化普通对话则受项目会话上限限制。
1637
1999
  if (persist) {
@@ -1690,7 +2052,7 @@ export class ClientSession {
1690
2052
  // syncPluginTools 不回补,模板热改不影响已运行的子代理(与 prompt/技能一致)。
1691
2053
  conv.subagentBarsPluginTools = !!apply && apply.enabledExtensions.length > 0;
1692
2054
  conv.listed = true;
1693
- conv.title = subagentTitle(prompt);
2055
+ conv.title = title ?? subagentTitle(prompt);
1694
2056
  this.convs.set(conv.id, conv);
1695
2057
  // 子代理会话同样订阅 SDK 事件:否则 onEvent 永不触发,点开查看时没有
1696
2058
  // message_delta 流式增量、快照也不刷新,只能靠切走切回时的 flushSnapshot
@@ -1778,8 +2140,9 @@ export class ClientSession {
1778
2140
  });
1779
2141
  }
1780
2142
  }
1781
- // 触发回合(后台执行;失败转识为通知)。
1782
- void conv.session.sendUserMessage(prompt).catch((err) => {
2143
+ // 触发回合(后台执行;失败转识为通知)。把 Promise 挂在对话上:目标模式的
2144
+ // 「等本回合结束」要先等它落定,才能区分「还没开跑」与「已跑完」。
2145
+ conv.kickoff = conv.session.sendUserMessage(prompt).catch((err) => {
1783
2146
  this.emit({
1784
2147
  type: "notice",
1785
2148
  level: "error",
@@ -1877,6 +2240,92 @@ export class ClientSession {
1877
2240
  }
1878
2241
  return {};
1879
2242
  }
2243
+ /**
2244
+ * 事件驱动地等某对话「当前回合结束」(目标模式委托执行用)。
2245
+ * 若当前并未在跑则立即返回既有结局;超时返回 "timeout";对话已消失返回 "gone"。
2246
+ * 不轮询:靠 agent_end 的 notifyTurnEnd / removeConversation 唤醒。
2247
+ */
2248
+ async waitConversationTurnEnd(convId, timeoutMs) {
2249
+ const isStreaming = (c) => {
2250
+ try {
2251
+ return c.session.isStreaming;
2252
+ }
2253
+ catch {
2254
+ return false;
2255
+ }
2256
+ };
2257
+ let conv = this.convs.get(convId);
2258
+ if (!conv)
2259
+ return Promise.resolve("gone");
2260
+ // spawn 的首回合是 fire-and-forget 投递的:先等它落定,否则「还没开跑」会被
2261
+ // 误判成「已跑完」(空取样 → 下一轮派活撞在一起)。
2262
+ if (conv.kickoff) {
2263
+ await conv.kickoff.catch(() => { });
2264
+ conv = this.convs.get(convId);
2265
+ if (!conv)
2266
+ return "gone";
2267
+ }
2268
+ // 极短的启动宽限:投递刚返回时 isStreaming 可能还差一拍。只在这里等,
2269
+ // 一旦开跑就交给事件驱动(agent_end),不是长轮询。
2270
+ if (!isStreaming(conv)) {
2271
+ const graceEnd = Date.now() + Math.min(3000, Math.max(0, timeoutMs));
2272
+ while (!isStreaming(conv) && Date.now() < graceEnd) {
2273
+ await new Promise((resolve) => setTimeout(resolve, 50));
2274
+ const again = this.convs.get(convId);
2275
+ if (!again)
2276
+ return "gone";
2277
+ conv = again;
2278
+ }
2279
+ }
2280
+ if (!isStreaming(conv))
2281
+ return this.roleOutcomeOf(conv);
2282
+ return new Promise((resolve) => {
2283
+ const set = this.turnEndWaiters.get(convId) ?? new Set();
2284
+ this.turnEndWaiters.set(convId, set);
2285
+ let settled = false;
2286
+ let timer;
2287
+ const finish = (o) => {
2288
+ if (settled)
2289
+ return;
2290
+ settled = true;
2291
+ if (timer)
2292
+ clearTimeout(timer);
2293
+ set.delete(finish);
2294
+ if (set.size === 0)
2295
+ this.turnEndWaiters.delete(convId);
2296
+ resolve(o);
2297
+ };
2298
+ timer = setTimeout(() => finish("timeout"), Math.max(1000, timeoutMs));
2299
+ timer.unref?.();
2300
+ set.add(finish);
2301
+ // 注册后复检:agent_end 可能恰好在 isStreaming 检查与注册之间到达。
2302
+ const still = this.convs.get(convId);
2303
+ if (!still) {
2304
+ finish("gone");
2305
+ return;
2306
+ }
2307
+ if (!isStreaming(still))
2308
+ finish(this.roleOutcomeOf(still));
2309
+ });
2310
+ }
2311
+ /** 对话结局 → 角色轮结局(报错 > 中止 > 正常)。 */
2312
+ roleOutcomeOf(conv) {
2313
+ const { error, canceled } = this.subagentRunOutcome(conv);
2314
+ if (error)
2315
+ return "error";
2316
+ if (canceled)
2317
+ return "canceled";
2318
+ return "done";
2319
+ }
2320
+ /** 某对话回合结束 → 唤醒它的等待者(agent_end / 中止路径都调)。 */
2321
+ notifyTurnEnd(conv) {
2322
+ const set = this.turnEndWaiters.get(conv.id);
2323
+ if (!set || set.size === 0)
2324
+ return;
2325
+ const outcome = this.roleOutcomeOf(conv);
2326
+ for (const fn of set)
2327
+ fn(outcome);
2328
+ }
1880
2329
  emitTerminal(conversationId, msg) {
1881
2330
  // Background conversations keep collecting output in their own PTY buffer.
1882
2331
  // Do not stream it into the active xterm; push the retained window on switch.
@@ -2096,6 +2545,9 @@ export class ClientSession {
2096
2545
  const handoffMessage = `[Peer Hand-off from ${fromType} subagent (${fromRunId.slice(0, 8)})]:\n\n${payload}`;
2097
2546
  const record = { fromRunId, toRunId, timestamp: Date.now() };
2098
2547
  this.subagentHandoffs.push(record);
2548
+ if (this.subagentHandoffs.length > 50) {
2549
+ this.subagentHandoffs = this.subagentHandoffs.slice(-50);
2550
+ }
2099
2551
  if (!fromConv?.peerHandoffTo) {
2100
2552
  if (fromConv)
2101
2553
  fromConv.peerHandoffTo = [];
@@ -2587,7 +3039,7 @@ export class ClientSession {
2587
3039
  * conversationId 记录谁问的:看门狗豁免、快照恢复都靠它。 */
2588
3040
  pendingQuestions = new Map();
2589
3041
  /** 任务计划管理器(Plan Mode / Step State Machine)。 */
2590
- planManager = new PlanManager();
3042
+ planManager;
2591
3043
  approvalSeq = 0;
2592
3044
  /** 待审批高危工具调用(Human-in-the-Loop: Edit & Run)。 */
2593
3045
  pendingApprovals = new Map();
@@ -2614,6 +3066,7 @@ export class ClientSession {
2614
3066
  this.subagentTemplates = new SubagentTemplatesStore(join(stateStore.dataDir, "subagent-templates.json"));
2615
3067
  this.approvalRules = new ApprovalRulesStore(join(stateStore.dataDir, "approval-rules.json"));
2616
3068
  this.drafts = new ComposerDraftsStore(join(stateStore.dataDir, "composer-drafts.json"));
3069
+ this.planManager = new PlanManager(join(stateStore.dataDir, "plans.json"));
2617
3070
  this.markerSvc = new MarkerService({
2618
3071
  clientId,
2619
3072
  stateStore,
@@ -2661,7 +3114,11 @@ export class ClientSession {
2661
3114
  },
2662
3115
  applyRetryOverrides: () => this.applyRetryOverrides(),
2663
3116
  applyCompactionOverrides: () => this.applyCompactionOverrides(),
2664
- applyToolGating: () => this.applyToolGating(this.session, this.conv?.agentPreset),
3117
+ applyToolGating: () => {
3118
+ for (const conv of this.convs.values()) {
3119
+ this.applyToolGating(conv.session, conv.agentPreset);
3120
+ }
3121
+ },
2665
3122
  promptSnapshot: () => this.promptSnapshot(),
2666
3123
  getMarkerState: () => ({
2667
3124
  markersEnabled: this.markerSvc.current.markersEnabled,
@@ -2669,6 +3126,10 @@ export class ClientSession {
2669
3126
  markers: this.markerSvc.listForUi(),
2670
3127
  }),
2671
3128
  getApprovalPolicy: () => this.approvalPolicyState(),
3129
+ // 目标模式总开关关闭 → 在飞的目标/调研立即停(幂等;无在飞目标时无声)。
3130
+ onGoalModeDisabled: () => {
3131
+ void this.goalSvc.stopAllGoals();
3132
+ },
2672
3133
  }, this.subagentTemplates, this.approvalRules);
2673
3134
  this.goalSvc = new GoalService({
2674
3135
  clientId,
@@ -2687,8 +3148,122 @@ export class ClientSession {
2687
3148
  activeConv: () => this.conv,
2688
3149
  getConv: (id) => this.convs.get(id),
2689
3150
  cwd: () => this.cwd,
2690
- reviewSettings: () => this.settingsSvc.reviewPrefs,
2691
3151
  gitDiff: (dir) => this.gitDiff(dir),
3152
+ // 任务计划看板(#389 目标与计划联动):审查者核验步骤看板,向导产物注入看板
3153
+ getPlan: (convId) => this.planManager.getPlan(convId),
3154
+ describePlan: (convId) => this.planManager.describePlan(convId),
3155
+ setPlan: (convId, steps) => {
3156
+ this.updatePlan(steps, undefined, convId);
3157
+ },
3158
+ // 工作区 git 可用性(目标模式停滞判定的可信信号开关):非仓库目录下
3159
+ // git diff 恒为空,取样会跳过停滞计数,纯问答类目标不再被误熔断。
3160
+ isGitRepo: async (cwd) => {
3161
+ try {
3162
+ const r = await this.runAsync("git", ["rev-parse", "--is-inside-work-tree"], 5_000, cwd || this.cwd);
3163
+ return r.code === 0;
3164
+ }
3165
+ catch {
3166
+ return false;
3167
+ }
3168
+ },
3169
+ // ---- 目标模式 2.0 的角色对话桥(唯一审查/执行路径)----
3170
+ // 复用子代理通道(同一套模板/模型/思考强度/配额/左栏展示),但角色对话**落盘**
3171
+ // (persist=true):转录进历史、服务重启后仍可打开回看。
3172
+ // 代价:落盘对话 isSubagent=false → 占「每项目 8 个普通对话」名额之一
3173
+ // (spawnSubagentConversation 满员时抛错 → GoalService 降级回 self 并提示),
3174
+ // 且左栏不再有「子代理」徽标 —— 故给它一个带前缀的标题保持可辨识。
3175
+ spawnRoleAgent: async ({ role, prompt, cwd, model, parentId, title }) => {
3176
+ const baseCwd = cwd || (parentId ? this.convs.get(parentId)?.cwd : undefined) || this.cwd;
3177
+ const convId = await this.spawnSubagentConversation(prompt, role === "executor" ? "goal-executor" : "goal-reviewer", baseCwd, undefined, model ?? null, parentId, true, title);
3178
+ if (title) {
3179
+ const conv = this.convs.get(convId);
3180
+ if (conv)
3181
+ conv.title = title;
3182
+ }
3183
+ return convId;
3184
+ },
3185
+ waitRoleAgent: (convId, timeoutMs) => this.waitConversationTurnEnd(convId, timeoutMs),
3186
+ sendRoleAgent: async (convId, message, deliverAs) => {
3187
+ const conv = this.convs.get(convId);
3188
+ if (!conv?.session)
3189
+ return false;
3190
+ await conv.session.sendUserMessage(message, deliverAs ? { deliverAs } : conv.session.isStreaming ? { deliverAs: "steer" } : undefined);
3191
+ return true;
3192
+ },
3193
+ readRoleAgent: (convId) => {
3194
+ const conv = this.convs.get(convId);
3195
+ if (!conv?.session)
3196
+ return undefined;
3197
+ let text = "";
3198
+ try {
3199
+ text = conv.session.getLastAssistantText() ?? "";
3200
+ }
3201
+ catch {
3202
+ text = "";
3203
+ }
3204
+ // 执行者 vitals(目标条实时进度 + 用量累计用;轮次边界才读一次,
3205
+ // getSessionStats 会遍历转写,不在高频路径上调)。
3206
+ let streaming;
3207
+ let lastTool;
3208
+ let usage;
3209
+ try {
3210
+ streaming = conv.session.isStreaming;
3211
+ }
3212
+ catch {
3213
+ streaming = undefined;
3214
+ }
3215
+ try {
3216
+ lastTool = lastToolNameOfSession(conv.session);
3217
+ }
3218
+ catch {
3219
+ lastTool = undefined;
3220
+ }
3221
+ try {
3222
+ const t = conv.session.getSessionStats()?.tokens;
3223
+ if (t && typeof t.input === "number" && typeof t.output === "number") {
3224
+ usage = { input: t.input, output: t.output };
3225
+ }
3226
+ }
3227
+ catch {
3228
+ usage = undefined;
3229
+ }
3230
+ return {
3231
+ text,
3232
+ errorSnippet: extractErrorSnippetFromSession(conv.session, text),
3233
+ streaming,
3234
+ lastTool,
3235
+ usage,
3236
+ };
3237
+ },
3238
+ stopRoleAgent: async (convId) => {
3239
+ const conv = this.convs.get(convId);
3240
+ if (!conv)
3241
+ return;
3242
+ try {
3243
+ if (conv.session.isStreaming || !conv.session.isIdle) {
3244
+ await this.interruptRun(conv, pick(this.getLang(), "目标模式停止角色对话", "Goal mode stopped a role conversation", "agent.role.stop"));
3245
+ }
3246
+ }
3247
+ catch {
3248
+ // best-effort
3249
+ }
3250
+ },
3251
+ dismissRoleAgent: async (convId) => {
3252
+ const conv = this.convs.get(convId);
3253
+ if (!conv)
3254
+ return;
3255
+ // 用户正看着这个角色对话时不要把他弹走(对话留着,用户可自行关闭)。
3256
+ if (convId === this.activeId)
3257
+ return;
3258
+ try {
3259
+ await this.dismissConversation(convId, true, true);
3260
+ }
3261
+ catch {
3262
+ // best-effort
3263
+ }
3264
+ },
3265
+ hasConv: (convId) => this.convs.has(convId),
3266
+ roleDeadlineMs: () => this.getBaseToolWatchdogTimeoutMs(),
2692
3267
  });
2693
3268
  this.modelAdmin = new ModelAdminService({
2694
3269
  agentDir,
@@ -2780,6 +3355,57 @@ export class ClientSession {
2780
3355
  }
2781
3356
  await cs.restoreProjectProviderKeysForCwd(cwd);
2782
3357
  await cs.restoreProjectModelForCwd(cwd);
3358
+ if (conv.session.sessionFile && cs.stateStore?.isSessionPinned(cwd, conv.session.sessionFile)) {
3359
+ conv.pinned = true;
3360
+ conv.listed = true;
3361
+ }
3362
+ // 恢复本项目其他已被钉住的会话(重启后常驻运行列表,issue #433)
3363
+ const pinnedPaths = cs.stateStore?.getPinnedSessions(cwd) ?? [];
3364
+ for (const p of pinnedPaths) {
3365
+ if (cs.convs.size >= MAX_OPEN_CONVERSATIONS)
3366
+ break;
3367
+ const normP = normalizePathKey(p);
3368
+ const alreadyOpen = [...cs.convs.values()].some((c) => c.session.sessionFile && normalizePathKey(c.session.sessionFile) === normP);
3369
+ if (alreadyOpen)
3370
+ continue;
3371
+ try {
3372
+ const targetPath = resolve(p);
3373
+ if (!existsSync(targetPath))
3374
+ continue;
3375
+ cs.repairTranscriptFileBeforeOpen(targetPath);
3376
+ const sm = SessionManager.open(targetPath);
3377
+ const convId = cs.nextConversationId();
3378
+ const terms = cs.makeTerminalManager(convId, cwd);
3379
+ const rt = await createAgentSessionRuntime(cs.makeRuntimeFactory(terms, undefined, convId), {
3380
+ cwd,
3381
+ agentDir,
3382
+ sessionManager: sm,
3383
+ });
3384
+ const pinnedConv = cs.makeConversation(rt, convId, terms);
3385
+ pinnedConv.pinned = true;
3386
+ pinnedConv.listed = true;
3387
+ pinnedConv.promptedSinceActive = true;
3388
+ cs.convs.set(pinnedConv.id, pinnedConv);
3389
+ pinnedConv.unsubscribe = pinnedConv.session.subscribe((event) => cs.onEvent(pinnedConv, event));
3390
+ try {
3391
+ await pinnedConv.session.bindExtensions({
3392
+ mode: "rpc",
3393
+ uiContext: WebUIContext.headless(),
3394
+ onError: cs.makeExtensionErrorReporter({
3395
+ text: `会话 ${convId}:`,
3396
+ textEn: `Conversation ${convId}: `,
3397
+ }),
3398
+ });
3399
+ }
3400
+ catch {
3401
+ // 忽略扩展绑定失败
3402
+ }
3403
+ cs.applyToolGating(pinnedConv.session, pinnedConv.agentPreset);
3404
+ }
3405
+ catch {
3406
+ // best effort
3407
+ }
3408
+ }
2783
3409
  return cs;
2784
3410
  }
2785
3411
  /**
@@ -2829,6 +3455,21 @@ export class ClientSession {
2829
3455
  out.push(pickTemplatePrompt(apply, this.getLang()));
2830
3456
  }
2831
3457
  // 主会话自定义「追加」已并入组合模板的 {{append}} 覆盖,不再在此注入。
3458
+ // 计划模式:只规划不实施 —— 硬闸门(server/plan-mode.ts 拒写类/旁路
3459
+ // 工具与非常规 bash)之外再补一段软约束,每轮重建提示词时读当前
3460
+ // 会话状态,切换后下一次 reload 生效。提示词正文可在设置面板改
3461
+ // (追加/替换内置默认,见 buildPlanModePrompt)。
3462
+ // ⚠️ 这一段与平台无关(曾在 win32 分支里被误嵌套,导致非 Windows
3463
+ // 上计划模式的软约束从来没进过提示词,只有硬闸门在挡)。
3464
+ if (this.planModeOf(ownerId)) {
3465
+ const planPrefs = this.settingsSvc.current;
3466
+ out.push(buildPlanModePrompt(planPrefs.planModePromptMode, planPrefs.planModePrompt));
3467
+ }
3468
+ // 审查者模式(自动委派):本对话只审阅,活由服务端派给常驻执行对话。
3469
+ // 与计划模式**互斥优先级**:计划模式开着时以计划模式为准,不派活。
3470
+ if (this.delegateModeOf(ownerId) && !this.planModeOf(ownerId)) {
3471
+ out.push(DELEGATION_SYSTEM_PROMPT);
3472
+ }
2832
3473
  if (process.platform === "win32") {
2833
3474
  // Windows 专属 persona:bash 工具跑 Git Bash 且无默认超时、终端
2834
3475
  // 是交互式 TTY——注入约束避免 heredoc/交互/长驻命令挂死整个会话;
@@ -2894,6 +3535,11 @@ export class ClientSession {
2894
3535
  hidden: true,
2895
3536
  factory: (pi) => {
2896
3537
  pi.on("before_agent_start", (event) => {
3538
+ // 我们是链上最后一个 handler:先把早前扩展的首尾增补摘出来(pre/post),
3539
+ // 只在 SDK 原始提示词 core 上做事,最后原样套回去——否则返回 systemPrompt
3540
+ // 会整体替换,把别的扩展注入的内容(如 <invoked_skill>)一起丢掉。
3541
+ const { pre, core, post } = splitAgentStartPrompt(event);
3542
+ const rewrap = (next) => pre + next + post;
2897
3543
  // 子代理模板 replace(无 SYSTEM.md 时):默认分支拼好的提示词里
2898
3544
  // 把灵魂段换成模板提示词,自动段保留;SYSTEM.md 情形已在
2899
3545
  // systemPromptOverride 整体替换,此处边界不存在会自然跳过。
@@ -2901,15 +3547,11 @@ export class ClientSession {
2901
3547
  const tplPrompt = pickTemplatePrompt(apply, this.getLang()).trim();
2902
3548
  if (apply.promptMode !== "replace" || !tplPrompt)
2903
3549
  return undefined;
2904
- const boundary = event.systemPrompt.indexOf("\n\nAvailable tools:");
3550
+ const boundary = core.indexOf("\n\nAvailable tools:");
2905
3551
  // 边界串是 SDK 提示词的内部格式:版本一变就可能对不上。
2906
3552
  // 对不上时不再静默回退默认 persona(模板等于没生效),而是把模板
2907
3553
  // 提示词前置拼接——角色约束仍在,只是灵魂段没被精确替换。
2908
- if (boundary === -1) {
2909
- const fallback = `${tplPrompt}\n\n${event.systemPrompt}`;
2910
- return fallback === event.systemPrompt ? undefined : { systemPrompt: fallback };
2911
- }
2912
- const swapped = tplPrompt + event.systemPrompt.slice(boundary);
3554
+ const swapped = rewrap(boundary === -1 ? `${tplPrompt}\n\n${core}` : tplPrompt + core.slice(boundary));
2913
3555
  return swapped === event.systemPrompt ? undefined : { systemPrompt: swapped };
2914
3556
  }
2915
3557
  // 主会话:按当前会话归属预设与真正活跃的工具列表组装系统提示词
@@ -2947,7 +3589,8 @@ export class ClientSession {
2947
3589
  })),
2948
3590
  preset: currentPreset,
2949
3591
  });
2950
- return rendered ? { systemPrompt: rendered } : undefined;
3592
+ // 不自定义时 rendered 为 undefined:不返回提示词,前面扩展的改动原样保留。
3593
+ return rendered ? { systemPrompt: rewrap(rendered) } : undefined;
2951
3594
  });
2952
3595
  },
2953
3596
  },
@@ -2994,6 +3637,8 @@ export class ClientSession {
2994
3637
  // 覆盖 SDK 内置 bash(customTools 按 name 覆盖)。双实现分流:
2995
3638
  // 「默认 bash 覆盖」开关(terminalBash)关 → 原生 SDK bash(纯进程、不开终端);
2996
3639
  // 开 → 终端接管 bash(persist 决定一次性/持久,可静默自动转后台)。
3640
+ // 整列工具统一过计划模式闸门(只规划不实施,写类/旁路工具执行前拒),
3641
+ // read/write/edit 三处覆盖在会话建好后另注入(见 installToolOverrides)。
2997
3642
  customTools: [
2998
3643
  // P1-5:bash/read 包插件拦截(pre 拒/问即拦、post 脱敏补上下文;
2999
3644
  // 未注入 toolGuard 时 withToolGuard 原样返回,零开销)。
@@ -3026,6 +3671,18 @@ export class ClientSession {
3026
3671
  "workspace-write-never", () => this.getLang()),
3027
3672
  ...makePersistentTerminalTools(terminals, effectiveCwd, () => this.getLang(), {
3028
3673
  checkSafety: (cmd) => {
3674
+ // 计划模式:终端只放行只读命令(常驻终端也走这条,与 bash 覆盖同口径)。
3675
+ if (this.planModeOf(ownerId)) {
3676
+ const denied = planModeDenial("bash", { command: cmd });
3677
+ if (denied)
3678
+ return { blocked: true, reason: denied.reason };
3679
+ }
3680
+ // 目标审查回合:常驻终端同样只放只读命令(审查者跑测试可以,改文件不行)。
3681
+ if (this.goalReviewTurnOf(ownerId)) {
3682
+ const denied = goalReviewDenial("bash", { command: cmd });
3683
+ if (denied)
3684
+ return { blocked: true, reason: denied.reason };
3685
+ }
3029
3686
  const perm = (ownerId ? this.convs.get(ownerId)?.permissionPreset : undefined) ??
3030
3687
  this.settingsSvc.current.defaultPermissionPreset ??
3031
3688
  "workspace-write-never";
@@ -3046,9 +3703,10 @@ export class ClientSession {
3046
3703
  // 扩展加载顺序无关,直接塞进来会静默顶掉第三方扩展注册的同名工具(见
3047
3704
  // tool-overrides.ts);它们改在会话建好后由 installToolOverrides 注入。
3048
3705
  // 不覆盖内置 edit 的独立宽松编辑工具(缩进不敏感匹配;开关看设置;带权限沙箱拦截与人机协同)。
3049
- wrapEditSoftToolWithPermission(makeEditSoftTool(effectiveCwd, () => this.getLang()), effectiveCwd, () => (ownerId ? this.convs.get(ownerId)?.permissionPreset : undefined) ??
3706
+ // 目标审查闸门包在最外层:审查回合的理由最贴合此刻(跟模型说「这是审查回合」)。
3707
+ withGoalReviewGate(withDelegationGate(withPlanModeGate(wrapEditSoftToolWithPermission(makeEditSoftTool(effectiveCwd, () => this.getLang()), effectiveCwd, () => (ownerId ? this.convs.get(ownerId)?.permissionPreset : undefined) ??
3050
3708
  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()),
3709
+ "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
3710
  // 插件注册的 AI 工具(创建时刻的实时快照,已按 disabledPluginTools 过滤;
3053
3711
  // 后续注册经 refreshPluginTools 动态补入已有会话)。
3054
3712
  // 子代理模板带非空扩展白名单时不注入:插件/MCP 工具没有 SDK extensionKey
@@ -3074,7 +3732,15 @@ export class ClientSession {
3074
3732
  // 内置标记只读查询工具(todo/svc 状态查询,写操作走内联标记)。
3075
3733
  makeMarkersListTool(() => this.activeId, this.markerSvc),
3076
3734
  // 结构化任务计划更新(Plan Mode / Step State Machine)。
3077
- makePlanUpdateTool(this.planManager, () => ownerId ?? this.activeId, (msg) => this.emit(msg), () => this.flushSnapshot()),
3735
+ makePlanUpdateTool(this.planManager, () => {
3736
+ const id = ownerId ?? this.activeId;
3737
+ const c = this.convs.get(id);
3738
+ return {
3739
+ id,
3740
+ sessionId: c?.session?.sessionId,
3741
+ sessionManager: c?.session?.sessionManager,
3742
+ };
3743
+ }, (msg) => this.emit(msg), () => this.flushSnapshot()),
3078
3744
  // 标准引擎的 ask_user_question:模型调用 → 浏览器富渲染问卷(复用 DSH
3079
3745
  // 的 question_pending/question_answer 协议,前端 DshQuestionDialog)。
3080
3746
  // DSH 引擎不经此(它走 goal-rpc 的 userQuestions provider)。
@@ -3130,10 +3796,14 @@ export class ClientSession {
3130
3796
  lang: () => this.getLang(),
3131
3797
  }),
3132
3798
  // 高可靠行补丁工具(patch,基于内容哈希与语法块级替换)。
3133
- makePatchTool({ cwd: effectiveCwd, ownerId }),
3799
+ makePatchTool({ cwd: effectiveCwd, ownerId, lang: () => this.getLang() }),
3134
3800
  // 原生语言服务器工具(lsp,定义跳转/引用/悬停/诊断)。
3135
- makeLspTool({ cwd: effectiveCwd, ownerId }),
3136
- ],
3801
+ makeLspTool({ cwd: effectiveCwd, ownerId, lang: () => this.getLang() }),
3802
+ ].map((t) =>
3803
+ // 目标审查闸门在最外层(理由最贴合此刻);插件工具也在这个数组里,
3804
+ // 创建时注册的同样被闸门覆盖(后续动态补入的走 syncPluginTools,与
3805
+ // 计划/审查者闸门同口径不在覆盖面,见 goal-review-gate.ts 头注)。
3806
+ withGoalReviewGate(withDelegationGate(withPlanModeGate(t, () => this.planModeOf(ownerId), () => this.getLang()), () => this.delegateModeOf(ownerId), () => this.getLang()), () => this.goalReviewTurnOf(ownerId), () => this.getLang())),
3137
3807
  });
3138
3808
  // 桥接工具归属锚点:SDK 会话对象在本 runtime 生命周期内稳定,过户只搬对话
3139
3809
  // 不改它(见 ClientSession.findConversationHome)。
@@ -3162,7 +3832,7 @@ export class ClientSession {
3162
3832
  }
3163
3833
  /** Wrap a fresh runtime as a new conversation record. */
3164
3834
  makeConversation(runtime, id, terminals) {
3165
- return {
3835
+ const conv = {
3166
3836
  id,
3167
3837
  title: conversationTitle(runtime.session),
3168
3838
  isSubagent: false,
@@ -3175,6 +3845,8 @@ export class ClientSession {
3175
3845
  permissionPreset: readPermissionFromSession(runtime.session.sessionManager) ??
3176
3846
  this.settingsSvc.current.defaultPermissionPreset ??
3177
3847
  "workspace-write-never",
3848
+ planMode: readPlanModeFromSession(runtime.session.sessionManager) === true,
3849
+ delegateMode: readDelegateModeFromSession(runtime.session.sessionManager) === true,
3178
3850
  // A brand-new conversation is not yet LISTED — it enters the running
3179
3851
  // list only when it is displaced to the background while still
3180
3852
  // streaming (its runtime is what `listed` protects). A blank chat is
@@ -3188,7 +3860,6 @@ export class ClientSession {
3188
3860
  stallNoticed: false,
3189
3861
  goal: this.makeGoalStatus(),
3190
3862
  goalGeneration: 0,
3191
- goalReviewGeneration: 0,
3192
3863
  wizardRunning: false,
3193
3864
  deltaSeq: 0,
3194
3865
  terminals,
@@ -3204,6 +3875,42 @@ export class ClientSession {
3204
3875
  toolWatchdogs: new Map(),
3205
3876
  workspaceSnapshots: [],
3206
3877
  };
3878
+ this.restorePlan(conv);
3879
+ return conv;
3880
+ }
3881
+ /** 会话计划回放恢复:持久化文件(sessionId)→ 转录 customType → 历史消息三重兜底。 */
3882
+ restorePlan(conv) {
3883
+ const sessionId = conv.session.sessionId;
3884
+ if (sessionId) {
3885
+ const existing = this.planManager.bindSession(conv.id, sessionId);
3886
+ if (existing)
3887
+ return;
3888
+ }
3889
+ // 2. 从 sessionManager entries 回放
3890
+ const smPlan = readPlanFromSession(conv.session.sessionManager);
3891
+ if (smPlan && Array.isArray(smPlan.steps) && smPlan.steps.length > 0) {
3892
+ this.planManager.setPlan(conv.id, smPlan.steps, smPlan.activeStepId, sessionId);
3893
+ return;
3894
+ }
3895
+ // 3. 从已有历史消息中回放最后一次成功的 plan_update 工具调用
3896
+ const msgs = conv.session.agent?.state?.messages;
3897
+ if (Array.isArray(msgs)) {
3898
+ for (let i = msgs.length - 1; i >= 0; i--) {
3899
+ const msg = msgs[i];
3900
+ if (msg?.role === "assistant" && Array.isArray(msg.content)) {
3901
+ for (const part of msg.content) {
3902
+ const p = part;
3903
+ if (p?.type === "tool_use" &&
3904
+ p.name === "plan_update" &&
3905
+ Array.isArray(p.input?.steps) &&
3906
+ p.input.steps.length > 0) {
3907
+ this.planManager.setPlan(conv.id, p.input.steps, p.input.activeStepId ?? null, sessionId);
3908
+ return;
3909
+ }
3910
+ }
3911
+ }
3912
+ }
3913
+ }
3207
3914
  }
3208
3915
  /** Summaries of conversations currently streaming — captured at shutdown
3209
3916
  * so the next attach can reopen them and continue their runs. */
@@ -3411,9 +4118,13 @@ export class ClientSession {
3411
4118
  });
3412
4119
  };
3413
4120
  }
3414
- /** (Re)attach event plumbing to the ACTIVE conversation's session. */
3415
- async bindSession() {
3416
- const conv = this.conv;
4121
+ /** (Re)attach event plumbing to a conversation's session. 默认绑当前活跃对话;
4122
+ * forceReset 重建非活跃对话(子代理/角色对话)时必须显式传入该对话,
4123
+ * 否则重建后的会话永远拿不回事件订阅(issue #484)。
4124
+ * **public**:过户(take_over_conversation)后由 ClientSessionPool 对 **其它页面** 的
4125
+ * ClientSession 调用(切会话只重建 runtime,订阅得重新挂上,见 idle-takeover-test)。 */
4126
+ async bindSession(target) {
4127
+ const conv = target ?? this.conv;
3417
4128
  conv.unsubscribe?.();
3418
4129
  conv.session = conv.runtime.session;
3419
4130
  await conv.session.bindExtensions({
@@ -3598,6 +4309,9 @@ export class ClientSession {
3598
4309
  const modelId = curModel ? `${curModel.provider}/${curModel.id}` : undefined;
3599
4310
  return {
3600
4311
  conversationId: target.id,
4312
+ sessionId: target.session.sessionId,
4313
+ sessionFile: target.session.sessionFile,
4314
+ sessionDir: target.session.sessionManager?.getSessionDir?.(),
3601
4315
  title: target.title,
3602
4316
  model: modelId,
3603
4317
  at: target.lastActiveAt,
@@ -3647,7 +4361,7 @@ export class ClientSession {
3647
4361
  }
3648
4362
  }
3649
4363
  /** 插件扩展点 v2(供 conversationSearcher):运行中对话标题 + 历史会话全文匹配,返回前 N 个 {id,title}。
3650
- * 复用 searchSessions 的 sessionMatchesSearch 判定(含转录全文),只读不 emit。 */
4364
+ * 复用 searchSessions 的匹配口径(元信息 + 按需加载的转录全文),只读不 emit。 */
3651
4365
  async searchForPlugins(query, limit = 20) {
3652
4366
  const q = query.trim().toLowerCase();
3653
4367
  if (!q)
@@ -3661,9 +4375,8 @@ export class ClientSession {
3661
4375
  }
3662
4376
  try {
3663
4377
  const infos = await this.loadSessionInfos();
3664
- for (const s of infos) {
3665
- if (!sessionMatchesSearch(q, s))
3666
- continue;
4378
+ const matched = await ClientSession.filterSessionsForSearch(q, infos);
4379
+ for (const s of matched) {
3667
4380
  out.push({
3668
4381
  id: s.path,
3669
4382
  title: (s.name?.trim() || s.firstMessage.trim() || basename(s.path)).slice(0, 60),
@@ -3885,6 +4598,11 @@ export class ClientSession {
3885
4598
  // Any SDK event proves the run is alive — feeds the stall watchdog below.
3886
4599
  conv.lastSdkEventAt = Date.now();
3887
4600
  conv.stallNoticed = false;
4601
+ // 模型可能在 pi-web-ui 之外被换(SDK 从会话历史恢复、/model 命令等),
4602
+ // 而软上限 reserve 是按注入那一刻的窗口换算的固定值——模型一变必须重算,
4603
+ // 否则大窗口算出的 reserve 泄漏到小窗口模型上,触发点被压成负数,
4604
+ // 上下文刚过几万 token 就反复触发压缩。热路径只做一次字符串比对。
4605
+ this.reapplySoftCapIfModelChanged(conv);
3888
4606
  switch (event.type) {
3889
4607
  case "bash_execution_update": {
3890
4608
  if (event.id) {
@@ -4141,6 +4859,8 @@ export class ClientSession {
4141
4859
  return a.role === "assistant" && a.stopReason === "aborted";
4142
4860
  });
4143
4861
  if (aborted) {
4862
+ // 角色轮等待者先唤醒(中止也算「本轮结束」),再让 GoalService 作废目标。
4863
+ this.notifyTurnEnd(conv);
4144
4864
  const stopNotice = this.goalSvc.onAgentEnd(conv, true);
4145
4865
  if (stopNotice) {
4146
4866
  this.emit({ type: "notice", level: "warning", text: stopNotice.text, textEn: stopNotice.textEn });
@@ -4169,6 +4889,9 @@ export class ClientSession {
4169
4889
  this.emitConversations();
4170
4890
  }
4171
4891
  // Goal review hook lives in GoalService.onAgentEnd(conv, false).
4892
+ // 先唤醒角色轮等待者:委托执行(Plan A)下服务端正阻塞在「等主对话给 verdict」
4893
+ // 或「等对话空闲」上,而 onAgentEnd 随后会把它需要的 verdict 交回。
4894
+ this.notifyTurnEnd(conv);
4172
4895
  this.goalSvc.onAgentEnd(conv, false);
4173
4896
  // Deferred settings reload: settings (system prompt / skills /
4174
4897
  // extensions) changed while the run was streaming — applying now
@@ -4437,7 +5160,41 @@ export class ClientSession {
4437
5160
  // 一次扫描同时收齐「当前转写里的全部缓存键」(live 集合,供
4438
5161
  // pruneMessageCache 精确回收死条目)与各自的序列化结果。
4439
5162
  const live = new Set();
4440
- let rawMessages = conv.session.agent.state.messages
5163
+ let agentMessages = conv.session.agent.state.messages;
5164
+ // issue #448: 多次压缩后 SDK state.messages 只保留最后一次压缩卡片。
5165
+ // 从当前分支祖先链中提取前序未展示的历史 compaction 并正序置前,
5166
+ // 使前端能逐段回溯展开历史折叠内容。
5167
+ if (conv.session.sessionManager?.getBranch) {
5168
+ try {
5169
+ const branch = conv.session.sessionManager.getBranch();
5170
+ const compEntries = branch.filter((e) => e.type === "compaction");
5171
+ if (compEntries.length > 1) {
5172
+ const existingCompKeys = new Set(agentMessages
5173
+ .filter((m) => m.role === "compactionSummary")
5174
+ .map((m) => `${m.timestamp}:${m.summary ?? ""}`));
5175
+ const missingComps = [];
5176
+ for (const ce of compEntries) {
5177
+ const ts = ce.timestamp ? new Date(ce.timestamp).getTime() : 0;
5178
+ const key = `${ts}:${ce.summary ?? ""}`;
5179
+ if (!existingCompKeys.has(key)) {
5180
+ missingComps.push({
5181
+ role: "compactionSummary",
5182
+ summary: ce.summary ?? "",
5183
+ tokensBefore: ce.tokensBefore,
5184
+ timestamp: ts,
5185
+ });
5186
+ }
5187
+ }
5188
+ if (missingComps.length > 0) {
5189
+ agentMessages = [...missingComps, ...agentMessages];
5190
+ }
5191
+ }
5192
+ }
5193
+ catch {
5194
+ // 获取分支异常不影响当前渲染
5195
+ }
5196
+ }
5197
+ let rawMessages = agentMessages
4441
5198
  .map((m) => {
4442
5199
  const k = this.uiMessageKey(conv, m);
4443
5200
  live.add(k.cacheKey);
@@ -4596,11 +5353,17 @@ export class ClientSession {
4596
5353
  pendingQuestion: this.pendingQuestionForSnapshot(),
4597
5354
  pendingApproval: this.pendingApprovalForSnapshot(),
4598
5355
  plan: this.planManager.getPlan(this.activeId),
5356
+ // 计划模式是**会话级**开关:快照恒给布尔(不用 undefined),否则
5357
+ // snapshot_delta 里 key 缺席 → 前端 spread 合并会残留上一对话的 true。
5358
+ planMode: conv?.planMode === true,
5359
+ // 审查者模式同样是**会话级**:布尔恒给(理由同 planMode),另带执行对话 id。
5360
+ delegateMode: conv?.delegateMode === true,
5361
+ delegateConvId: conv?.delegateConvId ?? null,
4599
5362
  subagentHandoffs: this.subagentHandoffs.length > 0 ? [...this.subagentHandoffs] : undefined,
4600
5363
  agentPreset: conv
4601
5364
  ? {
4602
5365
  id: conv.agentPreset ?? "standard",
4603
- name: PI_AGENT_PRESETS.find((p) => p.id === conv.agentPreset)?.name ?? "全功能",
5366
+ name: localizedName(PI_AGENT_PRESETS.find((p) => p.id === conv.agentPreset), this.getLang(), "全功能"),
4604
5367
  locked: conv.presetLocked || !this.isBlankConversation(conv),
4605
5368
  }
4606
5369
  : null,
@@ -5033,7 +5796,16 @@ export class ClientSession {
5033
5796
  }
5034
5797
  updatePlan(steps, activeStepId, conversationId) {
5035
5798
  const convId = conversationId ?? this.activeId;
5036
- const plan = this.planManager.setPlan(convId, steps, activeStepId);
5799
+ const conv = this.convs.get(convId);
5800
+ const sessionId = conv?.session?.sessionId;
5801
+ const plan = this.planManager.setPlan(convId, steps, activeStepId, sessionId);
5802
+ try {
5803
+ const sm = conv?.session?.sessionManager;
5804
+ sm?.appendCustomEntry?.("plan/update", { plan });
5805
+ }
5806
+ catch {
5807
+ // ignore
5808
+ }
5037
5809
  this.emit({
5038
5810
  type: "plan_updated",
5039
5811
  conversationId: convId,
@@ -5041,6 +5813,58 @@ export class ClientSession {
5041
5813
  });
5042
5814
  this.flushSnapshot();
5043
5815
  }
5816
+ updatePlanStep(stepId, patch, conversationId) {
5817
+ const convId = conversationId ?? this.activeId;
5818
+ const plan = this.planManager.updateStep(convId, stepId, patch);
5819
+ this.emit({
5820
+ type: "plan_updated",
5821
+ conversationId: convId,
5822
+ plan,
5823
+ });
5824
+ this.flushSnapshot();
5825
+ }
5826
+ deletePlanStep(stepId, conversationId) {
5827
+ const convId = conversationId ?? this.activeId;
5828
+ const plan = this.planManager.deleteStep(convId, stepId);
5829
+ this.emit({
5830
+ type: "plan_updated",
5831
+ conversationId: convId,
5832
+ plan,
5833
+ });
5834
+ this.flushSnapshot();
5835
+ }
5836
+ addPlanStep(step, afterStepId, conversationId) {
5837
+ const convId = conversationId ?? this.activeId;
5838
+ const plan = this.planManager.addStep(convId, step, afterStepId);
5839
+ this.emit({
5840
+ type: "plan_updated",
5841
+ conversationId: convId,
5842
+ plan,
5843
+ });
5844
+ this.flushSnapshot();
5845
+ }
5846
+ /** 「✨ 净室执行(Clean-session Handoff)」:
5847
+ * 关闭源会话的计划闸门,新建隔离会话,在新会话中原子设置计划步骤、可选迁移目标,并触发实施轮 prompt。 */
5848
+ async planCleanHandoff(steps, promptText) {
5849
+ const sourceConv = this.conv;
5850
+ if (sourceConv?.planMode) {
5851
+ await this.setPlanMode(false, sourceConv.id);
5852
+ }
5853
+ const sourceGoal = sourceConv?.goal?.goal;
5854
+ await this.newChat();
5855
+ const targetConv = this.conv;
5856
+ this.planManager.setPlan(targetConv.id, steps);
5857
+ if (sourceGoal) {
5858
+ try {
5859
+ await this.goalSvc.setGoal(sourceGoal, { targetConvId: targetConv.id, autoStart: false });
5860
+ }
5861
+ catch {
5862
+ // 目标迁移失败不阻塞实施
5863
+ }
5864
+ }
5865
+ this.flushSnapshot();
5866
+ await this.prompt(promptText);
5867
+ }
5044
5868
  /** 关闭所有挂起提问(dispose 时清理):以「取消」解析,避免模型挂死。 */
5045
5869
  cancelPendingQuestions() {
5046
5870
  for (const [, p] of this.pendingQuestions) {
@@ -5048,6 +5872,35 @@ export class ClientSession {
5048
5872
  }
5049
5873
  this.pendingQuestions.clear();
5050
5874
  }
5875
+ /** #487:结算指定会话的挂起提问/审批(按 conversationId 精确匹配)。
5876
+ * abort/移除/重建会话后残留的条目会让 isWaitingOnUser 恒真——stall 失联
5877
+ * 检测被永久豁免,且问卷/审批弹窗跨轮残留。dispose 口径范本:提问以
5878
+ * 「取消」解析、审批以「拒绝」解析并广播 tool_approval_resolved。 */
5879
+ settlePendingsForConv(convId) {
5880
+ for (const [id, q] of this.pendingQuestions) {
5881
+ if (q.conversationId !== convId)
5882
+ continue;
5883
+ this.pendingQuestions.delete(id);
5884
+ try {
5885
+ q.resolve(null);
5886
+ }
5887
+ catch {
5888
+ // 单个结算异常不影响其余清理
5889
+ }
5890
+ }
5891
+ for (const [id, a] of this.pendingApprovals) {
5892
+ if (a.conversationId !== convId)
5893
+ continue;
5894
+ this.pendingApprovals.delete(id);
5895
+ try {
5896
+ a.resolve({ decision: "deny", reason: "运行已停止" });
5897
+ }
5898
+ catch {
5899
+ // 单个结算异常不影响其余清理
5900
+ }
5901
+ this.emit({ type: "tool_approval_resolved", id });
5902
+ }
5903
+ }
5051
5904
  /** 关闭所有挂起审批(dispose 时清理):以「拒绝」解析,避免等答复的
5052
5905
  * Promise 与对应 runtime 泄漏(会话没了,审批弹窗永远不会有人点)。 */
5053
5906
  cancelPendingApprovals() {
@@ -5405,6 +6258,7 @@ export class ClientSession {
5405
6258
  * 主动检查已安装界面插件(<dataDir>/plugins)的更新状态并向客户端推送。
5406
6259
  */
5407
6260
  async checkPluginUpdates(manual = false) {
6261
+ this.updatesAllCache = null;
5408
6262
  const lang = () => this.getLang();
5409
6263
  try {
5410
6264
  const updates = await checkPluginUpdates(this.stateStore.dataDir, undefined, lang);
@@ -5532,11 +6386,12 @@ export class ClientSession {
5532
6386
  }));
5533
6387
  this.lastPluginUpdates = list;
5534
6388
  this.emit({ type: "plugin_updates", updates: list });
6389
+ const fmtVer = (v) => (v ? (/^[vV]/.test(v) ? v : `v${v}`) : null);
5535
6390
  pluginItems = pluginUpdates.map((p) => ({
5536
6391
  name: p.name ? `${p.name} (${p.id})` : p.id,
5537
6392
  kind: "plugin",
5538
- current: p.version ? `v${p.version}` : (p.localSha ?? "unknown"),
5539
- latest: p.latestVersion ? `v${p.latestVersion}` : (p.remoteSha ?? null),
6393
+ current: fmtVer(p.version) ?? p.localSha ?? "unknown",
6394
+ latest: fmtVer(p.latestVersion) ?? p.remoteSha ?? null,
5540
6395
  latestPublishedAt: null,
5541
6396
  upToDate: !p.updatable,
5542
6397
  error: p.error,
@@ -5699,6 +6554,8 @@ export class ClientSession {
5699
6554
  this.flushSnapshot();
5700
6555
  },
5701
6556
  refreshSessions: () => this.refreshSessions(),
6557
+ setPlanMode: (enabled) => this.setPlanMode(enabled),
6558
+ getPlanMode: () => this.conv?.planMode === true,
5702
6559
  afterReload: () => {
5703
6560
  // /reload 同样重读磁盘 settings.json——重放重试覆盖 + 软上限覆盖 + 终端门控。
5704
6561
  this.applyRetryOverrides();
@@ -6018,24 +6875,50 @@ export class ClientSession {
6018
6875
  * 合并视图,SDK 每次自动压缩检查前都重读 getCompactionSettings(),
6019
6876
  * 无需 reload;软上限关闭时回填 SDK 默认 reserve(不让旧覆盖泄漏)。
6020
6877
  * 窗口未知(会话未就绪/无模型)的会话跳过——创建/就绪/换模型路径
6021
- * 会重放(见各 applyRetryOverrides 调用点)。 */
6878
+ * 会重放(见各 applyRetryOverrides 调用点);此外 onEvent 按模型
6879
+ * key 变化兜底重放(reapplySoftCapIfModelChanged),保证会话模型
6880
+ * 在 pi-web-ui 之外被换(如 SDK 从会话历史恢复)后覆盖不泄漏。 */
6022
6881
  applyCompactionOverrides() {
6023
6882
  const s = this.settingsSvc.current;
6024
6883
  for (const c of this.convs.values()) {
6025
6884
  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
- });
6885
+ this.applyCompactionOverrideForConv(c, s);
6033
6886
  }
6034
6887
  catch {
6035
6888
  // 会话未就绪或已释放 → 其 runtime 创建时统一注入。
6036
6889
  }
6037
6890
  }
6038
6891
  }
6892
+ /** 单会话换算并注入软上限 reserve(applyCompactionOverrides 的循环体;
6893
+ * reapplySoftCapIfModelChanged 单会话重放也复用)。抛错交调用方处置。 */
6894
+ applyCompactionOverrideForConv(c, s) {
6895
+ const modelId = modelKeyOf(c.session);
6896
+ const contextWindow = contextWindowOf(c.session);
6897
+ const cap = effectiveSoftCap(s.softCapTokens, s.softCapByModel, modelId);
6898
+ const reserve = softCapToReserve(contextWindow, cap);
6899
+ c.session.settingsManager.applyOverrides({
6900
+ compaction: { reserveTokens: reserve ?? DEFAULT_COMPACTION_RESERVE_TOKENS },
6901
+ });
6902
+ }
6903
+ /** 会话模型变化时重放该会话的软上限覆盖。SDK 恢复历史会话的模型
6904
+ * (findInitialModel)与 /model 命令都不经过 pi-web-ui 的 setModel
6905
+ * 路径,注入过的 reserve 会停留在旧模型窗口的换算值上——例如默认
6906
+ * 模型(1M 窗口)算出 748576,泄漏到 500K 窗口的会话后压缩触发点
6907
+ * 变成负数,上下文几万 token 就被反复压缩。onEvent 每个事件比对
6908
+ * 一次 model key(O(1) 字符串比较),变化才重放。 */
6909
+ reapplySoftCapIfModelChanged(conv) {
6910
+ try {
6911
+ const key = modelKeyOf(conv.session);
6912
+ if (conv.lastModelKey === key)
6913
+ return;
6914
+ conv.lastModelKey = key;
6915
+ if (key)
6916
+ this.applyCompactionOverrideForConv(conv, this.settingsSvc.current);
6917
+ }
6918
+ catch {
6919
+ // 会话未就绪或已释放 → 留给创建/换模型路径与后续事件重试。
6920
+ }
6921
+ }
6039
6922
  /** 当前活动对话的生效软上限(快照底栏标记线用;null = 关闭/未知)。 */
6040
6923
  activeSoftCap(contextWindow) {
6041
6924
  try {
@@ -6151,6 +7034,12 @@ export class ClientSession {
6151
7034
  return c.agentPreset;
6152
7035
  return undefined;
6153
7036
  }
7037
+ convOfSession(session) {
7038
+ for (const c of this.convs.values())
7039
+ if (c.session === session)
7040
+ return c;
7041
+ return undefined;
7042
+ }
6154
7043
  /**
6155
7044
  * read / write / edit 三处覆盖的注入规格:基底 = 扩展注册的同名工具优先,否则 SDK 内置实现
6156
7045
  * (见 tool-overrides.ts —— 这三处覆盖不能塞进创建时的 `customTools`,那会静默顶掉扩展的
@@ -6177,8 +7066,10 @@ export class ClientSession {
6177
7066
  getRules: () => this.approvalRules.list(),
6178
7067
  };
6179
7068
  // 写/编的权限沙箱包装:同一个函数,有扩展同名工具时把它的定义当基底(末参)。
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);
7069
+ // 目标审查闸门包在**最外层**(权限沙箱之前):审查回合只读核实,写类直接拒。
7070
+ const planGate = (def) => withGoalReviewGate(withDelegationGate(withPlanModeGate(def, () => this.planModeOf(ownerId), () => this.getLang()), () => this.delegateModeOf(ownerId), () => this.getLang()), () => this.goalReviewTurnOf(ownerId), () => this.getLang());
7071
+ const composeWrite = (base) => planGate(wrapWriteToolWithPermission(cwd, currentPermission, () => this.roots, () => this.getLang(), approve, () => ownerId, () => this.approvalRules.list(), base));
7072
+ const composeEdit = (base) => planGate(wrapEditToolWithPermission(cwd, currentPermission, () => this.roots, () => this.getLang(), approve, () => ownerId, () => this.approvalRules.list(), base));
6182
7073
  return [
6183
7074
  {
6184
7075
  name: "read",
@@ -6197,8 +7088,24 @@ export class ClientSession {
6197
7088
  * session.reload() 与新会话创建都会把 custom 工具加回活跃集,
6198
7089
  * 所以这两条路径之后都要重放本方法(见 reloadSession/创建处)。 */
6199
7090
  applyToolGating(session, preset) {
6200
- const targetPreset = preset ?? this.presetOfSession(session) ?? this.settingsSvc.current.defaultAgentPreset ?? "standard";
6201
- applyAgentToolsGating(session, effectiveDisabledAgentTools(this.settingsSvc.current), targetPreset);
7091
+ const conv = this.convOfSession(session);
7092
+ const targetPreset = preset ??
7093
+ conv?.agentPreset ??
7094
+ this.presetOfSession(session) ??
7095
+ this.settingsSvc.current.defaultAgentPreset ??
7096
+ "standard";
7097
+ const isPlanMode = conv?.planMode === true;
7098
+ const disabled = new Set(effectiveDisabledAgentTools(this.settingsSvc.current));
7099
+ if (isPlanMode) {
7100
+ for (const toolName of PLAN_MODE_BLOCKED_TOOL_NAMES) {
7101
+ disabled.add(toolName);
7102
+ }
7103
+ }
7104
+ applyAgentToolsGating(session, [...disabled], targetPreset);
7105
+ const ownerId = conv?.id;
7106
+ syncSubagentOverride(session, disabled.has("subagent"), () => ownerId
7107
+ ? makeSubagentTools(withSubagentOwner(this.subagentHost, ownerId), undefined, ownerId)[0]
7108
+ : makeSubagentTools(this.subagentHost)[0]);
6202
7109
  this.syncPluginTools(session, targetPreset);
6203
7110
  this.sessionStatsCache = null;
6204
7111
  this.cachedBaseTokens = null;
@@ -6269,8 +7176,8 @@ export class ClientSession {
6269
7176
  this.emit({
6270
7177
  type: "notice",
6271
7178
  level: "info",
6272
- text: `已切换为「${hit.name}」预设`,
6273
- textEn: `Switched to preset "${hit.name}"`,
7179
+ text: `已切换为「${localizedName(hit, this.getLang())}」预设`,
7180
+ textEn: `Switched to preset "${localizedName(hit, this.getLang())}"`,
6274
7181
  });
6275
7182
  this.pushSettings();
6276
7183
  this.flushSnapshot();
@@ -6296,42 +7203,239 @@ export class ClientSession {
6296
7203
  /** 推送权限选项表 + 默认权限。 */
6297
7204
  async refreshPermission() {
6298
7205
  this.emit({
6299
- type: "dsh_permission",
6300
- options: PI_PERMISSION_OPTIONS,
6301
- defaultPreset: this.settingsSvc.current.defaultPermissionPreset ?? "workspace-write-never",
7206
+ type: "dsh_permission",
7207
+ options: PI_PERMISSION_OPTIONS,
7208
+ defaultPreset: this.settingsSvc.current.defaultPermissionPreset ?? "workspace-write-never",
7209
+ });
7210
+ }
7211
+ /** 切换当前会话的权限预设(热生效)。 */
7212
+ async setPermissionPreset(preset) {
7213
+ const hit = PI_PERMISSION_OPTIONS.find((p) => p.value === preset);
7214
+ if (!hit) {
7215
+ this.emit({
7216
+ type: "notice",
7217
+ level: "warning",
7218
+ text: `未知权限预设「${preset}」`,
7219
+ textEn: `Unknown permission preset "${preset}"`,
7220
+ });
7221
+ return;
7222
+ }
7223
+ if (this.conv.permissionPreset === hit.value) {
7224
+ return;
7225
+ }
7226
+ this.conv.permissionPreset = hit.value;
7227
+ try {
7228
+ // 持久会话:将权限变更写入会话转录日志,切会话/切项目重载时不丢失
7229
+ const sm = this.conv.session.sessionManager;
7230
+ sm?.appendCustomEntry?.("permission/preset", { preset: hit.value });
7231
+ }
7232
+ catch {
7233
+ // best effort for in-memory sessions
7234
+ }
7235
+ this.emit({
7236
+ type: "notice",
7237
+ level: "info",
7238
+ text: `当前会话权限已切换为「${localizedName(hit, this.getLang())}」`,
7239
+ textEn: `Current session permission switched to "${localizedName(hit, this.getLang())}"`,
7240
+ });
7241
+ this.flushSnapshot();
7242
+ }
7243
+ /** 该会话是否处于计划模式(ownerId 缺省 = 当前对话)。 */
7244
+ planModeOf(ownerId) {
7245
+ const id = ownerId ?? this.activeId;
7246
+ return this.convs.get(id)?.planMode === true;
7247
+ }
7248
+ /** 审查者模式(自动委派)是否对该 runtime 所属会话开着。 */
7249
+ delegateModeOf(ownerId) {
7250
+ const id = ownerId ?? this.activeId;
7251
+ return this.convs.get(id)?.delegateMode === true;
7252
+ }
7253
+ /** 该会话是否正在跑目标审查回合(awaitingVerdict 置位):是则写类 / 派发类工具
7254
+ * 走目标审查闸门(server/goal-review-gate.ts)。ownerId 恒为会话 id(创建时传入),
7255
+ * 缺省才回退活动对话 —— 与 planModeOf / delegateModeOf 同口径。 */
7256
+ goalReviewTurnOf(ownerId) {
7257
+ const id = ownerId ?? this.activeId;
7258
+ return this.convs.get(id)?.awaitingVerdict != null;
7259
+ }
7260
+ /**
7261
+ * 切换审查者模式(会话级,默认关)。
7262
+ * 开启后:① 本对话只审阅(写类/派发类工具硬闸门 + 提示词段);② 用户发的每条
7263
+ * prompt 由服务端转给一个**常驻落盘执行对话**执行。计划模式优先(两者同开不派活)。
7264
+ */
7265
+ async setDelegateMode(enabled, conversationId) {
7266
+ const target = conversationId ? this.convs.get(conversationId) : this.conv;
7267
+ if (!target)
7268
+ return;
7269
+ if (target.delegateMode === enabled) {
7270
+ this.flushSnapshot();
7271
+ return;
7272
+ }
7273
+ target.delegateMode = enabled;
7274
+ if (!enabled)
7275
+ target.delegateConvId = null; // 关闭后不再指向旧执行对话
7276
+ try {
7277
+ // 与 plan/mode 同口径写进转录:切会话/重载能回放(布尔),执行对话 id 不落盘。
7278
+ const sm = target.session.sessionManager;
7279
+ sm?.appendCustomEntry?.("delegate/mode", { enabled });
7280
+ }
7281
+ catch {
7282
+ // best effort for in-memory sessions
7283
+ }
7284
+ const notice = delegateNoticeText(enabled);
7285
+ this.emit({ type: "notice", level: "info", text: notice.text, textEn: notice.textEn });
7286
+ this.flushSnapshot();
7287
+ // 提示词段随之增减(下一轮生效);失败不阻断(硬闸门仍拦得住)。
7288
+ try {
7289
+ await target.session.reload?.();
7290
+ }
7291
+ catch {
7292
+ // best effort
7293
+ }
7294
+ }
7295
+ /**
7296
+ * 自动路由:把本轮用户输入转给常驻执行对话执行(审查者模式的干活路径)。
7297
+ * 返回 true = 已接管(调用方不要再跑主会话的模型);false = 没收走(走正常路径)。
7298
+ *
7299
+ * 与计划模式互斥:计划模式开着时**不派活**(那边已把 spawn/旁路工具全拒,
7300
+ * 再自动派活就是死锁),由计划模式那一轮自己在主对话里出计划。
7301
+ */
7302
+ async dispatchToDelegate(conv, text) {
7303
+ if (conv.delegateMode !== true)
7304
+ return false;
7305
+ if (conv.planMode === true)
7306
+ return false; // 计划模式优先
7307
+ if (this.quiesceBlocked())
7308
+ return false;
7309
+ const trimmed = text.trim();
7310
+ if (!trimmed)
7311
+ return false;
7312
+ let execId = conv.delegateConvId;
7313
+ if (execId && !this.convs.get(execId)?.session)
7314
+ execId = null;
7315
+ // 首轮:第一条 prompt 直接交给 spawnSubagentConversation(它会跑起这一轮)——
7316
+ // **不要再 sendUserMessage 一次**,否则执行对话正在处理中,第二次投递会被
7317
+ // SDK 拒("Agent is already processing a prompt")。之后每轮才走追加投递。
7318
+ const firstTurn = !execId;
7319
+ if (firstTurn) {
7320
+ try {
7321
+ // 常驻执行对话:落盘普通对话(进历史、可续聊),与目标模式的执行对话同一通道。
7322
+ // 满员(项目 8 个普通对话)→ 抛错 → 下面提示用户,不会静默把活留在主对话。
7323
+ execId = await this.spawnSubagentConversation(trimmed, "delegate-executor", conv.cwd ?? this.cwd, undefined, null, conv.id, true, pick(this.getLang(), "委派执行", "Delegate executor"));
7324
+ const execConv = this.convs.get(execId);
7325
+ if (execConv)
7326
+ execConv.title = pick(this.getLang(), "委派执行", "Delegate executor");
7327
+ conv.delegateConvId = execId;
7328
+ }
7329
+ catch (err) {
7330
+ const msg = err instanceof Error ? err.message : String(err);
7331
+ this.emit({
7332
+ type: "notice",
7333
+ level: "error",
7334
+ text: `审查者模式:无法创建执行对话(${msg})。请关闭审查者模式,或先释放一些对话名额。`,
7335
+ textEn: `Reviewer mode: could not create the executor conversation (${msg}). Turn reviewer mode off, or free up some conversation slots first.`,
7336
+ });
7337
+ this.flushSnapshot();
7338
+ return false;
7339
+ }
7340
+ }
7341
+ else {
7342
+ const execConv = this.convs.get(execId);
7343
+ if (!execConv?.session)
7344
+ return false;
7345
+ try {
7346
+ await execConv.session.sendUserMessage(trimmed, execConv.session.isStreaming ? { deliverAs: "steer" } : undefined);
7347
+ }
7348
+ catch (err) {
7349
+ const msg = err instanceof Error ? err.message : String(err);
7350
+ this.emit({
7351
+ type: "notice",
7352
+ level: "error",
7353
+ text: `审查者模式:派活失败(${msg})`,
7354
+ textEn: `Reviewer mode: dispatch failed (${msg})`,
7355
+ });
7356
+ this.flushSnapshot();
7357
+ return false;
7358
+ }
7359
+ }
7360
+ this.emit({
7361
+ type: "notice",
7362
+ level: "info",
7363
+ text: `🔎 已派给执行对话:${trimmed.slice(0, 120)}${trimmed.length > 120 ? "…" : ""}(本对话只审阅)`,
7364
+ textEn: `🔎 Dispatched to the executor conversation: ${trimmed.slice(0, 120)}${trimmed.length > 120 ? "…" : ""} (this conversation reviews only)`,
7365
+ });
7366
+ this.flushSnapshot();
7367
+ // 执行对话跑完 → 回报主对话(只通知,不自动验收:验收由人/主对话下一轮做)。
7368
+ void this.notifyWhenDelegateFinishes(conv, execId);
7369
+ return true;
7370
+ }
7371
+ /** 等常驻执行对话这一轮结束,回主对话一条通知(带它的最后一条回复摘要)。 */
7372
+ async notifyWhenDelegateFinishes(conv, execId) {
7373
+ try {
7374
+ await this.waitConversationTurnEnd(execId, 30 * 60 * 1000);
7375
+ }
7376
+ catch {
7377
+ // 超时/取消:仍给一条「执行对话还没结束」的弱提示,避免主对话永远静默。
7378
+ }
7379
+ const execConv = this.convs.get(execId);
7380
+ if (!execConv)
7381
+ return;
7382
+ const last = [...execConv.session.agent.state.messages]
7383
+ .reverse()
7384
+ .find((m) => m.role === "assistant");
7385
+ const summary = typeof last?.content === "string" ? last.content : JSON.stringify(last?.content ?? "").slice(0, 400);
7386
+ this.emit({
7387
+ type: "notice",
7388
+ level: "info",
7389
+ text: `✅ 执行对话已交付,请审阅:${summary.slice(0, 300)}${summary.length > 300 ? "…" : ""}(左栏「委派执行」可打开看全文)`,
7390
+ textEn: `✅ The executor conversation delivered — review it: ${summary.slice(0, 300)}${summary.length > 300 ? "…" : ""} (open “Delegate executor” in the sidebar for the full transcript)`,
6302
7391
  });
7392
+ this.flushSnapshot();
6303
7393
  }
6304
- /** 切换当前会话的权限预设(热生效)。 */
6305
- async setPermissionPreset(preset) {
6306
- const hit = PI_PERMISSION_OPTIONS.find((p) => p.value === preset);
6307
- if (!hit) {
6308
- this.emit({
6309
- type: "notice",
6310
- level: "warning",
6311
- text: `未知权限预设「${preset}」`,
6312
- textEn: `Unknown permission preset "${preset}"`,
6313
- });
7394
+ /**
7395
+ * 切换计划模式(会话级,只规划不实施)。
7396
+ * 软约束(系统提示词段)+ 硬闸门(写类/旁路工具与非常规 bash 直接拒)双管:
7397
+ * 状态随会话转录落盘(切会话/重载恢复),并即时提示用户当前口径。
7398
+ */
7399
+ async setPlanMode(enabled, conversationId) {
7400
+ const target = conversationId ? this.convs.get(conversationId) : this.conv;
7401
+ if (!target)
6314
7402
  return;
6315
- }
6316
- if (this.conv.permissionPreset === hit.value) {
7403
+ if (target.planMode === enabled) {
7404
+ this.flushSnapshot();
6317
7405
  return;
6318
7406
  }
6319
- this.conv.permissionPreset = hit.value;
7407
+ target.planMode = enabled;
6320
7408
  try {
6321
- // 持久会话:将权限变更写入会话转录日志,切会话/切项目重载时不丢失
6322
- const sm = this.conv.session.sessionManager;
6323
- sm?.appendCustomEntry?.("permission/preset", { preset: hit.value });
7409
+ // 持久会话:写入转录日志,切会话/重载后回放恢复(与 permission/preset 同口径)。
7410
+ const sm = target.session.sessionManager;
7411
+ sm?.appendCustomEntry?.("plan/mode", { enabled });
6324
7412
  }
6325
7413
  catch {
6326
7414
  // best effort for in-memory sessions
6327
7415
  }
6328
- this.emit({
6329
- type: "notice",
6330
- level: "info",
6331
- text: `当前会话权限已切换为「${hit.name}」`,
6332
- textEn: `Current session permission switched to "${hit.name}"`,
6333
- });
7416
+ // 动态应用工具门控:开启计划模式时剔除写类工具,关闭时恢复写类工具
7417
+ this.applyToolGating(target.session);
7418
+ // 计划模式有会话准入校验(空白对话也能开,但快照要立刻反映按钮态)。
7419
+ const notice = planModeNoticeText(enabled);
7420
+ this.emit({ type: "notice", level: "info", text: notice.text, textEn: notice.textEn });
6334
7421
  this.flushSnapshot();
7422
+ // 提示词段随之增减:重建资源让下一轮就带上/去掉计划模式约束。
7423
+ void this.reloadPromptForPlanMode(target);
7424
+ }
7425
+ /**
7426
+ * 提示词重建:切换计划模式后需要让**下一轮**带上/去掉计划模式段
7427
+ * (appendSystemPromptOverride 在资源加载重放时取值)。会话空闲时即时
7428
+ * 重建,用户下一句就生效;重建失败不阻断(硬闸门仍然拦得住)。
7429
+ */
7430
+ async reloadPromptForPlanMode(conv) {
7431
+ try {
7432
+ await conv.session.reload?.();
7433
+ // reload() 重新加载扩展时可能会将工具加回活跃集,重跑一次门控确保写工具被持续剔除
7434
+ this.applyToolGating(conv.session);
7435
+ }
7436
+ catch {
7437
+ // best effort:某些引擎/临时态不支持 reload
7438
+ }
6335
7439
  }
6336
7440
  /** 设置新会话默认权限预设。 */
6337
7441
  async setDefaultPermissionPreset(preset) {
@@ -6350,7 +7454,7 @@ export class ClientSession {
6350
7454
  this.refreshPermission();
6351
7455
  this.flushSnapshot();
6352
7456
  }
6353
- /** 当前启用的插件 AI 工具定义(provider 快照按 disabledPluginTools 过滤;
7457
+ /** 当前启用的插件 AI 工具定义(provider 快照按 disabledPlugins 与 disabledPluginTools 过滤;
6354
7458
  * 未知/已卸载插件的禁用条目保留但不影响现有工具)。
6355
7459
  * 预设是第二层门控(见 tool-manager.ts 语义总表):非 standard 预设下插件工具
6356
7460
  * 一律不可用(读写未知,保守处理),此时返回空表,调用方负责从会话移除。 */
@@ -6358,7 +7462,14 @@ export class ClientSession {
6358
7462
  if (!presetAllowsPluginTools(preset))
6359
7463
  return [];
6360
7464
  const off = new Set(normalizeDisabledPluginTools(this.settingsSvc.current.disabledPluginTools));
6361
- return (this.pluginToolsProvider?.() ?? []).filter((t) => !off.has(t.name)).map(pluginToolToDefinition);
7465
+ const disabledPlugins = new Set(this.settingsSvc.current.disabledPlugins ?? []);
7466
+ return (this.pluginToolsProvider?.() ?? [])
7467
+ .filter((t) => {
7468
+ if (t.pluginId && disabledPlugins.has(t.pluginId))
7469
+ return false;
7470
+ return !off.has(t.name);
7471
+ })
7472
+ .map(pluginToolToDefinition);
6362
7473
  }
6363
7474
  /** 把插件 AI 工具同步进一个已存在的会话(新增/更新/移除;禁用工具同步移除)。
6364
7475
  * 实际 diff 逻辑在 plugins.ts 的 syncPluginToolsIntoSession(可单测)。
@@ -6634,6 +7745,18 @@ export class ClientSession {
6634
7745
  * after the current turn settles, skipping remaining planned tool calls.
6635
7746
  */
6636
7747
  queue = false) {
7748
+ const trimmedText = (text ?? "").trim();
7749
+ const hasAttachments = Boolean(attachments && attachments.length > 0);
7750
+ if (!trimmedText && !hasAttachments) {
7751
+ this.emit({
7752
+ type: "notice",
7753
+ level: "warning",
7754
+ text: "发送已忽略:提示词为空且未附带文件或上下文引用。",
7755
+ textEn: "Prompt ignored: text is empty and no attachments were provided.",
7756
+ });
7757
+ this.flushSnapshot();
7758
+ return;
7759
+ }
6637
7760
  // Captured at the START (before any await): the conversation being
6638
7761
  // addressed by this prompt. See the naming block below — a concurrent
6639
7762
  // switch/new_chat while prompt() is in flight must never target a
@@ -6641,6 +7764,54 @@ export class ClientSession {
6641
7764
  const conv = this.conv;
6642
7765
  const promptAc = new AbortController();
6643
7766
  conv.activePromptAc = promptAc;
7767
+ if (conv.planMode === true) {
7768
+ this.applyToolGating(conv.session);
7769
+ }
7770
+ // 审查者模式(自动委派):本对话只审阅 —— 用户这条 prompt 直接转给常驻执行
7771
+ // 对话执行,主会话这一轮**不跑模型**。接在 promptAc 之后、draft 清理之前:
7772
+ // 转走的内容不该把主对话的草稿也清掉(用户可能还想在主对话里追一句)。
7773
+ // 计划模式优先(那边直接返回 false)。
7774
+ if (await this.dispatchToDelegate(conv, text)) {
7775
+ conv.presetLocked = true;
7776
+ return;
7777
+ }
7778
+ // 目标审查回合进行中:纯文本插话顺延到 verdict 落定后(steer 进去会污染
7779
+ // verdict,一次插话烧掉整个目标;followUp 由 SDK 排在整轮结束后才送达,
7780
+ // 本来就安全所以只拦 steer)。斜杠命令直通(原生配置类不进模型);带附件 /
7781
+ // 图片的不顺延(附件引用只在发送瞬间有效)→ 响亮拒绝,草稿保留在输入框。
7782
+ // 判定抽成纯函数(server/goal-review-gate.ts),单测见 goal-review-gate.test.ts。
7783
+ if (shouldDeferPromptForReview({
7784
+ queue,
7785
+ text,
7786
+ hasAttachments: !!attachments && attachments.length > 0,
7787
+ awaitingVerdict: conv?.awaitingVerdict != null,
7788
+ })) {
7789
+ const gc = conv;
7790
+ (gc.deferredPrompts ??= []).push(text);
7791
+ this.emit({
7792
+ type: "notice",
7793
+ level: "info",
7794
+ text: "审查回合进行中,你的消息已排队(审查结束后自动发送,不会打断审查)。",
7795
+ textEn: "A review round is running; your message is queued and will be sent automatically when it ends (without interrupting the review).",
7796
+ });
7797
+ this.flushSnapshot();
7798
+ return;
7799
+ }
7800
+ if (!queue &&
7801
+ !text.trim().startsWith("/") &&
7802
+ !!attachments &&
7803
+ attachments.length > 0 &&
7804
+ conv?.awaitingVerdict != null) {
7805
+ // 带附件的插话不顺延也不 steer:直接拒绝,用户稍后重发(草稿还在输入框)。
7806
+ this.emit({
7807
+ type: "notice",
7808
+ level: "warning",
7809
+ text: "审查回合进行中:带附件/图片的消息请等审查结束后再发(文本草稿已保留)。",
7810
+ textEn: "A review round is running; please resend messages with attachments/images after it ends (your draft is kept).",
7811
+ });
7812
+ this.flushSnapshot();
7813
+ return;
7814
+ }
6644
7815
  // 输入框内容被消费(发送/斜杠执行)→ 清掉该会话存过的草稿(best-effort)。
6645
7816
  // 快捷短语发送(不碰输入框)同样清:客户端发送成功后会把当前草稿重存回来。
6646
7817
  // clear() 同时记录 clear 时间戳水位:清掉之后才 landing 的旧 draft_update
@@ -6942,8 +8113,21 @@ export class ClientSession {
6942
8113
  // 立刻要有它(此刻还在流式输出,不能等 agent_end 的防抖刷新)。
6943
8114
  this.emitConversations();
6944
8115
  }
8116
+ // 置换守卫:从这一刻起进入异步前置(附件构建 + 影子快照)——在它们完成前
8117
+ // 对话既不 streaming 也没有新消息落盘,displaceActive() 会把它误判成
8118
+ // 空闲对话并销毁 runtime,导致投递中的消息被静默丢弃。放在所有同步校验 /
8119
+ // 原生斜杠命令拦截之后:原生命令(/new, /cwd 等)不进投递流程且可能
8120
+ // 立即切换会话,若提前置位会被 displaceActive 误判为有消息投递而报错通知。
8121
+ conv.promptInFlight = true;
6945
8122
  // Attach files as independent nextTurn context messages (asides) so the
6946
8123
  // user message stays clean; they render as separate attachment cards.
8124
+ // SDK 的流式队列只消费用户消息;引用须与对应队列项一起入队和撤回。
8125
+ const streamingQuotes = s.isStreaming
8126
+ ? (attachments ?? [])
8127
+ .filter((a) => a.mode === "quote")
8128
+ .map((a) => readTextQuote(a.quote))
8129
+ .filter((q) => q !== null)
8130
+ : [];
6947
8131
  const asides = await buildAttachmentMessages({
6948
8132
  cwd: this.cwd,
6949
8133
  clientId: this.clientId,
@@ -6952,9 +8136,17 @@ export class ClientSession {
6952
8136
  session: this.session,
6953
8137
  // issue #91:附件/视觉桥文案按客户端 UI 语言出中英(英文默认)。
6954
8138
  getLang: () => this.getLang(),
6955
- }, attachments);
8139
+ }, streamingQuotes.length ? attachments?.filter((a) => a.mode !== "quote") : attachments);
6956
8140
  if (promptAc.signal.aborted) {
6957
8141
  conv.activePromptAc = undefined;
8142
+ // #492:displacement(新对话顶替/会话被移出)在 prompt 前置阶段 abort 时
8143
+ // 原本静默 return——消息凭空消失、草稿已清,用户毫无回执。
8144
+ this.emit({
8145
+ type: "notice",
8146
+ level: "warning",
8147
+ text: `消息未送达:该对话已被关闭或运行已被停止`,
8148
+ textEn: `Message not delivered: the conversation was closed or the run was stopped`,
8149
+ });
6958
8150
  return;
6959
8151
  }
6960
8152
  for (const aside of asides) {
@@ -6979,9 +8171,26 @@ export class ClientSession {
6979
8171
  }
6980
8172
  if (promptAc.signal.aborted) {
6981
8173
  conv.activePromptAc = undefined;
8174
+ // #492:同上——影子快照阶段的 abort 也必须给回执,不能静默黑洞。
8175
+ this.emit({
8176
+ type: "notice",
8177
+ level: "warning",
8178
+ text: `消息未送达:该对话已被关闭或运行已被停止`,
8179
+ textEn: `Message not delivered: the conversation was closed or the run was stopped`,
8180
+ });
6982
8181
  return;
6983
8182
  }
6984
8183
  conv.activePromptAc = undefined;
8184
+ // 前置阶段(附件 / 影子快照)期间用户可能已经切走或新建了对话:消息仍然
8185
+ // 落在本对话里,指给用户看,免得「发出去的消息不见了」。
8186
+ if (conv.id !== this.activeId) {
8187
+ this.emit({
8188
+ type: "notice",
8189
+ level: "info",
8190
+ text: `消息已投递到「${conv.title}」——你刚切换了对话,它不在当前对话里`,
8191
+ textEn: `Message delivered to "${conv.title}" — you switched conversations before it landed`,
8192
+ });
8193
+ }
6985
8194
  if (s.isStreaming) {
6986
8195
  // queue=true (补充 button) → followUp: the message is delivered only
6987
8196
  // after the whole run finishes — the agent finishes what it started,
@@ -6993,12 +8202,12 @@ export class ClientSession {
6993
8202
  // Enter-during-streaming semantic (docs/usage: Enter queues a
6994
8203
  // steering message); followUp would wait for the whole run
6995
8204
  // to finish, which users perceive as ordinary queueing.
6996
- await s.prompt(text, {
8205
+ await s.prompt(formatQuotedPrompt(text, streamingQuotes), {
6997
8206
  streamingBehavior: queue ? "followUp" : "steer",
6998
8207
  });
6999
8208
  }
7000
8209
  else {
7001
- await s.prompt(text);
8210
+ await s.prompt(formatQuotedPrompt(text, streamingQuotes));
7002
8211
  }
7003
8212
  // 关联快照与本次 prompt 产生的用户消息 entry
7004
8213
  if (snapshotRef) {
@@ -7030,6 +8239,11 @@ export class ClientSession {
7030
8239
  textEn: `Failed to send prompt: ${err.message}`,
7031
8240
  });
7032
8241
  }
8242
+ finally {
8243
+ // 无论走哪条出口(斜杠命令 / quiesce 拒绝 / 中止 / 抛错 / 正常落定),
8244
+ // 投递窗口都结束了;留着 true 会让这条对话永远不被置换。
8245
+ conv.promptInFlight = false;
8246
+ }
7033
8247
  // The active conversation (captured at prompt start — see above) has been
7034
8248
  // continued since it was opened — it must not be dismissed when the user
7035
8249
  // switches away. (Also bumps the per-project "most recently active"
@@ -7187,7 +8401,23 @@ export class ClientSession {
7187
8401
  this.flushSnapshot();
7188
8402
  return;
7189
8403
  }
7190
- const { steering, followUp } = s.clearQueue();
8404
+ let steering;
8405
+ let followUp;
8406
+ try {
8407
+ ({ steering, followUp } = s.clearQueue());
8408
+ }
8409
+ catch (err) {
8410
+ // #491:runtime 被 forceReset 置换等半死状态下 clearQueue 可能抛错——
8411
+ // 回退到本地显示镜像移除,等 queue_update 对账;绝不把 reject 的 Promise
8412
+ // 扔回给 dispatch(无兜底 handler 时 unhandledRejection 直接崩进程)。
8413
+ console.error("[agent-service] clearQueue failed while removing a queued message:", err);
8414
+ const next = removeQueuedByIndexOrText(local, text, index);
8415
+ if (next.length !== local.length) {
8416
+ local.splice(0, local.length, ...next);
8417
+ }
8418
+ this.flushSnapshot();
8419
+ return;
8420
+ }
7191
8421
  // 气泡 ✕ 对应的是「第几个气泡」(index),不是「哪段文本」:同一文本排队两次时
7192
8422
  // 按文本只会删掉第一条,点第二个气泡却删掉第一个。用 index 定位,位置对不上
7193
8423
  // (队列在点击与执行之间变化)或旧客户端没发 index 时回落到第一处文本匹配。
@@ -7325,6 +8555,9 @@ export class ClientSession {
7325
8555
  }
7326
8556
  /** Interrupt a run: abort, with a force-reset fallback on timeout. */
7327
8557
  async interruptRun(conv, reason) {
8558
+ // #487:停下就结算该会话的挂起提问/审批——残留条目让 isWaitingOnUser 恒真
8559
+ // (stall 失联检测被永久豁免),弹窗也会跨轮残留;dispose 语义已范本化。
8560
+ this.settlePendingsForConv(conv.id);
7328
8561
  if (!this.conversationStreaming(conv) && conv.session.isIdle) {
7329
8562
  return;
7330
8563
  }
@@ -7398,6 +8631,11 @@ export class ClientSession {
7398
8631
  conv.toolStartTimes.clear();
7399
8632
  disposeEvalSession(conv.id);
7400
8633
  await conv.runtime.dispose();
8634
+ // #485:dispose 挂起期间会话可能已被强行关闭移出(forceDismiss 走
8635
+ // removeConversation)——此时绝不能幽灵重建,否则新 runtime 挂在已脱离
8636
+ // this.convs 的 conv 对象上,无人持有、永不回收。
8637
+ if (!this.convs.has(conv.id))
8638
+ return;
7401
8639
  // #280:dispose 丢弃了内存里的在飞状态(未落盘的工具结果蒸发),
7402
8640
  // 文件尾可能留下一个悬空 toolCall——先补合成 toolResult 再重建,
7403
8641
  // 否则重建后的 prompt 会把非法转录链喂给 provider(零落盘黑洞)。
@@ -7429,6 +8667,17 @@ export class ClientSession {
7429
8667
  sessionManager: m,
7430
8668
  }), async () => (ownFile && existsSync(ownFile) ? ownFile : undefined));
7431
8669
  const runtime = opened.runtime;
8670
+ // #485:openManagerAndRuntime 的长 await 期间会话被移出的同一守卫——
8671
+ // 刚建好的 runtime 无人认领,就地 dispose 防止扩展宿主子进程泄漏。
8672
+ if (!this.convs.has(conv.id)) {
8673
+ try {
8674
+ await runtime.dispose();
8675
+ }
8676
+ catch {
8677
+ // 已被移除的会话:dispose 失败无处上报,best-effort。
8678
+ }
8679
+ return;
8680
+ }
7432
8681
  if (opened.repair) {
7433
8682
  for (const n of this.transcriptRepairNotices(opened.repair))
7434
8683
  this.emit(n);
@@ -7462,7 +8711,10 @@ export class ClientSession {
7462
8711
  text: reason,
7463
8712
  textEn: `${reason} (forced reset: run did not terminate)`,
7464
8713
  });
7465
- await this.bindSession();
8714
+ // #484:把订阅重挂到**被重建的那个对话**上。非活跃对话(子代理/角色
8715
+ // 对话)此前永远走 bindSession() → 只给活跃对话挂订阅,重建后该对话
8716
+ // 的所有 SDK 事件失聪:快照冻结、看门狗不再布防、turnEndWaiters 死等。
8717
+ await this.bindSession(conv);
7466
8718
  this.emitConversations();
7467
8719
  void this.pushSlashCommands();
7468
8720
  }
@@ -7529,8 +8781,8 @@ export class ClientSession {
7529
8781
  this.emit({
7530
8782
  type: "notice",
7531
8783
  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.`,
8784
+ text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先移出不需要的对话(打开后离开不继续对话即移出;钉住的对话需先取消钉住)`,
8785
+ 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
8786
  });
7535
8787
  return false;
7536
8788
  }
@@ -7865,6 +9117,9 @@ export class ClientSession {
7865
9117
  }
7866
9118
  displaceActive() {
7867
9119
  const conv = this.conv;
9120
+ // 正在过户给其他会话:绝不就地释放(runtime/终端等整体搬迁给 target)。
9121
+ if (conv.transferring)
9122
+ return null;
7868
9123
  // 子代理不受切换关闭影响(见上)。
7869
9124
  if (conv.isSubagent) {
7870
9125
  conv.listed = true;
@@ -7901,11 +9156,22 @@ export class ClientSession {
7901
9156
  openTerminals: conv.terminals.countBlockingLive(),
7902
9157
  listed: conv.listed,
7903
9158
  promptedSinceActive: conv.promptedSinceActive,
9159
+ pinned: conv.pinned,
9160
+ // 投递中的消息(附件构建 / 影子快照阶段):见 Conversation.promptInFlight。
9161
+ promptInFlight: Boolean(conv.promptInFlight),
7904
9162
  hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
7905
9163
  hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
7906
9164
  });
7907
9165
  if (retained) {
7908
9166
  conv.listed = true;
9167
+ if (conv.promptInFlight && !conv.session.isStreaming) {
9168
+ this.emit({
9169
+ type: "notice",
9170
+ level: "info",
9171
+ text: `「${conv.title}」的消息正在投递,已为你保留该对话`,
9172
+ textEn: `"${conv.title}" is still sending your message — kept in the running list`,
9173
+ });
9174
+ }
7909
9175
  return null;
7910
9176
  }
7911
9177
  return conv;
@@ -7917,6 +9183,22 @@ export class ClientSession {
7917
9183
  const conv = this.convs.get(id);
7918
9184
  if (!conv || id === this.activeId)
7919
9185
  return;
9186
+ // #487:会话没了,挂起的提问/审批永远等不到人点——按 dispose 口径就地结算。
9187
+ this.settlePendingsForConv(id);
9188
+ // 正在投递的消息(附件构建 / 影子快照阶段)必须先打断:否则 prompt() 醒来
9189
+ // 后会把用户消息写进已销毁的 runtime,静默丢失。
9190
+ if (conv.activePromptAc) {
9191
+ conv.activePromptAc.abort();
9192
+ conv.activePromptAc = undefined;
9193
+ }
9194
+ // 角色轮等待者:对话被移出 → 等它的循环收到 gone(否则要等到超时)。
9195
+ const waiters = this.turnEndWaiters.get(id);
9196
+ if (waiters) {
9197
+ this.turnEndWaiters.delete(id);
9198
+ // 先摘表再逐个唤醒:回调里的自删不会弄脏迭代。
9199
+ for (const fn of waiters)
9200
+ fn("gone");
9201
+ }
7920
9202
  // 对话真关闭(dismiss/释放)→ 放掉它的认领。过户不走这里(对话换个会话
7921
9203
  // 继续,owner 不变,认领继续有效),所以只在此处释放。
7922
9204
  try {
@@ -7981,21 +9263,37 @@ export class ClientSession {
7981
9263
  const convs = [...this.convs.values()].filter((c) => set.has(c.id));
7982
9264
  if (convs.length === 0)
7983
9265
  return { ok: false, reason: "missing" };
7984
- if (set.has(this.activeId)) {
7985
- const remaining = [...this.convs.values()].find((c) => !set.has(c.id) && !c.isSubagent) ??
7986
- [...this.convs.values()].find((c) => !set.has(c.id));
7987
- if (remaining) {
7988
- await this.switchConversation(remaining.id);
7989
- }
7990
- else if (!(await this.newChat())) {
7991
- return { ok: false, reason: "empty" };
9266
+ for (const conv of convs)
9267
+ conv.transferring = true;
9268
+ try {
9269
+ if (set.has(this.activeId)) {
9270
+ const remaining = [...this.convs.values()].find((c) => !set.has(c.id) && !c.isSubagent) ??
9271
+ [...this.convs.values()].find((c) => !set.has(c.id));
9272
+ if (remaining) {
9273
+ await this.switchConversation(remaining.id);
9274
+ }
9275
+ else if (!(await this.newChat())) {
9276
+ return { ok: false, reason: "empty" };
9277
+ }
7992
9278
  }
7993
9279
  }
9280
+ finally {
9281
+ for (const conv of convs)
9282
+ delete conv.transferring;
9283
+ }
7994
9284
  for (const conv of convs) {
7995
9285
  this.convs.delete(conv.id);
7996
9286
  this.clearAllToolWatchdogs(conv);
7997
9287
  conv.unsubscribe?.();
7998
9288
  conv.unsubscribe = undefined;
9289
+ // issue #457: 唤醒角色轮等待者与目标审查等待者,避免会话搬走后原会话循环永久卡死
9290
+ const waiters = this.turnEndWaiters.get(conv.id);
9291
+ if (waiters) {
9292
+ this.turnEndWaiters.delete(conv.id);
9293
+ for (const fn of waiters)
9294
+ fn("gone");
9295
+ }
9296
+ this.goalSvc.notifyTakeover(conv);
7999
9297
  }
8000
9298
  const questions = [];
8001
9299
  for (const [qid, p] of this.pendingQuestions) {
@@ -8310,6 +9608,7 @@ export class ClientSession {
8310
9608
  return pq ? { hasQuestion: true, questionId: pq.id, questionTitle: pq.title } : {};
8311
9609
  })(),
8312
9610
  parentId: conv.parentId,
9611
+ ...(conv.pinned ? { pinned: true } : {}),
8313
9612
  ...(conv.forkFrom ? { forkFrom: conv.forkFrom } : {}),
8314
9613
  });
8315
9614
  }
@@ -8355,30 +9654,223 @@ export class ClientSession {
8355
9654
  * pushSessions() and searchSessions() share this fridge — opening the
8356
9655
  * panel warms it, then every keystroke inside the TTL is free.
8357
9656
  */
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;
9657
+ /** 最近项目列表缓存:跨客户端共享静态缓存,TTL 窗口内直接复用。 */
9658
+ static projectsCache = null;
9659
+ static PROJECTS_CACHE_TTL = 60_000;
9660
+ static projectsInFlight = null;
9661
+ /**
9662
+ * 会话单文件元信息缓存(path -> { mtime, size, info }):避免对未修改的会话文件
9663
+ * 重复读取与 JSON 解析。LRU 上限(issue #440):静态 Map 跨项目共享,原先没有任何
9664
+ * 上限/TTL —— 删除、更名、被挤出 200 名额的文件只要曾进过缓存就永久驻留,长驻
9665
+ * 服务 + 多项目重度使用下 RSS 单调上涨。
9666
+ *
9667
+ * 上限 512 的依据:单项目单轮扫描只取最新 200 个候选,512 ≈ 2.5 倍余量,足够容纳
9668
+ * 多个最近活跃项目的热集;且拆分后(issue #440)条目只含元信息不含转录全文
9669
+ * (单条 KB 级),总占用恒定且极小 —— 上限防的是「无界累积」,不是单条重量。
9670
+ *
9671
+ * LRU 淘汰语义:把 Map 当 LRU 用(Map 迭代序 = 插入序)—— 命中即 delete+set 重插
9672
+ * 到最新端,写入超上限从最旧端淘汰。mtime/size 未变的条目命中即复用(行为与原先
9673
+ * 一致),变化的条目由调用方重新解析后经 store 覆盖(也落到最新端)。
9674
+ */
9675
+ static sessionFileCache = new Map();
9676
+ static SESSION_FILE_CACHE_MAX = 512;
9677
+ /**
9678
+ * 转录全文缓存(path -> { text, at }):仅供 searchSessions / searchForPlugins 的
9679
+ * 全文命中判定按需加载(issue #440 拆分)。与列表缓存分离的原因:列表路径
9680
+ * (pushSessions / 插件列表 / 删除后切换)只需要元信息,而全文单条可达 MB 级,
9681
+ * 随列表常驻会让每个曾进过列表的文件都占住内存。
9682
+ *
9683
+ * 容量依据:256 ≥ 单项目候选上限 200,键入查询的其余按键在 TTL 窗口内全程免读盘;
9684
+ * 单条在 parseSessionInfoFast 里按 MAX_SEARCH_TEXT_CHARS(256K 字符)封顶。
9685
+ * TTL 只在访问时判断(无定时器),每次载入顺手清扫过期项 —— 搜索空闲后不长期
9686
+ * 占内存。删除/更名经 invalidateSessionInfos 同步清理。
9687
+ */
9688
+ static sessionTextCache = new Map();
9689
+ static SESSION_TEXT_CACHE_MAX = 256;
9690
+ static SESSION_TEXT_CACHE_TTL = 30_000;
9691
+ /** sessionFileCache LRU 读取:命中即重插到最新端(续期),未命中返回 undefined。 */
9692
+ static sessionFileCacheLookup(key) {
9693
+ const entry = ClientSession.sessionFileCache.get(key);
9694
+ if (!entry)
9695
+ return undefined;
9696
+ ClientSession.sessionFileCache.delete(key);
9697
+ ClientSession.sessionFileCache.set(key, entry);
9698
+ return entry;
9699
+ }
9700
+ /** sessionFileCache LRU 写入:新/更新条目放最新端,超上限从最旧端淘汰。 */
9701
+ static sessionFileCacheStore(key, entry) {
9702
+ ClientSession.sessionFileCache.delete(key);
9703
+ ClientSession.sessionFileCache.set(key, entry);
9704
+ while (ClientSession.sessionFileCache.size > ClientSession.SESSION_FILE_CACHE_MAX) {
9705
+ const oldest = ClientSession.sessionFileCache.keys().next();
9706
+ if (oldest.done)
9707
+ break;
9708
+ ClientSession.sessionFileCache.delete(oldest.value);
9709
+ }
9710
+ }
9711
+ /** sessionTextCache 读取:TTL 内命中即重插续期;过期即清并按未命中处理。 */
9712
+ static sessionTextCacheLookup(key, now) {
9713
+ const entry = ClientSession.sessionTextCache.get(key);
9714
+ if (!entry)
9715
+ return undefined;
9716
+ ClientSession.sessionTextCache.delete(key);
9717
+ if (now - entry.at >= ClientSession.SESSION_TEXT_CACHE_TTL)
9718
+ return undefined;
9719
+ ClientSession.sessionTextCache.set(key, entry);
9720
+ return entry.text;
9721
+ }
9722
+ /** sessionTextCache 写入:放最新端,超上限从最旧端淘汰。 */
9723
+ static sessionTextCacheStore(key, text, at) {
9724
+ ClientSession.sessionTextCache.delete(key);
9725
+ ClientSession.sessionTextCache.set(key, { text, at });
9726
+ while (ClientSession.sessionTextCache.size > ClientSession.SESSION_TEXT_CACHE_MAX) {
9727
+ const oldest = ClientSession.sessionTextCache.keys().next();
9728
+ if (oldest.done)
9729
+ break;
9730
+ ClientSession.sessionTextCache.delete(oldest.value);
9731
+ }
9732
+ }
9733
+ /** 惰性清扫 sessionTextCache 过期项:TTL 只在访问时判断,没有定时器兜底,
9734
+ * 靠每次全文载入顺手扫一遍,保证搜索空闲后全文不会无限期滞留内存。 */
9735
+ static sessionTextCacheSweep(now) {
9736
+ for (const [key, entry] of ClientSession.sessionTextCache) {
9737
+ if (now - entry.at >= ClientSession.SESSION_TEXT_CACHE_TTL)
9738
+ ClientSession.sessionTextCache.delete(key);
9739
+ }
9740
+ }
9741
+ /**
9742
+ * 转录全文按需加载(搜索专用):TTL 缓存命中直接回;未命中读盘提取全文
9743
+ * (与 parseSessionInfoFast 同口径:user/assistant 消息文本,MAX_SEARCH_TEXT_CHARS
9744
+ * 截断)。解析失败回空串且不缓存 —— 与列表路径一样下次重试。
9745
+ */
9746
+ static async loadSessionSearchText(filePath) {
9747
+ const now = Date.now();
9748
+ ClientSession.sessionTextCacheSweep(now);
9749
+ const hit = ClientSession.sessionTextCacheLookup(filePath, now);
9750
+ if (hit !== undefined)
9751
+ return hit;
9752
+ const info = await parseSessionInfoFast(filePath, 0, true);
9753
+ const text = info?.allMessagesText ?? "";
9754
+ if (text)
9755
+ ClientSession.sessionTextCacheStore(filePath, text, now);
9756
+ return text;
9757
+ }
9758
+ /**
9759
+ * searchSessions / searchForPlugins 共用的全文匹配:先比元信息(零 IO,覆盖绝大多数
9760
+ * 按标题/文件名/首条消息的查询),元信息未命中的会话再按需加载转录全文判定
9761
+ * (小容量短 TTL 缓存,见 sessionTextCache)。保序返回命中子集。
9762
+ * issue #440:全文不再随列表缓存常驻,改在这里按需加载。
9763
+ */
9764
+ static async filterSessionsForSearch(q, infos) {
9765
+ const flags = await Promise.all(infos.map(async (s) => {
9766
+ if (sessionMatchesMetadata(q, s))
9767
+ return true;
9768
+ const text = await ClientSession.loadSessionSearchText(s.path);
9769
+ return text.toLowerCase().includes(q);
9770
+ }));
9771
+ return infos.filter((_, i) => flags[i]);
9772
+ }
9773
+ /** 会话列表缓存(按 cwdKey 隔离,30s TTL)。 */
9774
+ static sessionInfosCache = new Map();
9775
+ static sessionInfosInFlight = new Map();
9776
+ static SESSION_INFO_CACHE_TTL = 30_000;
8366
9777
  async loadSessionInfos() {
8367
9778
  const now = Date.now();
8368
- const c = this.sessionInfosCache;
8369
- if (c && c.cwd === this.cwd && now - c.at < ClientSession.SESSION_INFO_CACHE_TTL) {
9779
+ const cwdKey = normalizePathKey(this.cwd);
9780
+ const c = ClientSession.sessionInfosCache.get(cwdKey);
9781
+ if (c && now - c.at < ClientSession.SESSION_INFO_CACHE_TTL) {
8370
9782
  return c.infos;
8371
9783
  }
8372
- const infos = await SessionManager.list(this.cwd, piSessionsRoot());
8373
- this.sessionInfosCache = { cwd: this.cwd, infos, at: now };
8374
- return infos;
9784
+ const inFlight = ClientSession.sessionInfosInFlight.get(cwdKey);
9785
+ if (inFlight)
9786
+ return inFlight;
9787
+ const run = (async () => {
9788
+ try {
9789
+ const sessionDir = piSessionsRoot()
9790
+ ? resolve(piSessionsRoot())
9791
+ : SessionManager.create(this.cwd).getSessionDir();
9792
+ if (!existsSync(sessionDir))
9793
+ return [];
9794
+ const dirEntries = await fsPromises.readdir(sessionDir);
9795
+ const files = dirEntries.filter((f) => f.endsWith(".jsonl"));
9796
+ if (files.length === 0)
9797
+ return [];
9798
+ // Stat files to detect modified/new files (takes only ~8ms for 600+ files)
9799
+ const stats = await Promise.all(files.map(async (f) => {
9800
+ const fp = join(sessionDir, f);
9801
+ try {
9802
+ const st = await fsPromises.stat(fp);
9803
+ return { path: fp, mtime: st.mtimeMs, size: st.size };
9804
+ }
9805
+ catch {
9806
+ return null;
9807
+ }
9808
+ }));
9809
+ const validStats = stats.filter((s) => s !== null);
9810
+ validStats.sort((a, b) => b.mtime - a.mtime);
9811
+ const results = await Promise.all(validStats.map(async (file) => {
9812
+ const cached = ClientSession.sessionFileCacheLookup(file.path);
9813
+ if (cached && cached.mtime === file.mtime && cached.size === file.size) {
9814
+ return cached.info;
9815
+ }
9816
+ // 列表路径不收集转录全文(issue #440)——全文只在搜索时按需加载
9817
+ const info = await parseSessionInfoFast(file.path, file.mtime);
9818
+ if (info) {
9819
+ ClientSession.sessionFileCacheStore(file.path, {
9820
+ mtime: file.mtime,
9821
+ size: file.size,
9822
+ info,
9823
+ });
9824
+ }
9825
+ return info;
9826
+ }));
9827
+ const validInfos = results.filter((info) => info !== null);
9828
+ // issue #438:扁平布局(PI_CODING_AGENT_SESSION_DIR)下根目录顶层直接是**所有项目**
9829
+ // 共享的 .jsonl,所属 cwd 是文件内字段;不过滤的话其他项目的会话会混进本项目
9830
+ // 「历史会话」列表,点开即把整个工作区切走。与 SessionManager.list(cwd, ...) 的
9831
+ // fallback 及 discoverRecentProjectsFromDisk 同口径:normalizePathKey(Windows
9832
+ // 大小写/斜杠归一)。空 cwd 的损坏文件须排除——normalizePathKey("") 会 resolve 到
9833
+ // process.cwd(),恰好等于本项目时会把垃圾文件误收进来。
9834
+ const ownInfos = validInfos.filter((info) => info.cwd !== "" && normalizePathKey(info.cwd) === cwdKey);
9835
+ // 200 名额只在 cwd 过滤**之后**分配:扁平布局下若先截断,本项目会话可能被
9836
+ // 其他项目的文件挤出列表。截断仍按上面的 mtime 降序序取(Promise.all 保序),
9837
+ // 与原有选集口径一致;非扁平布局每-cwd 子目录内文件全属本项目,过滤幂等。
9838
+ const candidates = ownInfos.slice(0, 200);
9839
+ candidates.sort((a, b) => b.modified.getTime() - a.modified.getTime());
9840
+ ClientSession.sessionInfosCache.set(cwdKey, { infos: candidates, at: Date.now() });
9841
+ return candidates;
9842
+ }
9843
+ catch {
9844
+ try {
9845
+ const fallback = await SessionManager.list(this.cwd, piSessionsRoot());
9846
+ // 统一口径:列表缓存一律不带转录全文(issue #440),搜索按需另行加载
9847
+ const stripped = fallback.map((s) => ({ ...s, allMessagesText: "" }));
9848
+ ClientSession.sessionInfosCache.set(cwdKey, { infos: stripped, at: Date.now() });
9849
+ return stripped;
9850
+ }
9851
+ catch {
9852
+ return [];
9853
+ }
9854
+ }
9855
+ finally {
9856
+ ClientSession.sessionInfosInFlight.delete(cwdKey);
9857
+ }
9858
+ })();
9859
+ ClientSession.sessionInfosInFlight.set(cwdKey, run);
9860
+ return run;
8375
9861
  }
8376
9862
  /** Session files on disk changed (delete / new-transcript) — drop the brief
8377
9863
  * TTL fridge so the NEXT listing re-reads the directory instead of serving
8378
9864
  * the pre-mutation snapshot (delete-then-refresh commonly runs inside the
8379
9865
  * window, which would re-push the just-removed session). */
8380
- invalidateSessionInfos() {
8381
- this.sessionInfosCache = null;
9866
+ invalidateSessionInfos(filePath) {
9867
+ ClientSession.sessionInfosCache.clear();
9868
+ if (filePath) {
9869
+ const abs = resolve(filePath);
9870
+ ClientSession.sessionFileCache.delete(abs);
9871
+ // 全文缓存一并清(issue #440):文件已删/更名,正文不该再被搜索命中
9872
+ ClientSession.sessionTextCache.delete(abs);
9873
+ }
8382
9874
  }
8383
9875
  /** Push the persisted session list to the client (client-requested). */
8384
9876
  async refreshSessions() {
@@ -8402,7 +9894,7 @@ export class ClientSession {
8402
9894
  const infos = await this.loadSessionInfos();
8403
9895
  // 隐藏「被 fork 掉的父会话」,历史列表只保留每条 fork 链最新的链尾会话
8404
9896
  // (全文搜索 searchSessions 保留全部会话,不受此影响)。
8405
- const normSessionPath = (p) => String(p).replace(/\\/g, "/").replace(/\/$/, "").toLowerCase();
9897
+ const normSessionPath = (p) => normalizePathKey(p);
8406
9898
  const forkedParentPaths = new Set();
8407
9899
  for (const info of infos) {
8408
9900
  if (typeof info.parentSessionPath === "string" && info.parentSessionPath) {
@@ -8417,6 +9909,7 @@ export class ClientSession {
8417
9909
  : infos;
8418
9910
  const sessions = new Map();
8419
9911
  for (const s of visibleInfos) {
9912
+ const isPinned = this.stateStore?.isSessionPinned(this.cwd, s.path);
8420
9913
  sessions.set(s.path, {
8421
9914
  path: s.path,
8422
9915
  name: s.name,
@@ -8424,6 +9917,7 @@ export class ClientSession {
8424
9917
  messageCount: s.messageCount,
8425
9918
  modified: s.modified.getTime(),
8426
9919
  source: "web",
9920
+ ...(isPinned ? { pinned: true } : {}),
8427
9921
  });
8428
9922
  }
8429
9923
  const sorted = [...sessions.values()].sort((a, b) => b.modified - a.modified).slice(0, 200); // newest first — the panel shows recent history
@@ -8482,7 +9976,7 @@ export class ClientSession {
8482
9976
  }
8483
9977
  if (holder) {
8484
9978
  // Same source the history panel uses (refreshSessions): newest first.
8485
- const infos = await SessionManager.list(this.cwd, piSessionsRoot());
9979
+ const infos = await this.loadSessionInfos();
8486
9980
  const next = infos
8487
9981
  .filter((s) => resolve(s.path) !== abs)
8488
9982
  .sort((a, b) => b.modified.getTime() - a.modified.getTime())[0];
@@ -8538,6 +10032,8 @@ export class ClientSession {
8538
10032
  return;
8539
10033
  }
8540
10034
  rmSync(abs, { force: true });
10035
+ // 转录删了,钉住记录一起清理(issue #433)。
10036
+ this.stateStore?.cleanPinnedSession(abs);
8541
10037
  // 转录删了,sidecar 再留着就是孤儿,一起清掉(不存在不报错)。
8542
10038
  removeTouchSidecar(abs);
8543
10039
  // 转录删了,未发送草稿再留着就是孤儿,一起清掉。
@@ -8550,7 +10046,7 @@ export class ClientSession {
8550
10046
  // Bust the brief session-info fridge: refreshSessions() below usually
8551
10047
  // lands inside its 3s TTL and would otherwise re-serve a listing that
8552
10048
  // still contains the deleted transcript.
8553
- this.invalidateSessionInfos();
10049
+ this.invalidateSessionInfos(abs);
8554
10050
  await this.refreshSessions();
8555
10051
  }
8556
10052
  catch (err) {
@@ -8583,7 +10079,7 @@ export class ClientSession {
8583
10079
  const mgr = SessionManager.open(abs);
8584
10080
  mgr.appendSessionInfo(trimmed);
8585
10081
  this.setConversationTitleForFile(abs, trimmed);
8586
- this.invalidateSessionInfos();
10082
+ this.invalidateSessionInfos(abs);
8587
10083
  await this.refreshSessions();
8588
10084
  }
8589
10085
  catch (err) {
@@ -8595,6 +10091,82 @@ export class ClientSession {
8595
10091
  });
8596
10092
  }
8597
10093
  }
10094
+ /** 钉住 / 取消钉住某个对话(左栏右键菜单)。钉住后切走也不从「运行的对话」
10095
+ * 释放(见 shouldRetainActive 的 pinned 判据,最高优先级);取消钉住不立即
10096
+ * 释放,下一次自然置换时按常规规则处理。*/
10097
+ async setConversationPinned(id, pinned) {
10098
+ const conv = this.convs.get(id);
10099
+ if (!conv) {
10100
+ this.emit({
10101
+ type: "notice",
10102
+ level: "warning",
10103
+ text: "该对话不存在或已关闭",
10104
+ textEn: "This conversation does not exist or is already closed",
10105
+ });
10106
+ return;
10107
+ }
10108
+ // 子代理本来就永久豁免置换(displaceActive 开头即返回),钉子对它们无意义。
10109
+ if (conv.isSubagent) {
10110
+ this.emit({
10111
+ type: "notice",
10112
+ level: "info",
10113
+ text: "子代理对话始终保留在运行列表,无需钉住",
10114
+ textEn: "Subagent chats always stay in the running list — no need to pin",
10115
+ });
10116
+ return;
10117
+ }
10118
+ if (pinned === !!conv.pinned) {
10119
+ this.emitConversations();
10120
+ return;
10121
+ }
10122
+ if (pinned) {
10123
+ conv.pinned = true;
10124
+ // 立即进入运行列表(含空白对话):listed 一旦置位,shownInRunningList 即放行。
10125
+ conv.listed = true;
10126
+ if (conv.session.sessionFile) {
10127
+ this.stateStore?.setSessionPinned(conv.cwd, conv.session.sessionFile, true);
10128
+ }
10129
+ }
10130
+ else {
10131
+ delete conv.pinned;
10132
+ if (conv.session.sessionFile) {
10133
+ this.stateStore?.setSessionPinned(conv.cwd, conv.session.sessionFile, false);
10134
+ }
10135
+ }
10136
+ this.emitConversations();
10137
+ this.flushSnapshot();
10138
+ this.emit({
10139
+ type: "notice",
10140
+ level: "info",
10141
+ text: pinned
10142
+ ? `已钉住对话「${conv.title}」,切换其他对话不会将它移出运行列表`
10143
+ : `已取消钉住对话「${conv.title}」,下次切换离开时按常规规则处理`,
10144
+ textEn: pinned
10145
+ ? `Pinned "${conv.title}" — switching away keeps it in the running list`
10146
+ : `Unpinned "${conv.title}" — it will be handled by the usual rules on the next switch`,
10147
+ });
10148
+ }
10149
+ /** 钉住 / 取消钉住历史会话(持久化并在运行中/历史列表生效)。 */
10150
+ async pinSession(path, pinned) {
10151
+ const targetPath = resolve(path);
10152
+ this.stateStore?.setSessionPinned(this.cwd, targetPath, pinned);
10153
+ // 如果当前已在运行列表中,同步其 pinned 状态
10154
+ for (const conv of this.convs.values()) {
10155
+ if (conv.session.sessionFile && resolve(conv.session.sessionFile) === targetPath) {
10156
+ if (pinned) {
10157
+ conv.pinned = true;
10158
+ conv.listed = true;
10159
+ }
10160
+ else {
10161
+ delete conv.pinned;
10162
+ }
10163
+ break;
10164
+ }
10165
+ }
10166
+ this.emitConversations();
10167
+ this.flushSnapshot();
10168
+ await this.refreshSessions();
10169
+ }
8598
10170
  /** Rename a live conversation by id: retitle in memory AND persist a
8599
10171
  * session_info entry to its transcript so History matches immediately. */
8600
10172
  async renameConversation(id, name) {
@@ -8660,6 +10232,7 @@ export class ClientSession {
8660
10232
  // Dismiss 口径:只看“用过”的用户终端(AI bash 不钉住,见上)。
8661
10233
  openTerminals: conv.terminals.countUserBlockingLive(),
8662
10234
  listed: false,
10235
+ promptInFlight: Boolean(conv.promptInFlight),
8663
10236
  promptedSinceActive: false,
8664
10237
  hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
8665
10238
  hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
@@ -8842,6 +10415,7 @@ export class ClientSession {
8842
10415
  openTerminals: 0,
8843
10416
  listed: conv.listed,
8844
10417
  promptedSinceActive: conv.promptedSinceActive,
10418
+ promptInFlight: Boolean(conv.promptInFlight),
8845
10419
  hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
8846
10420
  hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
8847
10421
  })) {
@@ -9090,6 +10664,7 @@ export class ClientSession {
9090
10664
  // Dismiss 口径:只看“用过”的用户终端(issue #181,AI bash 不钉住)。
9091
10665
  openTerminals: conv.terminals.countUserBlockingLive(),
9092
10666
  listed: false,
10667
+ promptInFlight: Boolean(conv.promptInFlight),
9093
10668
  promptedSinceActive: false,
9094
10669
  hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
9095
10670
  hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
@@ -9234,8 +10809,8 @@ export class ClientSession {
9234
10809
  this.emit({
9235
10810
  type: "notice",
9236
10811
  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.`,
10812
+ text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先移出不需要的对话(打开后离开不继续对话即移出;钉住的对话需先取消钉住)`,
10813
+ 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
10814
  });
9240
10815
  return;
9241
10816
  }
@@ -9243,6 +10818,10 @@ export class ClientSession {
9243
10818
  // Deliberately resumed — must not be dismissed when the user later
9244
10819
  // switches away without sending a new message.
9245
10820
  conv.promptedSinceActive = true;
10821
+ if (this.stateStore?.isSessionPinned(targetCwd, targetPath)) {
10822
+ conv.pinned = true;
10823
+ conv.listed = true;
10824
+ }
9246
10825
  this.noticeInterruptedCompaction(conv);
9247
10826
  this.convs.set(conv.id, conv);
9248
10827
  this.activeId = conv.id;
@@ -9251,6 +10830,10 @@ export class ClientSession {
9251
10830
  if (displaced)
9252
10831
  this.removeConversation(displaced.id);
9253
10832
  await this.bindSession();
10833
+ // #436:转录回放恢复的 planMode 在 runtime 工厂的 applyToolGating 时还读不到
10834
+ //(conv 尚未进 this.convs,planModeOf 落空)——注册后统一补一次门控,让
10835
+ // 计划模式的写类/旁路工具剥离对重开的会话同样生效。下同(forkSession/setCwd)。
10836
+ this.applyToolGating(conv.session, conv.agentPreset);
9254
10837
  this.cwd = targetCwd;
9255
10838
  // 打开的历史会话可能属于另一个项目 —— 工作区跟随面(roots/文件树/
9256
10839
  // 历史列表/命令目录/最近项目)必须跟着切,否则 UI 停在旧项目。
@@ -9324,6 +10907,21 @@ export class ClientSession {
9324
10907
  // 兜底:getEntry 查整棵树
9325
10908
  return found ?? conv.session.sessionManager.getEntry(messageId) ?? null;
9326
10909
  }
10910
+ /**
10911
+ * 获取被某个压缩卡片折叠的历史消息(issue #398,按需惰性加载)。
10912
+ */
10913
+ getCompactedMessages(compactionMessageId, targetConvId) {
10914
+ const targetConv = (targetConvId ? this.convs.get(targetConvId) : this.conv) ?? this.conv;
10915
+ const sm = targetConv.session.sessionManager;
10916
+ const result = getCompactedMessages(sm, compactionMessageId, (m) => this.uiMessageKey(targetConv, m).n);
10917
+ this.emit({
10918
+ type: "compacted_messages_result",
10919
+ compactionMessageId,
10920
+ conversationId: targetConv.id,
10921
+ messages: result.messages,
10922
+ error: result.error,
10923
+ });
10924
+ }
9327
10925
  /**
9328
10926
  * Fork a NEW branch conversation from a specific historical message position.
9329
10927
  * Truncates the transcript before (or at) that message as the context of the
@@ -9420,8 +11018,8 @@ export class ClientSession {
9420
11018
  this.emit({
9421
11019
  type: "notice",
9422
11020
  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.`,
11021
+ text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先移出不需要的对话(打开后离开不继续对话即移出;钉住的对话需先取消钉住)`,
11022
+ 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
11023
  });
9426
11024
  return;
9427
11025
  }
@@ -9430,6 +11028,9 @@ export class ClientSession {
9430
11028
  if (displaced)
9431
11029
  this.removeConversation(displaced.id);
9432
11030
  await this.bindSession();
11031
+ // #436:同 switchSession —— 派生出的会话若从转录回放恢复了 planMode,
11032
+ // 工厂里的门控当时读不到它,注册后补一次。
11033
+ this.applyToolGating(newConv.session, newConv.agentPreset);
9433
11034
  if (prevModel && this.sharedModelRuntime) {
9434
11035
  try {
9435
11036
  const pm = prevModel;
@@ -9598,7 +11199,8 @@ export class ClientSession {
9598
11199
  if (this.quiesceBlocked())
9599
11200
  return;
9600
11201
  const trimmed = text.trim();
9601
- if (!trimmed) {
11202
+ const hasAttachments = Boolean(attachments && attachments.length > 0);
11203
+ if (!trimmed && !hasAttachments) {
9602
11204
  this.emit({
9603
11205
  type: "notice",
9604
11206
  level: "warning",
@@ -9681,16 +11283,16 @@ export class ClientSession {
9681
11283
  */
9682
11284
  async pushProjects() {
9683
11285
  const now = Date.now();
9684
- const cached = this.projectsCache;
11286
+ const cached = ClientSession.projectsCache;
9685
11287
  // TTL 命中:直接复用(把当前 cwd 合并进去,刚 remember 的新项目也可见)。
9686
11288
  if (cached && now - cached.at < ClientSession.PROJECTS_CACHE_TTL) {
9687
11289
  this.emit({ type: "projects", projects: this.withCurrentCwd(cached.projects, now) });
9688
11290
  return;
9689
11291
  }
9690
11292
  // 已有扫描在跑:搭车等它,不要并发扫两遍盘。
9691
- if (this.projectsInFlight) {
11293
+ if (ClientSession.projectsInFlight) {
9692
11294
  try {
9693
- const projects = await this.projectsInFlight;
11295
+ const projects = await ClientSession.projectsInFlight;
9694
11296
  if (projects)
9695
11297
  this.emit({ type: "projects", projects: this.withCurrentCwd(projects, Date.now()) });
9696
11298
  }
@@ -9699,43 +11301,31 @@ export class ClientSession {
9699
11301
  }
9700
11302
  return;
9701
11303
  }
11304
+ // 执行轻量磁盘发现(仅读会话文件首行头部获取 cwd,不解析全部消息历史)
11305
+ const sessionRoots = [resolve(this.agentDir, "sessions")];
11306
+ const extra = piSessionsRoot();
11307
+ if (extra)
11308
+ sessionRoots.push(resolve(extra));
9702
11309
  const run = (async () => {
9703
11310
  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;
11311
+ const discovered = await discoverRecentProjectsFromDisk(sessionRoots, 30);
11312
+ this.stateStore.mergeDiscoveredProjects(discovered);
11313
+ const merged = await this.stateStore.getRecentProjects(this.clientId);
11314
+ ClientSession.projectsCache = { at: Date.now(), projects: merged };
11315
+ const result = this.withCurrentCwd(merged, Date.now());
11316
+ this.emit({ type: "projects", projects: result });
11317
+ return merged;
9729
11318
  }
9730
11319
  catch {
9731
- this.emit({ type: "projects", projects: [] });
9732
- return null;
11320
+ const fallback = this.withCurrentCwd(await this.stateStore.getRecentProjects(this.clientId), Date.now());
11321
+ this.emit({ type: "projects", projects: fallback });
11322
+ return fallback;
9733
11323
  }
9734
11324
  finally {
9735
- this.projectsInFlight = null;
11325
+ ClientSession.projectsInFlight = null;
9736
11326
  }
9737
11327
  })();
9738
- this.projectsInFlight = run;
11328
+ ClientSession.projectsInFlight = run;
9739
11329
  await run;
9740
11330
  }
9741
11331
  /** 缓存命中时把当前 cwd 并进去:命中则刷新 lastUsed 重排,未命中则补到首位
@@ -9756,7 +11346,7 @@ export class ClientSession {
9756
11346
  }
9757
11347
  /** 最近项目缓存失效(用户显式移除项目后,下一次推送必须重扫)。 */
9758
11348
  invalidateProjectsCache() {
9759
- this.projectsCache = null;
11349
+ ClientSession.projectsCache = null;
9760
11350
  }
9761
11351
  /** List a workspace directory (relative to the configured cwd). */
9762
11352
  async listFiles(relPath) {
@@ -9768,8 +11358,9 @@ export class ClientSession {
9768
11358
  }
9769
11359
  /** 全局搜索:在当前工作区的会话转录全文里做大小写不敏感匹配 ——
9770
11360
  * 不止首条消息,而是每一段 user 与 assistant 文本(AI 输出也在内)。
9771
- * 结果经 session_search_results 回推(reqId 匹配);复用 loadSessionInfos()
9772
- * 缓存,避免每个按键都重新解析全部转录文件。 */
11361
+ * 结果经 session_search_results 回推(reqId 匹配);元信息复用 loadSessionInfos()
11362
+ * 缓存,转录全文按需加载(issue #440,小容量短 TTL 缓存),避免每个按键都重新
11363
+ * 解析全部转录文件、也不让全文长期驻留内存。 */
9773
11364
  async searchSessions(query, reqId) {
9774
11365
  const q = query.trim().toLowerCase();
9775
11366
  if (!q) {
@@ -9778,11 +11369,12 @@ export class ClientSession {
9778
11369
  }
9779
11370
  try {
9780
11371
  const infos = await this.loadSessionInfos();
9781
- const results = infos
9782
- .filter((s) => sessionMatchesSearch(q, s))
11372
+ const matched = await ClientSession.filterSessionsForSearch(q, infos);
11373
+ const results = matched
9783
11374
  .sort((a, b) => b.modified.getTime() - a.modified.getTime())
9784
11375
  .slice(0, 50)
9785
11376
  .map((s) => {
11377
+ const isPinned = this.stateStore?.isSessionPinned(this.cwd, s.path);
9786
11378
  const base = {
9787
11379
  path: s.path,
9788
11380
  name: s.name,
@@ -9790,6 +11382,7 @@ export class ClientSession {
9790
11382
  messageCount: s.messageCount,
9791
11383
  modified: s.modified.getTime(),
9792
11384
  source: "web",
11385
+ ...(isPinned ? { pinned: true } : {}),
9793
11386
  };
9794
11387
  // 命中会话里再定位具体消息(供点击跳转);仅元数据命中则无锚点
9795
11388
  return { ...base, anchors: collectSessionAnchors(s.path, q) };
@@ -9918,26 +11511,6 @@ export class ClientSession {
9918
11511
  await this.setCwd(created);
9919
11512
  }
9920
11513
  }
9921
- async cycleModel() {
9922
- try {
9923
- const result = await this.session.cycleModel();
9924
- if (result?.model) {
9925
- const mid = `${result.model.provider}/${result.model.id}`;
9926
- await this.restoreKeyForModel(mid, this.cwd);
9927
- // Remember per-project like setModel — cycling is also a model switch.
9928
- this.rememberProjectModel(mid);
9929
- }
9930
- }
9931
- catch (err) {
9932
- this.emit({
9933
- type: "notice",
9934
- level: "error",
9935
- text: `切换模型失败:${err.message}`,
9936
- textEn: `Failed to switch model: ${err.message}`,
9937
- });
9938
- }
9939
- this.flushSnapshot();
9940
- }
9941
11514
  /**
9942
11515
  * Path completion for the cwd input: expand ~/relative paths, list the parent
9943
11516
  * directory, and return prefix matches (dirs first, capped).
@@ -10085,6 +11658,9 @@ export class ClientSession {
10085
11658
  }
10086
11659
  }
10087
11660
  await this.bindSession();
11661
+ // #436:同 switchSession —— 续聊最近会话若从转录回放恢复了 planMode,
11662
+ // 工厂里的门控当时读不到它,注册后补一次。
11663
+ this.applyToolGating(conv.session, conv.agentPreset);
10088
11664
  if (resumeSkipped) {
10089
11665
  this.emit(resumeSkipped.isStreaming
10090
11666
  ? {
@@ -10373,24 +11949,6 @@ export class ClientSession {
10373
11949
  }
10374
11950
  this.flushSnapshot();
10375
11951
  }
10376
- cycleThinking() {
10377
- try {
10378
- const nextLevel = this.session.cycleThinkingLevel({ persist: true });
10379
- const cur = this.session.model;
10380
- if (cur && nextLevel) {
10381
- this.session.settingsManager.setModelThinkingLevel(cur.provider, cur.id, nextLevel);
10382
- }
10383
- }
10384
- catch (err) {
10385
- this.emit({
10386
- type: "notice",
10387
- level: "error",
10388
- text: `切换思考强度失败:${err.message}`,
10389
- textEn: `Failed to switch thinking level: ${err.message}`,
10390
- });
10391
- }
10392
- this.flushSnapshot();
10393
- }
10394
11952
  /** Push the user command list (.pi/commands.json) to the client. */
10395
11953
  async listCommands() {
10396
11954
  const { commands, path, warning, warningEn } = await loadCommands(this.cwd);
@@ -10493,8 +12051,13 @@ export function checkPluginCwd(cwd) {
10493
12051
  }
10494
12052
  return { ok: true, abs };
10495
12053
  }
12054
+ /** 插件/调度伪客户端:sink 常驻(fire-and-forget 的空函数),不能按浏览器存活判断。 */
12055
+ export function isPseudoClientId(id) {
12056
+ return id.startsWith("plugin:") || id.startsWith("scheduler:");
12057
+ }
10496
12058
  export class AgentService {
10497
12059
  cwd;
12060
+ static isPseudoClientId = isPseudoClientId;
10498
12061
  /** index.ts 注入:SDK 工具执行事件的插件转发钩子,attach 时拷贝到每个新会话。 */
10499
12062
  onToolEvent = undefined;
10500
12063
  /** index.ts 注入:bash/read 插件拦截钩子,attach 时拷贝到每个新会话。 */
@@ -10808,10 +12371,16 @@ export class AgentService {
10808
12371
  continue;
10809
12372
  // issue #291:跳过无浏览器连接的残骸(非伪客户端 sinkCount=0 = 断连留存)。
10810
12373
  // 伪客户端(scheduler:/plugin:)不走浏览器,sinkCount 永远为 0,保留。
10811
- if (!AgentService.isPseudoClientId(clientId) && cs.sinkCount() === 0)
12374
+ const isPseudo = AgentService.isPseudoClientId(clientId);
12375
+ if (!isPseudo && cs.sinkCount() === 0)
10812
12376
  continue;
10813
- for (const r of cs.streamingSummariesAll())
10814
- out.push({ ...r, owner: clientId });
12377
+ for (const r of cs.streamingSummariesAll()) {
12378
+ out.push({
12379
+ ...r,
12380
+ owner: clientId,
12381
+ ...(isPseudo ? { pseudo: true } : {}),
12382
+ });
12383
+ }
10815
12384
  }
10816
12385
  return out;
10817
12386
  }
@@ -11086,10 +12655,7 @@ export class AgentService {
11086
12655
  service: toServiceInfo(launchOrigin()),
11087
12656
  };
11088
12657
  }
11089
- /** 插件/调度伪客户端:sink 常驻(fire-and-forget 的空函数),不能按浏览器存活判断。 */
11090
- static isPseudoClientId(id) {
11091
- return id.startsWith("plugin:") || id.startsWith("scheduler:");
11092
- }
12658
+ // (原 private static isPseudoClientId 已抽到模块级导出并挂为 static 属性)
11093
12659
  /**
11094
12660
  * 浏览器重启认领:给 fresh clientId 找一个可接管的断开残留会话(返回旧 id)。
11095
12661
  * 有别的在线浏览器时返回 null(issue #10 隔离优先)。
@@ -11186,7 +12752,7 @@ export class AgentService {
11186
12752
  return;
11187
12753
  }
11188
12754
  if (AgentService.isPseudoClientId(ownerId)) {
11189
- fail("定时任务/插件会话不支持过户", "Scheduler/plugin sessions cannot be taken over.");
12755
+ fail("定时任务/插件会话不支持过户 —— 报告会自动落回绑定的对话,可在后台任务面板查看进度", "Scheduler/plugin sessions cannot be taken over — reports are saved to the bound conversation automatically; check progress in the Background Tasks panel.");
11190
12756
  return;
11191
12757
  }
11192
12758
  const source = this.clients.get(ownerId);
@@ -11212,7 +12778,7 @@ export class AgentService {
11212
12778
  const movedMains = briefs.filter((b) => moveSet.has(b.id) && !b.isSubagent && !b.isEphemeral).length;
11213
12779
  const openInProject = target.takeoverBriefs().filter((b) => b.cwd === main.cwd && !b.isSubagent && !b.isEphemeral).length + movedMains;
11214
12780
  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.`);
12781
+ 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
12782
  return;
11217
12783
  }
11218
12784
  try {
@@ -11243,6 +12809,7 @@ export class AgentService {
11243
12809
  textEn: `"${main.title}" was moved to this page — pick up right where it left off.`,
11244
12810
  });
11245
12811
  await target.switchConversation(newMainId);
12812
+ await target.bindSession();
11246
12813
  }
11247
12814
  catch (err) {
11248
12815
  fail(`过户失败:${err.message}`, `Takeover failed: ${err.message}`);
@@ -11266,7 +12833,7 @@ export class AgentService {
11266
12833
  if (ownerId === targetId)
11267
12834
  return; // 自己的问卷走本地通道,不需要预告
11268
12835
  if (AgentService.isPseudoClientId(ownerId)) {
11269
- fail("定时任务/插件会话的问卷不支持跨页作答", "Scheduler/plugin session questions cannot be answered cross-page.");
12836
+ 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
12837
  return;
11271
12838
  }
11272
12839
  const source = this.clients.get(ownerId);