pi-web-ui 0.94.0 → 0.95.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +751 -206
  5. package/dist/server/agent-service.js +1869 -85
  6. package/dist/server/approval-rules.js +550 -0
  7. package/dist/server/attachment-store.js +112 -0
  8. package/dist/server/attachments.js +62 -171
  9. package/dist/server/client-state.js +4 -0
  10. package/dist/server/compact-context-tool.js +125 -0
  11. package/dist/server/context-budget.js +317 -0
  12. package/dist/server/dangling-tools.js +162 -0
  13. package/dist/server/dsh/dsh-agent-service.js +50 -60
  14. package/dist/server/edit-soft-tool.js +70 -5
  15. package/dist/server/eval-tool.js +535 -0
  16. package/dist/server/files-service.js +35 -7
  17. package/dist/server/goal-service.js +240 -68
  18. package/dist/server/hashline-engine.js +665 -0
  19. package/dist/server/host-metrics.js +26 -2
  20. package/dist/server/http-proxy.js +11 -1
  21. package/dist/server/index.js +157 -9
  22. package/dist/server/lsp-tool.js +679 -0
  23. package/dist/server/model-admin.js +20 -0
  24. package/dist/server/office-parse.js +309 -0
  25. package/dist/server/patch-tool.js +91 -0
  26. package/dist/server/plan-manager.js +104 -0
  27. package/dist/server/plugin-api-catalog.js +296 -0
  28. package/dist/server/plugin-install-spec.js +196 -0
  29. package/dist/server/plugin-installer.js +73 -0
  30. package/dist/server/plugin-manifest-validate.js +305 -0
  31. package/dist/server/plugin-project.js +47 -11
  32. package/dist/server/plugin-tool-guard.js +120 -0
  33. package/dist/server/plugins.js +745 -161
  34. package/dist/server/process-utils.js +12 -6
  35. package/dist/server/scheduler-tasks.js +6 -0
  36. package/dist/server/serialize.js +13 -3
  37. package/dist/server/settings-service.js +108 -1
  38. package/dist/server/subagents.js +41 -0
  39. package/dist/server/terminals.js +69 -14
  40. package/dist/server/tool-approval.js +84 -0
  41. package/dist/server/tool-manager.js +214 -6
  42. package/dist/server/workspace-snapshot.js +113 -0
  43. package/package.json +3 -2
  44. package/plugin-sdk/README.md +25 -0
  45. package/plugin-sdk/index.d.ts +31 -38
  46. package/plugin-sdk/index.mjs +35 -19
  47. package/plugins/catalog.json +20 -0
  48. package/themes/ayu-light.css +6 -6
  49. package/themes/catppuccin-latte.css +6 -6
  50. package/themes/claude-code-dark.css +144 -0
  51. package/themes/codex.css +6 -6
  52. package/themes/dark-teal.css +20 -6
  53. package/themes/everforest-light.css +6 -6
  54. package/themes/geist.css +6 -6
  55. package/themes/gruvbox-light.css +6 -6
  56. package/themes/kanagawa-lotus.css +6 -6
  57. package/themes/rose-pine-dawn.css +6 -6
  58. package/themes/solarized-light.css +6 -6
  59. package/themes/vs-code-dark.css +146 -0
  60. package/web/dist/assets/{TerminalPanel-B-CMQuzm.js → TerminalPanel-DlGBF2MD.js} +1 -1
  61. package/web/dist/assets/index-BXnCQL9p.js +364 -0
  62. package/web/dist/assets/index-RpIGFyi_.css +1 -0
  63. package/web/dist/index.html +2 -2
  64. package/web/dist/assets/index-18jK6al2.js +0 -364
  65. package/web/dist/assets/index-B3S9MxnN.css +0 -1
@@ -19,16 +19,18 @@ import { createHash, randomUUID } from "node:crypto";
19
19
  import { createRequire } from "node:module";
20
20
  import { appendFileSync, existsSync, readFileSync, rmSync, statSync, mkdirSync, watch, writeFileSync } from "node:fs";
21
21
  import { homedir } from "node:os";
22
- import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
22
+ import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path";
23
23
  import { fileURLToPath } from "node:url";
24
- import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashToolDefinition, createLocalBashOperations, getAgentDir, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
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
27
  import { checkAll as checkAllUpdates, collectTargets, compareVersions as compareSemver, resolveNpmRegistry, sortUpdateItems, } from "./update-check.js";
28
28
  import { hasActiveSubagentRun, hasPendingWaitSubscription, shouldRetainActive } from "./wait-subscription-scan.js";
29
29
  import { COMPACTION_PENDING_TYPE, looksLikeChainCorruption, makeCompactionMarkerId, repairSessionFile, } from "./compaction-markers.js";
30
+ import { DANGLING_TOOL_RESULT_TEXT, DANGLING_TOOL_RESULT_TEXT_EN, findDanglingToolCalls, healDanglingToolCallFile, } from "./dangling-tools.js";
30
31
  import { removeQueuedByIndexOrText } from "./queue-utils.js";
31
32
  import { syncPluginToolsIntoSession } from "./plugins.js";
33
+ import { denialText, } from "./plugin-tool-guard.js";
32
34
  import { SettingsService } from "./settings-service.js";
33
35
  import { GoalService } from "./goal-service.js";
34
36
  import { MarkerService } from "./marker-service.js";
@@ -38,17 +40,26 @@ import { FilesService, MACHINE_ROOT, desktopDirWire, workspacePath } from "./fil
38
40
  import { DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS, effectiveToolWatchdogMs, isExtensionDisabled, isExtensionEnabled, normalizeDisabledPluginTools, normalizeRetryMaxAttempts, normalizeSkillList, ClientStateStore, } from "./client-state.js";
39
41
  import { bilingual, pick, resolveServerLang } from "./i18n.js";
40
42
  import { SubagentTemplatesStore, pickTemplatePrompt } from "./subagent-templates.js";
43
+ import { ApprovalRulesStore } from "./approval-rules.js";
41
44
  import { ComposerDraftsStore } from "./composer-drafts.js";
45
+ import { createWorkspaceSnapshot, restoreWorkspaceSnapshot } from "./workspace-snapshot.js";
46
+ import { approvalSuppressionReason, checkDangerousToolCall, isApprovalPolicyEmpty, pluginApprovalCategory, } from "./tool-approval.js";
47
+ import { PlanManager } from "./plan-manager.js";
42
48
  import { applyHeadTail, makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, } from "./terminals.js";
43
- import { applyAgentToolsGating, ASK_USER_QUESTION_TOOL_NAME, BROWSER_PAGE_TOOL_NAME, effectiveDisabledAgentTools, isAgentToolEnabled, isTerminalGuidanceOn, MARKERS_LIST_TOOL_NAME, PRESENT_FILES_TOOL_NAME, } from "./tool-manager.js";
49
+ import { applyAgentToolsGating, ASK_USER_QUESTION_TOOL_NAME, BROWSER_PAGE_TOOL_NAME, effectiveDisabledAgentTools, isAgentToolEnabled, isTerminalGuidanceOn, MARKERS_LIST_TOOL_NAME, PI_AGENT_PRESETS, PI_PERMISSION_OPTIONS, PLAN_UPDATE_TOOL_NAME, PRESENT_FILES_TOOL_NAME, presetAllowsPluginTools, presetHasQuestionnaire, presetShowsSkillCatalog, } from "./tool-manager.js";
44
50
  import { WebUIContext } from "./webui-context.js";
45
51
  import { DEFAULT_COMPACTION_RESERVE_TOKENS, effectiveSoftCap, softCapToReserve } from "./soft-cap.js";
52
+ import { pruneContextHierarchically } from "./context-budget.js";
46
53
  import { decodeText } from "./text-sniff.js";
47
54
  import { makeEditSoftTool } from "./edit-soft-tool.js";
48
55
  // 覆盖 SDK 内置 read:路径是目录时列出目录条目(行为开关 readDirEnabled,默认开)。
49
56
  import { makeReadDirTool } from "./read-tool.js";
50
57
  // 展示文件给用户(present_files):图片/视频内联、文本开预览弹窗、本地打开按钮。
51
58
  import { makePresentFilesTool } from "./present-files-tool.js";
59
+ // 主动压缩上下文工具(compact_context):AI 主动根据当前问题精简上下文并自主控制范围。
60
+ import { makeCompactContextTool, buildCompactionInstructions, DEFAULT_KEEP_RECENT_TOKENS, } from "./compact-context-tool.js";
61
+ // 持久代码求值沙箱(eval):Python / Node.js 沙箱内核。
62
+ import { disposeAllEvalKernels, disposeEvalSession, makeEvalTool } from "./eval-tool.js";
52
63
  // 工具定义说明的归一化(工具卡右键 → 「显示工具详细信息」,见 getToolInfo)。
53
64
  import { normalizeToolInfo } from "./tool-info.js";
54
65
  import { collectSubagentDescendantIds, makeSubagentTools, subagentTitle, withSubagentOwner, } from "./subagents.js";
@@ -59,6 +70,8 @@ import { ClaimStore, matchClaims, mergeTouchSidecar, readTouchSidecar, removeTou
59
70
  import { makeClaimFilesTool } from "./claim-files-tool.js";
60
71
  import { makeSkillTool } from "./skill-tool.js";
61
72
  import { makeScheduleTools } from "./schedule-agent-tool.js";
73
+ import { makePatchTool } from "./patch-tool.js";
74
+ import { makeLspTool } from "./lsp-tool.js";
62
75
  import { sameSessionFile } from "./scheduler-tasks.js";
63
76
  import { buildAttachmentMessages, parseModelSpec } from "./attachments.js";
64
77
  import { buildVisionBridgePrompt, findVisionModels, transcribeImages } from "./vision-bridge.js";
@@ -247,6 +260,436 @@ export function makeAdaptiveBashTool(killable, terminalBacked, useTerminal) {
247
260
  },
248
261
  };
249
262
  }
263
+ /**
264
+ * 给已接管工具(bash/read)包上拦截守卫与人机协同审批:
265
+ * 1. pre guard 命中 deny → 直接阻断;
266
+ * 2. pre guard 命中 ask 或内置高危操作检测(rm -rf / 敏感文件覆盖等) →
267
+ * 触发 tool_approval_pending 等待用户审批;
268
+ * - 用户选择 approve: 放行执行
269
+ * - 用户选择 edit: 使用用户修改后的参数执行(Edit & Run)
270
+ * - 用户选择 deny: 阻断并告知模型
271
+ * 3. post guard 合并(脱敏/补上下文)。
272
+ */
273
+ export function withToolGuard(def, opts) {
274
+ const guard = opts.guard;
275
+ const toolName = opts.toolName;
276
+ if (!guard && !opts.askApproval)
277
+ return def;
278
+ return {
279
+ ...def,
280
+ execute: (async (toolCallId, params, signal, onUpdate, ctx) => {
281
+ const lang = opts.getLang();
282
+ const conversationId = opts.conversationId?.();
283
+ let pre;
284
+ if (guard) {
285
+ try {
286
+ pre = await guard.pre({ toolName, params, conversationId }, lang);
287
+ }
288
+ catch {
289
+ pre = { verdict: { decision: "allow" } };
290
+ }
291
+ }
292
+ // 检查是否需要人工协同审批(Human-in-the-Loop)
293
+ let needApproval = false;
294
+ let approvalReason;
295
+ let approvalReasonEn;
296
+ let approvalCategory;
297
+ if (pre?.verdict.decision === "deny") {
298
+ const text = denialText(pre.verdict, pre.pluginId ?? "plugin", lang);
299
+ return {
300
+ content: [{ type: "text", text }],
301
+ details: { guardDenied: true, decision: pre.verdict.decision, pluginId: pre.pluginId },
302
+ };
303
+ }
304
+ else if (pre?.verdict.decision === "ask") {
305
+ needApproval = true;
306
+ approvalReason = pre.verdict.reason ?? "插件要求确认本次操作";
307
+ approvalReasonEn = pre.verdict.reasonEn ?? "Plugin requested confirmation for this operation";
308
+ // 插件档位按插件 id 分:用户可以「允许同类」= 该插件的确认以后不再问。
309
+ approvalCategory = pluginApprovalCategory(pre.pluginId ?? "plugin");
310
+ }
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
+ let effectiveParams = params;
332
+ let userEdited = false;
333
+ if (needApproval && opts.askApproval) {
334
+ const res = await opts.askApproval(toolCallId, toolName, params, approvalReason, approvalReasonEn, conversationId, approvalCategory);
335
+ if (res.decision === "deny") {
336
+ const reasonText = res.reason ? ` 原因:${res.reason}` : "";
337
+ const reasonTextEn = res.reason ? ` Reason: ${res.reason}` : "";
338
+ return {
339
+ content: [
340
+ {
341
+ type: "text",
342
+ text: pick(lang, `【操作被用户拒绝】${reasonText}`, `[Operation denied by user]${reasonTextEn}`),
343
+ },
344
+ ],
345
+ details: { guardDenied: true, userDenied: true, reason: res.reason },
346
+ isError: true,
347
+ };
348
+ }
349
+ else if (res.decision === "edit") {
350
+ effectiveParams = res.editedParams ?? params;
351
+ userEdited = true;
352
+ }
353
+ }
354
+ else if (pre?.verdict.decision === "ask") {
355
+ // 无审批通道时回落至原先的阻断行为
356
+ const text = denialText(pre.verdict, pre.pluginId ?? "plugin", lang);
357
+ return {
358
+ content: [{ type: "text", text }],
359
+ details: { guardDenied: true, decision: pre.verdict.decision, pluginId: pre.pluginId },
360
+ };
361
+ }
362
+ const result = (await def.execute(toolCallId, effectiveParams, signal, onUpdate, ctx));
363
+ if (userEdited && result && typeof result === "object") {
364
+ result.details = {
365
+ ...(result.details ?? {}),
366
+ userEdited: true,
367
+ originalParams: params,
368
+ executedParams: effectiveParams,
369
+ };
370
+ }
371
+ if (guard) {
372
+ let post;
373
+ try {
374
+ post = await guard.post({ toolName, params: effectiveParams, result, conversationId }, lang);
375
+ }
376
+ catch {
377
+ post = undefined;
378
+ }
379
+ if (post?.content)
380
+ return { ...result, content: post.content };
381
+ }
382
+ return result;
383
+ }),
384
+ };
385
+ }
386
+ /** 校验目标路径是否在工作区(或多根工作区)内。 */
387
+ function isInsideWorkspaceRoots(targetPath, cwd, roots = []) {
388
+ const abs = isAbsolute(targetPath) ? targetPath : resolve(cwd, targetPath);
389
+ const allRoots = [resolve(cwd), ...roots.map((r) => resolve(r))];
390
+ return allRoots.some((r) => abs === r || abs.startsWith(r + sep));
391
+ }
392
+ /** 为 write 工具包装会话级权限沙箱与人机协同审批。 */
393
+ function wrapWriteToolWithPermission(cwd, getPermission, getRoots, getLang, askApproval, getConversationId, getRules) {
394
+ const base = createWriteToolDefinition(cwd);
395
+ return {
396
+ ...base,
397
+ execute: async (toolCallId, params, signal, onUpdate, ctx) => {
398
+ const perm = getPermission();
399
+ if (perm === "read-only") {
400
+ return {
401
+ content: [
402
+ {
403
+ type: "text",
404
+ text: pick(getLang(), "【权限被拒绝】当前会话处于「只读模式」(read-only),禁止写入文件。若需修改请切换权限预设。", "[Permission Denied] The current session is in 'read-only' mode; writing files is forbidden. Switch permission preset if needed."),
405
+ },
406
+ ],
407
+ isError: true,
408
+ };
409
+ }
410
+ if (perm === "workspace-write-never") {
411
+ const p = params?.path ?? "";
412
+ if (!isInsideWorkspaceRoots(p, cwd, getRoots())) {
413
+ return {
414
+ content: [
415
+ {
416
+ type: "text",
417
+ text: pick(getLang(), `【权限被拒绝】当前会话处于「工作区内修改」(workspace-write-never)模式,禁止修改工作区外部文件("${p}")。若需修改请切换至「完全权限」。`, `[Permission Denied] The current session is in 'workspace-write-never' mode; writing files outside the workspace ("${p}") is forbidden. Switch to 'danger-full-access' if needed.`),
418
+ },
419
+ ],
420
+ isError: true,
421
+ };
422
+ }
423
+ }
424
+ // 高危写操作人机协同审批拦截(如敏感配置修改)与规则匹配
425
+ let effectiveParams = params;
426
+ let userEdited = false;
427
+ if (askApproval) {
428
+ const danger = checkDangerousToolCall("write", params, cwd, getRoots(), getRules?.());
429
+ if (danger.denied) {
430
+ return {
431
+ content: [
432
+ {
433
+ type: "text",
434
+ text: pick(getLang(), `【文件写入被审批规则直接阻断】${danger.reason ? ` 原因:${danger.reason}` : ""}`, `[File write blocked by approval rule]${danger.reason ? ` Reason: ${danger.reason}` : ""}`),
435
+ },
436
+ ],
437
+ details: { guardDenied: true, ruleDenied: true, reason: danger.reason },
438
+ isError: true,
439
+ };
440
+ }
441
+ if (danger.dangerous) {
442
+ const res = await askApproval(toolCallId, "write", params, danger.reason, danger.reasonEn, getConversationId?.(), danger.category);
443
+ if (res.decision === "deny") {
444
+ return {
445
+ content: [
446
+ {
447
+ type: "text",
448
+ text: pick(getLang(), `【文件写入被用户拒绝】${res.reason ? ` 原因:${res.reason}` : ""}`, `[File write denied by user]${res.reason ? ` Reason: ${res.reason}` : ""}`),
449
+ },
450
+ ],
451
+ details: { guardDenied: true, userDenied: true, reason: res.reason },
452
+ isError: true,
453
+ };
454
+ }
455
+ else if (res.decision === "edit") {
456
+ effectiveParams = res.editedParams ?? params;
457
+ userEdited = true;
458
+ }
459
+ }
460
+ }
461
+ const result = (await base.execute(toolCallId, effectiveParams, signal, onUpdate, ctx));
462
+ if (userEdited && result && typeof result === "object") {
463
+ result.details = {
464
+ ...(result.details ?? {}),
465
+ userEdited: true,
466
+ originalParams: params,
467
+ executedParams: effectiveParams,
468
+ };
469
+ }
470
+ return result;
471
+ },
472
+ };
473
+ }
474
+ /** 为 edit 工具包装会话级权限沙箱与人机协同审批。 */
475
+ function wrapEditToolWithPermission(cwd, getPermission, getRoots, getLang, askApproval, getConversationId, getRules) {
476
+ const base = createEditToolDefinition(cwd);
477
+ return {
478
+ ...base,
479
+ execute: async (toolCallId, params, signal, onUpdate, ctx) => {
480
+ const perm = getPermission();
481
+ if (perm === "read-only") {
482
+ return {
483
+ content: [
484
+ {
485
+ type: "text",
486
+ text: pick(getLang(), "【权限被拒绝】当前会话处于「只读模式」(read-only),禁止编辑文件。若需修改请切换权限预设。", "[Permission Denied] The current session is in 'read-only' mode; editing files is forbidden. Switch permission preset if needed."),
487
+ },
488
+ ],
489
+ isError: true,
490
+ };
491
+ }
492
+ if (perm === "workspace-write-never") {
493
+ const p = params?.path ?? "";
494
+ if (!isInsideWorkspaceRoots(p, cwd, getRoots())) {
495
+ return {
496
+ content: [
497
+ {
498
+ type: "text",
499
+ text: pick(getLang(), `【权限被拒绝】当前会话处于「工作区内修改」(workspace-write-never)模式,禁止修改工作区外部文件("${p}")。若需修改请切换至「完全权限」。`, `[Permission Denied] The current session is in 'workspace-write-never' mode; editing files outside the workspace ("${p}") is forbidden. Switch to 'danger-full-access' if needed.`),
500
+ },
501
+ ],
502
+ isError: true,
503
+ };
504
+ }
505
+ }
506
+ // 高危修改人机协同审批拦截与规则匹配
507
+ let effectiveParams = params;
508
+ let userEdited = false;
509
+ if (askApproval) {
510
+ const danger = checkDangerousToolCall("edit", params, cwd, getRoots(), getRules?.());
511
+ if (danger.denied) {
512
+ return {
513
+ content: [
514
+ {
515
+ type: "text",
516
+ text: pick(getLang(), `【文件编辑被审批规则直接阻断】${danger.reason ? ` 原因:${danger.reason}` : ""}`, `[File edit blocked by approval rule]${danger.reason ? ` Reason: ${danger.reason}` : ""}`),
517
+ },
518
+ ],
519
+ details: { guardDenied: true, ruleDenied: true, reason: danger.reason },
520
+ isError: true,
521
+ };
522
+ }
523
+ if (danger.dangerous) {
524
+ const res = await askApproval(toolCallId, "edit", params, danger.reason, danger.reasonEn, getConversationId?.(), danger.category);
525
+ if (res.decision === "deny") {
526
+ return {
527
+ content: [
528
+ {
529
+ type: "text",
530
+ text: pick(getLang(), `【文件编辑被用户拒绝】${res.reason ? ` 原因:${res.reason}` : ""}`, `[File edit denied by user]${res.reason ? ` Reason: ${res.reason}` : ""}`),
531
+ },
532
+ ],
533
+ details: { guardDenied: true, userDenied: true, reason: res.reason },
534
+ isError: true,
535
+ };
536
+ }
537
+ else if (res.decision === "edit") {
538
+ effectiveParams = res.editedParams ?? params;
539
+ userEdited = true;
540
+ }
541
+ }
542
+ }
543
+ const result = (await base.execute(toolCallId, effectiveParams, signal, onUpdate, ctx));
544
+ if (userEdited && result && typeof result === "object") {
545
+ result.details = {
546
+ ...(result.details ?? {}),
547
+ userEdited: true,
548
+ originalParams: params,
549
+ executedParams: effectiveParams,
550
+ };
551
+ }
552
+ return result;
553
+ },
554
+ };
555
+ }
556
+ /** 为 edit_soft 工具包装会话级权限沙箱与人机协同审批。 */
557
+ function wrapEditSoftToolWithPermission(tool, cwd, getPermission, getRoots, getLang, askApproval, getConversationId, getRules) {
558
+ return {
559
+ ...tool,
560
+ execute: async (toolCallId, params, signal, onUpdate, ctx) => {
561
+ const perm = getPermission();
562
+ if (perm === "read-only") {
563
+ return {
564
+ content: [
565
+ {
566
+ type: "text",
567
+ text: pick(getLang(), "【权限被拒绝】当前会话处于「只读模式」(read-only),禁止编辑文件。若需修改请切换权限预设。", "[Permission Denied] The current session is in 'read-only' mode; editing files is forbidden. Switch permission preset if needed."),
568
+ },
569
+ ],
570
+ isError: true,
571
+ };
572
+ }
573
+ if (perm === "workspace-write-never") {
574
+ const p = params?.path ?? "";
575
+ if (!isInsideWorkspaceRoots(p, cwd, getRoots())) {
576
+ return {
577
+ content: [
578
+ {
579
+ type: "text",
580
+ text: pick(getLang(), `【权限被拒绝】当前会话处于「工作区内修改」(workspace-write-never)模式,禁止修改工作区外部文件("${p}")。若需修改请切换至「完全权限」。`, `[Permission Denied] The current session is in 'workspace-write-never' mode; editing files outside the workspace ("${p}") is forbidden. Switch to 'danger-full-access' if needed.`),
581
+ },
582
+ ],
583
+ isError: true,
584
+ };
585
+ }
586
+ }
587
+ // 高危修改人机协同审批拦截与规则匹配
588
+ let effectiveParams = params;
589
+ let userEdited = false;
590
+ if (askApproval) {
591
+ const danger = checkDangerousToolCall("edit_soft", params, cwd, getRoots(), getRules?.());
592
+ if (danger.denied) {
593
+ return {
594
+ content: [
595
+ {
596
+ type: "text",
597
+ text: pick(getLang(), `【文件编辑被审批规则直接阻断】${danger.reason ? ` 原因:${danger.reason}` : ""}`, `[File edit blocked by approval rule]${danger.reason ? ` Reason: ${danger.reason}` : ""}`),
598
+ },
599
+ ],
600
+ details: { guardDenied: true, ruleDenied: true, reason: danger.reason },
601
+ isError: true,
602
+ };
603
+ }
604
+ if (danger.dangerous) {
605
+ const res = await askApproval(toolCallId, "edit_soft", params, danger.reason, danger.reasonEn, getConversationId?.(), danger.category);
606
+ if (res.decision === "deny") {
607
+ return {
608
+ content: [
609
+ {
610
+ type: "text",
611
+ text: pick(getLang(), `【文件编辑被用户拒绝】${res.reason ? ` 原因:${res.reason}` : ""}`, `[File edit denied by user]${res.reason ? ` Reason: ${res.reason}` : ""}`),
612
+ },
613
+ ],
614
+ details: { guardDenied: true, userDenied: true, reason: res.reason },
615
+ isError: true,
616
+ };
617
+ }
618
+ else if (res.decision === "edit") {
619
+ effectiveParams = res.editedParams ?? params;
620
+ userEdited = true;
621
+ }
622
+ }
623
+ }
624
+ const result = (await tool.execute(toolCallId, effectiveParams, signal, onUpdate, ctx));
625
+ if (userEdited && result && typeof result === "object") {
626
+ result.details = {
627
+ ...(result.details ?? {}),
628
+ userEdited: true,
629
+ originalParams: params,
630
+ executedParams: effectiveParams,
631
+ };
632
+ }
633
+ return result;
634
+ },
635
+ };
636
+ }
637
+ /**
638
+ * 结构化任务执行计划更新工具(plan_update)— Plan Mode / Step State Machine。
639
+ */
640
+ function makePlanUpdateTool(planManager, getActiveConvId, emit, flushSnapshot) {
641
+ return {
642
+ name: PLAN_UPDATE_TOOL_NAME,
643
+ 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更新结构化任务执行计划(步骤状态机)。用于复杂工程任务拆解与实时进度推进。",
645
+ parameters: Type.Object({
646
+ steps: Type.Array(Type.Object({
647
+ id: Type.String({ description: "Unique step ID, e.g. '1', 'step-1'" }),
648
+ title: Type.String({ description: "Short step title" }),
649
+ 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" })),
651
+ }), { description: "List of plan steps" }),
652
+ activeStepId: Type.Optional(Type.String({ description: "ID of the step currently being executed" })),
653
+ }),
654
+ execute: async (toolCallId, params) => {
655
+ const convId = getActiveConvId();
656
+ const p = params;
657
+ const plan = planManager.setPlan(convId, p.steps, p.activeStepId);
658
+ emit({
659
+ type: "plan_updated",
660
+ conversationId: convId,
661
+ plan,
662
+ });
663
+ flushSnapshot();
664
+ const summary = planManager.describePlan(convId);
665
+ return {
666
+ content: [{ type: "text", text: `Plan updated successfully.\n\n${summary}` }],
667
+ details: { plan },
668
+ };
669
+ },
670
+ };
671
+ }
672
+ /** 为 bash 工具包装只读拦截。 */
673
+ function wrapBashToolWithPermission(tool, getPermission, getLang) {
674
+ return {
675
+ ...tool,
676
+ execute: async (toolCallId, params, signal, onUpdate, ctx) => {
677
+ const perm = getPermission();
678
+ if (perm === "read-only") {
679
+ return {
680
+ content: [
681
+ {
682
+ type: "text",
683
+ text: pick(getLang(), "【权限被拒绝】当前会话处于「只读模式」(read-only),禁止执行终端命令。若需执行请切换权限预设。", "[Permission Denied] The current session is in 'read-only' mode; running shell commands is forbidden. Switch permission preset if needed."),
684
+ },
685
+ ],
686
+ isError: true,
687
+ };
688
+ }
689
+ return tool.execute(toolCallId, params, signal, onUpdate, ctx);
690
+ },
691
+ };
692
+ }
250
693
  /**
251
694
  * 任务列表只读查询工具(todo_list)— 读操作仍走真工具。
252
695
  */
@@ -305,6 +748,15 @@ ownerId) {
305
748
  header: Type.Optional(Type.String({ description: "Optional short header for this question" })),
306
749
  options: Type.Optional(Type.Array(QuestionOptionSchema, { description: "Available options to choose from" })),
307
750
  multiSelect: Type.Optional(Type.Boolean({ description: "Allow selecting multiple options (default: false)" })),
751
+ dependsOn: Type.Optional(Type.Object({
752
+ questionId: Type.String({ description: "ID of the prior question this question depends on" }),
753
+ 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.",
755
+ })),
756
+ })),
757
+ optionsMap: Type.Optional(Type.Record(Type.String(), Type.Array(QuestionOptionSchema), {
758
+ description: "Dynamic options based on prior question's chosen value: e.g. { 'React': [opts...], 'Vue': [opts...] }",
759
+ })),
308
760
  });
309
761
  return {
310
762
  name: "ask_user_question",
@@ -940,7 +1392,7 @@ export class ClientSession {
940
1392
  emit: (msg) => this.emit(msg),
941
1393
  isDisposed: () => this.disposed,
942
1394
  getCwd: () => this.cwd,
943
- getActiveCwd: () => this.conv?.cwd ?? this.cwd,
1395
+ getActiveCwd: () => this.convs.get(this.activeId)?.cwd ?? this.cwd,
944
1396
  // issue #91:文件服务错误文案按客户端 UI 语言出中英(英文默认)。
945
1397
  getLang: () => this.getLang(),
946
1398
  });
@@ -954,6 +1406,9 @@ export class ClientSession {
954
1406
  /** index.ts 注入(经 AgentService 拷贝到每个新会话):把 SDK 工具执行事件转发给
955
1407
  * 插件(PluginManager.emitToolEvent)。未设置时不做任何事。 */
956
1408
  onToolEvent = undefined;
1409
+ /** index.ts 注入(P1-5,经 AgentService 拷贝到每个新会话):bash/read 执行前后的
1410
+ * 插件拦截(PluginManager.evaluateToolPre/evaluateToolPost)。未设置时直通。 */
1411
+ toolGuard = undefined;
957
1412
  /** index.ts 注入:把运行轨迹事件转发给插件(PluginManager.emitRunEvent,
958
1413
  * 轨迹视图插件靠它聚合时间线)。未设置时不做任何事。 */
959
1414
  onRunEvent = undefined;
@@ -993,10 +1448,10 @@ export class ClientSession {
993
1448
  return this.conv.terminals;
994
1449
  }
995
1450
  getTerminalManager(conversationId) {
996
- return (conversationId ? this.convs.get(conversationId) : this.conv)?.terminals;
1451
+ return (conversationId ? this.convs.get(conversationId) : this.convs.get(this.activeId))?.terminals;
997
1452
  }
998
1453
  getTerminalCwd(conversationId) {
999
- return (conversationId ? this.convs.get(conversationId) : this.conv)?.cwd ?? this.cwd;
1454
+ return (conversationId ? this.convs.get(conversationId) : this.convs.get(this.activeId))?.cwd ?? this.cwd;
1000
1455
  }
1001
1456
  makeTerminalManager(conversationId, cwd) {
1002
1457
  const mgr = new TerminalManager((msg) => this.emitTerminal(conversationId, msg), cwd,
@@ -1099,6 +1554,16 @@ export class ClientSession {
1099
1554
  const conversationId = persist ? `conv-${randomUUID().slice(0, 8)}` : `sa-${randomUUID().slice(0, 8)}`;
1100
1555
  const terminals = this.makeTerminalManager(conversationId, resolvedCwd);
1101
1556
  const sessionManager = persist ? SessionManager.create(resolvedCwd) : SessionManager.inMemory(resolvedCwd);
1557
+ if (!persist) {
1558
+ // 为内存子代理提供隔离的临时运行目录(供 SoL-Pi 等依赖 getSessionDir 的扩展正常放置缓存),
1559
+ // 但保持 persist = false(不写 .jsonl 对话文件、不污染历史记录)
1560
+ const ephemeralDir = join(this.agentDir, "subagent-sessions", conversationId);
1561
+ try {
1562
+ mkdirSync(ephemeralDir, { recursive: true });
1563
+ sessionManager.sessionDir = ephemeralDir;
1564
+ }
1565
+ catch { }
1566
+ }
1102
1567
  const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, apply, conversationId), {
1103
1568
  cwd: resolvedCwd,
1104
1569
  agentDir: this.agentDir,
@@ -1136,7 +1601,10 @@ export class ClientSession {
1136
1601
  // 局部 mock 缺失而崩),但它是 headless 的:UI 输出全部丢弃、弹窗按取消返回,
1137
1602
  // 因此既不与主对话的 widget/status 串台,也不会让扩展卡在永远无人应答的弹窗上。
1138
1603
  uiContext: WebUIContext.headless(),
1139
- onError: (err) => this.emit({ type: "notice", level: "error", text: err.error, textEn: err.error }),
1604
+ onError: this.makeExtensionErrorReporter({
1605
+ text: `子代理 ${conversationId}:`,
1606
+ textEn: `Subagent ${conversationId}: `,
1607
+ }),
1140
1608
  });
1141
1609
  }
1142
1610
  catch {
@@ -1268,6 +1736,8 @@ export class ClientSession {
1268
1736
  output: conv.session.getLastAssistantText() ?? "",
1269
1737
  parentId: conv.parentId,
1270
1738
  persisted: isPersisted,
1739
+ handoffTo: conv.peerHandoffTo ? [...conv.peerHandoffTo] : undefined,
1740
+ handoffFrom: conv.peerHandoffFrom ? [...conv.peerHandoffFrom] : undefined,
1271
1741
  };
1272
1742
  }
1273
1743
  /** 子代理最近一次运行的结局:最后一条 assistant 消息的 errorMessage / stopReason。
@@ -1406,7 +1876,7 @@ export class ClientSession {
1406
1876
  const sess = this.session;
1407
1877
  if (!sess)
1408
1878
  return undefined;
1409
- const cwd = this.conv?.cwd ?? this.cwd;
1879
+ const cwd = this.convs.get(this.activeId)?.cwd ?? this.cwd;
1410
1880
  const active = sess.getActiveToolNames();
1411
1881
  const snippets = {};
1412
1882
  const guidelines = [];
@@ -1463,6 +1933,96 @@ export class ClientSession {
1463
1933
  * 历史/resume 列表)、listed=true 出现在左栏「运行的对话」并向用户可见——
1464
1934
  * 切换查看 / 输入补充(steer)/ 中止(abort)/ 移出全部复用现有对话机制。
1465
1935
  */
1936
+ subagentHandoffs = [];
1937
+ /**
1938
+ * 多智能体同行协作与直接交接(Peer-to-Peer Subagents & Hand-off):
1939
+ * 将任务产物、分析结果或后续指令直接从一个子代理路由至另一个同行子代理,
1940
+ * 无需主会话反复充当传声筒消耗双倍 token。
1941
+ */
1942
+ async handoffSubagent(fromRunId, toRunId, payload) {
1943
+ if (fromRunId === toRunId) {
1944
+ throw new Error("Cannot hand off to oneself");
1945
+ }
1946
+ const toConv = this.convs.get(toRunId);
1947
+ if (!toConv) {
1948
+ throw new Error(`Target subagent ${toRunId} not found`);
1949
+ }
1950
+ const fromConv = this.convs.get(fromRunId);
1951
+ const fromType = fromConv?.subagentType ?? "peer";
1952
+ const toType = toConv.subagentType ?? "peer";
1953
+ const handoffMessage = `[Peer Hand-off from ${fromType} subagent (${fromRunId.slice(0, 8)})]:\n\n${payload}`;
1954
+ const record = { fromRunId, toRunId, timestamp: Date.now() };
1955
+ this.subagentHandoffs.push(record);
1956
+ if (!fromConv?.peerHandoffTo) {
1957
+ if (fromConv)
1958
+ fromConv.peerHandoffTo = [];
1959
+ }
1960
+ fromConv?.peerHandoffTo?.push(toRunId);
1961
+ if (!toConv.peerHandoffFrom)
1962
+ toConv.peerHandoffFrom = [];
1963
+ toConv.peerHandoffFrom.push(fromRunId);
1964
+ // 向目标子代理注入对等交接消息
1965
+ await toConv.session.sendUserMessage(handoffMessage, toConv.session.isStreaming ? { deliverAs: "steer" } : undefined);
1966
+ // 广播交接事件
1967
+ this.emit({
1968
+ type: "subagent_handoff",
1969
+ fromRunId,
1970
+ toRunId,
1971
+ payload,
1972
+ timestamp: record.timestamp,
1973
+ });
1974
+ // 向主会话推送通知
1975
+ this.emit({
1976
+ type: "notice",
1977
+ level: "info",
1978
+ text: `子代理 ${fromRunId.slice(0, 8)}(${fromType})已向同行子代理 ${toRunId.slice(0, 8)}(${toType})直接交接`,
1979
+ textEn: `Subagent ${fromRunId.slice(0, 8)} (${fromType}) handed off directly to ${toRunId.slice(0, 8)} (${toType})`,
1980
+ });
1981
+ this.emitConversations();
1982
+ this.flushSnapshot();
1983
+ }
1984
+ /**
1985
+ * 多级分层上下文预算裁剪(Hierarchical Context Budgeting):
1986
+ * 当上下文达到预警水位(例如 70%)时,自动执行第一级(远期工具输出裁剪)和
1987
+ * 第二级(已完成步骤折叠),推迟触发全量 LLM 压缩,保留近期关键代码的细节。
1988
+ */
1989
+ applyContextBudgetPruning(conv) {
1990
+ if (!conv?.session)
1991
+ return;
1992
+ try {
1993
+ const contextWindow = contextWindowOf(conv.session);
1994
+ if (!contextWindow || contextWindow <= 0)
1995
+ return;
1996
+ const s = this.settingsSvc.current;
1997
+ const modelId = modelKeyOf(conv.session);
1998
+ const cap = effectiveSoftCap(s.softCapTokens, s.softCapByModel, modelId);
1999
+ const reserve = softCapToReserve(contextWindow, cap) ?? DEFAULT_COMPACTION_RESERVE_TOKENS;
2000
+ const messages = conv.session.agent.state.messages;
2001
+ if (!messages || messages.length === 0)
2002
+ return;
2003
+ const result = pruneContextHierarchically(messages, {
2004
+ contextWindow,
2005
+ reserveTokens: reserve,
2006
+ softCap: cap > 0 ? cap : null,
2007
+ });
2008
+ if (result.tier1.trimmedCount > 0 || result.tier2.foldedCount > 0) {
2009
+ conv.session.agent.state.messages = result.messages;
2010
+ const saved = result.tokensBefore - result.tokensAfter;
2011
+ this.emit({
2012
+ type: "notice",
2013
+ level: "info",
2014
+ text: `分层上下文预算裁剪生效:已释放约 ${saved.toLocaleString()} tokens(裁剪远期工具输出/折叠已完成步骤),推迟全量压缩`,
2015
+ textEn: `Hierarchical context pruning active: freed ~${saved.toLocaleString()} tokens (trimmed tool outputs / folded steps), deferring full compaction`,
2016
+ });
2017
+ if (conv.id === this.conv.id) {
2018
+ this.flushSnapshot();
2019
+ }
2020
+ }
2021
+ }
2022
+ catch {
2023
+ /* 预算裁剪尽力而为,不阻断主流程 */
2024
+ }
2025
+ }
1466
2026
  subagentHost = {
1467
2027
  spawnSubagent: (prompt, type, cwd, templateName, model, parentId, persist) => {
1468
2028
  // 模板:存在且启用时应用;传了名字但不可用 → 抛错让工具转给 AI。
@@ -1475,6 +2035,9 @@ export class ClientSession {
1475
2035
  },
1476
2036
  getSubagent: (convId) => this.getSubagentSnapshot(convId),
1477
2037
  listSubagents: (scope) => this.listSubagentSnapshots(scope),
2038
+ handoffSubagent: async (fromRunId, toRunId, payload) => {
2039
+ await this.handoffSubagent(fromRunId, toRunId, payload);
2040
+ },
1478
2041
  steerSubagent: async (convId, message) => {
1479
2042
  const conv = this.convs.get(convId);
1480
2043
  if (!conv?.session)
@@ -1651,6 +2214,120 @@ export class ClientSession {
1651
2214
  store: () => this.getClaimStore?.(),
1652
2215
  };
1653
2216
  }
2217
+ /** compact_context 工具的数据宿主:提供消息统计用于预检,以及注册 pending 压缩请求。
2218
+ * ownerId 语义同 skill / claimFiles(本 runtime 所属会话,不是派发瞬间 active)。 */
2219
+ compactContextHost(ownerId) {
2220
+ const target = () => {
2221
+ try {
2222
+ return (ownerId ?? "").trim() !== "" ? this.convs.get(ownerId.trim()) : this.convs.get(this.activeId);
2223
+ }
2224
+ catch {
2225
+ return undefined;
2226
+ }
2227
+ };
2228
+ return {
2229
+ conversationId: () => target()?.id ?? this.activeId,
2230
+ getContextStats: () => {
2231
+ const conv = target();
2232
+ if (!conv)
2233
+ return { messageCount: 0, estimatedTokens: 0 };
2234
+ let messages = [];
2235
+ try {
2236
+ messages = conv.session.messages;
2237
+ }
2238
+ catch {
2239
+ messages = [];
2240
+ }
2241
+ let totalChars = 0;
2242
+ for (const m of messages) {
2243
+ if (m && typeof m === "object" && "content" in m) {
2244
+ const content = m.content;
2245
+ if (Array.isArray(content)) {
2246
+ for (const c of content) {
2247
+ if (c &&
2248
+ typeof c === "object" &&
2249
+ "type" in c &&
2250
+ c.type === "text" &&
2251
+ typeof c.text === "string") {
2252
+ totalChars += c.text.length;
2253
+ }
2254
+ }
2255
+ }
2256
+ else if (typeof content === "string") {
2257
+ totalChars += content.length;
2258
+ }
2259
+ }
2260
+ }
2261
+ return {
2262
+ messageCount: messages.length,
2263
+ estimatedTokens: Math.ceil(totalChars / 4),
2264
+ };
2265
+ },
2266
+ scheduleCompaction: (pending) => {
2267
+ const conv = target();
2268
+ if (conv) {
2269
+ conv.pendingCompaction = pending;
2270
+ }
2271
+ },
2272
+ };
2273
+ }
2274
+ /** 执行由 AI 调用 compact_context 安排的上下文压缩(在 agent_settled 阶段调用)。 */
2275
+ async executePendingCompaction(conv, pending) {
2276
+ const instructions = buildCompactionInstructions(pending.focus, pending.summary);
2277
+ const session = conv.session;
2278
+ const model = session.model;
2279
+ let originalKeepRecent;
2280
+ try {
2281
+ originalKeepRecent = session.settingsManager.getCompactionSettings(model).keepRecentTokens;
2282
+ }
2283
+ catch {
2284
+ originalKeepRecent = undefined;
2285
+ }
2286
+ try {
2287
+ if (pending.keepRecentTokens && pending.keepRecentTokens > 0) {
2288
+ session.settingsManager.applyOverrides({
2289
+ compaction: {
2290
+ keepRecentTokens: pending.keepRecentTokens,
2291
+ },
2292
+ });
2293
+ }
2294
+ await session.compact(instructions || undefined);
2295
+ const retainTokens = pending.keepRecentTokens ?? DEFAULT_KEEP_RECENT_TOKENS;
2296
+ this.emit({
2297
+ type: "notice",
2298
+ level: "info",
2299
+ text: `AI 已主动根据当前问题完成上下文压缩(保留 ~${retainTokens.toLocaleString()} tokens 近期上下文,重点保留当前问题相关内容)`,
2300
+ textEn: `Context compacted proactively based on current issue (retained ~${retainTokens.toLocaleString()} tokens, focused on current task)`,
2301
+ });
2302
+ }
2303
+ catch (err) {
2304
+ const msg = err instanceof Error ? err.message : String(err);
2305
+ this.emit({
2306
+ type: "notice",
2307
+ level: "warning",
2308
+ text: `主动上下文压缩未完成:${msg}`,
2309
+ textEn: `Proactive context compaction did not finish: ${msg}`,
2310
+ });
2311
+ }
2312
+ finally {
2313
+ if (pending.keepRecentTokens && pending.keepRecentTokens > 0) {
2314
+ try {
2315
+ if (originalKeepRecent !== undefined) {
2316
+ session.settingsManager.applyOverrides({
2317
+ compaction: {
2318
+ keepRecentTokens: originalKeepRecent,
2319
+ },
2320
+ });
2321
+ }
2322
+ }
2323
+ catch {
2324
+ // best effort
2325
+ }
2326
+ }
2327
+ this.applyCompactionOverrides();
2328
+ this.flushSnapshot();
2329
+ }
2330
+ }
1654
2331
  /** skill 工具的数据宿主:读所属会话 loader 的实时技能表 + 主会话禁用集过滤
1655
2332
  * (与 skillsOverride 主会话语义一致)。ownerId 语义同 browser_page(本
1656
2333
  * runtime 所属会话,不是派发瞬间的 active)。失败回空目录,不抛错。 */
@@ -1742,6 +2419,8 @@ export class ClientSession {
1742
2419
  watchTimer = null;
1743
2420
  /** 子代理模板库(全局共享,<dataDir>/subagent-templates.json)。 */
1744
2421
  subagentTemplates;
2422
+ /** 审批规则库(全局共享,<dataDir>/approval-rules.json)。 */
2423
+ approvalRules;
1745
2424
  /** 未发送输入框草稿(全局共享,<dataDir>/composer-drafts.json,按 sessionId 键入,见 server/composer-drafts.ts)。 */
1746
2425
  drafts;
1747
2426
  /** 内置标记服务(todo/notify/svc/rename 等,可全局/分组开关)。 */
@@ -1756,6 +2435,11 @@ export class ClientSession {
1756
2435
  /** 待答提问(id → 载荷 + resolve)。一次正常只有一个(agent 阻塞在工具执行);
1757
2436
  * conversationId 记录谁问的:看门狗豁免、快照恢复都靠它。 */
1758
2437
  pendingQuestions = new Map();
2438
+ /** 任务计划管理器(Plan Mode / Step State Machine)。 */
2439
+ planManager = new PlanManager();
2440
+ approvalSeq = 0;
2441
+ /** 待审批高危工具调用(Human-in-the-Loop: Edit & Run)。 */
2442
+ pendingApprovals = new Map();
1759
2443
  // -----------------------------------------------------------------------
1760
2444
  // 浏览器页面桥(标准 pi 引擎的 browser_page customTool):模型调工具 → 发
1761
2445
  // page_request 给浏览器 → 前端转 page-picker 扩展 → page_response 回到这里
@@ -1777,6 +2461,7 @@ export class ClientSession {
1777
2461
  this.stateStore = stateStore;
1778
2462
  this.roots = stateStore.getWorkspaceRoots(clientId, cwd);
1779
2463
  this.subagentTemplates = new SubagentTemplatesStore(join(stateStore.dataDir, "subagent-templates.json"));
2464
+ this.approvalRules = new ApprovalRulesStore(join(stateStore.dataDir, "approval-rules.json"));
1780
2465
  this.drafts = new ComposerDraftsStore(join(stateStore.dataDir, "composer-drafts.json"));
1781
2466
  this.markerSvc = new MarkerService({
1782
2467
  clientId,
@@ -1832,7 +2517,8 @@ export class ClientSession {
1832
2517
  disabledMarkers: [...this.markerSvc.current.disabledMarkers],
1833
2518
  markers: this.markerSvc.listForUi(),
1834
2519
  }),
1835
- }, this.subagentTemplates);
2520
+ getApprovalPolicy: () => this.approvalPolicyState(),
2521
+ }, this.subagentTemplates, this.approvalRules);
1836
2522
  this.goalSvc = new GoalService({
1837
2523
  clientId,
1838
2524
  agentDir,
@@ -1998,7 +2684,9 @@ export class ClientSession {
1998
2684
  // GBK 老中文文件让模型改用终端按正确编码读(iconv/chcp/Get-Content)。
1999
2685
  out.push(WINDOWS_PERSONA);
2000
2686
  }
2001
- if (isTerminalGuidanceOn(effectiveDisabledAgentTools(this.settingsSvc.current))) {
2687
+ // 终端引导只教「开关开着且预设下仍可用」的工具(见 tool-manager.ts 语义总表)。
2688
+ const presetForGuidance = this.convs.get(ownerId ?? "")?.agentPreset ?? this.settingsSvc.current.defaultAgentPreset ?? "standard";
2689
+ if (isTerminalGuidanceOn(effectiveDisabledAgentTools(this.settingsSvc.current), presetForGuidance)) {
2002
2690
  // 终端工具使用引导(全平台):告诉模型什么场景该用持久终端
2003
2691
  // 而不是一次性 bash——没有这段模型几乎从不主动选终端工具。
2004
2692
  // 组内工具全关时不注入(不教 AI 用不存在的工具)。
@@ -2011,12 +2699,17 @@ export class ClientSession {
2011
2699
  out.push(markerGuidance);
2012
2700
  return out;
2013
2701
  },
2014
- // 技能:模板非空白名单时只启用白名单里的;否则按主会话禁用集过滤。
2702
+ // 技能:模板非空白名单时只启用白名单里的(显式配置优先于预设);
2703
+ // 否则按主会话禁用集过滤。预设拿掉 skill 加载器时(minimal/code/ask)
2704
+ // 名录同步隐藏——列出来但调不动只是噪音(见 tool-manager.ts 语义总表)。
2015
2705
  skillsOverride: (res) => {
2016
2706
  if (apply && apply.enabledSkills.length > 0) {
2017
2707
  const set = new Set(apply.enabledSkills);
2018
2708
  return { ...res, skills: res.skills.filter((s) => set.has(s.name)) };
2019
2709
  }
2710
+ const preset = this.convs.get(ownerId ?? "")?.agentPreset ?? this.settingsSvc.current.defaultAgentPreset ?? "standard";
2711
+ if (!presetShowsSkillCatalog(preset))
2712
+ return { ...res, skills: [] };
2020
2713
  return {
2021
2714
  ...res,
2022
2715
  skills: res.skills.filter((s) => !this.settingsSvc.current.disabledSkills.includes(s.name)),
@@ -2130,31 +2823,64 @@ export class ClientSession {
2130
2823
  // 「默认 bash 覆盖」开关(terminalBash)关 → 原生 SDK bash(纯进程、不开终端);
2131
2824
  // 开 → 终端接管 bash(persist 决定一次性/持久,可静默自动转后台)。
2132
2825
  customTools: [
2133
- makeAdaptiveBashTool(
2826
+ // P1-5:bash/read 包插件拦截(pre 拒/问即拦、post 脱敏补上下文;
2827
+ // 未注入 toolGuard 时 withToolGuard 原样返回,零开销)。
2828
+ // 权限沙箱拦截(只读模式拦截终端执行)。
2829
+ wrapBashToolWithPermission(withToolGuard(makeAdaptiveBashTool(
2134
2830
  // issue #91:bash 返回按客户端 UI 语言出中英(英文默认)。
2135
2831
  makeKillableBashTool(effectiveCwd, this.bashKills, () => this.getLang()), makeTerminalBashTool(terminals, {
2136
2832
  cwd: effectiveCwd,
2137
2833
  // 设置开 = 用终端;此分支里 persist 未显式给时默认一次性(false)。
2138
2834
  defaultPersist: () => false,
2139
2835
  idleMs: () => Math.max(0, Math.floor(this.settingsSvc.current.terminalBashIdleMs) || 0),
2836
+ maxForegroundMs: () => Math.max(0, Math.floor(this.settingsSvc.current.terminalBashMaxForegroundMs) || 0),
2140
2837
  kills: this.bashKills,
2141
2838
  notifyBackgroundDone: (info) => this.notifyTerminalBashDone(terminals, info),
2142
2839
  // issue #91:bash 返回按客户端 UI 语言出中英(英文默认)。
2143
2840
  lang: () => this.getLang(),
2144
2841
  }),
2145
2842
  // 设置关 → 原生 bash;开 → 终端 bash。
2146
- () => this.settingsSvc.current.terminalBash),
2843
+ () => this.settingsSvc.current.terminalBash), {
2844
+ toolName: "bash",
2845
+ guard: this.toolGuard,
2846
+ conversationId: () => ownerId,
2847
+ getLang: () => this.getLang(),
2848
+ cwd: effectiveCwd,
2849
+ getRoots: () => this.roots,
2850
+ askApproval: (toolCallId, toolName, params, reason, reasonEn, convId, category) => this.askApproval(toolCallId, toolName, params, reason, reasonEn, convId, category),
2851
+ getRules: () => this.approvalRules.list(),
2852
+ }), () => (ownerId ? this.convs.get(ownerId)?.permissionPreset : undefined) ??
2853
+ this.settingsSvc.current.defaultPermissionPreset ??
2854
+ "workspace-write-never", () => this.getLang()),
2147
2855
  ...makePersistentTerminalTools(terminals, effectiveCwd, () => this.getLang()),
2148
2856
  // 覆盖 SDK 内置 read(customTools 按 name 覆盖):路径是目录时列出目录
2149
2857
  // 条目(复用 SDK ls 的排序/`/` 后缀/截断口径),其余情况原样转发内置实现。
2150
2858
  // 开关是行为开关(read 本体不可关),每次调用实时读设置——不进
2151
2859
  // tool-manager 的 ActiveSet 目录。DSH 引擎无 customTool 注册面,不接。
2152
- makeReadDirTool(effectiveCwd, {
2860
+ withToolGuard(makeReadDirTool(effectiveCwd, {
2153
2861
  dirEnabled: () => this.settingsSvc.current.readDirEnabled !== false,
2154
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(),
2155
2872
  }),
2156
- // 不覆盖内置 edit 的独立宽松编辑工具(缩进不敏感匹配;开关看设置)。
2157
- makeEditSoftTool(effectiveCwd, () => this.getLang()),
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()),
2880
+ // 不覆盖内置 edit 的独立宽松编辑工具(缩进不敏感匹配;开关看设置;带权限沙箱拦截与人机协同)。
2881
+ wrapEditSoftToolWithPermission(makeEditSoftTool(effectiveCwd, () => this.getLang()), effectiveCwd, () => (ownerId ? this.convs.get(ownerId)?.permissionPreset : undefined) ??
2882
+ this.settingsSvc.current.defaultPermissionPreset ??
2883
+ "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()),
2158
2884
  // 插件注册的 AI 工具(创建时刻的实时快照,已按 disabledPluginTools 过滤;
2159
2885
  // 后续注册经 refreshPluginTools 动态补入已有会话)。
2160
2886
  // 子代理模板带非空扩展白名单时不注入:插件/MCP 工具没有 SDK extensionKey
@@ -2179,6 +2905,8 @@ export class ClientSession {
2179
2905
  : [makeDelegateTaskTool(this.subagentHost)]),
2180
2906
  // 内置标记只读查询工具(todo/svc 状态查询,写操作走内联标记)。
2181
2907
  makeMarkersListTool(() => this.activeId, this.markerSvc),
2908
+ // 结构化任务计划更新(Plan Mode / Step State Machine)。
2909
+ makePlanUpdateTool(this.planManager, () => ownerId ?? this.activeId, (msg) => this.emit(msg), () => this.flushSnapshot()),
2182
2910
  // 标准引擎的 ask_user_question:模型调用 → 浏览器富渲染问卷(复用 DSH
2183
2911
  // 的 question_pending/question_answer 协议,前端 DshQuestionDialog)。
2184
2912
  // DSH 引擎不经此(它走 goal-rpc 的 userQuestions provider)。
@@ -2218,11 +2946,25 @@ export class ClientSession {
2218
2946
  // 子代理会话同样注册(owner 即真正派发的父对话,读该会话 loader)。
2219
2947
  // DSH 引擎无 customTool 注册面,不接。开关走统一工具 tab。
2220
2948
  makeSkillTool(this.skillToolHost(ownerId), () => this.getLang()),
2949
+ // 主动压缩上下文工具(compact_context):AI 主动根据当前问题精简上下文。
2950
+ // 开关走统一工具 tab(ActiveSet 门控)。DSH 引擎无 customTool 注册面,不接。
2951
+ makeCompactContextTool(this.compactContextHost(ownerId), () => this.getLang()),
2221
2952
  // 定时唤醒三件套(schedule_task/list/cancel,issue #193):默认绑定
2222
2953
  // 发起对话(ownerId,无则活动对话),到期 steer 语义唤醒它;子代理
2223
2954
  // 会话同样注册(owner 即真正派发的父对话)。开关走统一工具 tab。
2224
2955
  // DSH 引擎无 customTool 注册面,不接。
2225
2956
  ...makeScheduleTools(this.scheduleToolHost(), ownerId, () => this.getLang()),
2957
+ // 持久代码求值沙箱(eval):开关走统一工具 tab(ActiveSet 门控,默认关)。
2958
+ // ownerId 绑定当前会话;DSH 引擎无 customTool 注册面,不接。
2959
+ makeEvalTool({
2960
+ cwd: effectiveCwd,
2961
+ ownerId,
2962
+ lang: () => this.getLang(),
2963
+ }),
2964
+ // 高可靠行补丁工具(patch,基于内容哈希与语法块级替换)。
2965
+ makePatchTool({ cwd: effectiveCwd, ownerId }),
2966
+ // 原生语言服务器工具(lsp,定义跳转/引用/悬停/诊断)。
2967
+ makeLspTool({ cwd: effectiveCwd, ownerId }),
2226
2968
  ],
2227
2969
  });
2228
2970
  // 桥接工具归属锚点:SDK 会话对象在本 runtime 生命周期内稳定,过户只搬对话
@@ -2256,6 +2998,9 @@ export class ClientSession {
2256
2998
  session: runtime.session,
2257
2999
  cwd: runtime.cwd,
2258
3000
  createdAt: Date.now(),
3001
+ agentPreset: this.settingsSvc.current.defaultAgentPreset ?? "standard",
3002
+ presetLocked: false,
3003
+ permissionPreset: this.settingsSvc.current.defaultPermissionPreset ?? "workspace-write-never",
2259
3004
  // A brand-new conversation is not yet LISTED — it enters the running
2260
3005
  // list only when it is displaced to the background while still
2261
3006
  // streaming (its runtime is what `listed` protects). A blank chat is
@@ -2283,6 +3028,7 @@ export class ClientSession {
2283
3028
  queueFollowUp: [],
2284
3029
  toolStartTimes: new Map(),
2285
3030
  toolWatchdogs: new Map(),
3031
+ workspaceSnapshots: [],
2286
3032
  };
2287
3033
  }
2288
3034
  /** Summaries of conversations currently streaming — captured at shutdown
@@ -2396,6 +3142,9 @@ export class ClientSession {
2396
3142
  // Reconnect: push the global default model (the picker's ★ marker +
2397
3143
  // "set as global default" button state).
2398
3144
  this.pushDefaultModel();
3145
+ // Reconnect: push agent presets and permission presets (align with DSH).
3146
+ this.refreshAgentPresets();
3147
+ this.refreshPermission();
2399
3148
  // PTYs are conversation-owned and survive a socket reconnect.
2400
3149
  this.pushTerminals();
2401
3150
  }
@@ -2416,6 +3165,34 @@ export class ClientSession {
2416
3165
  for (const sink of [...this.sinks])
2417
3166
  sink(msg);
2418
3167
  }
3168
+ /** 扩展错误上报器:同一会话内「扩展 + 事件 + 错误文本」只提示一次,且全量落服务端日志。
3169
+ *
3170
+ * SDK 的 `ExtensionRunner.emitContext()` 在**每次 provider 请求**前都会跑一遍
3171
+ * 扩展的 `context` hook,并对每个 handler 的报错回调 `onError`。in-memory 会话
3172
+ * (子代理 / 无痕会话)取不到会话目录(`SessionManager.inMemory(cwd)` 的
3173
+ * `getSessionDir()` 返回空串),于是「会话目录依赖型」扩展(如 SoL-Pi 的
3174
+ * `runtimeRoot()`)每轮都抛同一个错——原样广播就等于按轮数刷屏(issue #298)。
3175
+ *
3176
+ * `prefix` 给 notice 带上会话归属:用户一眼能看出是后台会话的问题,
3177
+ * 而不是当前对话坏了(与同函数内其它子代理通知的口径一致)。 */
3178
+ makeExtensionErrorReporter(prefix) {
3179
+ const seen = new Set();
3180
+ return (err) => {
3181
+ const message = err?.error ?? String(err);
3182
+ const where = [err?.extensionPath, err?.event].filter(Boolean).join(" · ");
3183
+ console.error(`[extension] ${prefix?.text ?? "当前对话"}${where ? ` (${where})` : ""}: ${message}${err?.stack ? `\n${err.stack}` : ""}`);
3184
+ const key = `${err?.extensionPath ?? ""}|${err?.event ?? ""}|${message}`;
3185
+ if (seen.has(key))
3186
+ return;
3187
+ seen.add(key);
3188
+ this.emit({
3189
+ type: "notice",
3190
+ level: "error",
3191
+ text: prefix ? `${prefix.text}扩展报错:${message}` : message,
3192
+ textEn: prefix ? `${prefix.textEn} Extension error: ${message}` : message,
3193
+ });
3194
+ };
3195
+ }
2419
3196
  /** (Re)attach event plumbing to the ACTIVE conversation's session. */
2420
3197
  async bindSession() {
2421
3198
  const conv = this.conv;
@@ -2424,9 +3201,7 @@ export class ClientSession {
2424
3201
  await conv.session.bindExtensions({
2425
3202
  mode: "rpc",
2426
3203
  uiContext: this.webUi,
2427
- onError: (err) => {
2428
- this.emit({ type: "notice", level: "error", text: err.error, textEn: err.error });
2429
- },
3204
+ onError: this.makeExtensionErrorReporter(),
2430
3205
  });
2431
3206
  conv.unsubscribe = conv.session.subscribe((event) => this.onEvent(conv, event));
2432
3207
  // 新会话 / 切换会话 / 强杀重建的必经之路:刚创建的 runtime 用的是 SDK
@@ -3208,6 +3983,24 @@ export class ClientSession {
3208
3983
  catch {
3209
3984
  // sidecar 只是加速 + 防压缩丢失,失败了下次重算
3210
3985
  }
3986
+ // AI 主动上下文压缩:若本轮调过 compact_context,在回合结算后异步执行压缩
3987
+ if (!event.willRetry && !aborted && conv.pendingCompaction && !this.disposed) {
3988
+ const pending = conv.pendingCompaction;
3989
+ conv.pendingCompaction = null;
3990
+ setTimeout(() => {
3991
+ if (!this.disposed) {
3992
+ void this.executePendingCompaction(conv, pending);
3993
+ }
3994
+ }, 50);
3995
+ }
3996
+ break;
3997
+ }
3998
+ case "agent_settled": {
3999
+ if (conv.pendingCompaction && !this.disposed) {
4000
+ const pending = conv.pendingCompaction;
4001
+ conv.pendingCompaction = null;
4002
+ void this.executePendingCompaction(conv, pending);
4003
+ }
3211
4004
  break;
3212
4005
  }
3213
4006
  case "entry_appended": {
@@ -3519,6 +4312,12 @@ export class ClientSession {
3519
4312
  sessionId: this.session.sessionId,
3520
4313
  sessionFile: this.session.sessionFile,
3521
4314
  conversationId: this.activeId,
4315
+ // 临时对话标记(issue #285):提示条/转正按钮跟当前对话走。
4316
+ // 不能只靠 conversations 列表 —— 空白的临时对话不在运行列表里(shownInRunningList
4317
+ // 只列有内容的),刚新建时列表里查不到它,提示条就永远不出现。
4318
+ // 必须**恒存在**(不能只在 true 时展开):转正后它由 true→false,而增量快照是
4319
+ // `{...ui, ...d.state}` 浅合并,缺字段会把 true 残留下来。
4320
+ isEphemeral: !!this.conv?.isEphemeral,
3522
4321
  rev,
3523
4322
  streamingMessage,
3524
4323
  isStreaming: this.session.isStreaming,
@@ -3539,6 +4338,19 @@ export class ClientSession {
3539
4338
  retry: conv.retryState ?? null,
3540
4339
  compaction: conv.compactionState ?? null,
3541
4340
  pendingQuestion: this.pendingQuestionForSnapshot(),
4341
+ pendingApproval: this.pendingApprovalForSnapshot(),
4342
+ plan: this.planManager.getPlan(this.activeId),
4343
+ subagentHandoffs: this.subagentHandoffs.length > 0 ? [...this.subagentHandoffs] : undefined,
4344
+ agentPreset: conv
4345
+ ? {
4346
+ id: conv.agentPreset ?? "standard",
4347
+ name: PI_AGENT_PRESETS.find((p) => p.id === conv.agentPreset)?.name ?? "全功能",
4348
+ locked: conv.presetLocked || !this.isBlankConversation(conv),
4349
+ }
4350
+ : null,
4351
+ permission: conv
4352
+ ? (conv.permissionPreset ?? this.settingsSvc.current.defaultPermissionPreset ?? "workspace-write-never")
4353
+ : null,
3542
4354
  // 未发送草稿只跟全量快照走(切会话/new_chat/get_state):增量 delta 里
3543
4355
  // 这个 key 必须整个缺席(不能是显式的 undefined——前端 delta 合并是
3544
4356
  // {...ui, ...d.state} 整批覆盖,显式 undefined 会把上次全量带回的草稿洗掉)。
@@ -3715,6 +4527,10 @@ export class ClientSession {
3715
4527
  if (p.conversationId === undefined || p.conversationId === conversationId)
3716
4528
  return true;
3717
4529
  }
4530
+ for (const a of this.pendingApprovals.values()) {
4531
+ if (a.conversationId === undefined || a.conversationId === conversationId)
4532
+ return true;
4533
+ }
3718
4534
  return false;
3719
4535
  }
3720
4536
  /** 标准引擎的 question_answer 路由入口(index.ts 经 cs.answerQuestion?. 转发)。
@@ -3755,6 +4571,220 @@ export class ClientSession {
3755
4571
  ...(q.conversationTitle ? { conversationTitle: q.conversationTitle } : {}),
3756
4572
  });
3757
4573
  }
4574
+ // -----------------------------------------------------------------------
4575
+ // 人机协同工具审批桥(Tool Approval: Human-in-the-Loop Edit & Run)
4576
+ // -----------------------------------------------------------------------
4577
+ /**
4578
+ * 弹审批(Human-in-the-Loop)。**三档放行门禁在入口**(策略纯函数见
4579
+ * server/tool-approval.ts 的 approvalSuppressionReason):
4580
+ * - 全局开关关(设置 →「工具」页)→ 直接批准(不弹);
4581
+ * - 本对话「全部允许」/ 已记住该同类 → 直接批准(不弹)。
4582
+ * 门禁都返回已 resolve 的 Promise,调用方(withToolGuard / 三个权限包装)
4583
+ * 无需关心是否真的弹过窗。
4584
+ */
4585
+ askApproval(toolCallId, toolName, params, reason, reasonEn, conversationId, category) {
4586
+ return new Promise((resolve) => {
4587
+ if (this.disposed) {
4588
+ resolve({ decision: "deny", reason: "会话已关闭" });
4589
+ return;
4590
+ }
4591
+ const id = `appr-${++this.approvalSeq}`;
4592
+ const conv = conversationId ? this.convs.get(conversationId) : this.conv;
4593
+ const suppression = approvalSuppressionReason(conv?.approvalPolicy, this.settingsSvc.current.toolApprovalEnabled !== false, category?.id);
4594
+ if (suppression) {
4595
+ // 放行但不弹窗:不记 pending,不发消息,模型继续跑。
4596
+ resolve({ decision: "approve" });
4597
+ return;
4598
+ }
4599
+ const conversationTitle = conv?.title;
4600
+ const entry = {
4601
+ id,
4602
+ toolCallId,
4603
+ toolName,
4604
+ params: params,
4605
+ reason,
4606
+ reasonEn,
4607
+ ...(category ? { category } : {}),
4608
+ conversationId,
4609
+ conversationTitle,
4610
+ resolve,
4611
+ createdAt: Date.now(),
4612
+ };
4613
+ this.pendingApprovals.set(id, entry);
4614
+ this.emit({
4615
+ type: "tool_approval_pending",
4616
+ id,
4617
+ toolCallId,
4618
+ toolName,
4619
+ params: params,
4620
+ reason,
4621
+ reasonEn,
4622
+ ...(category ? { category } : {}),
4623
+ ...(conversationId !== undefined ? { conversationId } : {}),
4624
+ ...(conversationTitle ? { conversationTitle } : {}),
4625
+ });
4626
+ this.flushSnapshot();
4627
+ });
4628
+ }
4629
+ /**
4630
+ * 审批答复。scope(仅 approve 有效)是「不再问」的两档记忆:
4631
+ * - "category":记住本对话的该同类档位;
4632
+ * - "all":本对话后续全部允许。
4633
+ * 记住后,同一对话里**已被覆盖的其它待审批项一并放行**(否则用户点了
4634
+ * 「全部允许」却还有几张弹窗挂着等点),并推一次设置面板(撤销区)。
4635
+ */
4636
+ resolveToolApproval(id, decision, editedParams, reason, scope) {
4637
+ const pending = this.pendingApprovals.get(id);
4638
+ if (!pending)
4639
+ return false;
4640
+ const convId = pending.conversationId ?? this.activeId;
4641
+ const conv = this.convs.get(convId);
4642
+ this.pendingApprovals.delete(id);
4643
+ pending.resolve({ decision, editedParams, reason });
4644
+ this.emit({ type: "tool_approval_resolved", id });
4645
+ if (decision === "approve" && scope && scope !== "once" && conv) {
4646
+ const policy = this.ensureApprovalPolicy(conv);
4647
+ if (scope === "all")
4648
+ policy.allowAll = true;
4649
+ else if (pending.category)
4650
+ policy.categories.set(pending.category.id, pending.category);
4651
+ this.emitApprovalPolicyNotice(scope, pending.category, this.approveCoveredPending(convId, policy));
4652
+ this.settingsSvc.push();
4653
+ }
4654
+ this.flushSnapshot();
4655
+ return true;
4656
+ }
4657
+ /** 取出(或建出)对话的审批策略对象。 */
4658
+ ensureApprovalPolicy(conv) {
4659
+ if (!conv.approvalPolicy)
4660
+ conv.approvalPolicy = { allowAll: false, categories: new Map() };
4661
+ return conv.approvalPolicy;
4662
+ }
4663
+ /** 把某对话里已被策略覆盖的其它待审批项一并放行,返回放行条数。 */
4664
+ approveCoveredPending(convId, policy) {
4665
+ let n = 0;
4666
+ // eslint-disable-next-line unicorn/no-useless-spread -- 快照:循环里会从 map 删项(迭代中改集合)
4667
+ for (const [oid, o] of [...this.pendingApprovals]) {
4668
+ if ((o.conversationId ?? this.activeId) !== convId)
4669
+ continue;
4670
+ if (!approvalSuppressionReason(policy, true, o.category?.id))
4671
+ continue;
4672
+ this.pendingApprovals.delete(oid);
4673
+ o.resolve({ decision: "approve" });
4674
+ this.emit({ type: "tool_approval_resolved", id: oid });
4675
+ n++;
4676
+ }
4677
+ return n;
4678
+ }
4679
+ /** 记住策略后给用户一句回执(文本双语,前端按 locale 自选)。 */
4680
+ emitApprovalPolicyNotice(scope, category, auto = 0) {
4681
+ const tailZh = auto > 0 ? `,并已自动放行另外 ${auto} 项待审批` : "";
4682
+ const tailEn = auto > 0 ? ` (auto-approved ${auto} other pending request(s))` : "";
4683
+ if (scope === "all") {
4684
+ this.emit({
4685
+ type: "notice",
4686
+ level: "info",
4687
+ text: `本对话已允许全部工具审批,后续高危操作不再询问${tailZh};可在 设置 →「工具」页撤销。`,
4688
+ textEn: `All tool approvals are now allowed in this conversation${tailEn}; revoke it under Settings → Tools.`,
4689
+ });
4690
+ return;
4691
+ }
4692
+ const label = category?.label ?? "同类";
4693
+ const labelEn = category?.labelEn ?? "this category";
4694
+ this.emit({
4695
+ type: "notice",
4696
+ level: "info",
4697
+ text: `本对话已允许「${label}」类操作,后续同类不再询问${tailZh};可在 设置 →「工具」页撤销。`,
4698
+ textEn: `Allowed "${labelEn}" in this conversation${tailEn}; revoke it under Settings → Tools.`,
4699
+ });
4700
+ }
4701
+ /** 全局审批开关被关掉时:挂着的待审批全部按批准放行(否则弹窗还在等人点)。 */
4702
+ autoApprovePendingApprovals(reasonZh, reasonEn) {
4703
+ if (this.pendingApprovals.size === 0)
4704
+ return;
4705
+ let n = 0;
4706
+ // eslint-disable-next-line unicorn/no-useless-spread -- 快照:循环里会从 map 删项(迭代中改集合)
4707
+ for (const [oid, o] of [...this.pendingApprovals]) {
4708
+ this.pendingApprovals.delete(oid);
4709
+ o.resolve({ decision: "approve" });
4710
+ this.emit({ type: "tool_approval_resolved", id: oid });
4711
+ n++;
4712
+ }
4713
+ this.emit({
4714
+ type: "notice",
4715
+ level: "info",
4716
+ text: `${reasonZh},已自动放行 ${n} 项待审批。`,
4717
+ textEn: `${reasonEn} — auto-approved ${n} pending request(s).`,
4718
+ });
4719
+ this.flushSnapshot();
4720
+ }
4721
+ /**
4722
+ * 设置面板撤销区用:当前对话的审批策略(allowAll + 已记住的同类档位)。
4723
+ * 只回当前对话——撤销本来就是「在这里关掉这里的东西」,别的对话要撤销
4724
+ * 就切过去(策略跟对话走,见 Conversation.approvalPolicy)。
4725
+ */
4726
+ approvalPolicyState() {
4727
+ const conv = this.convs.get(this.activeId);
4728
+ const policy = conv?.approvalPolicy;
4729
+ return {
4730
+ conversationId: this.activeId,
4731
+ allowAll: policy?.allowAll ?? false,
4732
+ categories: policy ? [...policy.categories.values()] : [],
4733
+ };
4734
+ }
4735
+ /**
4736
+ * 设置某对话的审批策略(设置面板撤销用)。只应用给出的字段:allowAll 直接赋值;
4737
+ * categories 给出时作为保留名单整体替换(空数组 = 清掉全部同类记忆)。
4738
+ */
4739
+ setApprovalPolicy(partial) {
4740
+ const convId = partial.conversationId ?? this.activeId;
4741
+ const conv = this.convs.get(convId);
4742
+ if (!conv)
4743
+ return;
4744
+ const cur = conv.approvalPolicy;
4745
+ const next = {
4746
+ allowAll: partial.allowAll ?? cur?.allowAll ?? false,
4747
+ categories: new Map(cur?.categories ?? []),
4748
+ };
4749
+ if (partial.categories) {
4750
+ const keep = new Set(partial.categories);
4751
+ // eslint-disable-next-line unicorn/no-useless-spread -- 快照:循环里会从 map 删项(迭代中改集合)
4752
+ for (const key of [...next.categories.keys()])
4753
+ if (!keep.has(key))
4754
+ next.categories.delete(key);
4755
+ }
4756
+ conv.approvalPolicy = isApprovalPolicyEmpty(next) ? undefined : next;
4757
+ this.settingsSvc.push();
4758
+ this.flushSnapshot();
4759
+ }
4760
+ pendingApprovalForSnapshot() {
4761
+ for (const [id, p] of this.pendingApprovals) {
4762
+ if (p.conversationId !== undefined && p.conversationId !== this.activeId)
4763
+ continue;
4764
+ return {
4765
+ id,
4766
+ toolCallId: p.toolCallId,
4767
+ toolName: p.toolName,
4768
+ params: p.params,
4769
+ reason: p.reason,
4770
+ reasonEn: p.reasonEn,
4771
+ ...(p.category ? { category: p.category } : {}),
4772
+ conversationId: p.conversationId,
4773
+ conversationTitle: p.conversationTitle,
4774
+ };
4775
+ }
4776
+ return null;
4777
+ }
4778
+ updatePlan(steps, activeStepId, conversationId) {
4779
+ const convId = conversationId ?? this.activeId;
4780
+ const plan = this.planManager.setPlan(convId, steps, activeStepId);
4781
+ this.emit({
4782
+ type: "plan_updated",
4783
+ conversationId: convId,
4784
+ plan,
4785
+ });
4786
+ this.flushSnapshot();
4787
+ }
3758
4788
  /** 关闭所有挂起提问(dispose 时清理):以「取消」解析,避免模型挂死。 */
3759
4789
  cancelPendingQuestions() {
3760
4790
  for (const [, p] of this.pendingQuestions) {
@@ -4090,7 +5120,7 @@ export class ClientSession {
4090
5120
  }
4091
5121
  try {
4092
5122
  const targets = collectTargets(this.agentDir, ClientSession.currentAppVersion(), undefined, {
4093
- projectCwd: this.conv?.cwd ?? this.cwd,
5123
+ projectCwd: this.convs.get(this.activeId)?.cwd ?? this.cwd,
4094
5124
  });
4095
5125
  const items = sortUpdateItems(await checkAllUpdates(targets, undefined, () => this.getLang(), resolveNpmRegistry(this.agentDir)));
4096
5126
  this.updatesAllCache = { at: Date.now(), items };
@@ -4327,6 +5357,9 @@ export class ClientSession {
4327
5357
  fetchModelsList(reqId, baseUrl, apiKey, authHeader, api) {
4328
5358
  return this.modelAdmin.fetchModelsList(reqId, baseUrl, apiKey, authHeader, api, () => this.getLang());
4329
5359
  }
5360
+ testModelConnection(reqId, baseUrl, apiKey, authHeader, api) {
5361
+ return this.modelAdmin.testConnection(reqId, baseUrl, apiKey, authHeader, api, () => this.getLang());
5362
+ }
4330
5363
  refreshProviderModels(providerId, reqId) {
4331
5364
  return this.modelAdmin.refreshProviderModels(providerId, reqId, () => this.getLang());
4332
5365
  }
@@ -4591,6 +5624,11 @@ export class ClientSession {
4591
5624
  markerChanged = true;
4592
5625
  }
4593
5626
  await this.settingsSvc.set(rest);
5627
+ // 审批总开关被关掉 → 挂着的待审批全部按批准放行(否则弹窗还在等人点,
5628
+ // 而用户已经明确表示「不要再问我了」)。
5629
+ if (rest.toolApprovalEnabled === false) {
5630
+ this.autoApprovePendingApprovals("审批已全局关闭", "Tool approval was disabled globally");
5631
+ }
4594
5632
  if (rest.disabledPluginTools !== undefined) {
4595
5633
  this.refreshPluginTools();
4596
5634
  this.flushSnapshot();
@@ -4637,6 +5675,22 @@ export class ClientSession {
4637
5675
  async saveSubagentTemplate(template) {
4638
5676
  return this.settingsSvc.saveTemplate(template);
4639
5677
  }
5678
+ /** 保存一条审批规则(全局共享)。 */
5679
+ async saveApprovalRule(rule) {
5680
+ return this.settingsSvc.saveApprovalRule(rule);
5681
+ }
5682
+ /** 批量保存审批规则(全局共享)。 */
5683
+ async saveApprovalRules(rules) {
5684
+ return this.settingsSvc.saveApprovalRules(rules);
5685
+ }
5686
+ /** 删除一条自定义审批规则。 */
5687
+ async deleteApprovalRule(id) {
5688
+ return this.settingsSvc.deleteApprovalRule(id);
5689
+ }
5690
+ /** 恢复某条内置审批规则到系统默认。 */
5691
+ async resetBuiltinApprovalRule(id) {
5692
+ return this.settingsSvc.resetBuiltinApprovalRule(id);
5693
+ }
4640
5694
  /** 删除一个子代理模板。 */
4641
5695
  async deleteSubagentTemplate(name) {
4642
5696
  return this.settingsSvc.deleteTemplate(name);
@@ -4645,13 +5699,23 @@ export class ClientSession {
4645
5699
  async applyRuntimeSettings() {
4646
5700
  return this.settingsSvc.applyRuntime();
4647
5701
  }
5702
+ /** 按会话对象反查所属对话的预设(创建早期对话还没进 map 时返回 undefined,
5703
+ * 调用方回落默认预设;比按 activeId 猜更准——后台对话的门控不再吃当前页的预设)。 */
5704
+ presetOfSession(session) {
5705
+ for (const c of this.convs.values())
5706
+ if (c.session === session)
5707
+ return c.agentPreset;
5708
+ return undefined;
5709
+ }
4648
5710
  /** 统一工具门控(tool_manage 唯一落点):按 disabledAgentTools 把目录内工具
4649
5711
  * 逐个加回/剔除活跃集(工具仍留在注册表,重开可直接加回;live 生效无需
4650
- * reload)。session.reload() 与新会话创建都会把 custom 工具加回活跃集,
5712
+ * reload)。支持按当前会话预设(preset)进行工具过滤。
5713
+ * session.reload() 与新会话创建都会把 custom 工具加回活跃集,
4651
5714
  * 所以这两条路径之后都要重放本方法(见 reloadSession/创建处)。 */
4652
- applyToolGating(session) {
4653
- applyAgentToolsGating(session, effectiveDisabledAgentTools(this.settingsSvc.current));
4654
- this.syncPluginTools(session);
5715
+ applyToolGating(session, preset) {
5716
+ const targetPreset = preset ?? this.presetOfSession(session) ?? this.settingsSvc.current.defaultAgentPreset ?? "standard";
5717
+ applyAgentToolsGating(session, effectiveDisabledAgentTools(this.settingsSvc.current), targetPreset);
5718
+ this.syncPluginTools(session, targetPreset);
4655
5719
  // SDK 的 setActiveToolsByName 只改 agent.state.tools,不派发任何事件——门控后
4656
5720
  // 主动推一次快照,否则快照里的 tools 要等下一个 SDK 事件才对齐(会话空闲时永远
4657
5721
  // 等不到;回归:tests/terminal-smoke-test.mjs「agent exposes persistent terminal tools」)。
@@ -4660,9 +5724,139 @@ export class ClientSession {
4660
5724
  if (active && active.session === session)
4661
5725
  this.flushSnapshot();
4662
5726
  }
5727
+ /** 判断是否为空白对话(无消息、无队列、未在流式生成)。 */
5728
+ isBlankConversation(c) {
5729
+ try {
5730
+ return (c.session.state.messages.length === 0 &&
5731
+ c.queueSteering.length === 0 &&
5732
+ c.queueFollowUp.length === 0 &&
5733
+ !c.session.isStreaming);
5734
+ }
5735
+ catch {
5736
+ return false;
5737
+ }
5738
+ }
5739
+ /** 推送 Agent 预设名录(UI 预设条用)。 */
5740
+ async refreshAgentPresets() {
5741
+ this.emit({
5742
+ type: "dsh_presets",
5743
+ presets: PI_AGENT_PRESETS,
5744
+ defaultPreset: this.settingsSvc.current.defaultAgentPreset ?? "standard",
5745
+ });
5746
+ }
5747
+ /** 切换当前空白会话的预设。 */
5748
+ async selectAgentPreset(preset) {
5749
+ const conv = this.conv;
5750
+ if (conv.presetLocked || !this.isBlankConversation(conv)) {
5751
+ conv.presetLocked = true;
5752
+ this.emit({
5753
+ type: "notice",
5754
+ level: "warning",
5755
+ text: `会话已开始,预设锁定为「${PI_AGENT_PRESETS.find((p) => p.id === conv.agentPreset)?.name ?? conv.agentPreset}」(空白会话可切换)`,
5756
+ textEn: `Session already started; preset locked to "${conv.agentPreset}" (only blank sessions can switch)`,
5757
+ });
5758
+ this.flushSnapshot();
5759
+ return;
5760
+ }
5761
+ const hit = PI_AGENT_PRESETS.find((p) => p.id === preset);
5762
+ if (!hit) {
5763
+ this.emit({
5764
+ type: "notice",
5765
+ level: "warning",
5766
+ text: `未知预设「${preset}」`,
5767
+ textEn: `Unknown preset "${preset}"`,
5768
+ });
5769
+ return;
5770
+ }
5771
+ conv.agentPreset = hit.id;
5772
+ this.applyToolGating(conv.session, hit.id);
5773
+ // 技能名录段/终端引导是按 run 组装的提示词:重载 resourceLoader 让新预设
5774
+ // 即时生效(只有空白会话能切到这里,无历史可丢)。
5775
+ try {
5776
+ await conv.session.resourceLoader.reload();
5777
+ }
5778
+ catch {
5779
+ // loader 未就绪——首轮 run 组装时自然读到新预设。
5780
+ }
5781
+ this.emit({
5782
+ type: "notice",
5783
+ level: "info",
5784
+ text: `已切换为「${hit.name}」预设`,
5785
+ textEn: `Switched to preset "${hit.name}"`,
5786
+ });
5787
+ this.flushSnapshot();
5788
+ }
5789
+ /** 设置新会话默认预设。 */
5790
+ async setDefaultAgentPreset(preset) {
5791
+ const hit = PI_AGENT_PRESETS.find((p) => p.id === preset);
5792
+ if (!hit) {
5793
+ this.emit({
5794
+ type: "notice",
5795
+ level: "warning",
5796
+ text: `未知预设「${preset}」,默认预设未改`,
5797
+ textEn: `Unknown preset "${preset}"; default preset unchanged`,
5798
+ });
5799
+ return;
5800
+ }
5801
+ this.settingsSvc.current.defaultAgentPreset = hit.id;
5802
+ this.stateStore.saveSettings(this.clientId, { defaultAgentPreset: hit.id });
5803
+ this.pushSettings();
5804
+ this.refreshAgentPresets();
5805
+ this.flushSnapshot();
5806
+ }
5807
+ /** 推送权限选项表 + 默认权限。 */
5808
+ async refreshPermission() {
5809
+ this.emit({
5810
+ type: "dsh_permission",
5811
+ options: PI_PERMISSION_OPTIONS,
5812
+ defaultPreset: this.settingsSvc.current.defaultPermissionPreset ?? "workspace-write-never",
5813
+ });
5814
+ }
5815
+ /** 切换当前会话的权限预设(热生效)。 */
5816
+ async setPermissionPreset(preset) {
5817
+ const hit = PI_PERMISSION_OPTIONS.find((p) => p.value === preset);
5818
+ if (!hit) {
5819
+ this.emit({
5820
+ type: "notice",
5821
+ level: "warning",
5822
+ text: `未知权限预设「${preset}」`,
5823
+ textEn: `Unknown permission preset "${preset}"`,
5824
+ });
5825
+ return;
5826
+ }
5827
+ this.conv.permissionPreset = hit.value;
5828
+ this.emit({
5829
+ type: "notice",
5830
+ level: "info",
5831
+ text: `当前会话权限已切换为「${hit.name}」`,
5832
+ textEn: `Current session permission switched to "${hit.name}"`,
5833
+ });
5834
+ this.flushSnapshot();
5835
+ }
5836
+ /** 设置新会话默认权限预设。 */
5837
+ async setDefaultPermissionPreset(preset) {
5838
+ const hit = PI_PERMISSION_OPTIONS.find((p) => p.value === preset);
5839
+ if (!hit) {
5840
+ this.emit({
5841
+ type: "notice",
5842
+ level: "warning",
5843
+ text: `未知权限预设「${preset}」,默认未改`,
5844
+ textEn: `Unknown permission preset "${preset}"; default unchanged`,
5845
+ });
5846
+ return;
5847
+ }
5848
+ this.settingsSvc.current.defaultPermissionPreset = hit.value;
5849
+ this.stateStore.saveSettings(this.clientId, { defaultPermissionPreset: hit.value });
5850
+ this.refreshPermission();
5851
+ this.flushSnapshot();
5852
+ }
4663
5853
  /** 当前启用的插件 AI 工具定义(provider 快照按 disabledPluginTools 过滤;
4664
- * 未知/已卸载插件的禁用条目保留但不影响现有工具)。 */
4665
- enabledPluginToolDefs() {
5854
+ * 未知/已卸载插件的禁用条目保留但不影响现有工具)。
5855
+ * 预设是第二层门控(见 tool-manager.ts 语义总表):非 standard 预设下插件工具
5856
+ * 一律不可用(读写未知,保守处理),此时返回空表,调用方负责从会话移除。 */
5857
+ enabledPluginToolDefs(preset) {
5858
+ if (!presetAllowsPluginTools(preset))
5859
+ return [];
4666
5860
  const off = new Set(normalizeDisabledPluginTools(this.settingsSvc.current.disabledPluginTools));
4667
5861
  return (this.pluginToolsProvider?.() ?? []).filter((t) => !off.has(t.name)).map(pluginToolToDefinition);
4668
5862
  }
@@ -4670,14 +5864,24 @@ export class ClientSession {
4670
5864
  * 实际 diff 逻辑在 plugins.ts 的 syncPluginToolsIntoSession(可单测)。
4671
5865
  * 模板白名单的子代理(subagentBarsPluginTools)跳过:工厂期就没注册,这里
4672
5866
  * 不回补,否则白名单等于没关门。 */
4673
- syncPluginTools(session) {
5867
+ syncPluginTools(session, preset) {
5868
+ let targetPreset = preset;
4674
5869
  for (const conv of this.convs.values()) {
4675
- if (conv.session === session && conv.subagentBarsPluginTools)
5870
+ if (conv.session !== session)
5871
+ continue;
5872
+ if (conv.subagentBarsPluginTools)
4676
5873
  return;
5874
+ targetPreset ??= conv.agentPreset;
4677
5875
  }
4678
5876
  try {
4679
- const defs = this.enabledPluginToolDefs();
4680
- const next = syncPluginToolsIntoSession(session, defs, this.appliedPluginToolNames);
5877
+ const defs = this.enabledPluginToolDefs(targetPreset);
5878
+ // 移除口径 = 已同步过的 ∪ 全量插件宇宙:创建时工厂直接注册进
5879
+ // _customTools 的工具不在 applied 表里,首轮同步(defs 为空时)否则删不掉。
5880
+ const universe = new Set([
5881
+ ...this.appliedPluginToolNames,
5882
+ ...(this.pluginToolsProvider?.() ?? []).map((t) => t.name),
5883
+ ]);
5884
+ const next = syncPluginToolsIntoSession(session, defs, universe);
4681
5885
  if (next)
4682
5886
  this.appliedPluginToolNames = new Set(next);
4683
5887
  }
@@ -4935,6 +6139,8 @@ export class ClientSession {
4935
6139
  // switch/new_chat while prompt() is in flight must never target a
4936
6140
  // different conversation.
4937
6141
  const conv = this.conv;
6142
+ const promptAc = new AbortController();
6143
+ conv.activePromptAc = promptAc;
4938
6144
  // 输入框内容被消费(发送/斜杠执行)→ 清掉该会话存过的草稿(best-effort)。
4939
6145
  // 快捷短语发送(不碰输入框)同样清:客户端发送成功后会把当前草稿重存回来。
4940
6146
  // clear() 同时记录 clear 时间戳水位:清掉之后才 landing 的旧 draft_update
@@ -4960,6 +6166,89 @@ export class ClientSession {
4960
6166
  // is refused until admission reopens.
4961
6167
  if (this.quiesceBlocked())
4962
6168
  return;
6169
+ // 首轮用户发言后锁定当前会话的预设(对齐 DSH 预设语义)
6170
+ conv.presetLocked = true;
6171
+ // #280:悬空 toolCall 守卫——转录尾是「有调用、无结果」时直接 prompt
6172
+ // 会把非法链喂给 provider(有发起迹象但零落盘、零报错的黑洞)。
6173
+ // 非流式时先补合成结果再继续;补不上则响亮拒绝。
6174
+ if (conv.transcriptBlocked && !s.isStreaming) {
6175
+ this.emit({
6176
+ type: "notice",
6177
+ level: "error",
6178
+ text: `发送已拒绝:该对话的记录尾是一个没有结果的工具调用(上次运行被强制终止),且自动修复失败。请从历史记录重新打开该对话,或新建对话后重试。`,
6179
+ textEn: `Prompt refused: this transcript ends with a tool call that never got a result (last run was force-terminated) and auto-repair failed. Reopen it from history or start a new conversation.`,
6180
+ });
6181
+ this.flushSnapshot();
6182
+ return;
6183
+ }
6184
+ if (!s.isStreaming) {
6185
+ try {
6186
+ const live = s.agent.state.messages;
6187
+ const dangling = Array.isArray(live) ? findDanglingToolCalls(live) : [];
6188
+ if (dangling.length > 0) {
6189
+ let healed = 0;
6190
+ try {
6191
+ const sm = s.sessionManager;
6192
+ if (typeof sm?.appendMessage === "function") {
6193
+ for (const d of dangling) {
6194
+ sm.appendMessage({
6195
+ role: "toolResult",
6196
+ toolCallId: d.toolCallId,
6197
+ toolName: d.toolName,
6198
+ content: [
6199
+ {
6200
+ type: "text",
6201
+ text: `${DANGLING_TOOL_RESULT_TEXT}\n${DANGLING_TOOL_RESULT_TEXT_EN}`,
6202
+ },
6203
+ ],
6204
+ isError: true,
6205
+ timestamp: Date.now(),
6206
+ });
6207
+ healed += 1;
6208
+ }
6209
+ }
6210
+ }
6211
+ catch {
6212
+ // 落盘失败走下面的拒绝分支。
6213
+ }
6214
+ if (healed === dangling.length && healed > 0) {
6215
+ conv.transcriptBlocked = false;
6216
+ try {
6217
+ await s.reload();
6218
+ this.applyRetryOverrides();
6219
+ this.applyCompactionOverrides();
6220
+ this.applyToolGating(s, conv.agentPreset);
6221
+ }
6222
+ catch {
6223
+ // reload 失败兜底
6224
+ }
6225
+ this.emit({
6226
+ type: "notice",
6227
+ level: "warning",
6228
+ text: `检测到上次运行残留的 ${dangling.length} 个无结果工具调用,已自动填入合成结果后继续。如任务未完成请重新执行该工具。`,
6229
+ textEn: `Found ${dangling.length} tool call(s) without results from the last run; synthetic results were inserted automatically before continuing. Re-run the tool if the task is incomplete.`,
6230
+ });
6231
+ }
6232
+ else {
6233
+ conv.transcriptBlocked = true;
6234
+ this.emit({
6235
+ type: "notice",
6236
+ level: "error",
6237
+ text: `发送已拒绝:该对话的记录尾是一个没有结果的工具调用(上次运行被强制终止),且自动修复失败。请从历史记录重新打开该对话,或新建对话后重试。`,
6238
+ textEn: `Prompt refused: this transcript ends with a tool call that never got a result (last run was force-terminated) and auto-repair failed. Reopen it from history or start a new conversation.`,
6239
+ });
6240
+ this.flushSnapshot();
6241
+ return;
6242
+ }
6243
+ }
6244
+ else {
6245
+ conv.transcriptBlocked = false;
6246
+ }
6247
+ }
6248
+ catch {
6249
+ // 守卫本身绝不挡发送:查不到就按原路径走。
6250
+ }
6251
+ }
4963
6252
  // issue #145:发之前再查一次同文件持有者 —— 拦住「打开时空闲、发送时在跑」的竞态。
4964
6253
  // 没有第二个 writer,就不可能有看不见的第二个 agent。
4965
6254
  const activeFile = this.activeSessionFileResolved();
@@ -5075,16 +6364,23 @@ export class ClientSession {
5075
6364
  .filter((r) => r.hits.length > 0);
5076
6365
  const extNoteEn = externalRunners.length > 0 ? ` Touched files of external run(s) are unknown.` : "";
5077
6366
  const extNoteZh = externalRunners.length > 0 ? `外部运行的文件触碰未知。` : "";
6367
+ // 预设拿掉问卷工具时(minimal/code/ask),提醒文案不点不存在的工具名,
6368
+ // 改走正文提问(见 tool-manager.ts 语义总表;认领信息本身照常有用)。
6369
+ const canAsk = presetHasQuestionnaire(conv.agentPreset ?? this.settingsSvc.current.defaultAgentPreset ?? "standard");
6370
+ const askClauseEn = canAsk
6371
+ ? "use ask_user_question when unsure "
6372
+ : "ask the user in your reply text when unsure ";
6373
+ const askClauseZh = canAsk ? "拿不准就用 ask_user_question 让用户选择:" : "拿不准就在回复正文里直接问用户:";
5078
6374
  let aiReminder;
5079
6375
  if (clashes.length === 0 && myClaimed.length === 0) {
5080
6376
  aiReminder =
5081
6377
  `(System reminder: ${aiItems.length} other run(s) [${aiItems.join("; ")}] ` +
5082
6378
  `are currently running in the same project directory. No file written by both you and them was detected, ` +
5083
6379
  `so working on different files in parallel is fine; before writing the same files or running project-wide ` +
5084
- `commands, assess the conflict risk first, and use ask_user_question when unsure ` +
6380
+ `commands, assess the conflict risk first, and ${askClauseEn}` +
5085
6381
  `(continue in parallel / wait / watch read-only).${extNoteEn}${claimsSummaryEn})\n` +
5086
6382
  `(系统提醒:同一项目另有 ${aiItems.length} 处运行(${shown}${more})。未发现双方都写过的文件,` +
5087
- `改不同文件可并行;动同一文件或跑全局命令前先评估冲突,拿不准就用 ask_user_question 让用户选择:` +
6383
+ `改不同文件可并行;动同一文件或跑全局命令前先评估冲突,${askClauseZh}` +
5088
6384
  `并行 / 等它跑完 / 只读围观。${extNoteZh}${claimsSummaryZh})`;
5089
6385
  }
5090
6386
  else {
@@ -5102,10 +6398,10 @@ export class ClientSession {
5102
6398
  aiReminder =
5103
6399
  `(System reminder: ${aiItems.length} other run(s) [${aiItems.join("; ")}] ` +
5104
6400
  `are currently running in the same project directory. ⚠ ${clashEn} — re-read these files before ` +
5105
- `touching them again, and use ask_user_question when unsure ` +
6401
+ `touching them again, and ${askClauseEn}` +
5106
6402
  `(continue in parallel / wait / watch read-only).${extNoteEn})\n` +
5107
6403
  `(系统提醒:同一项目另有 ${aiItems.length} 处运行(${shown}${more})。` +
5108
- `${clashZh} —— 再动这些文件前先读最新内容,拿不准就用 ask_user_question 让用户选择:` +
6404
+ `${clashZh} —— 再动这些文件前先读最新内容,${askClauseZh}` +
5109
6405
  `并行 / 等它跑完 / 只读围观。${extNoteZh})`;
5110
6406
  }
5111
6407
  try {
@@ -5157,9 +6453,35 @@ export class ClientSession {
5157
6453
  // issue #91:附件/视觉桥文案按客户端 UI 语言出中英(英文默认)。
5158
6454
  getLang: () => this.getLang(),
5159
6455
  }, attachments);
6456
+ if (promptAc.signal.aborted) {
6457
+ conv.activePromptAc = undefined;
6458
+ return;
6459
+ }
5160
6460
  for (const aside of asides) {
5161
- await s.sendCustomMessage(aside.message, { deliverAs: "nextTurn" });
6461
+ if (s.isStreaming) {
6462
+ await s.sendCustomMessage(aside.message, { deliverAs: "nextTurn" });
6463
+ }
6464
+ else {
6465
+ await s.sendCustomMessage(aside.message);
6466
+ }
6467
+ }
6468
+ // 附件处理完毕后立即刷新快照,让引用的文件卡片在等待模型首字前瞬间出现在界面上
6469
+ if (asides.length > 0) {
6470
+ this.flushSnapshot();
5162
6471
  }
6472
+ // 创建工作区版本影子快照(Dual-State Rollback)
6473
+ let snapshotRef = null;
6474
+ try {
6475
+ snapshotRef = await createWorkspaceSnapshot(conv.cwd);
6476
+ }
6477
+ catch {
6478
+ // best-effort
6479
+ }
6480
+ if (promptAc.signal.aborted) {
6481
+ conv.activePromptAc = undefined;
6482
+ return;
6483
+ }
6484
+ conv.activePromptAc = undefined;
5163
6485
  if (s.isStreaming) {
5164
6486
  // queue=true (补充 button) → followUp: the message is delivered only
5165
6487
  // after the whole run finishes — the agent finishes what it started,
@@ -5178,8 +6500,29 @@ export class ClientSession {
5178
6500
  else {
5179
6501
  await s.prompt(text);
5180
6502
  }
6503
+ // 关联快照与本次 prompt 产生的用户消息 entry
6504
+ if (snapshotRef) {
6505
+ try {
6506
+ const entries = conv.session.sessionManager.buildContextEntries();
6507
+ const lastUserEntry = [...entries]
6508
+ .reverse()
6509
+ .find((e) => e.type === "message" && e.message?.role === "user");
6510
+ conv.workspaceSnapshots.push({
6511
+ entryId: lastUserEntry?.id,
6512
+ timestamp: Date.now(),
6513
+ snapshotRef,
6514
+ });
6515
+ if (conv.workspaceSnapshots.length > 50) {
6516
+ conv.workspaceSnapshots.shift();
6517
+ }
6518
+ }
6519
+ catch {
6520
+ // best-effort
6521
+ }
6522
+ }
5181
6523
  }
5182
6524
  catch (err) {
6525
+ conv.activePromptAc = undefined;
5183
6526
  this.emit({
5184
6527
  type: "notice",
5185
6528
  level: "error",
@@ -5201,15 +6544,12 @@ export class ClientSession {
5201
6544
  /**
5202
6545
  * Turn attached files into custom-message payloads.
5203
6546
  *
5204
- * Text files are size-aware: small files are inlined into the message so the
5205
- * model sees them immediately; large files are passed as a <file path="...">
5206
- * reference and the model reads them on demand with its read tool (which has
5207
- * built-in truncation). Images are always passed as image content. Mode
5208
- * "lines" inlines only a 1-based inclusive line range of the file. Raw
5209
- * pasted/dropped/uploaded images (attachment.imageData) skip the workspace
5210
- * path entirely and go straight to the model as image content. Raw uploaded
5211
- * files (attachment.fileData) are persisted under <dataDir>/uploads/ and
5212
- * attached as absolute-path references (small text ones are inlined).
6547
+ * 一律只给路径引用:文本文件(无论大小)都只发 `<file path="..." />`,模型用
6548
+ * 自己的 read 工具按需读(自带截断/分页)——文件内容永不进 prompt。行范围模式
6549
+ * (mode "lines")在引用上带 lines 属性,告诉模型用户选的是哪几行。
6550
+ * 图片始终作为 image 内容发送。粘贴/拖入/上传的原始图片(attachment.imageData)
6551
+ * 不走工作区路径,直接进模型;上传文件(attachment.fileData)落在
6552
+ * <dataDir>/uploads/ 下,以绝对路径引用。
5213
6553
  */
5214
6554
  /**
5215
6555
  * Hard-abort the running agent (Stop button / global 中断). Tries
@@ -5220,6 +6560,15 @@ export class ClientSession {
5220
6560
  * overnight. The notice fires only on the forced-reset path.
5221
6561
  */
5222
6562
  async abort() {
6563
+ if (this.conv.activePromptAc) {
6564
+ this.conv.activePromptAc.abort();
6565
+ this.conv.activePromptAc = undefined;
6566
+ }
6567
+ const isRunning = this.conversationStreaming(this.conv) || !this.conv.session.isIdle;
6568
+ if (!isRunning) {
6569
+ this.flushSnapshot();
6570
+ return;
6571
+ }
5223
6572
  // 只停止智能体运行本身;AI 在后台启动的服务由「后台任务」面板单独
5224
6573
  // 管理(可逐个停止或全部关闭),不会在停止对话时被连带杀掉。
5225
6574
  await this.interruptRun(this.conv, "已停止");
@@ -5476,6 +6825,9 @@ export class ClientSession {
5476
6825
  }
5477
6826
  /** Interrupt a run: abort, with a force-reset fallback on timeout. */
5478
6827
  async interruptRun(conv, reason) {
6828
+ if (!this.conversationStreaming(conv) && conv.session.isIdle) {
6829
+ return;
6830
+ }
5479
6831
  // The run is only truly stopped when its agent_end event arrives:
5480
6832
  // session.abort() can return without stopping anything when the run is
5481
6833
  // stuck before the agent even started (e.g. a model stream that never
@@ -5513,14 +6865,14 @@ export class ClientSession {
5513
6865
  textEn: `Abort failed: ${err.message}`,
5514
6866
  });
5515
6867
  }
5516
- // 3) abort returned but no agent_end within the settle window → the
5517
- // run was stuck before it started; force-reset to recover.
5518
- if (!ended) {
6868
+ // 3) abort returned but no agent_end within the settle window:
6869
+ // 仅在 session 依然处于非 idle 状态(即真正卡住)时才等待并强制重置
6870
+ if (!ended && !conv.session.isIdle) {
5519
6871
  await new Promise((r) => setTimeout(r, ClientSession.HARD_ABORT_SETTLE_MS));
5520
6872
  }
5521
6873
  clearTimeout(abortTimer);
5522
6874
  off();
5523
- if (!ended)
6875
+ if (!ended && !conv.session.isIdle)
5524
6876
  force();
5525
6877
  }
5526
6878
  /** Force-reset a conversation: dispose the stuck runtime (kills the hung
@@ -5528,18 +6880,44 @@ export class ClientSession {
5528
6880
  * persisted session. The conversation record itself is kept (same id,
5529
6881
  * same cwd, same serialization caches), so the UI stays attached. */
5530
6882
  async forceResetConversation(conv, reason) {
6883
+ // #280:先记下本次对话自己的会话文件——重建必须回到同一个文件,
6884
+ // 不能用 continueRecent(cwd) 按 mtime 取「最近」(同 cwd 多会话时会接错文件)。
6885
+ const ownFile = (() => {
6886
+ try {
6887
+ const f = conv.session.sessionFile;
6888
+ return typeof f === "string" && f ? f : undefined;
6889
+ }
6890
+ catch {
6891
+ return undefined;
6892
+ }
6893
+ })();
5531
6894
  try {
5532
6895
  conv.unsubscribe?.();
5533
6896
  conv.unsubscribe = undefined;
5534
6897
  this.clearAllToolWatchdogs(conv);
5535
6898
  conv.toolStartTimes.clear();
6899
+ disposeEvalSession(conv.id);
5536
6900
  await conv.runtime.dispose();
6901
+ // #280:dispose 丢弃了内存里的在飞状态(未落盘的工具结果蒸发),
6902
+ // 文件尾可能留下一个悬空 toolCall——先补合成 toolResult 再重建,
6903
+ // 否则重建后的 prompt 会把非法转录链喂给 provider(零落盘黑洞)。
6904
+ let healedCount = 0;
6905
+ if (ownFile && existsSync(ownFile)) {
6906
+ try {
6907
+ const n = healDanglingToolCallFile(ownFile);
6908
+ if (n > 0)
6909
+ healedCount = n;
6910
+ }
6911
+ catch {
6912
+ // best-effort:修不好就按原路径重建,下面的守卫会在 prompt 前再拦。
6913
+ }
6914
+ }
5537
6915
  // #235:转录链损坏时修一次再试(见 openManagerAndRuntime)。
5538
- const opened = await this.openManagerAndRuntime(() => SessionManager.continueRecent(conv.cwd), (m) => createAgentSessionRuntime(this.makeRuntimeFactory(conv.terminals, undefined, conv.id), {
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), {
5539
6917
  cwd: conv.cwd,
5540
6918
  agentDir: this.agentDir,
5541
6919
  sessionManager: m,
5542
- }), async () => (await SessionManager.list(conv.cwd))[0]?.path);
6920
+ }), async () => (ownFile && existsSync(ownFile) ? ownFile : (await SessionManager.list(conv.cwd))[0]?.path));
5543
6921
  const runtime = opened.runtime;
5544
6922
  if (opened.repair) {
5545
6923
  for (const n of this.transcriptRepairNotices(opened.repair))
@@ -5547,6 +6925,27 @@ export class ClientSession {
5547
6925
  }
5548
6926
  conv.runtime = runtime;
5549
6927
  conv.session = runtime.session;
6928
+ if (healedCount > 0) {
6929
+ this.emit({
6930
+ type: "notice",
6931
+ level: "warning",
6932
+ text: `上次运行被强制终止,${healedCount} 个无结果的工具调用已自动填入合成结果(转录链已修复)。建议检查任务状态,必要时重新执行该工具。`,
6933
+ textEn: `The last run was force-terminated; ${healedCount} tool call(s) without results were filled with synthetic results (transcript healed). Verify task state and re-run the tool if needed.`,
6934
+ });
6935
+ }
6936
+ // #280:重建后复查——新 runtime 仍以悬空 toolCall 开头说明修复没落盘
6937
+ // (文件被删/只读等),此时响亮拒绝后续 prompt 而不是静默黑洞。
6938
+ try {
6939
+ const msgs = conv.session.agent.state.messages;
6940
+ const still = Array.isArray(msgs) ? findDanglingToolCalls(msgs) : [];
6941
+ if (still.length > 0)
6942
+ conv.transcriptBlocked = true;
6943
+ else
6944
+ conv.transcriptBlocked = false;
6945
+ }
6946
+ catch {
6947
+ // ignore:守卫是兜底,查不到就让 prompt 路径再查。
6948
+ }
5550
6949
  this.emit({
5551
6950
  type: "notice",
5552
6951
  level: "warning",
@@ -5570,8 +6969,7 @@ export class ClientSession {
5570
6969
  * 空白新对话」——/new <prompt> 只在 true 时投递首条提示;false 表示没能进入
5571
6970
  * 新对话(准入关闭 / 同项目对话数达上限 / runtime 创建失败),此时照发会把
5572
6971
  * 首条提示投进用户原本正在用的那个对话里。 */
5573
- async newChat(_preset) {
5574
- // _preset: DSH Agent 预设(pi 引擎无此概念,忽略;wire 统一见 protocol new_chat)。
6972
+ async newChat(_preset, ephemeral) {
5575
6973
  if (this.quiesceBlocked())
5576
6974
  return false;
5577
6975
  // Reuse an already-open blank conversation instead of piling up new ones
@@ -5589,31 +6987,41 @@ export class ClientSession {
5589
6987
  }
5590
6988
  };
5591
6989
  const active = this.conv;
5592
- if (active && isBlank(active)) {
5593
- this.flushSnapshot();
6990
+ if (!ephemeral && active && isBlank(active)) {
6991
+ if (_preset)
6992
+ await this.selectAgentPreset(_preset);
6993
+ else
6994
+ this.flushSnapshot();
5594
6995
  return true;
5595
6996
  }
5596
- for (const conv of this.convs.values()) {
5597
- if (conv.id === this.activeId)
5598
- continue;
5599
- if (isBlank(conv)) {
5600
- await this.switchConversation(conv.id);
5601
- this.flushSnapshot();
5602
- return true;
6997
+ if (!ephemeral) {
6998
+ for (const conv of this.convs.values()) {
6999
+ if (conv.id === this.activeId)
7000
+ continue;
7001
+ if (isBlank(conv)) {
7002
+ await this.switchConversation(conv.id);
7003
+ if (_preset)
7004
+ await this.selectAgentPreset(_preset);
7005
+ else
7006
+ this.flushSnapshot();
7007
+ return true;
7008
+ }
5603
7009
  }
5604
7010
  }
5605
7011
  // Cap is per project — conversations of other projects keep their own
5606
7012
  // lists and don't consume this project's slots. Subagents don't count
5607
- // (inMemory 后台任务,不占位)。
5608
- const openInProject = [...this.convs.values()].filter((c) => c.cwd === this.cwd && !c.isSubagent).length;
5609
- if (openInProject >= MAX_OPEN_CONVERSATIONS) {
5610
- this.emit({
5611
- type: "notice",
5612
- level: "warning",
5613
- text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先打开某个对话并离开(不继续对话)以移出列表`,
5614
- 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.`,
5615
- });
5616
- return false;
7013
+ // (inMemory 后台任务,不占位)。临时会话也不占名额。
7014
+ if (!ephemeral) {
7015
+ const openInProject = [...this.convs.values()].filter((c) => c.cwd === this.cwd && !c.isSubagent && !c.isEphemeral).length;
7016
+ if (openInProject >= MAX_OPEN_CONVERSATIONS) {
7017
+ this.emit({
7018
+ type: "notice",
7019
+ level: "warning",
7020
+ text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先打开某个对话并离开(不继续对话)以移出列表`,
7021
+ 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.`,
7022
+ });
7023
+ return false;
7024
+ }
5617
7025
  }
5618
7026
  // The outgoing conversation is left behind — apply the running-list
5619
7027
  // lifecycle. Removal is deferred until the new chat exists so the active
@@ -5630,11 +7038,32 @@ export class ClientSession {
5630
7038
  const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, undefined, conversationId, prevModel ?? undefined), {
5631
7039
  cwd: this.cwd,
5632
7040
  agentDir: this.agentDir,
5633
- sessionManager: SessionManager.create(this.cwd),
7041
+ sessionManager: ephemeral ? SessionManager.inMemory(this.cwd) : SessionManager.create(this.cwd),
5634
7042
  });
5635
7043
  const conv = this.makeConversation(runtime, conversationId, terminals);
7044
+ if (ephemeral)
7045
+ conv.isEphemeral = true;
7046
+ if (_preset) {
7047
+ const hit = PI_AGENT_PRESETS.find((p) => p.id === _preset);
7048
+ if (hit)
7049
+ conv.agentPreset = hit.id;
7050
+ }
7051
+ // 创建即按归属预设门控:工厂内只能按 active/默认兜底(conv 还没进 map),
7052
+ // 默认预设非 standard 时那个结果是错的,这里用 conv 自身预设显式重放一次。
7053
+ // conv 尚未进 map,preset 必须显式传,插件同步才能正确剥离。
7054
+ this.applyToolGating(conv.session, conv.agentPreset);
5636
7055
  this.convs.set(conv.id, conv);
5637
7056
  this.activeId = conv.id;
7057
+ if (_preset && conv.agentPreset !== (this.settingsSvc.current.defaultAgentPreset ?? "standard")) {
7058
+ // 显式预设与默认不一致:工厂组装提示词时用的是默认视图,重载一次对齐
7059
+ // (技能名录/终端引导;此时 conv 已进 map,override 能读到归属预设)。
7060
+ try {
7061
+ await conv.session.resourceLoader.reload();
7062
+ }
7063
+ catch {
7064
+ // loader 未就绪——首轮 run 组装时自然对齐。
7065
+ }
7066
+ }
5638
7067
  if (displaced)
5639
7068
  this.removeConversation(displaced.id);
5640
7069
  await this.bindSession();
@@ -5848,6 +7277,7 @@ export class ClientSession {
5848
7277
  /**
5849
7278
  * #235:已知路径先修后开(openConversation 走这条——单文件预扫描零负担)。
5850
7279
  * 返回 null = 文件健康或无需处理;返回 repair = 修过,调用方弹提示。
7280
+ * #280:顺带修悬空 toolCall(强制重置/崩溃残留的有调用无结果),修过同样弹提示。
5851
7281
  */
5852
7282
  repairTranscriptFileBeforeOpen(filePath) {
5853
7283
  let repair = null;
@@ -5857,8 +7287,27 @@ export class ClientSession {
5857
7287
  catch {
5858
7288
  return null;
5859
7289
  }
5860
- if (!repair?.changed)
7290
+ if (!repair?.changed) {
7291
+ // #280:压缩链健康时仍要查悬空 toolCall(崩溃/强制重置残留)。
7292
+ let healed = 0;
7293
+ try {
7294
+ const n = healDanglingToolCallFile(filePath);
7295
+ if (n > 0)
7296
+ healed = n;
7297
+ }
7298
+ catch {
7299
+ // best-effort
7300
+ }
7301
+ if (healed <= 0)
7302
+ return null;
7303
+ this.emit({
7304
+ type: "notice",
7305
+ level: "warning",
7306
+ text: `该对话上次运行残留 ${healed} 个无结果的工具调用,已自动填入合成结果。如任务未完成请重新执行该工具。`,
7307
+ textEn: `${healed} tool call(s) without results from the last run were filled with synthetic results. Re-run the tool if the task is incomplete.`,
7308
+ });
5861
7309
  return null;
7310
+ }
5862
7311
  for (const n of this.transcriptRepairNotices(repair))
5863
7312
  this.emit(n);
5864
7313
  return repair;
@@ -5950,9 +7399,19 @@ export class ClientSession {
5950
7399
  }
5951
7400
  this.convs.delete(id);
5952
7401
  this.clearAllToolWatchdogs(conv);
7402
+ // 关对话 → 连它的 eval 内核(Python/Node 子进程 + 临时沙箱目录)一起回收:
7403
+ // 这些进程是 detached 进程组,父进程退出不会自动带走它们。
7404
+ disposeEvalSession(id);
5953
7405
  conv.terminals.killAll();
5954
7406
  conv.unsubscribe?.();
5955
7407
  conv.unsubscribe = undefined;
7408
+ if (conv.isSubagent) {
7409
+ const ephemeralDir = join(this.agentDir, "subagent-sessions", conv.id);
7410
+ try {
7411
+ rmSync(ephemeralDir, { recursive: true, force: true });
7412
+ }
7413
+ catch { }
7414
+ }
5956
7415
  void conv.runtime.dispose().catch(() => { });
5957
7416
  }
5958
7417
  /** 本会话是否持有这个 SDK 会话对应的对话;命中则给出它的**当前** id
@@ -6228,6 +7687,7 @@ export class ClientSession {
6228
7687
  messageCount,
6229
7688
  isStreaming,
6230
7689
  isSubagent: !!conv.isSubagent,
7690
+ ...(conv.isEphemeral ? { isEphemeral: true } : {}),
6231
7691
  // 落盘会话才有文件(inMemory 子代理缺省):右键复制路径 / AI 按 path 读历史时用。
6232
7692
  ...(() => {
6233
7693
  try {
@@ -6246,6 +7706,7 @@ export class ClientSession {
6246
7706
  return pq ? { hasQuestion: true, questionId: pq.id, questionTitle: pq.title } : {};
6247
7707
  })(),
6248
7708
  parentId: conv.parentId,
7709
+ ...(conv.forkFrom ? { forkFrom: conv.forkFrom } : {}),
6249
7710
  });
6250
7711
  }
6251
7712
  // issue #145:流式集合签名变化 → 通知其他客户端重推(左栏「另一处正在运行」近实时)。
@@ -6584,8 +8045,8 @@ export class ClientSession {
6584
8045
  });
6585
8046
  }
6586
8047
  /**
6587
- * 将内存子代理(inMemory)固化为普通持久化对话:
6588
- * 写入磁盘 .jsonl 会话文件,清除 isSubagent 标记,使它进入历史会话列表并长久保留。
8048
+ * 将内存会话(inMemory 子代理 / 临时对话)固化为普通持久化对话:
8049
+ * 写入磁盘 .jsonl 会话文件,清除 isSubagent / isEphemeral 标记,使它进入历史会话列表并长久保留。
6589
8050
  */
6590
8051
  async persistConversation(id) {
6591
8052
  const conv = this.convs.get(id);
@@ -6599,7 +8060,7 @@ export class ClientSession {
6599
8060
  return;
6600
8061
  }
6601
8062
  const sm = conv.session.sessionManager;
6602
- if (!conv.isSubagent && sm?.isPersisted?.()) {
8063
+ if (!conv.isSubagent && !conv.isEphemeral && sm?.isPersisted?.()) {
6603
8064
  this.emit({
6604
8065
  type: "notice",
6605
8066
  level: "info",
@@ -6608,6 +8069,8 @@ export class ClientSession {
6608
8069
  });
6609
8070
  return;
6610
8071
  }
8072
+ // 固化对象是临时对话时文案换一套(用户看到的是「临时对话」而非「子代理」)。
8073
+ const wasEphemeral = !!conv.isEphemeral;
6611
8074
  try {
6612
8075
  const cwd = conv.cwd || this.cwd;
6613
8076
  const sampleSm = SessionManager.create(cwd);
@@ -6641,14 +8104,19 @@ export class ClientSession {
6641
8104
  sm.flushed = true;
6642
8105
  }
6643
8106
  conv.isSubagent = false;
8107
+ conv.isEphemeral = false;
6644
8108
  this.emitConversations();
6645
8109
  await this.pushProjects();
6646
8110
  this.flushSnapshot();
6647
8111
  this.emit({
6648
8112
  type: "notice",
6649
8113
  level: "info",
6650
- text: `已将子代理「${conv.title}」固化为普通对话,并保存至历史记录`,
6651
- textEn: `Solidified subagent "${conv.title}" into a regular conversation saved to history`,
8114
+ text: wasEphemeral
8115
+ ? `已将临时对话「${conv.title}」保存为正式对话,并存入历史记录`
8116
+ : `已将子代理「${conv.title}」固化为普通对话,并保存至历史记录`,
8117
+ textEn: wasEphemeral
8118
+ ? `Saved ephemeral conversation "${conv.title}" as a regular conversation in history`
8119
+ : `Solidified subagent "${conv.title}" into a regular conversation saved to history`,
6652
8120
  });
6653
8121
  }
6654
8122
  catch (err) {
@@ -6656,8 +8124,8 @@ export class ClientSession {
6656
8124
  this.emit({
6657
8125
  type: "notice",
6658
8126
  level: "error",
6659
- text: `固化子代理失败:${msg}`,
6660
- textEn: `Failed to persist subagent: ${msg}`,
8127
+ text: `固化失败:${msg}`,
8128
+ textEn: `Failed to persist conversation: ${msg}`,
6661
8129
  });
6662
8130
  }
6663
8131
  }
@@ -7208,6 +8676,288 @@ export class ClientSession {
7208
8676
  }
7209
8677
  return null;
7210
8678
  }
8679
+ /**
8680
+ * Map any rendered message id (u-*, a-*, t-*, b-*, c-*, or raw entry id)
8681
+ * back to its append-only session entry in the given conversation.
8682
+ */
8683
+ resolveMessageEntry(conv, messageId) {
8684
+ const userSeqByTs = new Map();
8685
+ const assistantSeqByTs = new Map();
8686
+ let globalSeq = 0;
8687
+ 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
+ }
8725
+ // 兜底:getEntry 查整棵树
8726
+ return conv.session.sessionManager.getEntry(messageId) ?? null;
8727
+ }
8728
+ /**
8729
+ * Fork a NEW branch conversation from a specific historical message position.
8730
+ * Truncates the transcript before (or at) that message as the context of the
8731
+ * new conversation, allowing the user to explore alternative lines of thought
8732
+ * without affecting the original conversation.
8733
+ */
8734
+ async forkSession(messageId, position = "before", targetConvId) {
8735
+ if (this.quiesceBlocked())
8736
+ return;
8737
+ const targetConv = (targetConvId ? this.convs.get(targetConvId) : this.conv) ?? this.conv;
8738
+ const entry = this.resolveMessageEntry(targetConv, messageId);
8739
+ if (!entry) {
8740
+ this.emit({
8741
+ type: "notice",
8742
+ level: "error",
8743
+ text: "找不到指定的消息节点(可能已被压缩或不在当前分支)",
8744
+ textEn: "Message node to fork from not found (may have been compacted or is on another branch)",
8745
+ });
8746
+ this.flushSnapshot();
8747
+ return;
8748
+ }
8749
+ const targetLeafId = position === "at" ? entry.id : entry.parentId;
8750
+ try {
8751
+ const currentSessionFile = targetConv.session.sessionFile;
8752
+ const isPersisted = targetConv.session.sessionManager.isPersisted() && currentSessionFile && existsSync(currentSessionFile);
8753
+ const prevModel = targetConv.session.agent.state.model ?? null;
8754
+ const prevThinking = targetConv.session.thinkingLevel ?? null;
8755
+ const conversationId = this.nextConversationId();
8756
+ const terminals = this.makeTerminalManager(conversationId, targetConv.cwd);
8757
+ let forkedManager;
8758
+ if (isPersisted) {
8759
+ const sessionDir = targetConv.session.sessionManager.getSessionDir();
8760
+ let forkedSessionPath;
8761
+ if (!targetLeafId) {
8762
+ const sm = SessionManager.create(targetConv.cwd, sessionDir);
8763
+ sm.newSession({ parentSession: currentSessionFile });
8764
+ forkedSessionPath = sm.getSessionFile();
8765
+ }
8766
+ else {
8767
+ const sm = SessionManager.open(currentSessionFile, sessionDir);
8768
+ forkedSessionPath = sm.createBranchedSession(targetLeafId);
8769
+ }
8770
+ if (!forkedSessionPath) {
8771
+ throw new Error("Failed to create forked session file");
8772
+ }
8773
+ forkedManager = SessionManager.open(forkedSessionPath, sessionDir);
8774
+ }
8775
+ else {
8776
+ forkedManager = SessionManager.create(targetConv.cwd);
8777
+ if (targetLeafId) {
8778
+ const branch = targetConv.session.sessionManager.getBranch(targetLeafId);
8779
+ for (const e of branch) {
8780
+ if (e.type === "message") {
8781
+ try {
8782
+ forkedManager.appendMessage(e.message);
8783
+ }
8784
+ catch {
8785
+ // skip malformed/unsupported message entries in memory mode
8786
+ }
8787
+ }
8788
+ }
8789
+ }
8790
+ }
8791
+ const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, undefined, conversationId, prevModel ?? undefined), {
8792
+ cwd: targetConv.cwd,
8793
+ agentDir: this.agentDir,
8794
+ sessionManager: forkedManager,
8795
+ });
8796
+ const newConv = this.makeConversation(runtime, conversationId, terminals);
8797
+ if (targetConv.agentPreset)
8798
+ newConv.agentPreset = targetConv.agentPreset;
8799
+ if (targetConv.permissionPreset)
8800
+ newConv.permissionPreset = targetConv.permissionPreset;
8801
+ newConv.forkFrom = {
8802
+ conversationId: targetConv.id,
8803
+ messageId,
8804
+ title: targetConv.title,
8805
+ };
8806
+ this.convs.set(conversationId, newConv);
8807
+ this.activeId = conversationId;
8808
+ await this.bindSession();
8809
+ if (prevModel && this.sharedModelRuntime) {
8810
+ try {
8811
+ const pm = prevModel;
8812
+ await this.restoreKeyForModel(`${pm.provider}/${pm.id}`, targetConv.cwd);
8813
+ await this.session.setModel(prevModel);
8814
+ }
8815
+ catch {
8816
+ // keep default
8817
+ }
8818
+ }
8819
+ if (prevThinking) {
8820
+ try {
8821
+ this.session.setThinkingLevel(prevThinking);
8822
+ }
8823
+ catch {
8824
+ // keep default
8825
+ }
8826
+ }
8827
+ this.emit({
8828
+ type: "notice",
8829
+ level: "info",
8830
+ text: "🌱 已派生新分支会话并自动切换(原对话保持不变)",
8831
+ textEn: "🌱 Forked new branch session and switched to it (original preserved)",
8832
+ });
8833
+ }
8834
+ catch (err) {
8835
+ this.emit({
8836
+ type: "notice",
8837
+ level: "error",
8838
+ text: `派生分支会话失败:${err.message}`,
8839
+ textEn: `Failed to fork branch session: ${err.message}`,
8840
+ });
8841
+ }
8842
+ this.flushSnapshot();
8843
+ }
8844
+ /**
8845
+ * 回滚会话至指定消息检查点(Checkpoint Rollback):
8846
+ * 丢弃该消息之后的所有内容,并就地重置 session 状态,用户可直接在此继续提问。
8847
+ */
8848
+ async rollbackSession(messageId, targetConvId, restoreWorkspace) {
8849
+ if (this.quiesceBlocked())
8850
+ return;
8851
+ const targetConv = (targetConvId ? this.convs.get(targetConvId) : this.conv) ?? this.conv;
8852
+ const entry = this.resolveMessageEntry(targetConv, messageId);
8853
+ if (!entry) {
8854
+ this.emit({
8855
+ type: "notice",
8856
+ level: "error",
8857
+ text: "找不到指定的回滚检查点(可能已被压缩或不存在)",
8858
+ textEn: "Rollback checkpoint not found (may have been compacted or not exist)",
8859
+ });
8860
+ this.flushSnapshot();
8861
+ return;
8862
+ }
8863
+ try {
8864
+ if (targetConv.session.isStreaming) {
8865
+ await targetConv.session.abort();
8866
+ }
8867
+ // 重置分支 leaf 到目标 entry.id
8868
+ targetConv.session.sessionManager.branch(entry.id);
8869
+ const branchedContext = targetConv.session.sessionManager.buildSessionContext();
8870
+ targetConv.session.agent.state.messages = [...branchedContext.messages];
8871
+ if (branchedContext.thinkingLevel) {
8872
+ try {
8873
+ targetConv.session.setThinkingLevel(branchedContext.thinkingLevel);
8874
+ }
8875
+ catch {
8876
+ // ignore
8877
+ }
8878
+ }
8879
+ await targetConv.session.reload();
8880
+ // 重新应用设置和门控
8881
+ this.applyRetryOverrides();
8882
+ this.applyCompactionOverrides();
8883
+ this.applyToolGating(targetConv.session, targetConv.agentPreset);
8884
+ // 清理该会话的 UI 消息缓存和序列化映射
8885
+ targetConv.uiMessageCache.clear();
8886
+ targetConv.msgIds.clear();
8887
+ targetConv.userSeqByTs.clear();
8888
+ targetConv.nextMsgId = 1;
8889
+ targetConv.lastMessagesSig = "";
8890
+ targetConv.lastMessagesArray = [];
8891
+ targetConv.queueSteering = [];
8892
+ targetConv.queueFollowUp = [];
8893
+ try {
8894
+ targetConv.session.clearQueue?.();
8895
+ }
8896
+ catch {
8897
+ // best effort
8898
+ }
8899
+ // 联动还原物理工作区文件(Dual-State Rollback)
8900
+ let workspaceRestored = false;
8901
+ if (restoreWorkspace) {
8902
+ const entryTs = entry.message?.timestamp ??
8903
+ entry.timestamp ??
8904
+ 0;
8905
+ // 查找最贴近该 entry 的快照(按 entryId 或 <= entryTs 的最后一份快照)
8906
+ const snapshots = targetConv.workspaceSnapshots ?? [];
8907
+ let matched = snapshots.find((s) => s.entryId === entry.id);
8908
+ if (!matched) {
8909
+ const eligible = snapshots.filter((s) => entryTs === 0 || s.timestamp <= entryTs + 2000);
8910
+ matched = eligible[eligible.length - 1];
8911
+ }
8912
+ if (!matched && snapshots.length > 0) {
8913
+ matched = snapshots[0];
8914
+ }
8915
+ if (matched) {
8916
+ const res = await restoreWorkspaceSnapshot(targetConv.cwd, matched.snapshotRef);
8917
+ if (res.success) {
8918
+ workspaceRestored = true;
8919
+ }
8920
+ else {
8921
+ this.emit({
8922
+ type: "notice",
8923
+ level: "warning",
8924
+ text: `会话已回滚,但工作区物理文件还原失败:${res.error}`,
8925
+ textEn: `Session rolled back, but workspace file restore failed: ${res.error}`,
8926
+ });
8927
+ }
8928
+ }
8929
+ else {
8930
+ this.emit({
8931
+ type: "notice",
8932
+ level: "warning",
8933
+ text: "未找到对应检查点的工作区快照(可能非 Git 仓库或尚未记录),工作区文件未改动。",
8934
+ textEn: "No workspace snapshot found for this checkpoint (not a Git repo or snapshot unavailable); files left untouched.",
8935
+ });
8936
+ }
8937
+ }
8938
+ this.emit({
8939
+ type: "notice",
8940
+ level: "info",
8941
+ text: workspaceRestored
8942
+ ? "已回滚至选定消息,工作区物理文件已联动还原至该检查点时刻。"
8943
+ : "已回滚至选定消息,后续内容已丢弃,可直接继续对话。",
8944
+ textEn: workspaceRestored
8945
+ ? "Rolled back to selected message; workspace physical files restored to checkpoint state."
8946
+ : "Rolled back to selected message; subsequent content discarded, ready to continue.",
8947
+ });
8948
+ this.emittedMessages = null;
8949
+ this.emitConversations();
8950
+ this.flushSnapshot(true);
8951
+ }
8952
+ catch (err) {
8953
+ this.emit({
8954
+ type: "notice",
8955
+ level: "error",
8956
+ text: `回滚失败:${err.message}`,
8957
+ textEn: `Rollback failed: ${err.message}`,
8958
+ });
8959
+ }
8960
+ }
7211
8961
  /**
7212
8962
  * Edit a past user question and re-ask it: forks a NEW session file that
7213
8963
  * keeps everything up to (but not including) that question, then sends the
@@ -7432,7 +9182,7 @@ export class ClientSession {
7432
9182
  */
7433
9183
  async scmGenCommitMessage(reqId) {
7434
9184
  const lang = this.getLang();
7435
- const cwd = this.conv?.cwd ?? this.cwd;
9185
+ const cwd = this.convs.get(this.activeId)?.cwd ?? this.cwd;
7436
9186
  const reply = (ok, extra) => {
7437
9187
  this.emit({ type: "scm_data", reqId, kind: "commitmsg", ok, ...extra });
7438
9188
  };
@@ -8061,6 +9811,8 @@ export class ClientSession {
8061
9811
  this.bg.stop();
8062
9812
  for (const conv of this.convs.values()) {
8063
9813
  this.clearAllToolWatchdogs(conv);
9814
+ // 逐个对话回收 eval 内核;下面的兜底再清一次表(含已 delete 的残留)。
9815
+ disposeEvalSession(conv.id);
8064
9816
  conv.unsubscribe?.();
8065
9817
  try {
8066
9818
  await conv.runtime.dispose();
@@ -8069,6 +9821,7 @@ export class ClientSession {
8069
9821
  // best effort
8070
9822
  }
8071
9823
  }
9824
+ disposeAllEvalKernels();
8072
9825
  }
8073
9826
  }
8074
9827
  /** issue #226:插件无头调用的工作目录校验(纯函数,可单测)。
@@ -8111,6 +9864,8 @@ export class AgentService {
8111
9864
  cwd;
8112
9865
  /** index.ts 注入:SDK 工具执行事件的插件转发钩子,attach 时拷贝到每个新会话。 */
8113
9866
  onToolEvent = undefined;
9867
+ /** index.ts 注入:bash/read 插件拦截钩子,attach 时拷贝到每个新会话。 */
9868
+ toolGuard = undefined;
8114
9869
  /** index.ts 注入:运行轨迹事件的插件转发钩子,attach 时拷贝到每个新会话。 */
8115
9870
  onRunEvent = undefined;
8116
9871
  /** index.ts 注入:对话切换通知钩子,attach 时拷贝到每个新会话。 */
@@ -8418,12 +10173,29 @@ export class AgentService {
8418
10173
  for (const [clientId, cs] of this.clients) {
8419
10174
  if (clientId === excludeClientId)
8420
10175
  continue;
10176
+ // issue #291:跳过无浏览器连接的残骸(非伪客户端 sinkCount=0 = 断连留存)。
10177
+ // 伪客户端(scheduler:/plugin:)不走浏览器,sinkCount 永远为 0,保留。
10178
+ if (!AgentService.isPseudoClientId(clientId) && cs.sinkCount() === 0)
10179
+ continue;
8421
10180
  for (const r of cs.streamingSummariesAll())
8422
10181
  out.push({ ...r, owner: clientId });
8423
10182
  }
8424
10183
  return out;
8425
10184
  }
8426
- /** issue #145:某客户端流式集合变化 → 其他客户端重推 conversations。 */
10185
+ /** issue #291:删除定时任务后回收对应伪客户端,避免残留在 elsewhere 列表。 */
10186
+ releaseSchedulerClient(taskId) {
10187
+ const safe = String(taskId ?? "").replace(/[^A-Za-z0-9_-]/g, "") || "task";
10188
+ const clientId = `scheduler:${safe}`;
10189
+ const cs = this.clients.get(clientId);
10190
+ if (!cs)
10191
+ return;
10192
+ // 伪客户端常驻一个 noop sink(chatFromScheduler 的 attach),所以不能按
10193
+ // sinkCount 判断「有没有浏览器」—— 它永远为 1。这里就是它的回收点。
10194
+ this.clients.delete(clientId);
10195
+ // 通知其他客户端刷新 elsewhere 列表。
10196
+ this.pokeExternalRunning(clientId);
10197
+ }
10198
+ /** issue #145: 某客户端流式集合变化 → 其他客户端重推 conversations。 */
8427
10199
  pokeExternalRunning(excludeClientId) {
8428
10200
  for (const [clientId, cs] of this.clients) {
8429
10201
  if (clientId === excludeClientId)
@@ -8932,11 +10704,15 @@ export class AgentService {
8932
10704
  const saved = this.stateStore.get(clientId);
8933
10705
  if (saved.lastCwd && saved.lastCwd !== this.cwd) {
8934
10706
  try {
8935
- if (statSync(saved.lastCwd).isDirectory())
10707
+ // issue #295:异步 stat —— 同步 stat 落在坏挂载(已卸载的外部卷/
10708
+ // autofs 触发点)上会在内核里挂起,冻住整个事件循环(含控制
10709
+ // socket 与其他客户端的心跳);异步版本只挡本连接,超时提示照发。
10710
+ const { stat } = await import("node:fs/promises");
10711
+ if ((await stat(saved.lastCwd)).isDirectory())
8936
10712
  cwd = saved.lastCwd;
8937
10713
  }
8938
10714
  catch {
8939
- // gone (unmounted drive / deleted) — fall back to the default
10715
+ // gone (unmounted drive / deleted / hanging mount) — fall back to the default
8940
10716
  }
8941
10717
  }
8942
10718
  // Sessions use the SDK default per-project dir — no per-client dir.
@@ -8988,6 +10764,7 @@ export class AgentService {
8988
10764
  // Forward hooks (set once by index.ts) to every session.
8989
10765
  cs.onQuit = this.onQuit;
8990
10766
  cs.onToolEvent = this.onToolEvent;
10767
+ cs.toolGuard = this.toolGuard;
8991
10768
  cs.onRunEvent = this.onRunEvent;
8992
10769
  cs.onConversationChanged = () => this.onConversationChanged?.();
8993
10770
  cs.pluginToolsProvider = this.pluginToolsProvider;
@@ -9040,7 +10817,14 @@ export class AgentService {
9040
10817
  }
9041
10818
  /** Remove a socket from a client's broadcast set (called on socket close). */
9042
10819
  detach(clientId, send) {
9043
- this.clients.get(clientId)?.detachSink(send);
10820
+ const cs = this.clients.get(clientId);
10821
+ cs?.detachSink(send);
10822
+ // issue #291:最后一个 sink 断开 = 该客户端不再在线 → 它的对话不该再出现在
10823
+ // 别人的 elsewhere 列表(listExternalRunning 已跳过 sinkCount=0 的非伪客户端,
10824
+ // 但列表是推过去的,得让其他客户端重推一次才能立刻消失)。
10825
+ if (cs && !AgentService.isPseudoClientId(clientId) && cs.sinkCount() === 0) {
10826
+ this.pokeExternalRunning(clientId);
10827
+ }
9044
10828
  }
9045
10829
  get(clientId) {
9046
10830
  return this.clients.get(clientId);