pi-web-ui 0.95.0 → 0.96.1

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 (75) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/bin/pi-web-ui.mjs +159 -31
  3. package/dist/server/agent-service.js +880 -240
  4. package/dist/server/approval-rules.js +56 -10
  5. package/dist/server/attachment-store.js +21 -4
  6. package/dist/server/attachments.js +30 -3
  7. package/dist/server/bg-servers.js +45 -7
  8. package/dist/server/claim-files-tool.js +4 -8
  9. package/dist/server/claim-store.js +4 -2
  10. package/dist/server/client-state.js +63 -14
  11. package/dist/server/compact-context-tool.js +6 -5
  12. package/dist/server/composer-drafts.js +9 -0
  13. package/dist/server/control-socket.js +57 -28
  14. package/dist/server/conversation-read-tool.js +7 -17
  15. package/dist/server/dangling-tools.js +72 -5
  16. package/dist/server/delegate-task.js +25 -28
  17. package/dist/server/dsh/dsh-agent-service.js +19 -6
  18. package/dist/server/edit-soft-tool.js +14 -11
  19. package/dist/server/eval-tool.js +67 -14
  20. package/dist/server/file-archives.js +16 -5
  21. package/dist/server/files-service.js +30 -11
  22. package/dist/server/goal-service.js +141 -19
  23. package/dist/server/hashline-engine.js +59 -21
  24. package/dist/server/host-guard.js +94 -0
  25. package/dist/server/i18n.js +3 -3
  26. package/dist/server/index.js +331 -78
  27. package/dist/server/lsp-tool.js +752 -60
  28. package/dist/server/mcp-bridge.js +47 -3
  29. package/dist/server/model-admin.js +94 -34
  30. package/dist/server/office-parse.js +96 -30
  31. package/dist/server/patch-tool.js +9 -15
  32. package/dist/server/permission-preset.js +25 -0
  33. package/dist/server/plan-manager.js +11 -1
  34. package/dist/server/plugin-catalog-sync.js +36 -19
  35. package/dist/server/plugin-catalog.js +11 -4
  36. package/dist/server/plugin-facilities.js +92 -21
  37. package/dist/server/plugin-installer.js +30 -0
  38. package/dist/server/plugin-llm.js +71 -65
  39. package/dist/server/plugin-project.js +70 -2
  40. package/dist/server/plugin-updater.js +115 -12
  41. package/dist/server/plugins.js +205 -44
  42. package/dist/server/present-files-tool.js +9 -12
  43. package/dist/server/process-utils.js +4 -0
  44. package/dist/server/prompt-composer.js +9 -0
  45. package/dist/server/protocol-version.js +1 -1
  46. package/dist/server/read-tool.js +69 -30
  47. package/dist/server/resolve-global-sdk.js +30 -16
  48. package/dist/server/schedule-agent-tool.js +12 -15
  49. package/dist/server/sdk-origin.js +18 -2
  50. package/dist/server/serialize.js +108 -21
  51. package/dist/server/settings-service.js +4 -2
  52. package/dist/server/skill-tool.js +5 -7
  53. package/dist/server/subagent-templates.js +51 -0
  54. package/dist/server/subagents.js +48 -72
  55. package/dist/server/terminals.js +181 -69
  56. package/dist/server/tool-manager.js +24 -7
  57. package/dist/server/tool-overrides.js +59 -0
  58. package/dist/server/update-check.js +28 -1
  59. package/dist/server/uploads.js +17 -2
  60. package/dist/server/wait-subscription-scan.js +18 -21
  61. package/dist/server/ws-client-id.js +29 -0
  62. package/dist/server/ws-pending-queue.js +60 -0
  63. package/extensions/webui.ts +60 -2
  64. package/package.json +4 -2
  65. package/plugins/catalog.json +20 -0
  66. package/themes/aetheris.css +457 -0
  67. package/themes/zhupi-dark.css +718 -0
  68. package/themes/zhupi.css +771 -0
  69. package/web/dist/assets/{TerminalPanel-DlGBF2MD.js → TerminalPanel-O3GyFIGG.js} +1 -1
  70. package/web/dist/assets/index-B7Zp23Vc.css +1 -0
  71. package/web/dist/assets/index-ByfoaDwp.js +366 -0
  72. package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
  73. package/web/dist/index.html +3 -3
  74. package/web/dist/assets/index-BXnCQL9p.js +0 -364
  75. package/web/dist/assets/index-RpIGFyi_.css +0 -1
@@ -17,14 +17,16 @@ import "./patch-remote-catalog.js";
17
17
  import { spawn } from "node:child_process";
18
18
  import { createHash, randomUUID } from "node:crypto";
19
19
  import { createRequire } from "node:module";
20
- import { appendFileSync, existsSync, readFileSync, rmSync, statSync, mkdirSync, watch, writeFileSync } from "node:fs";
20
+ import { appendFileSync, existsSync, readFileSync, renameSync, rmSync, statSync, mkdirSync, watch, writeFileSync, } from "node:fs";
21
21
  import { homedir } from "node:os";
22
- import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path";
22
+ import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
23
23
  import { fileURLToPath } from "node:url";
24
24
  import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashToolDefinition, createEditToolDefinition, createLocalBashOperations, createWriteToolDefinition, getAgentDir, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
25
25
  import { Type } from "typebox";
26
26
  import { BgServerTracker } from "./bg-servers.js";
27
- import { checkAll as checkAllUpdates, collectTargets, compareVersions as compareSemver, resolveNpmRegistry, sortUpdateItems, } from "./update-check.js";
27
+ import { checkAll as checkAllUpdates, collectTargets, compareVersions as compareSemver, detectPiSdkSplit, resolveNpmRegistry, sortUpdateItems, } from "./update-check.js";
28
+ import { checkPluginUpdates } from "./plugin-updater.js";
29
+ import { isBundledInUse, sdkCopies } from "./sdk-origin.js";
28
30
  import { hasActiveSubagentRun, hasPendingWaitSubscription, shouldRetainActive } from "./wait-subscription-scan.js";
29
31
  import { COMPACTION_PENDING_TYPE, looksLikeChainCorruption, makeCompactionMarkerId, repairSessionFile, } from "./compaction-markers.js";
30
32
  import { DANGLING_TOOL_RESULT_TEXT, DANGLING_TOOL_RESULT_TEXT_EN, findDanglingToolCalls, healDanglingToolCallFile, } from "./dangling-tools.js";
@@ -32,17 +34,19 @@ import { removeQueuedByIndexOrText } from "./queue-utils.js";
32
34
  import { syncPluginToolsIntoSession } from "./plugins.js";
33
35
  import { denialText, } from "./plugin-tool-guard.js";
34
36
  import { SettingsService } from "./settings-service.js";
35
- import { GoalService } from "./goal-service.js";
37
+ import { GoalService, buildDiffFingerprint } from "./goal-service.js";
36
38
  import { MarkerService } from "./marker-service.js";
37
39
  import { SlashCommandsService, parseSlash } from "./slash-commands.js";
38
40
  import { ModelAdminService } from "./model-admin.js";
39
41
  import { FilesService, MACHINE_ROOT, desktopDirWire, workspacePath } from "./files-service.js";
40
- import { DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS, effectiveToolWatchdogMs, isExtensionDisabled, isExtensionEnabled, normalizeDisabledPluginTools, normalizeRetryMaxAttempts, normalizeSkillList, ClientStateStore, } from "./client-state.js";
41
- import { bilingual, pick, resolveServerLang } from "./i18n.js";
42
+ import { DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS, effectiveToolWatchdogMs, isExtensionDisabled, isExtensionEnabled, normalizeDisabledPluginTools, normalizePathKey, normalizeRetryMaxAttempts, normalizeSkillList, ClientStateStore, } from "./client-state.js";
43
+ import { pick, resolveServerLang } from "./i18n.js";
42
44
  import { SubagentTemplatesStore, pickTemplatePrompt } from "./subagent-templates.js";
43
- import { ApprovalRulesStore } from "./approval-rules.js";
45
+ import { ApprovalRulesStore, extractTargetPath } from "./approval-rules.js";
44
46
  import { ComposerDraftsStore } from "./composer-drafts.js";
47
+ import { readPermissionFromSession } from "./permission-preset.js";
45
48
  import { createWorkspaceSnapshot, restoreWorkspaceSnapshot } from "./workspace-snapshot.js";
49
+ import { isPathInsideRoot } from "./approval-rules.js";
46
50
  import { approvalSuppressionReason, checkDangerousToolCall, isApprovalPolicyEmpty, pluginApprovalCategory, } from "./tool-approval.js";
47
51
  import { PlanManager } from "./plan-manager.js";
48
52
  import { applyHeadTail, makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, } from "./terminals.js";
@@ -53,7 +57,9 @@ import { pruneContextHierarchically } from "./context-budget.js";
53
57
  import { decodeText } from "./text-sniff.js";
54
58
  import { makeEditSoftTool } from "./edit-soft-tool.js";
55
59
  // 覆盖 SDK 内置 read:路径是目录时列出目录条目(行为开关 readDirEnabled,默认开)。
56
- import { makeReadDirTool } from "./read-tool.js";
60
+ // 覆盖定义与「与扩展同名工具共存」的注入辅助分在两个文件(后者的依据见 tool-overrides.ts)。
61
+ import { makeReadDirTool, withReadDirSupport } from "./read-tool.js";
62
+ import { installToolOverrides, } from "./tool-overrides.js";
57
63
  // 展示文件给用户(present_files):图片/视频内联、文本开预览弹窗、本地打开按钮。
58
64
  import { makePresentFilesTool } from "./present-files-tool.js";
59
65
  // 主动压缩上下文工具(compact_context):AI 主动根据当前问题精简上下文并自主控制范围。
@@ -77,9 +83,9 @@ import { buildAttachmentMessages, parseModelSpec } from "./attachments.js";
77
83
  import { buildVisionBridgePrompt, findVisionModels, transcribeImages } from "./vision-bridge.js";
78
84
  import { isNotRepoError, scmCommitContext } from "./scm.js";
79
85
  import { buildCommitMsgInput, buildCommitMsgPrompt, sanitizeCommitMessage } from "./scm-commitmsg.js";
80
- import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, buildToolsSchemaText, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
86
+ import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, buildToolsSchemaText, estimatePromptTokens, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
81
87
  import { launchOrigin, toServiceInfo } from "./launch-origin.js";
82
- import { serializeMessage, serializeStreamingMessage, stripTransientRetryErrors, } from "./serialize.js";
88
+ import { findEntryByUiId, serializeMessage, serializeStreamingMessage, stripTransientRetryErrors, } from "./serialize.js";
83
89
  import { loadCommands, saveCommandsFile, TerminalManager } from "./terminals.js";
84
90
  const SNAPSHOT_INTERVAL_MS = 60;
85
91
  /** 服务进程所在机器的用户主目录(wire 格式):进程内不变,模块加载时求值一次,
@@ -181,7 +187,7 @@ Many legacy Chinese text files (.html/.txt/.md/.log, exported documents) are GBK
181
187
  * schema stays consistent with the terminal-backed tool.
182
188
  */
183
189
  export function makeKillableBashTool(cwd, kills,
184
- /** per-call 返回文本的服务端语言(默认英文);工具 definition 走 bilingual 内联双语。 */
190
+ /** per-call 返回文本的服务端语言(默认英文);工具 definition 为纯英文。 */
185
191
  lang = () => "en") {
186
192
  const base = createLocalBashOperations();
187
193
  const tool = createBashToolDefinition(cwd, {
@@ -206,7 +212,7 @@ lang = () => "en") {
206
212
  return {
207
213
  name: tool.name,
208
214
  label: tool.label,
209
- description: "Run a shell command natively (process spawn, no terminal) and return its full output plus exit code — the SDK's plain bash tool. persist is ignored here (no terminal); use head/tail to trim the returned output.",
215
+ description: "Run a shell command natively (process spawn, no terminal); returns full output plus exit code. persist is ignored here; use head/tail to trim returned output.",
210
216
  parameters: Type.Object({
211
217
  command: Type.String({ description: "The shell command to run" }),
212
218
  timeout: Type.Optional(Type.Number({ description: "Optional timeout in seconds" })),
@@ -246,9 +252,9 @@ export function makeAdaptiveBashTool(killable, terminalBacked, useTerminal) {
246
252
  return {
247
253
  ...killable,
248
254
  description: "Run a shell command and return its full output plus exit code. Behavior depends on the「default bash override」setting (terminalBash):\n" +
249
- "Setting OFF → runs natively (process spawn, no terminal) — the SDK's plain bash tool. persist has no effect.\n" +
250
- "Setting ON → runs in a visible terminal. persist=true keeps that terminal alive ('ai-bash': shell state such as cd/venv/ssh retained across calls, silent commands move to the background and notify when done); persist=false (default in terminal mode) creates a one-shot terminal that exits when the command finishes while its output stays for review.\n" +
251
- "Run the bare command — do NOT pipe through head/tail/more/less (use the head/tail parameters to trim the returned output instead; piping also hides live progress in the visible terminal). For interactive commands (REPLs, prompts, installers asking y/n) set persist=true (terminal mode) and drive them with terminal_input / terminal_key.",
255
+ "OFF → runs natively (process spawn, no terminal); persist has no effect.\n" +
256
+ "ON → runs in a visible terminal. persist=true keeps the 'ai-bash' terminal alive (shell state cd/venv/ssh retained across calls); persist=false (default) is a one-shot terminal whose output stays viewable.\n" +
257
+ "Run the bare command — never pipe through head/tail/more/less (use the head/tail params; pipes hide live progress). For interactive commands (REPLs, y/n prompts) set persist=true and drive them with terminal_input / terminal_key.",
252
258
  promptSnippet: "run shell commands",
253
259
  execute: (id, params, signal, onUpdate, ctx) => {
254
260
  const p = params;
@@ -294,13 +300,37 @@ export function withToolGuard(def, opts) {
294
300
  let approvalReason;
295
301
  let approvalReasonEn;
296
302
  let approvalCategory;
303
+ // 1. 系统核心安全:内置高危操作检测与自定义审批规则先行(优先级最高,防短路)
304
+ let danger;
305
+ if (opts.cwd && opts.askApproval) {
306
+ danger = checkDangerousToolCall(toolName, params, opts.cwd, opts.getRoots?.() ?? [], opts.getRules?.());
307
+ if (danger.denied) {
308
+ const reasonText = danger.reason ? ` 原因:${danger.reason}` : "";
309
+ const reasonTextEn = danger.reasonEn ? ` Reason: ${danger.reasonEn}` : "";
310
+ const text = pick(lang, `【工具执行被审批规则直接阻断】${reasonText}`, `[Tool execution blocked by approval rule]${reasonTextEn}`);
311
+ return {
312
+ content: [{ type: "text", text }],
313
+ details: { guardDenied: true, ruleDenied: true, reason: danger.reason },
314
+ isError: true,
315
+ };
316
+ }
317
+ }
318
+ // 2. 插件前置守卫 deny 拦截(带 isError 标记)
297
319
  if (pre?.verdict.decision === "deny") {
298
320
  const text = denialText(pre.verdict, pre.pluginId ?? "plugin", lang);
299
321
  return {
300
322
  content: [{ type: "text", text }],
301
323
  details: { guardDenied: true, decision: pre.verdict.decision, pluginId: pre.pluginId },
324
+ isError: true,
302
325
  };
303
326
  }
327
+ // 3. 决定是否需要弹窗审批(系统高危 ask 优先于插件通用 ask,防止恶意或低危插件掩盖高危告警)
328
+ if (danger?.dangerous) {
329
+ needApproval = true;
330
+ approvalReason = danger.reason;
331
+ approvalReasonEn = danger.reasonEn;
332
+ approvalCategory = danger.category;
333
+ }
304
334
  else if (pre?.verdict.decision === "ask") {
305
335
  needApproval = true;
306
336
  approvalReason = pre.verdict.reason ?? "插件要求确认本次操作";
@@ -308,26 +338,6 @@ export function withToolGuard(def, opts) {
308
338
  // 插件档位按插件 id 分:用户可以「允许同类」= 该插件的确认以后不再问。
309
339
  approvalCategory = pluginApprovalCategory(pre.pluginId ?? "plugin");
310
340
  }
311
- // 内置高危操作检测(如 bash 破坏性命令等)与自定义审批规则
312
- if (!needApproval && opts.cwd && opts.askApproval) {
313
- const danger = checkDangerousToolCall(toolName, params, opts.cwd, opts.getRoots?.() ?? [], opts.getRules?.());
314
- if (danger.denied) {
315
- const reasonText = danger.reason ? ` 原因:${danger.reason}` : "";
316
- const reasonTextEn = danger.reasonEn ? ` Reason: ${danger.reasonEn}` : "";
317
- const text = pick(lang, `【工具执行被审批规则直接阻断】${reasonText}`, `[Tool execution blocked by approval rule]${reasonTextEn}`);
318
- return {
319
- content: [{ type: "text", text }],
320
- details: { guardDenied: true, ruleDenied: true, reason: danger.reason },
321
- isError: true,
322
- };
323
- }
324
- if (danger.dangerous) {
325
- needApproval = true;
326
- approvalReason = danger.reason;
327
- approvalReasonEn = danger.reasonEn;
328
- approvalCategory = danger.category;
329
- }
330
- }
331
341
  let effectiveParams = params;
332
342
  let userEdited = false;
333
343
  if (needApproval && opts.askApproval) {
@@ -362,7 +372,7 @@ export function withToolGuard(def, opts) {
362
372
  const result = (await def.execute(toolCallId, effectiveParams, signal, onUpdate, ctx));
363
373
  if (userEdited && result && typeof result === "object") {
364
374
  result.details = {
365
- ...(result.details ?? {}),
375
+ ...result.details,
366
376
  userEdited: true,
367
377
  originalParams: params,
368
378
  executedParams: effectiveParams,
@@ -383,15 +393,18 @@ export function withToolGuard(def, opts) {
383
393
  }),
384
394
  };
385
395
  }
386
- /** 校验目标路径是否在工作区(或多根工作区)内。 */
396
+ /** 校验目标路径是否在工作区(或多根工作区)内。严格规范化防止 ".." 逃逸与 Windows 盘符大小写不一致。 */
387
397
  function isInsideWorkspaceRoots(targetPath, cwd, roots = []) {
388
- const abs = isAbsolute(targetPath) ? targetPath : resolve(cwd, targetPath);
398
+ const abs = resolve(cwd, targetPath);
389
399
  const allRoots = [resolve(cwd), ...roots.map((r) => resolve(r))];
390
- return allRoots.some((r) => abs === r || abs.startsWith(r + sep));
400
+ return allRoots.some((r) => isPathInsideRoot(abs, r));
391
401
  }
392
- /** 为 write 工具包装会话级权限沙箱与人机协同审批。 */
393
- function wrapWriteToolWithPermission(cwd, getPermission, getRoots, getLang, askApproval, getConversationId, getRules) {
394
- const base = createWriteToolDefinition(cwd);
402
+ /**
403
+ * 为 write 工具包装会话级权限沙箱与人机协同审批。
404
+ * `base` = 覆盖基底:第三方扩展注册的同名 write 优先(见 tool-overrides.ts),
405
+ * 省略则是 SDK 内置实现 —— 于是权限门禁叠在扩展实现之上,而不是把它顶掉。
406
+ */
407
+ function wrapWriteToolWithPermission(cwd, getPermission, getRoots, getLang, askApproval, getConversationId, getRules, base = createWriteToolDefinition(cwd)) {
395
408
  return {
396
409
  ...base,
397
410
  execute: async (toolCallId, params, signal, onUpdate, ctx) => {
@@ -408,7 +421,7 @@ function wrapWriteToolWithPermission(cwd, getPermission, getRoots, getLang, askA
408
421
  };
409
422
  }
410
423
  if (perm === "workspace-write-never") {
411
- const p = params?.path ?? "";
424
+ const p = extractTargetPath(params);
412
425
  if (!isInsideWorkspaceRoots(p, cwd, getRoots())) {
413
426
  return {
414
427
  content: [
@@ -461,7 +474,7 @@ function wrapWriteToolWithPermission(cwd, getPermission, getRoots, getLang, askA
461
474
  const result = (await base.execute(toolCallId, effectiveParams, signal, onUpdate, ctx));
462
475
  if (userEdited && result && typeof result === "object") {
463
476
  result.details = {
464
- ...(result.details ?? {}),
477
+ ...result.details,
465
478
  userEdited: true,
466
479
  originalParams: params,
467
480
  executedParams: effectiveParams,
@@ -471,9 +484,10 @@ function wrapWriteToolWithPermission(cwd, getPermission, getRoots, getLang, askA
471
484
  },
472
485
  };
473
486
  }
474
- /** 为 edit 工具包装会话级权限沙箱与人机协同审批。 */
475
- function wrapEditToolWithPermission(cwd, getPermission, getRoots, getLang, askApproval, getConversationId, getRules) {
476
- const base = createEditToolDefinition(cwd);
487
+ /**
488
+ * 为 edit 工具包装会话级权限沙箱与人机协同审批(基底语义同 write)。
489
+ */
490
+ function wrapEditToolWithPermission(cwd, getPermission, getRoots, getLang, askApproval, getConversationId, getRules, base = createEditToolDefinition(cwd)) {
477
491
  return {
478
492
  ...base,
479
493
  execute: async (toolCallId, params, signal, onUpdate, ctx) => {
@@ -490,7 +504,7 @@ function wrapEditToolWithPermission(cwd, getPermission, getRoots, getLang, askAp
490
504
  };
491
505
  }
492
506
  if (perm === "workspace-write-never") {
493
- const p = params?.path ?? "";
507
+ const p = extractTargetPath(params);
494
508
  if (!isInsideWorkspaceRoots(p, cwd, getRoots())) {
495
509
  return {
496
510
  content: [
@@ -543,7 +557,7 @@ function wrapEditToolWithPermission(cwd, getPermission, getRoots, getLang, askAp
543
557
  const result = (await base.execute(toolCallId, effectiveParams, signal, onUpdate, ctx));
544
558
  if (userEdited && result && typeof result === "object") {
545
559
  result.details = {
546
- ...(result.details ?? {}),
560
+ ...result.details,
547
561
  userEdited: true,
548
562
  originalParams: params,
549
563
  executedParams: effectiveParams,
@@ -624,7 +638,7 @@ function wrapEditSoftToolWithPermission(tool, cwd, getPermission, getRoots, getL
624
638
  const result = (await tool.execute(toolCallId, effectiveParams, signal, onUpdate, ctx));
625
639
  if (userEdited && result && typeof result === "object") {
626
640
  result.details = {
627
- ...(result.details ?? {}),
641
+ ...result.details,
628
642
  userEdited: true,
629
643
  originalParams: params,
630
644
  executedParams: effectiveParams,
@@ -637,17 +651,25 @@ function wrapEditSoftToolWithPermission(tool, cwd, getPermission, getRoots, getL
637
651
  /**
638
652
  * 结构化任务执行计划更新工具(plan_update)— Plan Mode / Step State Machine。
639
653
  */
640
- function makePlanUpdateTool(planManager, getActiveConvId, emit, flushSnapshot) {
654
+ export function makePlanUpdateTool(planManager, getActiveConvId, emit, flushSnapshot) {
641
655
  return {
642
656
  name: PLAN_UPDATE_TOOL_NAME,
643
657
  label: "plan_update",
644
- description: "Update the structured task execution plan / step state machine (Plan Mode). Use it for non-trivial tasks to break down work into steps, track live progress, and update status (pending -> in_progress -> done/failed).\n更新结构化任务执行计划(步骤状态机)。用于复杂工程任务拆解与实时进度推进。",
658
+ description: "Update the structured task plan / step state machine (Plan Mode): break non-trivial work into decision-ready steps and track progress (pending -> in_progress -> done/failed). Prefer steps that note discovery conclusions, files to be touched, and a rollback strategy.",
659
+ promptSnippet: "update structured task plan with decision-ready steps, file touch list, and live status",
660
+ promptGuidelines: [
661
+ "When executing non-trivial tasks, use plan_update early to outline decision-ready steps before coding: " +
662
+ "specify discovery conclusions, explicitly list files to be touched (File Touch List), and note potential rollback strategies",
663
+ "Keep step status updated as work progresses (pending -> in_progress -> done/failed) so the user has real-time visibility",
664
+ ],
645
665
  parameters: Type.Object({
646
666
  steps: Type.Array(Type.Object({
647
667
  id: Type.String({ description: "Unique step ID, e.g. '1', 'step-1'" }),
648
668
  title: Type.String({ description: "Short step title" }),
649
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)" })),
650
- description: Type.Optional(Type.String({ description: "Optional detailed description or acceptance criteria" })),
670
+ description: Type.Optional(Type.String({
671
+ description: "Optional detailed description, acceptance criteria, file touch list, or rollback note.",
672
+ })),
651
673
  }), { description: "List of plan steps" }),
652
674
  activeStepId: Type.Optional(Type.String({ description: "ID of the step currently being executed" })),
653
675
  }),
@@ -697,12 +719,12 @@ function makeMarkersListTool(getActiveId, markerSvc) {
697
719
  return {
698
720
  name: MARKERS_LIST_TOOL_NAME,
699
721
  label: "List marker state",
700
- description: "Read-only query of inline marker state. All WRITE operations must use inline markers ([[todo:new:...]] etc.) in the reply body — never use this tool for writes.\n只读查询内联标记状态。状态【写】操作请一律用内联标记([[todo:new:...]] 等)写在回答正文里,不要调用本工具做写操作。",
722
+ 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.",
701
723
  parameters: Type.Object({
702
724
  action: Type.Unsafe({ enum: ["list"] }),
703
725
  tool: Type.Optional(Type.Literal("todo")),
704
726
  includeDeleted: Type.Optional(Type.Boolean({
705
- description: "Whether to include deleted tasks (tombstones, todo only).\n是否包含已删除任务(tombstone,仅 todo)。",
727
+ description: "Include deleted tasks (tombstones, todo only).",
706
728
  })),
707
729
  }),
708
730
  execute: async (_id, params) => {
@@ -735,23 +757,29 @@ export function makeAskUserQuestionTool(clientSession,
735
757
  /** 本 runtime 所属会话:提问跟着对话走,快照只把当前对话的问卷推给客户端。 */
736
758
  ownerId) {
737
759
  const QuestionOptionSchema = Type.Object({
738
- label: Type.String({ description: "Display label for the option" }),
739
- description: Type.Optional(Type.String({ description: "Optional description shown below label" })),
760
+ label: Type.String({ description: "Display label for the option (1-5 words)" }),
761
+ description: Type.Optional(Type.String({
762
+ description: "One short sentence explaining the impact or tradeoff if selected.",
763
+ })),
740
764
  preview: Type.Optional(Type.String({
741
765
  description: "Optional preview rendered below when this option is selected (markdown or HTML — use for mockups/code/config).",
742
766
  })),
743
767
  });
744
768
  const QuestionSchema = Type.Object({
745
- id: Type.String({ description: "Unique identifier for this question" }),
769
+ id: Type.String({ description: "Unique identifier for this question (snake_case)" }),
746
770
  question: Type.String({ description: "The full question text to display (markdown/HTML ok)" }),
747
771
  detail: Type.Optional(Type.String({ description: "Optional detail/context shown under the question" })),
748
772
  header: Type.Optional(Type.String({ description: "Optional short header for this question" })),
749
- options: Type.Optional(Type.Array(QuestionOptionSchema, { description: "Available options to choose from" })),
773
+ options: Type.Optional(Type.Array(QuestionOptionSchema, {
774
+ description: "2-4 mutually exclusive choices. Put the recommended option first when there is a clear default.",
775
+ minItems: 2,
776
+ maxItems: 4,
777
+ })),
750
778
  multiSelect: Type.Optional(Type.Boolean({ description: "Allow selecting multiple options (default: false)" })),
751
779
  dependsOn: Type.Optional(Type.Object({
752
780
  questionId: Type.String({ description: "ID of the prior question this question depends on" }),
753
781
  value: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())], {
754
- description: "Show this question only when the prior question's answer matches this value (or any in the array). Omit to show whenever answered.",
782
+ description: "Show only when the prior answer equals this value (or is in the array). Omit to show whenever answered.",
755
783
  })),
756
784
  })),
757
785
  optionsMap: Type.Optional(Type.Record(Type.String(), Type.Array(QuestionOptionSchema), {
@@ -761,20 +789,32 @@ ownerId) {
761
789
  return {
762
790
  name: "ask_user_question",
763
791
  label: "Ask the user",
764
- description: "Ask the user focused questions to pin down ambiguous requirements. Use for clarifying the task, confirming decisions, or getting preferences. Each question renders a browser dialog with markdown/HTML rich text; options may carry a `preview`. Submit or cancel to resume.",
765
- promptSnippet: bilingual("ask the user focused questions to clarify ambiguous requirements (browser dialog with options/preview)", "向用户提问以澄清含糊的需求(浏览器对话框,支持选项/预览)"),
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.",
796
+ promptSnippet: "ask the user 1-3 focused questions with recommended options and tradeoffs to clarify requirements",
766
797
  promptGuidelines: [
767
- bilingual("When requirements are ambiguous, use ask_user_question to ask the user instead of guessing; prefer multiple-choice options, each option may carry a preview", "需求含糊时用 ask_user_question 向用户提问而不是猜测;优先给多选选项,选项可带 preview 预览"),
768
- bilingual("A cancelled question comes back as a tool error — respect it and continue without re-asking immediately", "用户取消提问会以工具错误返回——尊重取消决定,不要马上重复追问"),
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",
769
802
  ],
770
803
  parameters: Type.Object({
771
- questions: Type.Array(QuestionSchema, { description: "Questions to ask the user" }),
804
+ questions: Type.Array(QuestionSchema, {
805
+ description: "Questions to ask the user (strictly 1 to 3 questions; prefer 1).",
806
+ minItems: 1,
807
+ maxItems: 3,
808
+ }),
772
809
  }),
773
810
  execute: async (_id, params, signal) => {
774
811
  const qs = params.questions;
775
812
  if (!Array.isArray(qs) || qs.length === 0) {
776
813
  throw new Error("ask_user_question requires at least one question");
777
814
  }
815
+ if (qs.length > 3) {
816
+ throw new Error("ask_user_question allows at most 3 questions per call to prevent question fatigue (单次提问最多不得超过 3 个问题)");
817
+ }
778
818
  const answers = await clientSession.askUser(qs, {
779
819
  aborted: signal?.aborted,
780
820
  }, ownerId);
@@ -877,8 +917,8 @@ ownerId) {
877
917
  name: BROWSER_PAGE_TOOL_NAME,
878
918
  label: "Browser page",
879
919
  description: [
880
- 'Read or act on a page in the USER\'S OWN browser through the pi-web-ui page-picker extension (the extension talks to this page; the server only forwards the request). Only pages the user has explicitly allowed/paired in that extension can be touched. Call it with op:"pages" first to see which pages are currently available, and use it ONLY when the user asked you to read or operate a web page — never click/type on their pages on your own initiative.',
881
- "ops (forwarded to the extension as-is, the server does not interpret them):",
920
+ 'Read or act on a page in the USER\'S OWN browser via the pi-web-ui page-picker extension (the server only forwards). Only pages the user explicitly allowed/paired can be touched. Start with op:"pages" to list available pages, and use ONLY when the user asked you to read or operate a page — never click/type on their pages unprompted.',
921
+ "ops (forwarded to the extension as-is):",
882
922
  " pages — no args; lists the pages you may act on",
883
923
  ' read — { what?: "text" | "html" | "title" | "url" | "query", selector?, all? }',
884
924
  " click — { selector, index? }",
@@ -889,10 +929,12 @@ ownerId) {
889
929
  " eval — { code } runs JS inside the page (extension-side switch, off by default)",
890
930
  "Op options that are not fields of this tool (e.g. read's `limit`) fall back to the extension's defaults. `target` selects the page by origin when more than one is allowed; `timeoutMs` is how long the SERVER waits for the browser (1000-120000, default 30000) before failing the call.",
891
931
  ].join("\n"),
892
- promptSnippet: bilingual("read or operate a page in the user's browser (page-picker extension; allowed pages only)", "读取/操作用户浏览器里已授权的页面(page-picker 扩展,仅限已授权页面)"),
932
+ promptSnippet: "read or operate a page in the user's browser (page-picker extension; allowed pages only)",
893
933
  promptGuidelines: [
894
- bilingual("Only use browser_page when the user asked you to read or act on a page in their browser; never click or type on their pages on your own initiative", "只在用户明确要求读取/操作浏览器页面时才用 browser_page;不要自作主张去点用户的页面"),
895
- bilingual('Start with op:"pages" to see which pages are available; the target page must already be allowed in the page-picker extension — when it fails, tell the user what to enable instead of retrying blindly', '先用 op:"pages" 看有哪些可操作页面;目标页面必须已在 page-picker 扩展里授权——失败时把需要开什么告诉用户,不要盲目重试'),
934
+ "Only use browser_page when the user asked you to read or act on a page in their browser; " +
935
+ "never click or type on their pages on your own initiative",
936
+ 'Start with op:"pages" to see which pages are available; ' +
937
+ "the target page must already be allowed in the page-picker extension — when it fails, tell the user what to enable instead of retrying blindly",
896
938
  ],
897
939
  parameters: Type.Object({
898
940
  op: Type.String({
@@ -1126,6 +1168,33 @@ function previewToolResult(result) {
1126
1168
  return "[unserializable result]";
1127
1169
  }
1128
1170
  }
1171
+ /**
1172
+ * 转录整文件重写的原子落盘:先写同目录临时文件再 rename。转录是 append-only
1173
+ * 的唯一事实源,compaction 收尾/清理的整文件重写若中途被打断(进程被杀/断电),
1174
+ * 原地 writeFileSync 会留下截断的 JSONL;同目录 rename 是原子的,最坏情况是
1175
+ * 旧文件原样保留,绝不会出现半份转录。Windows 下目标被外部占用(阅读器/杀软
1176
+ * 锁)时 rename 可能 EPERM —— 退回原地写保住功能(旧行为),不因小概率锁失败丢更新。
1177
+ */
1178
+ 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
+ }
1197
+ }
1129
1198
  /** Cap on simultaneously open NON-subagent conversations of ONE project (each keeps a full
1130
1199
  * runtime alive; conversations of other projects keep their own lists).
1131
1200
  * 子代理不计入:子代理是 inMemory 后台任务,不参与此上限,既不占位也不被此上限拦截。 */
@@ -1211,13 +1280,20 @@ function messageSearchText(m) {
1211
1280
  }
1212
1281
  return parts.join("\n");
1213
1282
  }
1283
+ /** 转录全文扫描/整读的大小上限(16MB,与 readHistorySession 一致):
1284
+ * 超限文件同步 readFileSync 会卡事件循环数百毫秒起,搜索不值得。 */
1285
+ const MAX_TRANSCRIPT_SCAN_BYTES = 16 * 1024 * 1024;
1214
1286
  /** 扫描一个会话转录文件,收集文本命中查询的消息锚点(role + timestamp,
1215
- * 按转录顺序,最多 cap 个)。仅 user/assistant 消息参与,与搜索范围一致。 */
1287
+ * 按转录顺序,最多 cap 个)。仅 user/assistant 消息参与,与搜索范围一致。
1288
+ * 超过大小上限的转录直接跳过(stat 先行,不整读)—— 全局搜索逐会话同步
1289
+ * 扫描,无上限时一个大转录就能冻住整个事件循环。 */
1216
1290
  function collectSessionAnchors(filePath, q, cap = 10) {
1217
1291
  const anchors = [];
1218
1292
  if (!q)
1219
1293
  return anchors;
1220
1294
  try {
1295
+ if (statSync(filePath).size > MAX_TRANSCRIPT_SCAN_BYTES)
1296
+ return anchors;
1221
1297
  const lines = readFileSync(filePath, "utf8").split("\n");
1222
1298
  for (const line of lines) {
1223
1299
  if (!line.trim())
@@ -1342,6 +1418,35 @@ export function sameCwd(a, b) {
1342
1418
  return false;
1343
1419
  }
1344
1420
  }
1421
+ /**
1422
+ * 超时杀进程树的决策(纯函数,可单测)。runAsync 的超时兜底用:
1423
+ * - win32:shell:true 实际起的是 cmd.exe,p.kill() 只杀 cmd 本身,npm/git 的
1424
+ * 孙进程照活 —— 必须走 `taskkill /PID <pid> /T /F` 才能整树带走;
1425
+ * - posix:配合 spawn 的 detached:true,向负 pid(整组)发 SIGTERM。
1426
+ * 返回决策而不是直接执行,执行留在调用方(便于注入/测试)。
1427
+ */
1428
+ export function processTreeKillPlan(platform, pid) {
1429
+ if (!pid || pid <= 0)
1430
+ return { kind: "none" };
1431
+ if (platform === "win32")
1432
+ return { kind: "taskkill", cmd: "taskkill", args: ["/PID", String(pid), "/T", "/F"] };
1433
+ return { kind: "group-signal", signal: "SIGTERM" };
1434
+ }
1435
+ /**
1436
+ * /cwd 目标解析(纯函数,可单测):
1437
+ * - 相对路径以**当前会话 cwd** 为基准(/cwd src = 当前项目的 src),不按 server
1438
+ * 进程 cwd 解析 —— 两者经常不同,按进程 cwd 切必错位。绝对路径不受基准影响
1439
+ * (resolve 遇到绝对段会丢弃前面的基准)。
1440
+ * - win32 裸盘符("C:"):resolve 会按该盘当前目录解析,必须显式指到盘根;仅
1441
+ * win32 生效——posix 下 "C:" 仍是普通相对路径,避免误伤同名目录。
1442
+ */
1443
+ export function resolveCwdTarget(raw, sessionCwd, platform = process.platform) {
1444
+ const trimmed = String(raw ?? "").trim();
1445
+ if (platform === "win32" && /^[A-Za-z]:$/.test(trimmed)) {
1446
+ return `${trimmed.toUpperCase()}\\`;
1447
+ }
1448
+ return resolve(sessionCwd, trimmed);
1449
+ }
1345
1450
  export class ClientSession {
1346
1451
  clientId;
1347
1452
  /** Set by AgentService.attach: reflects the SERVICE-wide quiesce flag
@@ -1532,7 +1637,7 @@ export class ClientSession {
1532
1637
  if (persist) {
1533
1638
  const baseCwdForLimit = parentId ? (this.convs.get(parentId)?.cwd ?? this.cwd) : this.cwd;
1534
1639
  const resolvedCwdForLimit = cwd ? resolve(baseCwdForLimit, cwd) : baseCwdForLimit;
1535
- const openInProject = [...this.convs.values()].filter((c) => c.cwd === resolvedCwdForLimit && !c.isSubagent).length;
1640
+ const openInProject = [...this.convs.values()].filter((c) => c.cwd === resolvedCwdForLimit && !c.isSubagent && !c.isEphemeral).length;
1536
1641
  if (openInProject >= MAX_OPEN_CONVERSATIONS) {
1537
1642
  throw new Error(pick(this.getLang(), `目标项目运行的普通对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),无法创建持久化对话。请先关闭不需要的对话,或以轻量子代理(persist=false)方式运行`, `The project already has max open regular conversations (${MAX_OPEN_CONVERSATIONS}). Please close some or run as a lightweight subagent (persist=false)`, "agent.conv.limit.reached", { limit: MAX_OPEN_CONVERSATIONS }));
1538
1643
  }
@@ -1577,6 +1682,9 @@ export class ClientSession {
1577
1682
  conv.parentId = parentId ?? this.activeId ?? undefined;
1578
1683
  conv.subagentType = type;
1579
1684
  conv.subagentPrompt = prompt;
1685
+ // 模板快照留在对话上:forceReset 重建 runtime 时工厂要按同一模板组装
1686
+ // (system prompt/技能/扩展白名单),否则重建后回落主会话设置。
1687
+ conv.subagentTemplate = apply;
1580
1688
  // 插件工具门与模板扩展白名单对齐:白名单非空时插件/MCP 工具(无 SDK
1581
1689
  // extensionKey 身份)不进该会话。工厂期 customTools 不注册 + 下面的
1582
1690
  // syncPluginTools 不回补,模板热改不影响已运行的子代理(与 prompt/技能一致)。
@@ -1808,9 +1916,12 @@ export class ClientSession {
1808
1916
  lastSdkAppendFiles = [];
1809
1917
  /** 当前活动会话的工具/资源快照 → composer 输入。cwd 取活动对话的。 */
1810
1918
  composeInputs(src) {
1919
+ const preset = src.preset ?? this.conv?.agentPreset ?? this.settingsSvc.current.defaultAgentPreset ?? "standard";
1811
1920
  // 技能名录指纹观测(只打日志,不干预组装;预览与 run 共用此入口,
1812
1921
  // 变化才记一行,首轮静默)。用未注文的目录(fill 前),全文注入不影响指纹。
1813
1922
  this.noteSkillCatalogDigest(src.skills);
1923
+ const showSkills = presetShowsSkillCatalog(preset);
1924
+ const effectiveSkills = showSkills ? this.fillSkillContents(src.skills) : [];
1814
1925
  return {
1815
1926
  cwd: src.cwd,
1816
1927
  systemPromptFile: this.lastBaseSystemPrompt || undefined,
@@ -1823,15 +1934,15 @@ export class ClientSession {
1823
1934
  piExamples: PI_DOC_PATHS.examples,
1824
1935
  appendFiles: this.lastSdkAppendFiles,
1825
1936
  windowsPersona: process.platform === "win32" ? WINDOWS_PERSONA : "",
1826
- terminalGuidance: isTerminalGuidanceOn(effectiveDisabledAgentTools(this.settingsSvc.current))
1937
+ terminalGuidance: isTerminalGuidanceOn(effectiveDisabledAgentTools(this.settingsSvc.current), preset)
1827
1938
  ? TERMINAL_TOOLS_GUIDANCE
1828
1939
  : "",
1829
1940
  markersGuidance: this.markerSvc.buildGuidance(),
1830
1941
  // issue #91:组合模板各来源段按客户端 UI 语言渲染(英文默认)。
1831
1942
  lang: this.getLang(),
1832
1943
  contextFiles: src.contextFiles,
1833
- skills: this.fillSkillContents(src.skills),
1834
- skillsFullText: normalizeSkillList(this.settingsSvc.current.skillsFullText),
1944
+ skills: effectiveSkills,
1945
+ skillsFullText: showSkills ? normalizeSkillList(this.settingsSvc.current.skillsFullText) : [],
1835
1946
  };
1836
1947
  }
1837
1948
  /** skill 全文注入({{skills}} 全文模式):最好努力读名单里技能的文件正文。
@@ -1858,25 +1969,31 @@ export class ClientSession {
1858
1969
  }
1859
1970
  });
1860
1971
  }
1861
- /** 渲染当前组合模板。模板为空且无任何覆盖时返回 undefined(用 SDK 默认拼装,
1862
- * 零开销且与原始行为逐字节一致)。 */
1972
+ /** 渲染当前组合模板。当存在自定义模板/覆盖,或者当前会话预设非 standard(如 code/minimal/ask/reader),
1973
+ * 或者存在被禁用的工具时,必须渲染完整系统提示词,保证工具门控、技能隐藏与 Guidelines 严格对齐当前预设;
1974
+ * 仅在完全默认且全功能 standard 状态下返回 undefined 让 SDK 拼装。 */
1863
1975
  renderMainCompose(src) {
1864
1976
  const tpl = (this.settingsSvc.current.promptTemplate ?? "").trim();
1865
1977
  const ovs = this.settingsSvc.current.promptOverrides ?? {};
1866
1978
  const hasOverride = Object.values(ovs).some((v) => typeof v === "string" && v.trim());
1867
- if (!tpl && !hasOverride)
1979
+ const preset = src.preset ?? this.conv?.agentPreset ?? this.settingsSvc.current.defaultAgentPreset ?? "standard";
1980
+ const isCustomized = !!tpl || hasOverride || preset !== "standard" || effectiveDisabledAgentTools(this.settingsSvc.current).length > 0;
1981
+ if (!isCustomized)
1868
1982
  return undefined;
1869
1983
  const texts = resolveSectionTexts(this.composeInputs(src));
1870
- return renderPromptTemplate(tpl || DEFAULT_PROMPT_TEMPLATE, texts, ovs);
1984
+ return renderPromptTemplate(tpl || DEFAULT_PROMPT_TEMPLATE, texts, hasOverride ? ovs : undefined);
1871
1985
  }
1872
- /** 从活动会话收集工具/资源快照 → 一次算出 ①各来源默认(自动)内容 ②实际生效的
1873
- * 完整提示词。会话未就绪(或出错)返回 undefined,调用方给空值。 */
1874
- sessionPromptSnapshot() {
1986
+ /** 从指定会话(缺省 = 活跃会话)收集工具/资源快照 → 一次算出 ①各来源默认(自动)
1987
+ * 内容 ②实际生效的完整提示词。会话未就绪(或出错)返回 undefined,调用方给空值。
1988
+ * 注意必须传目标 conv:preset/工具 schema 都是按会话走的,拿活跃会话的快照
1989
+ * 算后台会话的基线会串账(lastTurnBaseTokens 跨会话污染)。 */
1990
+ sessionPromptSnapshot(target) {
1875
1991
  try {
1876
- const sess = this.session;
1877
- if (!sess)
1992
+ const conv = target ?? this.convs.get(this.activeId);
1993
+ if (!conv)
1878
1994
  return undefined;
1879
- const cwd = this.convs.get(this.activeId)?.cwd ?? this.cwd;
1995
+ const sess = conv.session;
1996
+ const cwd = conv.cwd;
1880
1997
  const active = sess.getActiveToolNames();
1881
1998
  const snippets = {};
1882
1999
  const guidelines = [];
@@ -1896,6 +2013,7 @@ export class ClientSession {
1896
2013
  });
1897
2014
  }
1898
2015
  const loader = sess.resourceLoader;
2016
+ const preset = conv?.agentPreset ?? this.settingsSvc.current.defaultAgentPreset ?? "standard";
1899
2017
  const texts = resolveSectionTexts(this.composeInputs({
1900
2018
  cwd,
1901
2019
  selectedTools: active,
@@ -1907,13 +2025,14 @@ export class ClientSession {
1907
2025
  description: s.description ?? "",
1908
2026
  filePath: s.filePath ?? "",
1909
2027
  })),
2028
+ preset,
1910
2029
  }));
1911
- // 模板/覆盖渲染(无则保持 SDK 默认拼装,与 renderMainCompose 同规则)。
2030
+ // 模板/覆盖渲染(无自定义模板时用默认模板渲染完整提示词,确保与真实 run 规则一致且包含预设过滤)。
1912
2031
  const tpl = (this.settingsSvc.current.promptTemplate ?? "").trim();
1913
2032
  const ovs = this.settingsSvc.current.promptOverrides ?? {};
1914
2033
  const hasOverride = Object.values(ovs).some((v) => typeof v === "string" && v.trim());
1915
- const rendered = !tpl && !hasOverride ? undefined : renderPromptTemplate(tpl || DEFAULT_PROMPT_TEMPLATE, texts, ovs);
1916
- return { texts, full: rendered ?? sess.systemPrompt, toolsSchema: buildToolsSchemaText(schemaEntries) };
2034
+ const rendered = renderPromptTemplate(tpl || DEFAULT_PROMPT_TEMPLATE, texts, hasOverride ? ovs : undefined);
2035
+ return { texts, full: rendered, toolsSchema: buildToolsSchemaText(schemaEntries) };
1917
2036
  }
1918
2037
  catch {
1919
2038
  // Session not ready yet.
@@ -1925,6 +2044,30 @@ export class ClientSession {
1925
2044
  promptSnapshot() {
1926
2045
  return this.sessionPromptSnapshot() ?? { full: "", texts: {}, toolsSchema: "" };
1927
2046
  }
2047
+ /** 会话级 Base Tokens 缓存(避免在节流快照热路径上重复计算正则)。 */
2048
+ cachedBaseTokens = null;
2049
+ /** 指定会话(缺省 = 活跃会话)系统提示词 + 工具 schema 的基础 token 开销
2050
+ * (与设置面板中的「合计」完全一致)。会话未就绪返回 null——调用方必须区分
2051
+ * null 与 0:把 0 写进 lastTurnBaseTokens 会让后续轮次双计全额 base。 */
2052
+ currentBaseTokens(target) {
2053
+ const conv = target ?? this.convs.get(this.activeId);
2054
+ if (!conv)
2055
+ return null;
2056
+ const now = Date.now();
2057
+ if (this.cachedBaseTokens &&
2058
+ this.cachedBaseTokens.convId === conv.id &&
2059
+ now - this.cachedBaseTokens.at < STATS_CACHE_MS) {
2060
+ return this.cachedBaseTokens.tokens;
2061
+ }
2062
+ const snap = this.sessionPromptSnapshot(conv);
2063
+ if (!snap)
2064
+ return null;
2065
+ const prompt = estimatePromptTokens(snap.full);
2066
+ const schema = estimatePromptTokens(snap.toolsSchema);
2067
+ const tokens = prompt + schema;
2068
+ this.cachedBaseTokens = { at: now, convId: conv.id, tokens };
2069
+ return tokens;
2070
+ }
1928
2071
  /** Web-facing extension UI context (widgets, notifications). */
1929
2072
  webUi = new WebUIContext((msg) => this.emit(msg));
1930
2073
  /**
@@ -2172,11 +2315,18 @@ export class ClientSession {
2172
2315
  },
2173
2316
  readHistorySession: async (path) => {
2174
2317
  const all = await SessionManager.listAll(piSessionsRoot());
2175
- const hit = all.find((s) => resolve(s.path) === resolve(path));
2318
+ // win32 路径大小写不敏感:客户端回传的盘符/目录大小写常与服务端
2319
+ // 列表不一致,严格相等会把合法请求误判为“不在白名单”。大小写
2320
+ // 归一后再比(posix 不受影响)。
2321
+ const norm = (p) => {
2322
+ const r = resolve(p);
2323
+ return process.platform === "win32" ? r.toLowerCase() : r;
2324
+ };
2325
+ const hit = all.find((s) => norm(s.path) === norm(path));
2176
2326
  if (!hit)
2177
2327
  return undefined;
2178
2328
  try {
2179
- if (statSync(hit.path).size > 16 * 1024 * 1024)
2329
+ if (statSync(hit.path).size > MAX_TRANSCRIPT_SCAN_BYTES)
2180
2330
  return undefined;
2181
2331
  const text = readFileSync(hit.path, "utf8");
2182
2332
  return {
@@ -2278,7 +2428,8 @@ export class ClientSession {
2278
2428
  const model = session.model;
2279
2429
  let originalKeepRecent;
2280
2430
  try {
2281
- originalKeepRecent = session.settingsManager.getCompactionSettings(model).keepRecentTokens;
2431
+ originalKeepRecent =
2432
+ session.settingsManager.getCompactionSettings(model)?.keepRecentTokens ?? session.settingsManager.getCompactionSettings().keepRecentTokens;
2282
2433
  }
2283
2434
  catch {
2284
2435
  originalKeepRecent = undefined;
@@ -2504,13 +2655,13 @@ export class ClientSession {
2504
2655
  // (含重试次数覆盖)——依次重放:重试覆盖 → 软上限覆盖 → 终端门控。
2505
2656
  this.applyRetryOverrides();
2506
2657
  this.applyCompactionOverrides();
2507
- // reload() 会把 custom 工具重新加回活跃集——重放终端开关。
2508
- this.applyToolGating(this.session);
2658
+ // reload() 会把 custom 工具重新加回活跃集——重放当前会话归属预设门控。
2659
+ this.applyToolGating(this.session, this.conv?.agentPreset);
2509
2660
  await this.pushSlashCommands();
2510
2661
  },
2511
2662
  applyRetryOverrides: () => this.applyRetryOverrides(),
2512
2663
  applyCompactionOverrides: () => this.applyCompactionOverrides(),
2513
- applyToolGating: () => this.applyToolGating(this.session),
2664
+ applyToolGating: () => this.applyToolGating(this.session, this.conv?.agentPreset),
2514
2665
  promptSnapshot: () => this.promptSnapshot(),
2515
2666
  getMarkerState: () => ({
2516
2667
  markersEnabled: this.markerSvc.current.markersEnabled,
@@ -2640,7 +2791,7 @@ export class ClientSession {
2640
2791
  * 应用(prompt replace/append + 白名单),其余(终端接管、Windows persona
2641
2792
  * 等)仍跟随主会话设置。undefined = 按主会话设置(普通对话/不选模板的子代理)。
2642
2793
  */
2643
- makeRuntimeFactory(terminals, apply, ownerId, initialModel) {
2794
+ makeRuntimeFactory(terminals, apply, ownerId, initialModel, targetPreset) {
2644
2795
  return async ({ cwd: effectiveCwd, sessionManager }) => {
2645
2796
  const services = await createAgentSessionServices({
2646
2797
  cwd: effectiveCwd,
@@ -2761,19 +2912,40 @@ export class ClientSession {
2761
2912
  const swapped = tplPrompt + event.systemPrompt.slice(boundary);
2762
2913
  return swapped === event.systemPrompt ? undefined : { systemPrompt: swapped };
2763
2914
  }
2764
- // 主会话:组合模板渲染(模板为空且无覆盖时返回 undefined = 用 SDK 默认)。
2915
+ // 主会话:按当前会话归属预设与真正活跃的工具列表组装系统提示词
2916
+ const conv = this.convs.get(ownerId ?? this.activeId) ?? this.conv;
2917
+ const sess = conv?.session;
2918
+ const activeToolNames = sess ? sess.getActiveToolNames() : [];
2919
+ const activeSet = new Set(activeToolNames);
2920
+ const activeSnippets = {};
2921
+ const activeGuidelines = [];
2922
+ if (sess) {
2923
+ for (const name of activeSet) {
2924
+ const def = sess.getToolDefinition(name);
2925
+ if (!def)
2926
+ continue;
2927
+ if (def.promptSnippet && def.promptSnippet.trim()) {
2928
+ activeSnippets[name] = def.promptSnippet.trim();
2929
+ }
2930
+ if (def.promptGuidelines) {
2931
+ activeGuidelines.push(...def.promptGuidelines);
2932
+ }
2933
+ }
2934
+ }
2935
+ const currentPreset = conv?.agentPreset ?? targetPreset ?? this.settingsSvc.current.defaultAgentPreset ?? "standard";
2765
2936
  const opts = event.systemPromptOptions;
2766
2937
  const rendered = this.renderMainCompose({
2767
- cwd: typeof opts?.cwd === "string" ? opts.cwd : this.cwd,
2768
- selectedTools: opts?.selectedTools ?? [],
2769
- toolSnippets: opts?.toolSnippets ?? {},
2770
- toolGuidelines: opts?.promptGuidelines ?? [],
2938
+ cwd: typeof opts?.cwd === "string" ? opts.cwd : (conv?.cwd ?? effectiveCwd ?? this.cwd),
2939
+ selectedTools: activeToolNames.length > 0 ? activeToolNames : (opts?.selectedTools ?? []),
2940
+ toolSnippets: activeSnippets,
2941
+ toolGuidelines: activeGuidelines,
2771
2942
  contextFiles: opts?.contextFiles ?? [],
2772
2943
  skills: (opts?.skills ?? []).map((s) => ({
2773
2944
  name: s.name,
2774
2945
  description: s.description ?? "",
2775
2946
  filePath: s.filePath ?? "",
2776
2947
  })),
2948
+ preset: currentPreset,
2777
2949
  });
2778
2950
  return rendered ? { systemPrompt: rendered } : undefined;
2779
2951
  });
@@ -2852,31 +3024,27 @@ export class ClientSession {
2852
3024
  }), () => (ownerId ? this.convs.get(ownerId)?.permissionPreset : undefined) ??
2853
3025
  this.settingsSvc.current.defaultPermissionPreset ??
2854
3026
  "workspace-write-never", () => this.getLang()),
2855
- ...makePersistentTerminalTools(terminals, effectiveCwd, () => this.getLang()),
2856
- // 覆盖 SDK 内置 read(customTools 按 name 覆盖):路径是目录时列出目录
2857
- // 条目(复用 SDK ls 的排序/`/` 后缀/截断口径),其余情况原样转发内置实现。
2858
- // 开关是行为开关(read 本体不可关),每次调用实时读设置——不进
2859
- // tool-manager 的 ActiveSet 目录。DSH 引擎无 customTool 注册面,不接。
2860
- withToolGuard(makeReadDirTool(effectiveCwd, {
2861
- dirEnabled: () => this.settingsSvc.current.readDirEnabled !== false,
2862
- getLang: () => this.getLang(),
2863
- }), {
2864
- toolName: "read",
2865
- guard: this.toolGuard,
2866
- conversationId: () => ownerId,
2867
- getLang: () => this.getLang(),
2868
- cwd: effectiveCwd,
2869
- getRoots: () => this.roots,
2870
- askApproval: (toolCallId, toolName, params, reason, reasonEn, convId, category) => this.askApproval(toolCallId, toolName, params, reason, reasonEn, convId, category),
2871
- getRules: () => this.approvalRules.list(),
3027
+ ...makePersistentTerminalTools(terminals, effectiveCwd, () => this.getLang(), {
3028
+ checkSafety: (cmd) => {
3029
+ const perm = (ownerId ? this.convs.get(ownerId)?.permissionPreset : undefined) ??
3030
+ this.settingsSvc.current.defaultPermissionPreset ??
3031
+ "workspace-write-never";
3032
+ if (perm === "read-only") {
3033
+ const danger = checkDangerousToolCall("bash", { command: cmd }, effectiveCwd, this.roots, this.approvalRules.list());
3034
+ if (danger.denied || danger.dangerous) {
3035
+ return { blocked: true, reason: danger.reason || "只读模式禁止执行高危/破坏性命令" };
3036
+ }
3037
+ }
3038
+ const danger = checkDangerousToolCall("bash", { command: cmd }, effectiveCwd, this.roots, this.approvalRules.list());
3039
+ if (danger.denied) {
3040
+ return { blocked: true, reason: danger.reason || "命中系统阻断规则" };
3041
+ }
3042
+ return {};
3043
+ },
2872
3044
  }),
2873
- // 覆盖 SDK 内置 write / edit,并在执行层注入会话级三档权限沙箱门禁与人机协同审批。
2874
- wrapWriteToolWithPermission(effectiveCwd, () => (ownerId ? this.convs.get(ownerId)?.permissionPreset : undefined) ??
2875
- this.settingsSvc.current.defaultPermissionPreset ??
2876
- "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()),
2877
- wrapEditToolWithPermission(effectiveCwd, () => (ownerId ? this.convs.get(ownerId)?.permissionPreset : undefined) ??
2878
- this.settingsSvc.current.defaultPermissionPreset ??
2879
- "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()),
3045
+ // read / write / edit 三处覆盖**不在这里注册**:创建时的 customTools 恒胜、与
3046
+ // 扩展加载顺序无关,直接塞进来会静默顶掉第三方扩展注册的同名工具(见
3047
+ // tool-overrides.ts);它们改在会话建好后由 installToolOverrides 注入。
2880
3048
  // 不覆盖内置 edit 的独立宽松编辑工具(缩进不敏感匹配;开关看设置;带权限沙箱拦截与人机协同)。
2881
3049
  wrapEditSoftToolWithPermission(makeEditSoftTool(effectiveCwd, () => this.getLang()), effectiveCwd, () => (ownerId ? this.convs.get(ownerId)?.permissionPreset : undefined) ??
2882
3050
  this.settingsSvc.current.defaultPermissionPreset ??
@@ -2970,8 +3138,12 @@ export class ClientSession {
2970
3138
  // 桥接工具归属锚点:SDK 会话对象在本 runtime 生命周期内稳定,过户只搬对话
2971
3139
  // 不改它(见 ClientSession.findConversationHome)。
2972
3140
  bridgeAnchor.session = created.session;
2973
- // 终端工具开关从创建起就生效(工具始终注册进注册表,只调活跃集)。
2974
- this.applyToolGating(created.session);
3141
+ // read / write / edit 三处覆盖在会话建好后注入(见 tool-overrides.ts):SDK 的合并链是
3142
+ // [...扩展工具, ...customTools] 后写赢 ⇒ 创建时塞进 customTools 会**恒定顶掉**第三方
3143
+ // 扩展注册的同名工具(官方 docs/extensions.md 明写扩展可覆盖 read/write/edit)。
3144
+ installToolOverrides(created.session, this.toolOverrideSpecs(ownerId, effectiveCwd));
3145
+ // 终端工具开关与预设门控从创建起就生效(工具始终注册进注册表,只调活跃集)。
3146
+ this.applyToolGating(created.session, targetPreset);
2975
3147
  return {
2976
3148
  ...created,
2977
3149
  services,
@@ -3000,7 +3172,9 @@ export class ClientSession {
3000
3172
  createdAt: Date.now(),
3001
3173
  agentPreset: this.settingsSvc.current.defaultAgentPreset ?? "standard",
3002
3174
  presetLocked: false,
3003
- permissionPreset: this.settingsSvc.current.defaultPermissionPreset ?? "workspace-write-never",
3175
+ permissionPreset: readPermissionFromSession(runtime.session.sessionManager) ??
3176
+ this.settingsSvc.current.defaultPermissionPreset ??
3177
+ "workspace-write-never",
3004
3178
  // A brand-new conversation is not yet LISTED — it enters the running
3005
3179
  // list only when it is displaced to the background while still
3006
3180
  // streaming (its runtime is what `listed` protects). A blank chat is
@@ -3087,7 +3261,7 @@ export class ClientSession {
3087
3261
  textEn: `Restart interrupted "${r.title}" — reopening it and continuing automatically.`,
3088
3262
  });
3089
3263
  await this.switchSession(r.sessionFile);
3090
- await this.prompt(continueText);
3264
+ await this.promptResumedConversation(r.sessionFile, continueText);
3091
3265
  }
3092
3266
  catch {
3093
3267
  // switchSession/prompt already surface failures as notices;
@@ -3105,6 +3279,50 @@ export class ClientSession {
3105
3279
  /* staying on the last resumed session is a fine fallback */
3106
3280
  }
3107
3281
  }
3282
+ /** 找持有指定转录文件的本地对话 id(找不到 = 已被关闭/搬走/还没建好)。 */
3283
+ conversationIdBySessionFile(file) {
3284
+ let abs;
3285
+ try {
3286
+ abs = resolve(file);
3287
+ }
3288
+ catch {
3289
+ return null;
3290
+ }
3291
+ for (const c of this.convs.values()) {
3292
+ try {
3293
+ const f = c.session.sessionFile;
3294
+ if (f && resolve(f) === abs)
3295
+ return c.id;
3296
+ }
3297
+ catch {
3298
+ // session 被替换中 —— 跳过
3299
+ }
3300
+ }
3301
+ return null;
3302
+ }
3303
+ /**
3304
+ * 恢复流程的「继续」投递(resumeInterrupted 专用)。
3305
+ * 读码结论:prompt() 在**调用时刻**同步捕获 active 对话(进入函数第一行取
3306
+ * this.conv,先于任何 await),调用之后发生的切换不影响投递目标。唯一的错投
3307
+ * 窗口在 switchSession 内部:activeId 置位后还要 await bindSession/恢复模型,
3308
+ * 期间用户的并发切换会把 active 挪走 —— 恢复循环接着调 prompt 就会把「继续」
3309
+ * 投进用户当前对话。因此投递前把目标对话修回 active,并在同一同步执行段内
3310
+ * (上一个 await 恢复点之后、无新 await 处)核对 activeId 后才调用 prompt;
3311
+ * 修不回(再次被抢)就放弃自动继续 —— 宁可少投,不投错对话。
3312
+ */
3313
+ async promptResumedConversation(sessionFile, text) {
3314
+ for (let attempt = 0; attempt < 2; attempt++) {
3315
+ const targetId = this.conversationIdBySessionFile(sessionFile);
3316
+ if (!targetId)
3317
+ return; // 目标对话已没了(被关/被过户),不投
3318
+ if (this.activeId === targetId) {
3319
+ await this.prompt(text);
3320
+ return;
3321
+ }
3322
+ await this.switchConversation(targetId);
3323
+ }
3324
+ // 两次都没能稳定拿回 active:放弃自动继续,用户可手动点开那条对话。
3325
+ }
3108
3326
  /** Add a socket to this client's broadcast set; flushes buffered startup notices. */
3109
3327
  attachSink(send) {
3110
3328
  this.sinks.add(send);
@@ -3201,7 +3419,7 @@ export class ClientSession {
3201
3419
  await conv.session.bindExtensions({
3202
3420
  mode: "rpc",
3203
3421
  uiContext: this.webUi,
3204
- onError: this.makeExtensionErrorReporter(),
3422
+ onError: this.makeExtensionErrorReporter(conv.isEphemeral ? { text: `临时对话 ${conv.id}:`, textEn: `Ephemeral chat ${conv.id}: ` } : undefined),
3205
3423
  });
3206
3424
  conv.unsubscribe = conv.session.subscribe((event) => this.onEvent(conv, event));
3207
3425
  // 新会话 / 切换会话 / 强杀重建的必经之路:刚创建的 runtime 用的是 SDK
@@ -3927,6 +4145,7 @@ export class ClientSession {
3927
4145
  if (stopNotice) {
3928
4146
  this.emit({ type: "notice", level: "warning", text: stopNotice.text, textEn: stopNotice.textEn });
3929
4147
  }
4148
+ this.emitConversations();
3930
4149
  break;
3931
4150
  }
3932
4151
  // 子代理运行报错(provider 400 / 超时等)→ 通知主对话,让用户/AI 知道
@@ -3993,9 +4212,18 @@ export class ClientSession {
3993
4212
  }
3994
4213
  }, 50);
3995
4214
  }
4215
+ // 本轮真正结束且不再重试:立即向客户端广播最新会话状态(流式状态及时复位)
4216
+ if (!event.willRetry) {
4217
+ this.emitConversations();
4218
+ }
3996
4219
  break;
3997
4220
  }
3998
4221
  case "agent_settled": {
4222
+ // 记录本会话自己的 Base 基线:currentBaseTokens 必须传 conv(否则串成
4223
+ // 活跃会话的基线);快照未就绪时保持 undefined,宁可少补偿也不把 0 钉死。
4224
+ const settledBaseTokens = this.currentBaseTokens(conv);
4225
+ if (settledBaseTokens != null)
4226
+ conv.lastTurnBaseTokens = settledBaseTokens;
3999
4227
  if (conv.pendingCompaction && !this.disposed) {
4000
4228
  const pending = conv.pendingCompaction;
4001
4229
  conv.pendingCompaction = null;
@@ -4278,14 +4506,42 @@ export class ClientSession {
4278
4506
  tokens: conv.lastCompactionTokens,
4279
4507
  contextWindow: cu.contextWindow,
4280
4508
  percent: (conv.lastCompactionTokens / cu.contextWindow) * 100,
4281
- estimated: true,
4509
+ estimated: false,
4282
4510
  softCap: this.activeSoftCap(cu.contextWindow),
4283
4511
  };
4284
4512
  }
4513
+ // 展示用兜底 0(会话未就绪时旧行为也是 0);落账 lastTurnBaseTokens 的路径
4514
+ // 在 agent_settled 处对 null 跳过,不受此兜底影响。
4515
+ const baseTokens = this.currentBaseTokens(conv) ?? 0;
4516
+ // 空白会话(尚未发言,SDK 报 0 或 null):真实反映当前模式/工具配置下的 Base 开销。
4517
+ if (cu.contextWindow > 0 && (this.isBlankConversation(conv) || cu.tokens == null || cu.tokens === 0)) {
4518
+ return {
4519
+ tokens: baseTokens,
4520
+ contextWindow: cu.contextWindow,
4521
+ percent: (baseTokens / cu.contextWindow) * 100,
4522
+ estimated: false,
4523
+ softCap: this.activeSoftCap(cu.contextWindow),
4524
+ };
4525
+ }
4526
+ // 首轮对话中(尚未有定稿 assistant 消息,SDK cu.tokens 仅为当前消息估算):叠加 Base 开销
4527
+ if (conv.lastTurnBaseTokens == null && cu.contextWindow > 0) {
4528
+ const total = baseTokens + (cu.tokens ?? 0);
4529
+ return {
4530
+ tokens: total,
4531
+ contextWindow: cu.contextWindow,
4532
+ percent: (total / cu.contextWindow) * 100,
4533
+ estimated: false,
4534
+ softCap: this.activeSoftCap(cu.contextWindow),
4535
+ };
4536
+ }
4537
+ // 已有多轮对话历史:若在对话间歇切预设或开关工具,叠加当前 Base 开销相比上一轮定稿时的差额
4538
+ const baseDelta = baseTokens - (conv.lastTurnBaseTokens ?? baseTokens);
4539
+ const effectiveTokens = Math.max(0, (cu.tokens ?? 0) + baseDelta);
4285
4540
  return {
4286
- tokens: cu.tokens,
4541
+ tokens: effectiveTokens,
4287
4542
  contextWindow: cu.contextWindow,
4288
- percent: cu.percent,
4543
+ percent: cu.contextWindow > 0 ? (effectiveTokens / cu.contextWindow) * 100 : cu.percent,
4544
+ estimated: false,
4289
4545
  softCap: this.activeSoftCap(cu.contextWindow),
4290
4546
  };
4291
4547
  })(),
@@ -4792,6 +5048,20 @@ export class ClientSession {
4792
5048
  }
4793
5049
  this.pendingQuestions.clear();
4794
5050
  }
5051
+ /** 关闭所有挂起审批(dispose 时清理):以「拒绝」解析,避免等答复的
5052
+ * Promise 与对应 runtime 泄漏(会话没了,审批弹窗永远不会有人点)。 */
5053
+ cancelPendingApprovals() {
5054
+ for (const [id, a] of this.pendingApprovals) {
5055
+ this.pendingApprovals.delete(id);
5056
+ try {
5057
+ a.resolve({ decision: "deny", reason: "会话已关闭" });
5058
+ }
5059
+ catch {
5060
+ // 单个 resolve 异常不影响其余清理
5061
+ }
5062
+ this.emit({ type: "tool_approval_resolved", id });
5063
+ }
5064
+ }
4795
5065
  // -----------------------------------------------------------------------
4796
5066
  // 浏览器页面桥(标准 pi 引擎 browser_page customTool)
4797
5067
  // -----------------------------------------------------------------------
@@ -4975,6 +5245,10 @@ export class ClientSession {
4975
5245
  // Windows: npm and friends are .cmd shims — Node can only exec
4976
5246
  // them through the shell (otherwise spawn npm → ENOENT).
4977
5247
  shell: process.platform === "win32",
5248
+ // posix 下让子进程自成进程组:超时时能整组杀掉(孙进程不残留)。
5249
+ // 代价是父进程退出后子进程可能短暂存活——这些都是带超时的短命令,
5250
+ // 可接受;win32 不需要(走 taskkill /T)。
5251
+ detached: process.platform !== "win32",
4978
5252
  });
4979
5253
  }
4980
5254
  catch (err) {
@@ -4990,7 +5264,27 @@ export class ClientSession {
4990
5264
  clearTimeout(t);
4991
5265
  resolve({ code, out: text ?? out });
4992
5266
  };
4993
- const t = setTimeout(() => p.kill(), timeoutMs);
5267
+ const t = setTimeout(() => {
5268
+ // 只 p.kill() 杀不掉整棵树(win32 下杀的是 cmd.exe 壳,posix 下
5269
+ // 杀不到孙进程)—— 按平台走整树杀,失败再退回 p.kill 兜底。
5270
+ const plan = processTreeKillPlan(process.platform, p.pid);
5271
+ try {
5272
+ if (plan.kind === "taskkill") {
5273
+ // taskkill 独立进程执行:父 cmd 死活不影响命中目标树。
5274
+ spawn(plan.cmd, plan.args, { stdio: "ignore", windowsHide: true });
5275
+ }
5276
+ else if (plan.kind === "group-signal") {
5277
+ process.kill(-p.pid, plan.signal);
5278
+ }
5279
+ else {
5280
+ p.kill();
5281
+ }
5282
+ }
5283
+ catch {
5284
+ // 进程组已不在(正常退出竞态)等 —— 退回直接杀。
5285
+ p.kill();
5286
+ }
5287
+ }, timeoutMs);
4994
5288
  p.stdout?.on("data", (d) => (out += d.toString()));
4995
5289
  p.stderr?.on("data", (d) => (out += d.toString()));
4996
5290
  p.on("error", (err) => done(-1, String(err)));
@@ -5105,26 +5399,176 @@ export class ClientSession {
5105
5399
  /** Cache window for the all-source check: 30 minutes. */
5106
5400
  static UPDATE_ALL_CACHE_MS = 30 * 60_000;
5107
5401
  updatesAllCache = null;
5402
+ notifiedPluginUpdates = new Set();
5403
+ lastPluginUpdates = [];
5404
+ /**
5405
+ * 主动检查已安装界面插件(<dataDir>/plugins)的更新状态并向客户端推送。
5406
+ */
5407
+ async checkPluginUpdates(manual = false) {
5408
+ const lang = () => this.getLang();
5409
+ try {
5410
+ const updates = await checkPluginUpdates(this.stateStore.dataDir, undefined, lang);
5411
+ const list = updates.map((p) => ({
5412
+ id: p.id,
5413
+ name: p.name,
5414
+ version: p.version,
5415
+ latestVersion: p.latestVersion ?? null,
5416
+ source: p.source,
5417
+ localSha: p.localSha,
5418
+ remoteSha: p.remoteSha,
5419
+ updatable: p.updatable,
5420
+ builtin: p.builtin,
5421
+ error: p.error,
5422
+ }));
5423
+ this.lastPluginUpdates = list;
5424
+ this.emit({ type: "plugin_updates", updates: list });
5425
+ const updatableBuiltins = list.filter((p) => p.builtin && p.updatable);
5426
+ if (manual) {
5427
+ if (updatableBuiltins.length > 0) {
5428
+ const names = updatableBuiltins.map((p) => p.name || p.id).join(", ");
5429
+ this.emit({
5430
+ type: "notice",
5431
+ level: "info",
5432
+ text: `发现 ${updatableBuiltins.length} 个内置插件有更新:${names}`,
5433
+ textEn: `Update available for ${updatableBuiltins.length} built-in plugin(s): ${names}`,
5434
+ });
5435
+ }
5436
+ else {
5437
+ const allUpdatable = list.filter((p) => p.updatable);
5438
+ if (allUpdatable.length > 0) {
5439
+ const names = allUpdatable.map((p) => p.name || p.id).join(", ");
5440
+ this.emit({
5441
+ type: "notice",
5442
+ level: "info",
5443
+ text: `发现 ${allUpdatable.length} 个插件有更新:${names}`,
5444
+ textEn: `Update available for ${allUpdatable.length} plugin(s): ${names}`,
5445
+ });
5446
+ }
5447
+ else {
5448
+ this.emit({
5449
+ type: "notice",
5450
+ level: "info",
5451
+ text: "所有插件均为最新版本。",
5452
+ textEn: "All plugins are up to date.",
5453
+ });
5454
+ }
5455
+ }
5456
+ }
5457
+ else {
5458
+ const newUpdatables = updatableBuiltins.filter((p) => {
5459
+ const key = `${p.id}@${p.latestVersion || p.remoteSha || "upd"}`;
5460
+ if (this.notifiedPluginUpdates.has(key))
5461
+ return false;
5462
+ this.notifiedPluginUpdates.add(key);
5463
+ return true;
5464
+ });
5465
+ if (newUpdatables.length > 0) {
5466
+ const names = newUpdatables.map((p) => p.name || p.id).join(", ");
5467
+ this.emit({
5468
+ type: "notice",
5469
+ level: "info",
5470
+ text: `发现 ${newUpdatables.length} 个内置插件有更新可用:${names},可前往设置或更新面板中更新。`,
5471
+ textEn: `Update available for ${newUpdatables.length} built-in plugin(s): ${names}. You can update in Settings or the Updates panel.`,
5472
+ });
5473
+ }
5474
+ }
5475
+ }
5476
+ catch (err) {
5477
+ if (manual) {
5478
+ this.emit({
5479
+ type: "notice",
5480
+ level: "error",
5481
+ text: `检查插件更新失败:${err.message}`,
5482
+ textEn: `Failed to check plugin updates: ${err.message}`,
5483
+ });
5484
+ }
5485
+ }
5486
+ }
5108
5487
  /**
5109
5488
  * All-source update check: pi-web-ui + the pi core + direct pi extensions
5110
- * from the agent manifest (fallback: raw walk). Re-emits the cached list
5489
+ * from the agent manifest (fallback: raw walk) + installed UI plugins. Re-emits the cached list
5111
5490
  * within UPDATE_ALL_CACHE_MS; pass force=true (explicit refresh) to bypass.
5112
5491
  */
5113
5492
  async checkUpdatesAll(force = false) {
5493
+ // issue #321:pi SDK 副本状态随结果下发(运行中是哪份 / 是否自带 / 机器上有没有
5494
+ // 更新的),UI 据此在更新面板亮「重启跟上」与「安装全局引擎并切换」入口。
5495
+ // 缓存命中也现算:用户升级全局 pi 不经本服务,缓存的 items 不影响这个判据,
5496
+ // 探针本身有 10s memoize,重算便宜。
5497
+ const piSdk = {
5498
+ running: VERSION,
5499
+ bundledInUse: isBundledInUse(sdkCopies(), VERSION),
5500
+ newerInstalled: detectPiSdkSplit(VERSION)?.installed ?? null,
5501
+ };
5114
5502
  if (!force && this.updatesAllCache && Date.now() - this.updatesAllCache.at < ClientSession.UPDATE_ALL_CACHE_MS) {
5115
5503
  this.emit({
5116
5504
  type: "update_status_all",
5117
5505
  items: this.updatesAllCache.items,
5506
+ piSdk,
5118
5507
  });
5508
+ if (this.lastPluginUpdates.length > 0) {
5509
+ this.emit({ type: "plugin_updates", updates: this.lastPluginUpdates });
5510
+ }
5119
5511
  return;
5120
5512
  }
5121
5513
  try {
5122
5514
  const targets = collectTargets(this.agentDir, ClientSession.currentAppVersion(), undefined, {
5123
5515
  projectCwd: this.convs.get(this.activeId)?.cwd ?? this.cwd,
5124
5516
  });
5125
- const items = sortUpdateItems(await checkAllUpdates(targets, undefined, () => this.getLang(), resolveNpmRegistry(this.agentDir)));
5126
- this.updatesAllCache = { at: Date.now(), items };
5127
- this.emit({ type: "update_status_all", items });
5517
+ const items = await checkAllUpdates(targets, undefined, () => this.getLang(), resolveNpmRegistry(this.agentDir));
5518
+ let pluginItems = [];
5519
+ try {
5520
+ const pluginUpdates = await checkPluginUpdates(this.stateStore.dataDir, undefined, () => this.getLang());
5521
+ const list = pluginUpdates.map((p) => ({
5522
+ id: p.id,
5523
+ name: p.name,
5524
+ version: p.version,
5525
+ latestVersion: p.latestVersion ?? null,
5526
+ source: p.source,
5527
+ localSha: p.localSha,
5528
+ remoteSha: p.remoteSha,
5529
+ updatable: p.updatable,
5530
+ builtin: p.builtin,
5531
+ error: p.error,
5532
+ }));
5533
+ this.lastPluginUpdates = list;
5534
+ this.emit({ type: "plugin_updates", updates: list });
5535
+ pluginItems = pluginUpdates.map((p) => ({
5536
+ name: p.name ? `${p.name} (${p.id})` : p.id,
5537
+ kind: "plugin",
5538
+ current: p.version ? `v${p.version}` : (p.localSha ?? "unknown"),
5539
+ latest: p.latestVersion ? `v${p.latestVersion}` : (p.remoteSha ?? null),
5540
+ latestPublishedAt: null,
5541
+ upToDate: !p.updatable,
5542
+ error: p.error,
5543
+ source: p.source,
5544
+ pluginId: p.id,
5545
+ builtin: p.builtin,
5546
+ }));
5547
+ const newUpdatables = pluginUpdates
5548
+ .filter((p) => p.builtin && p.updatable)
5549
+ .filter((p) => {
5550
+ const key = `${p.id}@${p.latestVersion || p.remoteSha || "upd"}`;
5551
+ if (this.notifiedPluginUpdates.has(key))
5552
+ return false;
5553
+ this.notifiedPluginUpdates.add(key);
5554
+ return true;
5555
+ });
5556
+ if (newUpdatables.length > 0) {
5557
+ const names = newUpdatables.map((p) => p.name || p.id).join(", ");
5558
+ this.emit({
5559
+ type: "notice",
5560
+ level: "info",
5561
+ text: `发现 ${newUpdatables.length} 个内置插件有更新可用:${names},可前往设置或更新面板中更新。`,
5562
+ textEn: `Update available for ${newUpdatables.length} built-in plugin(s): ${names}. You can update in Settings or the Updates panel.`,
5563
+ });
5564
+ }
5565
+ }
5566
+ catch (err) {
5567
+ console.warn("[agent-service] 检查插件更新失败:", err);
5568
+ }
5569
+ const allItems = sortUpdateItems([...items, ...pluginItems]);
5570
+ this.updatesAllCache = { at: Date.now(), items: allItems };
5571
+ this.emit({ type: "update_status_all", items: allItems, piSdk });
5128
5572
  }
5129
5573
  catch (err) {
5130
5574
  // checkAll degrades per-item; only local enumeration blowing up lands
@@ -5259,7 +5703,7 @@ export class ClientSession {
5259
5703
  // /reload 同样重读磁盘 settings.json——重放重试覆盖 + 软上限覆盖 + 终端门控。
5260
5704
  this.applyRetryOverrides();
5261
5705
  this.applyCompactionOverrides();
5262
- this.applyToolGating(this.session);
5706
+ this.applyToolGating(this.session, this.conv?.agentPreset);
5263
5707
  },
5264
5708
  pluginCommands: () => this.pluginCommandsProvider?.() ?? [],
5265
5709
  execPluginCommand: async (name, args) => {
@@ -5354,11 +5798,11 @@ export class ClientSession {
5354
5798
  reloadModelsConfig() {
5355
5799
  return this.modelAdmin.reloadModelsConfig();
5356
5800
  }
5357
- fetchModelsList(reqId, baseUrl, apiKey, authHeader, api) {
5358
- return this.modelAdmin.fetchModelsList(reqId, baseUrl, apiKey, authHeader, api, () => this.getLang());
5801
+ fetchModelsList(reqId, baseUrl, apiKey, authHeader, api, providerId) {
5802
+ return this.modelAdmin.fetchModelsList(reqId, baseUrl, apiKey, authHeader, api, () => this.getLang(), providerId);
5359
5803
  }
5360
- testModelConnection(reqId, baseUrl, apiKey, authHeader, api) {
5361
- return this.modelAdmin.testConnection(reqId, baseUrl, apiKey, authHeader, api, () => this.getLang());
5804
+ testModelConnection(reqId, baseUrl, apiKey, authHeader, api, providerId) {
5805
+ return this.modelAdmin.testConnection(reqId, baseUrl, apiKey, authHeader, api, () => this.getLang(), providerId);
5362
5806
  }
5363
5807
  refreshProviderModels(providerId, reqId) {
5364
5808
  return this.modelAdmin.refreshProviderModels(providerId, reqId, () => this.getLang());
@@ -5643,7 +6087,7 @@ export class ClientSession {
5643
6087
  await this.session.reload();
5644
6088
  this.applyRetryOverrides();
5645
6089
  this.applyCompactionOverrides();
5646
- this.applyToolGating(this.session);
6090
+ this.applyToolGating(this.session, this.conv?.agentPreset);
5647
6091
  await this.pushSlashCommands();
5648
6092
  this.pushSettings();
5649
6093
  }
@@ -5707,6 +6151,46 @@ export class ClientSession {
5707
6151
  return c.agentPreset;
5708
6152
  return undefined;
5709
6153
  }
6154
+ /**
6155
+ * read / write / edit 三处覆盖的注入规格:基底 = 扩展注册的同名工具优先,否则 SDK 内置实现
6156
+ * (见 tool-overrides.ts —— 这三处覆盖不能塞进创建时的 `customTools`,那会静默顶掉扩展的
6157
+ * 同名工具,而官方 docs/extensions.md 明写扩展可覆盖 read/write/edit)。
6158
+ */
6159
+ toolOverrideSpecs(ownerId, cwd) {
6160
+ const currentPermission = () => (ownerId ? this.convs.get(ownerId)?.permissionPreset : undefined) ??
6161
+ this.settingsSvc.current.defaultPermissionPreset ??
6162
+ "workspace-write-never";
6163
+ const approve = (toolCallId, toolName, params, reason, reasonEn, convId, category) => this.askApproval(toolCallId, toolName, params, reason, reasonEn, convId, category);
6164
+ // 行为开关(read 本体不可关):每次调用实时读设置 —— 不进 tool-manager 的 ActiveSet 目录。
6165
+ const readDirOptions = {
6166
+ dirEnabled: () => this.settingsSvc.current.readDirEnabled !== false,
6167
+ getLang: () => this.getLang(),
6168
+ };
6169
+ const readGuardOptions = {
6170
+ toolName: "read",
6171
+ guard: this.toolGuard,
6172
+ conversationId: () => ownerId,
6173
+ getLang: () => this.getLang(),
6174
+ cwd,
6175
+ getRoots: () => this.roots,
6176
+ askApproval: approve,
6177
+ getRules: () => this.approvalRules.list(),
6178
+ };
6179
+ // 写/编的权限沙箱包装:同一个函数,有扩展同名工具时把它的定义当基底(末参)。
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);
6182
+ return [
6183
+ {
6184
+ name: "read",
6185
+ // 没有扩展 read:完整覆盖(内置基底 + 英文描述 + file_path 别名)。
6186
+ fallback: () => withToolGuard(makeReadDirTool(cwd, readDirOptions), readGuardOptions),
6187
+ // 有扩展 read:只叠「目录列条目」,它的锚协议/独有参数/渲染全保留(行为委托它)。
6188
+ composeWith: (base) => withToolGuard(withReadDirSupport(base, cwd, readDirOptions), readGuardOptions),
6189
+ },
6190
+ { name: "write", fallback: () => composeWrite(), composeWith: (base) => composeWrite(base) },
6191
+ { name: "edit", fallback: () => composeEdit(), composeWith: (base) => composeEdit(base) },
6192
+ ];
6193
+ }
5710
6194
  /** 统一工具门控(tool_manage 唯一落点):按 disabledAgentTools 把目录内工具
5711
6195
  * 逐个加回/剔除活跃集(工具仍留在注册表,重开可直接加回;live 生效无需
5712
6196
  * reload)。支持按当前会话预设(preset)进行工具过滤。
@@ -5716,6 +6200,8 @@ export class ClientSession {
5716
6200
  const targetPreset = preset ?? this.presetOfSession(session) ?? this.settingsSvc.current.defaultAgentPreset ?? "standard";
5717
6201
  applyAgentToolsGating(session, effectiveDisabledAgentTools(this.settingsSvc.current), targetPreset);
5718
6202
  this.syncPluginTools(session, targetPreset);
6203
+ this.sessionStatsCache = null;
6204
+ this.cachedBaseTokens = null;
5719
6205
  // SDK 的 setActiveToolsByName 只改 agent.state.tools,不派发任何事件——门控后
5720
6206
  // 主动推一次快照,否则快照里的 tools 要等下一个 SDK 事件才对齐(会话空闲时永远
5721
6207
  // 等不到;回归:tests/terminal-smoke-test.mjs「agent exposes persistent terminal tools」)。
@@ -5770,6 +6256,8 @@ export class ClientSession {
5770
6256
  }
5771
6257
  conv.agentPreset = hit.id;
5772
6258
  this.applyToolGating(conv.session, hit.id);
6259
+ this.sessionStatsCache = null;
6260
+ this.cachedBaseTokens = null;
5773
6261
  // 技能名录段/终端引导是按 run 组装的提示词:重载 resourceLoader 让新预设
5774
6262
  // 即时生效(只有空白会话能切到这里,无历史可丢)。
5775
6263
  try {
@@ -5784,6 +6272,7 @@ export class ClientSession {
5784
6272
  text: `已切换为「${hit.name}」预设`,
5785
6273
  textEn: `Switched to preset "${hit.name}"`,
5786
6274
  });
6275
+ this.pushSettings();
5787
6276
  this.flushSnapshot();
5788
6277
  }
5789
6278
  /** 设置新会话默认预设。 */
@@ -5824,7 +6313,18 @@ export class ClientSession {
5824
6313
  });
5825
6314
  return;
5826
6315
  }
6316
+ if (this.conv.permissionPreset === hit.value) {
6317
+ return;
6318
+ }
5827
6319
  this.conv.permissionPreset = hit.value;
6320
+ try {
6321
+ // 持久会话:将权限变更写入会话转录日志,切会话/切项目重载时不丢失
6322
+ const sm = this.conv.session.sessionManager;
6323
+ sm?.appendCustomEntry?.("permission/preset", { preset: hit.value });
6324
+ }
6325
+ catch {
6326
+ // best effort for in-memory sessions
6327
+ }
5828
6328
  this.emit({
5829
6329
  type: "notice",
5830
6330
  level: "info",
@@ -6912,12 +7412,22 @@ export class ClientSession {
6912
7412
  // best-effort:修不好就按原路径重建,下面的守卫会在 prompt 前再拦。
6913
7413
  }
6914
7414
  }
6915
- // #235:转录链损坏时修一次再试(见 openManagerAndRuntime)。
6916
- const opened = await this.openManagerAndRuntime(() => (ownFile && existsSync(ownFile) ? SessionManager.open(ownFile) : SessionManager.continueRecent(conv.cwd)), (m) => createAgentSessionRuntime(this.makeRuntimeFactory(conv.terminals, undefined, conv.id), {
7415
+ // #280 & #335:转录链损坏时修一次再试(见 openManagerAndRuntime)。
7416
+ // 严禁在 ownFile 不存在时回退到 continueRecent(conv.cwd) 或按 mtime list 历史文件,
7417
+ // 否则会直接接错并顶替同项目的其它历史会话,污染别人的转录记录。
7418
+ const opened = await this.openManagerAndRuntime(() => {
7419
+ if (ownFile && existsSync(ownFile)) {
7420
+ return SessionManager.open(ownFile);
7421
+ }
7422
+ return conv.isEphemeral ? SessionManager.inMemory(conv.cwd) : SessionManager.create(conv.cwd);
7423
+ }, (m) =>
7424
+ // 子代理带模板时按原模板重建(conv.subagentTemplate 是派发时工厂
7425
+ // 用的同一快照);普通对话 undefined,行为不变。
7426
+ createAgentSessionRuntime(this.makeRuntimeFactory(conv.terminals, conv.subagentTemplate, conv.id), {
6917
7427
  cwd: conv.cwd,
6918
7428
  agentDir: this.agentDir,
6919
7429
  sessionManager: m,
6920
- }), async () => (ownFile && existsSync(ownFile) ? ownFile : (await SessionManager.list(conv.cwd))[0]?.path));
7430
+ }), async () => (ownFile && existsSync(ownFile) ? ownFile : undefined));
6921
7431
  const runtime = opened.runtime;
6922
7432
  if (opened.repair) {
6923
7433
  for (const n of this.transcriptRepairNotices(opened.repair))
@@ -6990,8 +7500,10 @@ export class ClientSession {
6990
7500
  if (!ephemeral && active && isBlank(active)) {
6991
7501
  if (_preset)
6992
7502
  await this.selectAgentPreset(_preset);
6993
- else
7503
+ else {
7504
+ this.pushSettings();
6994
7505
  this.flushSnapshot();
7506
+ }
6995
7507
  return true;
6996
7508
  }
6997
7509
  if (!ephemeral) {
@@ -7035,10 +7547,25 @@ export class ClientSession {
7035
7547
  try {
7036
7548
  const conversationId = this.nextConversationId();
7037
7549
  const terminals = this.makeTerminalManager(conversationId, this.cwd);
7038
- const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, undefined, conversationId, prevModel ?? undefined), {
7550
+ let sessionManager;
7551
+ if (ephemeral) {
7552
+ sessionManager = SessionManager.inMemory(this.cwd);
7553
+ // 为无痕临时会话提供隔离的临时运行目录(供 SoL-Pi 等依赖 getSessionDir 的扩展正常放置缓存),
7554
+ // 但保持 persist = false(不写 .jsonl 对话文件、不污染历史记录)
7555
+ const ephemeralDir = join(this.agentDir, "ephemeral-sessions", conversationId);
7556
+ try {
7557
+ mkdirSync(ephemeralDir, { recursive: true });
7558
+ sessionManager.sessionDir = ephemeralDir;
7559
+ }
7560
+ catch { }
7561
+ }
7562
+ else {
7563
+ sessionManager = SessionManager.create(this.cwd);
7564
+ }
7565
+ const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, undefined, conversationId, prevModel ?? undefined, _preset), {
7039
7566
  cwd: this.cwd,
7040
7567
  agentDir: this.agentDir,
7041
- sessionManager: ephemeral ? SessionManager.inMemory(this.cwd) : SessionManager.create(this.cwd),
7568
+ sessionManager,
7042
7569
  });
7043
7570
  const conv = this.makeConversation(runtime, conversationId, terminals);
7044
7571
  if (ephemeral)
@@ -7111,6 +7638,7 @@ export class ClientSession {
7111
7638
  void this.pushSlashCommands();
7112
7639
  // 新对话即当前打开 → 插件重拉(轨迹视图跟随)。
7113
7640
  this.notifyConversationChanged();
7641
+ this.pushSettings();
7114
7642
  ready = true;
7115
7643
  }
7116
7644
  catch (err) {
@@ -7217,7 +7745,7 @@ export class ClientSession {
7217
7745
  };
7218
7746
  lines[idx] = JSON.stringify(done);
7219
7747
  }
7220
- writeFileSync(file, lines.join("\n"));
7748
+ atomicWriteFileSync(file, lines.join("\n"));
7221
7749
  }
7222
7750
  catch {
7223
7751
  // Best-effort, same as the write path.
@@ -7238,7 +7766,7 @@ export class ClientSession {
7238
7766
  if (!raw.includes('"pi-web-ui/compaction-pending"'))
7239
7767
  return;
7240
7768
  const kept = raw.split("\n").filter((line) => !line.includes("pi-web-ui/compaction-pending"));
7241
- writeFileSync(file, kept.join("\n"));
7769
+ atomicWriteFileSync(file, kept.join("\n"));
7242
7770
  this.emitCompactionInterruptedNotice();
7243
7771
  }
7244
7772
  catch {
@@ -7412,6 +7940,13 @@ export class ClientSession {
7412
7940
  }
7413
7941
  catch { }
7414
7942
  }
7943
+ if (conv.isEphemeral) {
7944
+ const ephemeralDir = join(this.agentDir, "ephemeral-sessions", conv.id);
7945
+ try {
7946
+ rmSync(ephemeralDir, { recursive: true, force: true });
7947
+ }
7948
+ catch { }
7949
+ }
7415
7950
  void conv.runtime.dispose().catch(() => { });
7416
7951
  }
7417
7952
  /** 本会话是否持有这个 SDK 会话对应的对话;命中则给出它的**当前** id
@@ -7431,6 +7966,7 @@ export class ClientSession {
7431
7966
  cwd: c.cwd,
7432
7967
  ...(c.parentId ? { parentId: c.parentId } : {}),
7433
7968
  isSubagent: c.isSubagent,
7969
+ isEphemeral: !!c.isEphemeral,
7434
7970
  }));
7435
7971
  }
7436
7972
  /**
@@ -7479,9 +8015,30 @@ export class ClientSession {
7479
8015
  pageCalls.push({ resolve: p.resolve, req: p.req, timeoutMs: p.timeoutMs, conversationId: p.conversationId });
7480
8016
  }
7481
8017
  }
8018
+ // 待审批跟对话走:留在源会话就是幽灵弹窗(id 失效点不动,runtime 已搬走,
8019
+ // 永远等不到答复)。只搬归属明确的(conversationId 对得上,与问卷/页调用
8020
+ // 同一规则);目标侧按新 id 重发弹窗,等待中的 Promise 原样过户不 resolve。
8021
+ const approvals = [];
8022
+ for (const [aid, a] of this.pendingApprovals) {
8023
+ if (a.conversationId !== undefined && set.has(a.conversationId)) {
8024
+ this.pendingApprovals.delete(aid);
8025
+ approvals.push({
8026
+ resolve: a.resolve,
8027
+ toolCallId: a.toolCallId,
8028
+ toolName: a.toolName,
8029
+ params: a.params,
8030
+ reason: a.reason,
8031
+ reasonEn: a.reasonEn,
8032
+ ...(a.category ? { category: a.category } : {}),
8033
+ conversationId: a.conversationId,
8034
+ ...(a.conversationTitle ? { conversationTitle: a.conversationTitle } : {}),
8035
+ createdAt: a.createdAt,
8036
+ });
8037
+ }
8038
+ }
7482
8039
  this.emitConversations();
7483
8040
  this.flushSnapshot();
7484
- return { ok: true, payload: { convs, questions, pageCalls } };
8041
+ return { ok: true, payload: { convs, questions, pageCalls, approvals } };
7485
8042
  }
7486
8043
  /**
7487
8044
  * 过户转入:把另一会话摘除的对话整体接过来,返回主对话的新 id。
@@ -7562,8 +8119,65 @@ export class ClientSession {
7562
8119
  timeoutMs: p.timeoutMs,
7563
8120
  });
7564
8121
  }
8122
+ // 迁入的待审批按新 id 重新挂上(等待中的 Promise 未动,模型还在等)。
8123
+ // 主对话的立即弹窗(子代理的靠角标与快照呈现,与问卷同一口径)。
8124
+ for (const a of payload.approvals) {
8125
+ const nid = `appr-${++this.approvalSeq}`;
8126
+ const aConvId = fix(a.conversationId);
8127
+ this.pendingApprovals.set(nid, {
8128
+ id: nid,
8129
+ toolCallId: a.toolCallId,
8130
+ toolName: a.toolName,
8131
+ params: a.params,
8132
+ reason: a.reason,
8133
+ reasonEn: a.reasonEn,
8134
+ ...(a.category ? { category: a.category } : {}),
8135
+ conversationId: aConvId,
8136
+ ...(a.conversationTitle ? { conversationTitle: a.conversationTitle } : {}),
8137
+ resolve: a.resolve,
8138
+ createdAt: a.createdAt,
8139
+ });
8140
+ if (aConvId === mainId) {
8141
+ this.emit({
8142
+ type: "tool_approval_pending",
8143
+ id: nid,
8144
+ toolCallId: a.toolCallId,
8145
+ toolName: a.toolName,
8146
+ params: a.params,
8147
+ reason: a.reason,
8148
+ reasonEn: a.reasonEn,
8149
+ ...(a.category ? { category: a.category } : {}),
8150
+ conversationId: aConvId,
8151
+ ...(a.conversationTitle ? { conversationTitle: a.conversationTitle } : {}),
8152
+ });
8153
+ }
8154
+ }
7565
8155
  return mainId;
7566
8156
  }
8157
+ /**
8158
+ * 切到新工作目录后的“跟随面”刷新(roots / 插件钩子 / 最近项目 / 历史列表 /
8159
+ * 文件树 / 命令目录)。调用方先把 this.cwd 改好再调本函数。
8160
+ * switchConversation 跨项目切换、set_cwd、switchSession 打开别项目的历史
8161
+ * 会话三处共用 —— 之前 switchSession 只改了 cwd 没同步 roots,文件树/
8162
+ * 工作区插件/历史列表都还停在旧项目。集中一处避免再漏。
8163
+ */
8164
+ applyCwdSideEffects(abs) {
8165
+ this.roots = this.stateStore.getWorkspaceRoots(this.clientId, abs);
8166
+ // 工作区跟随型插件(编辑器文件树等)同步切根。
8167
+ try {
8168
+ this.onCwdChanged?.(abs, this.roots);
8169
+ }
8170
+ catch {
8171
+ /* 钩子异常不影响主流程 */
8172
+ }
8173
+ // Remember the new workspace (restore target + recent-project entry).
8174
+ this.stateStore.remember(this.clientId, abs);
8175
+ void this.pushProjects();
8176
+ this.refreshSessionsOnSwitch();
8177
+ void this.listFiles(undefined);
8178
+ // Commands are per-project (.pi/commands.json in the current cwd).
8179
+ void this.listCommands();
8180
+ }
7567
8181
  /** Switch the ACTIVE conversation without interrupting any other chat. */
7568
8182
  async switchConversation(id) {
7569
8183
  if (!this.convs.has(id) || id === this.activeId)
@@ -7588,7 +8202,6 @@ export class ClientSession {
7588
8202
  void this.pushSlashCommands();
7589
8203
  if (cwdChanged) {
7590
8204
  this.cwd = newCwd;
7591
- this.roots = this.stateStore.getWorkspaceRoots(this.clientId, newCwd);
7592
8205
  // 模型/key 恢复不挡快照:后台做,带代际 guard(用户又切走就跳过),
7593
8206
  // 做完补一次 flush 刷新模型栏。
7594
8207
  {
@@ -7610,20 +8223,11 @@ export class ClientSession {
7610
8223
  }
7611
8224
  // Mirror set_cwd's project-switch side-effects so the whole UI follows
7612
8225
  // the new workspace, not just the chat pane.
7613
- try {
7614
- this.onCwdChanged?.(newCwd, this.roots);
7615
- }
7616
- catch {
7617
- /* hook failure must not break the switch */
7618
- }
7619
- this.stateStore.remember(this.clientId, newCwd);
7620
- void this.pushProjects();
7621
- this.refreshSessionsOnSwitch();
7622
- void this.listFiles(undefined);
7623
- void this.listCommands();
8226
+ this.applyCwdSideEffects(newCwd);
7624
8227
  }
7625
8228
  // 当前打开对话变了 → 插件重拉(轨迹视图切会话后即刷新,不等轮询)。
7626
8229
  this.notifyConversationChanged();
8230
+ this.pushSettings();
7627
8231
  this.flushSnapshot();
7628
8232
  }
7629
8233
  /** 左栏「运行的对话」的展示口径(issue #140)。
@@ -7916,6 +8520,23 @@ export class ClientSession {
7916
8520
  return;
7917
8521
  }
7918
8522
  }
8523
+ // #145 同款守卫:删之前查一遍其他客户端是否持有这份转录 ——
8524
+ // 对端 runtime 还开着时硬删等于把文件从活 writer 身下抽走(跑着时
8525
+ // 更是两支并发写)。被持有就拒绝删除;本客户端自己的持有已在上面处理。
8526
+ const otherOwner = this.findSessionOwner?.(abs);
8527
+ if (otherOwner) {
8528
+ this.emit({
8529
+ type: "notice",
8530
+ level: "warning",
8531
+ text: otherOwner.isStreaming
8532
+ ? `该对话正在另一处运行中(「${otherOwner.title}」),已停止删除;请先回到原窗口停止或关闭它`
8533
+ : `该对话在另一处仍开着(「${otherOwner.title}」),已停止删除;请先在原窗口关闭它再删`,
8534
+ textEn: otherOwner.isStreaming
8535
+ ? `This conversation is running in another window ("${otherOwner.title}"); delete aborted — stop or close it there first`
8536
+ : `This conversation is still open in another window ("${otherOwner.title}"); delete aborted — close it there first`,
8537
+ });
8538
+ return;
8539
+ }
7919
8540
  rmSync(abs, { force: true });
7920
8541
  // 转录删了,sidecar 再留着就是孤儿,一起清掉(不存在不报错)。
7921
8542
  removeTouchSidecar(abs);
@@ -8105,6 +8726,13 @@ export class ClientSession {
8105
8726
  }
8106
8727
  conv.isSubagent = false;
8107
8728
  conv.isEphemeral = false;
8729
+ if (wasEphemeral) {
8730
+ const ephemeralDir = join(this.agentDir, "ephemeral-sessions", conv.id);
8731
+ try {
8732
+ rmSync(ephemeralDir, { recursive: true, force: true });
8733
+ }
8734
+ catch { }
8735
+ }
8108
8736
  this.emitConversations();
8109
8737
  await this.pushProjects();
8110
8738
  this.flushSnapshot();
@@ -8591,9 +9219,9 @@ export class ClientSession {
8591
9219
  // is known-good. This keeps a failed history open entirely non-destructive.
8592
9220
  const oldListed = this.conv.listed;
8593
9221
  const displaced = this.displaceActive();
8594
- const openInProject = [...this.convs.values()].filter((c) => c.cwd === targetCwd && !c.isSubagent).length +
9222
+ const openInProject = [...this.convs.values()].filter((c) => c.cwd === targetCwd && !c.isSubagent && !c.isEphemeral).length +
8595
9223
  1 -
8596
- (displaced?.cwd === targetCwd && !displaced?.isSubagent ? 1 : 0);
9224
+ (displaced?.cwd === targetCwd && !displaced?.isSubagent && !displaced?.isEphemeral ? 1 : 0);
8597
9225
  if (openInProject > MAX_OPEN_CONVERSATIONS) {
8598
9226
  // displaceActive() may have promoted a streaming conversation into the
8599
9227
  // running list. Roll that presentation-only mutation back because no
@@ -8624,6 +9252,9 @@ export class ClientSession {
8624
9252
  this.removeConversation(displaced.id);
8625
9253
  await this.bindSession();
8626
9254
  this.cwd = targetCwd;
9255
+ // 打开的历史会话可能属于另一个项目 —— 工作区跟随面(roots/文件树/
9256
+ // 历史列表/命令目录/最近项目)必须跟着切,否则 UI 停在旧项目。
9257
+ this.applyCwdSideEffects(targetCwd);
8627
9258
  await this.restoreProjectProviderKeysForCwd(targetCwd);
8628
9259
  await this.restoreProjectModelForCwd(targetCwd);
8629
9260
  this.conv.lastActiveAt = Date.now();
@@ -8635,6 +9266,7 @@ export class ClientSession {
8635
9266
  void this.pushSlashCommands();
8636
9267
  // 切历史会话成功 → 插件重拉(轨迹视图立即显示该会话时间线)。
8637
9268
  this.notifyConversationChanged();
9269
+ this.pushSettings();
8638
9270
  }
8639
9271
  catch (err) {
8640
9272
  openedTerminals?.killAll();
@@ -8681,49 +9313,16 @@ export class ClientSession {
8681
9313
  * back to its append-only session entry in the given conversation.
8682
9314
  */
8683
9315
  resolveMessageEntry(conv, messageId) {
8684
- const userSeqByTs = new Map();
8685
- const assistantSeqByTs = new Map();
8686
- let globalSeq = 0;
9316
+ // The matcher re-derives rendered ids with the SAME derivation
9317
+ // serializeCachedFor() used to hand them to the browser (uiMessageId +
9318
+ // the counter below). Historically this recomputed ids with its own
9319
+ // numbering (per-timestamp assistant seq, branch-entry global seq),
9320
+ // which never matched what was rendered — fork/rollback on any
9321
+ // assistant bubble failed 100% of the time (issue #381).
8687
9322
  const entries = conv.session.sessionManager.buildContextEntries();
8688
- for (const entry of entries) {
8689
- globalSeq += 1;
8690
- if (entry.id === messageId)
8691
- return entry;
8692
- if (entry.type === "message") {
8693
- const m = entry.message;
8694
- if (!m)
8695
- continue;
8696
- let uiId = "";
8697
- if (m.role === "user") {
8698
- const ts = m.timestamp ?? 0;
8699
- const seq = (userSeqByTs.get(ts) ?? 0) + 1;
8700
- userSeqByTs.set(ts, seq);
8701
- uiId = `u-${ts}-${seq}`;
8702
- }
8703
- else if (m.role === "assistant") {
8704
- const ts = m.timestamp ?? 0;
8705
- const seq = (assistantSeqByTs.get(ts) ?? 0) + 1;
8706
- assistantSeqByTs.set(ts, seq);
8707
- uiId = `a-${ts}-${seq}`;
8708
- }
8709
- else if (m.role === "toolResult") {
8710
- uiId = `t-${m.toolCallId}`;
8711
- }
8712
- else if (m.role === "bashExecution") {
8713
- uiId = `b-${m.timestamp}-${globalSeq}`;
8714
- }
8715
- if (uiId === messageId)
8716
- return entry;
8717
- }
8718
- else if (entry.type === "custom_message") {
8719
- const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
8720
- const uiId = `c-${ts}-${globalSeq}`;
8721
- if (uiId === messageId)
8722
- return entry;
8723
- }
8724
- }
9323
+ const found = findEntryByUiId(entries, messageId, (m) => this.uiMessageKey(conv, m).n);
8725
9324
  // 兜底:getEntry 查整棵树
8726
- return conv.session.sessionManager.getEntry(messageId) ?? null;
9325
+ return found ?? conv.session.sessionManager.getEntry(messageId) ?? null;
8727
9326
  }
8728
9327
  /**
8729
9328
  * Fork a NEW branch conversation from a specific historical message position.
@@ -8803,8 +9402,33 @@ export class ClientSession {
8803
9402
  messageId,
8804
9403
  title: targetConv.title,
8805
9404
  };
9405
+ // 与 newChat/switchSession 同一护栏:fork 出的对话也占项目名额,被换下
9406
+ // 的旧 active 也要走运行列表生命周期(该保留的保留、该释放的释放)。
9407
+ // 之前两者皆无 —— 可以无限 fork 堆爆名额,旧对话也永远留在列表里。
9408
+ // 顺序照 switchSession:新 runtime 建好才 displace,失败不伤现有对话。
9409
+ const oldListed = this.conv.listed;
9410
+ const displaced = this.displaceActive();
9411
+ const openInProject = [...this.convs.values()].filter((c) => c.cwd === targetConv.cwd && !c.isSubagent && !c.isEphemeral).length +
9412
+ 1 -
9413
+ (displaced?.cwd === targetConv.cwd && !displaced?.isSubagent && !displaced?.isEphemeral ? 1 : 0);
9414
+ if (openInProject > MAX_OPEN_CONVERSATIONS) {
9415
+ // displaceActive() 可能只是把旧对话标成后台展示(presentation-only);
9416
+ // 没有切换发生时回滚该标记,新 fork 的 runtime/终端直接释放。
9417
+ this.conv.listed = oldListed;
9418
+ terminals.killAll();
9419
+ await runtime.dispose();
9420
+ this.emit({
9421
+ type: "notice",
9422
+ 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.`,
9425
+ });
9426
+ return;
9427
+ }
8806
9428
  this.convs.set(conversationId, newConv);
8807
9429
  this.activeId = conversationId;
9430
+ if (displaced)
9431
+ this.removeConversation(displaced.id);
8808
9432
  await this.bindSession();
8809
9433
  if (prevModel && this.sharedModelRuntime) {
8810
9434
  try {
@@ -9078,7 +9702,7 @@ export class ClientSession {
9078
9702
  const run = (async () => {
9079
9703
  try {
9080
9704
  const saved = this.stateStore.get(this.clientId);
9081
- const removedProjects = new Set(this.stateStore.getRemovedProjects(this.clientId));
9705
+ const removedProjects = new Set(this.stateStore.getRemovedProjects(this.clientId).map(normalizePathKey));
9082
9706
  const map = new Map();
9083
9707
  for (const p of saved.projects)
9084
9708
  map.set(p.path, p.lastUsed);
@@ -9095,7 +9719,7 @@ export class ClientSession {
9095
9719
  // is useless in the picker. Tombstoned entries (explicitly removed by
9096
9720
  // the user) stay hidden even though session files still mention them.
9097
9721
  const projects = [...map.entries()]
9098
- .filter(([path]) => !removedProjects.has(path) && existsSync(path))
9722
+ .filter(([path]) => !removedProjects.has(normalizePathKey(path)) && existsSync(path))
9099
9723
  .map(([path, lastUsed]) => ({ path, lastUsed }))
9100
9724
  .sort((a, b) => b.lastUsed - a.lastUsed)
9101
9725
  .slice(0, 20);
@@ -9115,11 +9739,17 @@ export class ClientSession {
9115
9739
  await run;
9116
9740
  }
9117
9741
  /** 缓存命中时把当前 cwd 并进去:命中则刷新 lastUsed 重排,未命中则补到首位
9118
- * (remember 刚写入的新项目在 TTL 窗口内也可见,不必等下一次扫盘)。 */
9742
+ * (remember 刚写入的新项目在 TTL 窗口内也可见,不必等下一次扫盘)。
9743
+ * 若当前工作区已被显式移出,则不强行塞回最近列表。 */
9119
9744
  withCurrentCwd(projects, now) {
9120
- if (projects.some((p) => p.path === this.cwd)) {
9745
+ const currentKey = normalizePathKey(this.cwd);
9746
+ const removedKeys = new Set(this.stateStore.getRemovedProjects(this.clientId).map(normalizePathKey));
9747
+ if (removedKeys.has(currentKey)) {
9748
+ return projects;
9749
+ }
9750
+ if (projects.some((p) => normalizePathKey(p.path) === currentKey)) {
9121
9751
  return projects
9122
- .map((p) => (p.path === this.cwd && p.lastUsed < now ? { ...p, lastUsed: now } : p))
9752
+ .map((p) => (normalizePathKey(p.path) === currentKey && p.lastUsed < now ? { ...p, lastUsed: now } : p))
9123
9753
  .sort((a, b) => b.lastUsed - a.lastUsed);
9124
9754
  }
9125
9755
  return [{ path: this.cwd, lastUsed: now }, ...projects].slice(0, 20);
@@ -9349,7 +9979,7 @@ export class ClientSession {
9349
9979
  }
9350
9980
  async setCwd(newCwd) {
9351
9981
  try {
9352
- const { resolve, sep } = await import("node:path");
9982
+ const { resolve } = await import("node:path");
9353
9983
  this.files.unwatchGit(); // stale repo's watcher must not fire across projects
9354
9984
  const fs = await import("node:fs/promises");
9355
9985
  const trimmed = newCwd.trim();
@@ -9363,11 +9993,9 @@ export class ClientSession {
9363
9993
  });
9364
9994
  return;
9365
9995
  }
9366
- // Windows 裸盘符("C:"):resolve 会按该盘当前目录解析,必须显式指到盘根;
9367
- // 仅 win32 生效——posix 下 "C:" 仍是普通相对路径,避免误伤同名目录。
9368
- const abs = process.platform === "win32" && /^[A-Za-z]:$/.test(trimmed)
9369
- ? `${trimmed.toUpperCase()}${sep}`
9370
- : resolve(trimmed);
9996
+ // Windows 裸盘符("C:")与相对路径基准的处理收敛到 resolveCwdTarget
9997
+ // (纯函数,含 win32/posix 差异说明与单测)。
9998
+ const abs = resolveCwdTarget(trimmed, this.cwd);
9371
9999
  const st = await fs.stat(abs);
9372
10000
  if (!st.isDirectory()) {
9373
10001
  throw new Error("路径不是目录");
@@ -9477,7 +10105,6 @@ export class ClientSession {
9477
10105
  this.conv.promptedSinceActive = false;
9478
10106
  this.conv.lastActiveAt = Date.now();
9479
10107
  this.cwd = abs;
9480
- this.roots = this.stateStore.getWorkspaceRoots(this.clientId, abs);
9481
10108
  // 模型/key 恢复不挡快照:后台做,带切换代际 guard(用户又切走就跳过,
9482
10109
  // 否则会把旧项目的 key 套到新对话上),做完补一次 flush 刷新模型栏。
9483
10110
  {
@@ -9497,16 +10124,7 @@ export class ClientSession {
9497
10124
  }
9498
10125
  })();
9499
10126
  }
9500
- // 工作区跟随型插件(编辑器文件树等)同步切根。
9501
- try {
9502
- this.onCwdChanged?.(abs, this.roots);
9503
- }
9504
- catch {
9505
- /* 钩子异常不影响主流程 */
9506
- }
9507
- // Remember the new workspace (restore target + recent-project entry).
9508
- this.stateStore.remember(this.clientId, abs);
9509
- void this.pushProjects();
10127
+ this.applyCwdSideEffects(abs);
9510
10128
  this.webUi.refresh();
9511
10129
  this.emitConversations();
9512
10130
  this.goalSvc.emitGoalStatus();
@@ -9518,12 +10136,9 @@ export class ClientSession {
9518
10136
  text: `已切换到工作目录:${abs}`,
9519
10137
  textEn: `Switched to directory: ${abs}`,
9520
10138
  });
9521
- this.refreshSessionsOnSwitch();
9522
- void this.listFiles(undefined);
9523
- // Commands are per-project (.pi/commands.json in the current cwd).
9524
- void this.listCommands();
9525
10139
  // 切项目即换了当前打开对话 → 插件重拉。
9526
10140
  this.notifyConversationChanged();
10141
+ this.pushSettings();
9527
10142
  }
9528
10143
  catch (err) {
9529
10144
  this.emit({
@@ -9589,14 +10204,30 @@ export class ClientSession {
9589
10204
  async clearGoal() {
9590
10205
  return this.goalSvc.clearGoal();
9591
10206
  }
9592
- /** Run a git diff (unstaged + staged) in a conversation's workspace, or
9593
- * "" when not a repo. */
10207
+ /**
10208
+ * Run a git diff (unstaged + staged) in a conversation's workspace, or
10209
+ * "" when not a repo.
10210
+ *
10211
+ * 返回值是 goal 审查用的「变更指纹」,构造规则(截断与等值比较的相互作用)
10212
+ * 见 buildDiffFingerprint:diff 为空时以排序后的 `git status --porcelain`
10213
+ * 兜底(未跟踪文件也算变更),diff 非空时正文截断后拼 [diff-meta] 尾段,
10214
+ * 避免大 diff 截断后两轮前缀相同被误判成停滞。
10215
+ */
9594
10216
  async gitDiff(cwd) {
9595
10217
  try {
9596
10218
  const { code, out } = await this.runAsync("git", ["diff", "HEAD"], 10_000, cwd);
9597
10219
  if (code !== 0)
9598
10220
  return "";
9599
- return out.slice(0, 60_000);
10221
+ let status = "";
10222
+ try {
10223
+ const st = await this.runAsync("git", ["status", "--porcelain"], 10_000, cwd);
10224
+ if (st.code === 0)
10225
+ status = st.out;
10226
+ }
10227
+ catch {
10228
+ // status 拍不到 → 指纹退化为纯 diff,行为与旧版一致
10229
+ }
10230
+ return buildDiffFingerprint(out, status);
9600
10231
  }
9601
10232
  catch {
9602
10233
  return "";
@@ -9808,6 +10439,8 @@ export class ClientSession {
9808
10439
  this.cancelPendingQuestions();
9809
10440
  // 同理关闭挂起的页面调用(以失败解析:对面是扩展,没有答可等)。
9810
10441
  this.cancelPendingPageCalls();
10442
+ // 挂起的工具审批一并清掉(以「拒绝」解析,防 Promise/runtime 泄漏)。
10443
+ this.cancelPendingApprovals();
9811
10444
  this.bg.stop();
9812
10445
  for (const conv of this.convs.values()) {
9813
10446
  this.clearAllToolWatchdogs(conv);
@@ -10192,6 +10825,11 @@ export class AgentService {
10192
10825
  // 伪客户端常驻一个 noop sink(chatFromScheduler 的 attach),所以不能按
10193
10826
  // sinkCount 判断「有没有浏览器」—— 它永远为 1。这里就是它的回收点。
10194
10827
  this.clients.delete(clientId);
10828
+ // 先摘出 map 再异步 dispose:只删 map 的话,对话的 runtime/终端/看门狗
10829
+ // 计时器全都留在内存里(泄漏),转录还挂着活 writer(同文件双写者风险)。
10830
+ void cs.dispose().catch(() => {
10831
+ // 回收失败不拦主流程:尽力而为,进程退出时 OS 兜底
10832
+ });
10195
10833
  // 通知其他客户端刷新 elsewhere 列表。
10196
10834
  this.pokeExternalRunning(clientId);
10197
10835
  }
@@ -10570,9 +11208,9 @@ export class AgentService {
10570
11208
  }
10571
11209
  const moveIds = [convId, ...collectSubagentDescendantIds(briefs, convId)];
10572
11210
  const moveSet = new Set(moveIds);
10573
- // 容量:与 switchSession 同口径(目标项目非子代理 8 个)。
10574
- const movedMains = briefs.filter((b) => moveSet.has(b.id) && !b.isSubagent).length;
10575
- const openInProject = target.takeoverBriefs().filter((b) => b.cwd === main.cwd && !b.isSubagent).length + movedMains;
11211
+ // 容量:与 switchSession 同口径(目标项目非子代理且非临时会话 8 个)。
11212
+ const movedMains = briefs.filter((b) => moveSet.has(b.id) && !b.isSubagent && !b.isEphemeral).length;
11213
+ const openInProject = target.takeoverBriefs().filter((b) => b.cwd === main.cwd && !b.isSubagent && !b.isEphemeral).length + movedMains;
10576
11214
  if (openInProject > MAX_OPEN_CONVERSATIONS) {
10577
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.`);
10578
11216
  return;
@@ -10676,7 +11314,9 @@ export class AgentService {
10676
11314
  // 流式增量经尾部 attachSink 直接推给新 socket)。
10677
11315
  // 有其他在线标签时不认领(issue #10 隔离优先);quiesce 排空期也放行
10678
11316
  // (这是重连既有工作,不是新工作)。本段无 await,并发 attach 原子。
10679
- const orphan = this.findAdoptableOrphan(clientId);
11317
+ // 伪客户端(scheduler:/plugin:,定时任务/插件的无头调用)绝不认领:
11318
+ // 否则无头会话会改键劫持用户关浏览器留下的残会话。
11319
+ const orphan = AgentService.isPseudoClientId(clientId) ? null : this.findAdoptableOrphan(clientId);
10680
11320
  if (orphan) {
10681
11321
  this.clients.delete(orphan.oldId);
10682
11322
  this.clients.set(clientId, orphan.cs);