pi-web-ui 0.94.1 → 0.96.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.
- package/CHANGELOG.md +91 -2
- package/README.md +5 -6
- package/README.zh-CN.md +4 -5
- package/bin/pi-web-ui.mjs +910 -237
- package/dist/server/agent-service.js +2655 -231
- package/dist/server/approval-rules.js +596 -0
- package/dist/server/attachment-store.js +129 -0
- package/dist/server/attachments.js +92 -174
- package/dist/server/bg-servers.js +45 -7
- package/dist/server/claim-files-tool.js +4 -8
- package/dist/server/claim-store.js +4 -2
- package/dist/server/client-state.js +67 -14
- package/dist/server/compact-context-tool.js +126 -0
- package/dist/server/composer-drafts.js +9 -0
- package/dist/server/context-budget.js +317 -0
- package/dist/server/control-socket.js +57 -28
- package/dist/server/conversation-read-tool.js +7 -17
- package/dist/server/dangling-tools.js +229 -0
- package/dist/server/delegate-task.js +25 -28
- package/dist/server/dsh/dsh-agent-service.js +69 -66
- package/dist/server/edit-soft-tool.js +84 -16
- package/dist/server/eval-tool.js +588 -0
- package/dist/server/file-archives.js +16 -5
- package/dist/server/files-service.js +65 -18
- package/dist/server/goal-service.js +371 -77
- package/dist/server/hashline-engine.js +703 -0
- package/dist/server/host-guard.js +94 -0
- package/dist/server/host-metrics.js +26 -2
- package/dist/server/i18n.js +3 -3
- package/dist/server/index.js +456 -80
- package/dist/server/lsp-tool.js +1371 -0
- package/dist/server/mcp-bridge.js +47 -3
- package/dist/server/model-admin.js +112 -32
- package/dist/server/office-parse.js +375 -0
- package/dist/server/patch-tool.js +85 -0
- package/dist/server/permission-preset.js +25 -0
- package/dist/server/plan-manager.js +114 -0
- package/dist/server/plugin-api-catalog.js +296 -0
- package/dist/server/plugin-catalog-sync.js +36 -19
- package/dist/server/plugin-catalog.js +11 -4
- package/dist/server/plugin-facilities.js +92 -21
- package/dist/server/plugin-install-spec.js +196 -0
- package/dist/server/plugin-installer.js +73 -0
- package/dist/server/plugin-llm.js +71 -65
- package/dist/server/plugin-manifest-validate.js +305 -0
- package/dist/server/plugin-project.js +117 -13
- package/dist/server/plugin-tool-guard.js +120 -0
- package/dist/server/plugin-updater.js +115 -12
- package/dist/server/plugins.js +950 -205
- package/dist/server/present-files-tool.js +9 -12
- package/dist/server/process-utils.js +16 -6
- package/dist/server/prompt-composer.js +9 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/read-tool.js +69 -30
- package/dist/server/resolve-global-sdk.js +30 -16
- package/dist/server/schedule-agent-tool.js +12 -15
- package/dist/server/scheduler-tasks.js +6 -0
- package/dist/server/sdk-origin.js +18 -2
- package/dist/server/serialize.js +111 -14
- package/dist/server/settings-service.js +112 -3
- package/dist/server/skill-tool.js +5 -7
- package/dist/server/subagent-templates.js +51 -0
- package/dist/server/subagents.js +83 -66
- package/dist/server/terminals.js +248 -81
- package/dist/server/tool-approval.js +84 -0
- package/dist/server/tool-manager.js +237 -12
- package/dist/server/tool-overrides.js +59 -0
- package/dist/server/update-check.js +28 -1
- package/dist/server/uploads.js +17 -2
- package/dist/server/wait-subscription-scan.js +18 -21
- package/dist/server/workspace-snapshot.js +113 -0
- package/dist/server/ws-client-id.js +29 -0
- package/dist/server/ws-pending-queue.js +60 -0
- package/extensions/webui.ts +60 -2
- package/package.json +5 -3
- package/plugin-sdk/README.md +25 -0
- package/plugin-sdk/index.d.ts +31 -38
- package/plugin-sdk/index.mjs +35 -19
- package/plugins/catalog.json +40 -0
- package/themes/aetheris.css +457 -0
- package/themes/ayu-light.css +6 -6
- package/themes/catppuccin-latte.css +6 -6
- package/themes/claude-code-dark.css +144 -0
- package/themes/codex.css +6 -6
- package/themes/everforest-light.css +6 -6
- package/themes/geist.css +6 -6
- package/themes/gruvbox-light.css +6 -6
- package/themes/kanagawa-lotus.css +6 -6
- package/themes/rose-pine-dawn.css +6 -6
- package/themes/solarized-light.css +6 -6
- package/themes/vs-code-dark.css +146 -0
- package/themes/zhupi-dark.css +658 -0
- package/themes/zhupi.css +711 -0
- package/web/dist/assets/{TerminalPanel-MVoxpJOA.js → TerminalPanel-DDChcYOh.js} +1 -1
- package/web/dist/assets/index-Do9RgJC3.js +366 -0
- package/web/dist/assets/index-DryOsILO.css +1 -0
- package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
- package/web/dist/index.html +3 -3
- package/web/dist/assets/index-B3S9MxnN.css +0 -1
- package/web/dist/assets/index-DVLrHI2E.js +0 -364
|
@@ -17,38 +17,55 @@ import "./patch-remote-catalog.js";
|
|
|
17
17
|
import { spawn } from "node:child_process";
|
|
18
18
|
import { createHash, randomUUID } from "node:crypto";
|
|
19
19
|
import { createRequire } from "node:module";
|
|
20
|
-
import { appendFileSync, existsSync, readFileSync, rmSync, statSync, mkdirSync, watch, writeFileSync } from "node:fs";
|
|
20
|
+
import { appendFileSync, existsSync, readFileSync, renameSync, rmSync, statSync, mkdirSync, watch, writeFileSync, } from "node:fs";
|
|
21
21
|
import { homedir } from "node:os";
|
|
22
22
|
import { basename, delimiter, dirname, 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
|
-
import { checkAll as checkAllUpdates, collectTargets, compareVersions as compareSemver, resolveNpmRegistry, sortUpdateItems, } from "./update-check.js";
|
|
27
|
+
import { checkAll as checkAllUpdates, collectTargets, compareVersions as compareSemver, detectPiSdkSplit, resolveNpmRegistry, sortUpdateItems, } from "./update-check.js";
|
|
28
|
+
import { checkPluginUpdates } from "./plugin-updater.js";
|
|
29
|
+
import { isBundledInUse, sdkCopies } from "./sdk-origin.js";
|
|
28
30
|
import { hasActiveSubagentRun, hasPendingWaitSubscription, shouldRetainActive } from "./wait-subscription-scan.js";
|
|
29
31
|
import { COMPACTION_PENDING_TYPE, looksLikeChainCorruption, makeCompactionMarkerId, repairSessionFile, } from "./compaction-markers.js";
|
|
32
|
+
import { DANGLING_TOOL_RESULT_TEXT, DANGLING_TOOL_RESULT_TEXT_EN, findDanglingToolCalls, healDanglingToolCallFile, } from "./dangling-tools.js";
|
|
30
33
|
import { removeQueuedByIndexOrText } from "./queue-utils.js";
|
|
31
34
|
import { syncPluginToolsIntoSession } from "./plugins.js";
|
|
35
|
+
import { denialText, } from "./plugin-tool-guard.js";
|
|
32
36
|
import { SettingsService } from "./settings-service.js";
|
|
33
|
-
import { GoalService } from "./goal-service.js";
|
|
37
|
+
import { GoalService, buildDiffFingerprint } from "./goal-service.js";
|
|
34
38
|
import { MarkerService } from "./marker-service.js";
|
|
35
39
|
import { SlashCommandsService, parseSlash } from "./slash-commands.js";
|
|
36
40
|
import { ModelAdminService } from "./model-admin.js";
|
|
37
41
|
import { FilesService, MACHINE_ROOT, desktopDirWire, workspacePath } from "./files-service.js";
|
|
38
|
-
import { DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS, effectiveToolWatchdogMs, isExtensionDisabled, isExtensionEnabled, normalizeDisabledPluginTools, normalizeRetryMaxAttempts, normalizeSkillList, ClientStateStore, } from "./client-state.js";
|
|
39
|
-
import {
|
|
42
|
+
import { DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS, effectiveToolWatchdogMs, isExtensionDisabled, isExtensionEnabled, normalizeDisabledPluginTools, normalizePathKey, normalizeRetryMaxAttempts, normalizeSkillList, ClientStateStore, } from "./client-state.js";
|
|
43
|
+
import { pick, resolveServerLang } from "./i18n.js";
|
|
40
44
|
import { SubagentTemplatesStore, pickTemplatePrompt } from "./subagent-templates.js";
|
|
45
|
+
import { ApprovalRulesStore, extractTargetPath } from "./approval-rules.js";
|
|
41
46
|
import { ComposerDraftsStore } from "./composer-drafts.js";
|
|
47
|
+
import { readPermissionFromSession } from "./permission-preset.js";
|
|
48
|
+
import { createWorkspaceSnapshot, restoreWorkspaceSnapshot } from "./workspace-snapshot.js";
|
|
49
|
+
import { isPathInsideRoot } from "./approval-rules.js";
|
|
50
|
+
import { approvalSuppressionReason, checkDangerousToolCall, isApprovalPolicyEmpty, pluginApprovalCategory, } from "./tool-approval.js";
|
|
51
|
+
import { PlanManager } from "./plan-manager.js";
|
|
42
52
|
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";
|
|
53
|
+
import { applyAgentToolsGating, ASK_USER_QUESTION_TOOL_NAME, BROWSER_PAGE_TOOL_NAME, effectiveDisabledAgentTools, isAgentToolEnabled, isTerminalGuidanceOn, MARKERS_LIST_TOOL_NAME, PI_AGENT_PRESETS, PI_PERMISSION_OPTIONS, PLAN_UPDATE_TOOL_NAME, PRESENT_FILES_TOOL_NAME, presetAllowsPluginTools, presetHasQuestionnaire, presetShowsSkillCatalog, } from "./tool-manager.js";
|
|
44
54
|
import { WebUIContext } from "./webui-context.js";
|
|
45
55
|
import { DEFAULT_COMPACTION_RESERVE_TOKENS, effectiveSoftCap, softCapToReserve } from "./soft-cap.js";
|
|
56
|
+
import { pruneContextHierarchically } from "./context-budget.js";
|
|
46
57
|
import { decodeText } from "./text-sniff.js";
|
|
47
58
|
import { makeEditSoftTool } from "./edit-soft-tool.js";
|
|
48
59
|
// 覆盖 SDK 内置 read:路径是目录时列出目录条目(行为开关 readDirEnabled,默认开)。
|
|
49
|
-
|
|
60
|
+
// 覆盖定义与「与扩展同名工具共存」的注入辅助分在两个文件(后者的依据见 tool-overrides.ts)。
|
|
61
|
+
import { makeReadDirTool, withReadDirSupport } from "./read-tool.js";
|
|
62
|
+
import { installToolOverrides, } from "./tool-overrides.js";
|
|
50
63
|
// 展示文件给用户(present_files):图片/视频内联、文本开预览弹窗、本地打开按钮。
|
|
51
64
|
import { makePresentFilesTool } from "./present-files-tool.js";
|
|
65
|
+
// 主动压缩上下文工具(compact_context):AI 主动根据当前问题精简上下文并自主控制范围。
|
|
66
|
+
import { makeCompactContextTool, buildCompactionInstructions, DEFAULT_KEEP_RECENT_TOKENS, } from "./compact-context-tool.js";
|
|
67
|
+
// 持久代码求值沙箱(eval):Python / Node.js 沙箱内核。
|
|
68
|
+
import { disposeAllEvalKernels, disposeEvalSession, makeEvalTool } from "./eval-tool.js";
|
|
52
69
|
// 工具定义说明的归一化(工具卡右键 → 「显示工具详细信息」,见 getToolInfo)。
|
|
53
70
|
import { normalizeToolInfo } from "./tool-info.js";
|
|
54
71
|
import { collectSubagentDescendantIds, makeSubagentTools, subagentTitle, withSubagentOwner, } from "./subagents.js";
|
|
@@ -59,14 +76,16 @@ import { ClaimStore, matchClaims, mergeTouchSidecar, readTouchSidecar, removeTou
|
|
|
59
76
|
import { makeClaimFilesTool } from "./claim-files-tool.js";
|
|
60
77
|
import { makeSkillTool } from "./skill-tool.js";
|
|
61
78
|
import { makeScheduleTools } from "./schedule-agent-tool.js";
|
|
79
|
+
import { makePatchTool } from "./patch-tool.js";
|
|
80
|
+
import { makeLspTool } from "./lsp-tool.js";
|
|
62
81
|
import { sameSessionFile } from "./scheduler-tasks.js";
|
|
63
82
|
import { buildAttachmentMessages, parseModelSpec } from "./attachments.js";
|
|
64
83
|
import { buildVisionBridgePrompt, findVisionModels, transcribeImages } from "./vision-bridge.js";
|
|
65
84
|
import { isNotRepoError, scmCommitContext } from "./scm.js";
|
|
66
85
|
import { buildCommitMsgInput, buildCommitMsgPrompt, sanitizeCommitMessage } from "./scm-commitmsg.js";
|
|
67
|
-
import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, buildToolsSchemaText, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
|
|
86
|
+
import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, buildToolsSchemaText, estimatePromptTokens, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
|
|
68
87
|
import { launchOrigin, toServiceInfo } from "./launch-origin.js";
|
|
69
|
-
import { serializeMessage, serializeStreamingMessage, stripTransientRetryErrors, } from "./serialize.js";
|
|
88
|
+
import { findEntryByUiId, serializeMessage, serializeStreamingMessage, stripTransientRetryErrors, } from "./serialize.js";
|
|
70
89
|
import { loadCommands, saveCommandsFile, TerminalManager } from "./terminals.js";
|
|
71
90
|
const SNAPSHOT_INTERVAL_MS = 60;
|
|
72
91
|
/** 服务进程所在机器的用户主目录(wire 格式):进程内不变,模块加载时求值一次,
|
|
@@ -168,7 +187,7 @@ Many legacy Chinese text files (.html/.txt/.md/.log, exported documents) are GBK
|
|
|
168
187
|
* schema stays consistent with the terminal-backed tool.
|
|
169
188
|
*/
|
|
170
189
|
export function makeKillableBashTool(cwd, kills,
|
|
171
|
-
/** per-call 返回文本的服务端语言(默认英文);工具 definition
|
|
190
|
+
/** per-call 返回文本的服务端语言(默认英文);工具 definition 为纯英文。 */
|
|
172
191
|
lang = () => "en") {
|
|
173
192
|
const base = createLocalBashOperations();
|
|
174
193
|
const tool = createBashToolDefinition(cwd, {
|
|
@@ -193,7 +212,7 @@ lang = () => "en") {
|
|
|
193
212
|
return {
|
|
194
213
|
name: tool.name,
|
|
195
214
|
label: tool.label,
|
|
196
|
-
description: "Run a shell command natively (process spawn, no terminal)
|
|
215
|
+
description: "Run a shell command natively (process spawn, no terminal); returns full output plus exit code. persist is ignored here; use head/tail to trim returned output.",
|
|
197
216
|
parameters: Type.Object({
|
|
198
217
|
command: Type.String({ description: "The shell command to run" }),
|
|
199
218
|
timeout: Type.Optional(Type.Number({ description: "Optional timeout in seconds" })),
|
|
@@ -233,9 +252,9 @@ export function makeAdaptiveBashTool(killable, terminalBacked, useTerminal) {
|
|
|
233
252
|
return {
|
|
234
253
|
...killable,
|
|
235
254
|
description: "Run a shell command and return its full output plus exit code. Behavior depends on the「default bash override」setting (terminalBash):\n" +
|
|
236
|
-
"
|
|
237
|
-
"
|
|
238
|
-
"Run the bare command —
|
|
255
|
+
"OFF → runs natively (process spawn, no terminal); persist has no effect.\n" +
|
|
256
|
+
"ON → runs in a visible terminal. persist=true keeps the 'ai-bash' terminal alive (shell state cd/venv/ssh retained across calls); persist=false (default) is a one-shot terminal whose output stays viewable.\n" +
|
|
257
|
+
"Run the bare command — never pipe through head/tail/more/less (use the head/tail params; pipes hide live progress). For interactive commands (REPLs, y/n prompts) set persist=true and drive them with terminal_input / terminal_key.",
|
|
239
258
|
promptSnippet: "run shell commands",
|
|
240
259
|
execute: (id, params, signal, onUpdate, ctx) => {
|
|
241
260
|
const p = params;
|
|
@@ -247,6 +266,452 @@ export function makeAdaptiveBashTool(killable, terminalBacked, useTerminal) {
|
|
|
247
266
|
},
|
|
248
267
|
};
|
|
249
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* 给已接管工具(bash/read)包上拦截守卫与人机协同审批:
|
|
271
|
+
* 1. pre guard 命中 deny → 直接阻断;
|
|
272
|
+
* 2. pre guard 命中 ask 或内置高危操作检测(rm -rf / 敏感文件覆盖等) →
|
|
273
|
+
* 触发 tool_approval_pending 等待用户审批;
|
|
274
|
+
* - 用户选择 approve: 放行执行
|
|
275
|
+
* - 用户选择 edit: 使用用户修改后的参数执行(Edit & Run)
|
|
276
|
+
* - 用户选择 deny: 阻断并告知模型
|
|
277
|
+
* 3. post guard 合并(脱敏/补上下文)。
|
|
278
|
+
*/
|
|
279
|
+
export function withToolGuard(def, opts) {
|
|
280
|
+
const guard = opts.guard;
|
|
281
|
+
const toolName = opts.toolName;
|
|
282
|
+
if (!guard && !opts.askApproval)
|
|
283
|
+
return def;
|
|
284
|
+
return {
|
|
285
|
+
...def,
|
|
286
|
+
execute: (async (toolCallId, params, signal, onUpdate, ctx) => {
|
|
287
|
+
const lang = opts.getLang();
|
|
288
|
+
const conversationId = opts.conversationId?.();
|
|
289
|
+
let pre;
|
|
290
|
+
if (guard) {
|
|
291
|
+
try {
|
|
292
|
+
pre = await guard.pre({ toolName, params, conversationId }, lang);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
pre = { verdict: { decision: "allow" } };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// 检查是否需要人工协同审批(Human-in-the-Loop)
|
|
299
|
+
let needApproval = false;
|
|
300
|
+
let approvalReason;
|
|
301
|
+
let approvalReasonEn;
|
|
302
|
+
let approvalCategory;
|
|
303
|
+
// 1. 系统核心安全:内置高危操作检测与自定义审批规则先行(优先级最高,防短路)
|
|
304
|
+
let danger;
|
|
305
|
+
if (opts.cwd && opts.askApproval) {
|
|
306
|
+
danger = checkDangerousToolCall(toolName, params, opts.cwd, opts.getRoots?.() ?? [], opts.getRules?.());
|
|
307
|
+
if (danger.denied) {
|
|
308
|
+
const reasonText = danger.reason ? ` 原因:${danger.reason}` : "";
|
|
309
|
+
const reasonTextEn = danger.reasonEn ? ` Reason: ${danger.reasonEn}` : "";
|
|
310
|
+
const text = pick(lang, `【工具执行被审批规则直接阻断】${reasonText}`, `[Tool execution blocked by approval rule]${reasonTextEn}`);
|
|
311
|
+
return {
|
|
312
|
+
content: [{ type: "text", text }],
|
|
313
|
+
details: { guardDenied: true, ruleDenied: true, reason: danger.reason },
|
|
314
|
+
isError: true,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// 2. 插件前置守卫 deny 拦截(带 isError 标记)
|
|
319
|
+
if (pre?.verdict.decision === "deny") {
|
|
320
|
+
const text = denialText(pre.verdict, pre.pluginId ?? "plugin", lang);
|
|
321
|
+
return {
|
|
322
|
+
content: [{ type: "text", text }],
|
|
323
|
+
details: { guardDenied: true, decision: pre.verdict.decision, pluginId: pre.pluginId },
|
|
324
|
+
isError: true,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
// 3. 决定是否需要弹窗审批(系统高危 ask 优先于插件通用 ask,防止恶意或低危插件掩盖高危告警)
|
|
328
|
+
if (danger?.dangerous) {
|
|
329
|
+
needApproval = true;
|
|
330
|
+
approvalReason = danger.reason;
|
|
331
|
+
approvalReasonEn = danger.reasonEn;
|
|
332
|
+
approvalCategory = danger.category;
|
|
333
|
+
}
|
|
334
|
+
else if (pre?.verdict.decision === "ask") {
|
|
335
|
+
needApproval = true;
|
|
336
|
+
approvalReason = pre.verdict.reason ?? "插件要求确认本次操作";
|
|
337
|
+
approvalReasonEn = pre.verdict.reasonEn ?? "Plugin requested confirmation for this operation";
|
|
338
|
+
// 插件档位按插件 id 分:用户可以「允许同类」= 该插件的确认以后不再问。
|
|
339
|
+
approvalCategory = pluginApprovalCategory(pre.pluginId ?? "plugin");
|
|
340
|
+
}
|
|
341
|
+
let effectiveParams = params;
|
|
342
|
+
let userEdited = false;
|
|
343
|
+
if (needApproval && opts.askApproval) {
|
|
344
|
+
const res = await opts.askApproval(toolCallId, toolName, params, approvalReason, approvalReasonEn, conversationId, approvalCategory);
|
|
345
|
+
if (res.decision === "deny") {
|
|
346
|
+
const reasonText = res.reason ? ` 原因:${res.reason}` : "";
|
|
347
|
+
const reasonTextEn = res.reason ? ` Reason: ${res.reason}` : "";
|
|
348
|
+
return {
|
|
349
|
+
content: [
|
|
350
|
+
{
|
|
351
|
+
type: "text",
|
|
352
|
+
text: pick(lang, `【操作被用户拒绝】${reasonText}`, `[Operation denied by user]${reasonTextEn}`),
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
details: { guardDenied: true, userDenied: true, reason: res.reason },
|
|
356
|
+
isError: true,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
else if (res.decision === "edit") {
|
|
360
|
+
effectiveParams = res.editedParams ?? params;
|
|
361
|
+
userEdited = true;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
else if (pre?.verdict.decision === "ask") {
|
|
365
|
+
// 无审批通道时回落至原先的阻断行为
|
|
366
|
+
const text = denialText(pre.verdict, pre.pluginId ?? "plugin", lang);
|
|
367
|
+
return {
|
|
368
|
+
content: [{ type: "text", text }],
|
|
369
|
+
details: { guardDenied: true, decision: pre.verdict.decision, pluginId: pre.pluginId },
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
const result = (await def.execute(toolCallId, effectiveParams, signal, onUpdate, ctx));
|
|
373
|
+
if (userEdited && result && typeof result === "object") {
|
|
374
|
+
result.details = {
|
|
375
|
+
...result.details,
|
|
376
|
+
userEdited: true,
|
|
377
|
+
originalParams: params,
|
|
378
|
+
executedParams: effectiveParams,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
if (guard) {
|
|
382
|
+
let post;
|
|
383
|
+
try {
|
|
384
|
+
post = await guard.post({ toolName, params: effectiveParams, result, conversationId }, lang);
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
post = undefined;
|
|
388
|
+
}
|
|
389
|
+
if (post?.content)
|
|
390
|
+
return { ...result, content: post.content };
|
|
391
|
+
}
|
|
392
|
+
return result;
|
|
393
|
+
}),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
/** 校验目标路径是否在工作区(或多根工作区)内。严格规范化防止 ".." 逃逸与 Windows 盘符大小写不一致。 */
|
|
397
|
+
function isInsideWorkspaceRoots(targetPath, cwd, roots = []) {
|
|
398
|
+
const abs = resolve(cwd, targetPath);
|
|
399
|
+
const allRoots = [resolve(cwd), ...roots.map((r) => resolve(r))];
|
|
400
|
+
return allRoots.some((r) => isPathInsideRoot(abs, r));
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* 为 write 工具包装会话级权限沙箱与人机协同审批。
|
|
404
|
+
* `base` = 覆盖基底:第三方扩展注册的同名 write 优先(见 tool-overrides.ts),
|
|
405
|
+
* 省略则是 SDK 内置实现 —— 于是权限门禁叠在扩展实现之上,而不是把它顶掉。
|
|
406
|
+
*/
|
|
407
|
+
function wrapWriteToolWithPermission(cwd, getPermission, getRoots, getLang, askApproval, getConversationId, getRules, base = createWriteToolDefinition(cwd)) {
|
|
408
|
+
return {
|
|
409
|
+
...base,
|
|
410
|
+
execute: async (toolCallId, params, signal, onUpdate, ctx) => {
|
|
411
|
+
const perm = getPermission();
|
|
412
|
+
if (perm === "read-only") {
|
|
413
|
+
return {
|
|
414
|
+
content: [
|
|
415
|
+
{
|
|
416
|
+
type: "text",
|
|
417
|
+
text: pick(getLang(), "【权限被拒绝】当前会话处于「只读模式」(read-only),禁止写入文件。若需修改请切换权限预设。", "[Permission Denied] The current session is in 'read-only' mode; writing files is forbidden. Switch permission preset if needed."),
|
|
418
|
+
},
|
|
419
|
+
],
|
|
420
|
+
isError: true,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
if (perm === "workspace-write-never") {
|
|
424
|
+
const p = extractTargetPath(params);
|
|
425
|
+
if (!isInsideWorkspaceRoots(p, cwd, getRoots())) {
|
|
426
|
+
return {
|
|
427
|
+
content: [
|
|
428
|
+
{
|
|
429
|
+
type: "text",
|
|
430
|
+
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.`),
|
|
431
|
+
},
|
|
432
|
+
],
|
|
433
|
+
isError: true,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// 高危写操作人机协同审批拦截(如敏感配置修改)与规则匹配
|
|
438
|
+
let effectiveParams = params;
|
|
439
|
+
let userEdited = false;
|
|
440
|
+
if (askApproval) {
|
|
441
|
+
const danger = checkDangerousToolCall("write", params, cwd, getRoots(), getRules?.());
|
|
442
|
+
if (danger.denied) {
|
|
443
|
+
return {
|
|
444
|
+
content: [
|
|
445
|
+
{
|
|
446
|
+
type: "text",
|
|
447
|
+
text: pick(getLang(), `【文件写入被审批规则直接阻断】${danger.reason ? ` 原因:${danger.reason}` : ""}`, `[File write blocked by approval rule]${danger.reason ? ` Reason: ${danger.reason}` : ""}`),
|
|
448
|
+
},
|
|
449
|
+
],
|
|
450
|
+
details: { guardDenied: true, ruleDenied: true, reason: danger.reason },
|
|
451
|
+
isError: true,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
if (danger.dangerous) {
|
|
455
|
+
const res = await askApproval(toolCallId, "write", params, danger.reason, danger.reasonEn, getConversationId?.(), danger.category);
|
|
456
|
+
if (res.decision === "deny") {
|
|
457
|
+
return {
|
|
458
|
+
content: [
|
|
459
|
+
{
|
|
460
|
+
type: "text",
|
|
461
|
+
text: pick(getLang(), `【文件写入被用户拒绝】${res.reason ? ` 原因:${res.reason}` : ""}`, `[File write denied by user]${res.reason ? ` Reason: ${res.reason}` : ""}`),
|
|
462
|
+
},
|
|
463
|
+
],
|
|
464
|
+
details: { guardDenied: true, userDenied: true, reason: res.reason },
|
|
465
|
+
isError: true,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
else if (res.decision === "edit") {
|
|
469
|
+
effectiveParams = res.editedParams ?? params;
|
|
470
|
+
userEdited = true;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const result = (await base.execute(toolCallId, effectiveParams, signal, onUpdate, ctx));
|
|
475
|
+
if (userEdited && result && typeof result === "object") {
|
|
476
|
+
result.details = {
|
|
477
|
+
...result.details,
|
|
478
|
+
userEdited: true,
|
|
479
|
+
originalParams: params,
|
|
480
|
+
executedParams: effectiveParams,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
return result;
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* 为 edit 工具包装会话级权限沙箱与人机协同审批(基底语义同 write)。
|
|
489
|
+
*/
|
|
490
|
+
function wrapEditToolWithPermission(cwd, getPermission, getRoots, getLang, askApproval, getConversationId, getRules, base = createEditToolDefinition(cwd)) {
|
|
491
|
+
return {
|
|
492
|
+
...base,
|
|
493
|
+
execute: async (toolCallId, params, signal, onUpdate, ctx) => {
|
|
494
|
+
const perm = getPermission();
|
|
495
|
+
if (perm === "read-only") {
|
|
496
|
+
return {
|
|
497
|
+
content: [
|
|
498
|
+
{
|
|
499
|
+
type: "text",
|
|
500
|
+
text: pick(getLang(), "【权限被拒绝】当前会话处于「只读模式」(read-only),禁止编辑文件。若需修改请切换权限预设。", "[Permission Denied] The current session is in 'read-only' mode; editing files is forbidden. Switch permission preset if needed."),
|
|
501
|
+
},
|
|
502
|
+
],
|
|
503
|
+
isError: true,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
if (perm === "workspace-write-never") {
|
|
507
|
+
const p = extractTargetPath(params);
|
|
508
|
+
if (!isInsideWorkspaceRoots(p, cwd, getRoots())) {
|
|
509
|
+
return {
|
|
510
|
+
content: [
|
|
511
|
+
{
|
|
512
|
+
type: "text",
|
|
513
|
+
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.`),
|
|
514
|
+
},
|
|
515
|
+
],
|
|
516
|
+
isError: true,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
// 高危修改人机协同审批拦截与规则匹配
|
|
521
|
+
let effectiveParams = params;
|
|
522
|
+
let userEdited = false;
|
|
523
|
+
if (askApproval) {
|
|
524
|
+
const danger = checkDangerousToolCall("edit", params, cwd, getRoots(), getRules?.());
|
|
525
|
+
if (danger.denied) {
|
|
526
|
+
return {
|
|
527
|
+
content: [
|
|
528
|
+
{
|
|
529
|
+
type: "text",
|
|
530
|
+
text: pick(getLang(), `【文件编辑被审批规则直接阻断】${danger.reason ? ` 原因:${danger.reason}` : ""}`, `[File edit blocked by approval rule]${danger.reason ? ` Reason: ${danger.reason}` : ""}`),
|
|
531
|
+
},
|
|
532
|
+
],
|
|
533
|
+
details: { guardDenied: true, ruleDenied: true, reason: danger.reason },
|
|
534
|
+
isError: true,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
if (danger.dangerous) {
|
|
538
|
+
const res = await askApproval(toolCallId, "edit", params, danger.reason, danger.reasonEn, getConversationId?.(), danger.category);
|
|
539
|
+
if (res.decision === "deny") {
|
|
540
|
+
return {
|
|
541
|
+
content: [
|
|
542
|
+
{
|
|
543
|
+
type: "text",
|
|
544
|
+
text: pick(getLang(), `【文件编辑被用户拒绝】${res.reason ? ` 原因:${res.reason}` : ""}`, `[File edit denied by user]${res.reason ? ` Reason: ${res.reason}` : ""}`),
|
|
545
|
+
},
|
|
546
|
+
],
|
|
547
|
+
details: { guardDenied: true, userDenied: true, reason: res.reason },
|
|
548
|
+
isError: true,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
else if (res.decision === "edit") {
|
|
552
|
+
effectiveParams = res.editedParams ?? params;
|
|
553
|
+
userEdited = true;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
const result = (await base.execute(toolCallId, effectiveParams, signal, onUpdate, ctx));
|
|
558
|
+
if (userEdited && result && typeof result === "object") {
|
|
559
|
+
result.details = {
|
|
560
|
+
...result.details,
|
|
561
|
+
userEdited: true,
|
|
562
|
+
originalParams: params,
|
|
563
|
+
executedParams: effectiveParams,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
return result;
|
|
567
|
+
},
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
/** 为 edit_soft 工具包装会话级权限沙箱与人机协同审批。 */
|
|
571
|
+
function wrapEditSoftToolWithPermission(tool, cwd, getPermission, getRoots, getLang, askApproval, getConversationId, getRules) {
|
|
572
|
+
return {
|
|
573
|
+
...tool,
|
|
574
|
+
execute: async (toolCallId, params, signal, onUpdate, ctx) => {
|
|
575
|
+
const perm = getPermission();
|
|
576
|
+
if (perm === "read-only") {
|
|
577
|
+
return {
|
|
578
|
+
content: [
|
|
579
|
+
{
|
|
580
|
+
type: "text",
|
|
581
|
+
text: pick(getLang(), "【权限被拒绝】当前会话处于「只读模式」(read-only),禁止编辑文件。若需修改请切换权限预设。", "[Permission Denied] The current session is in 'read-only' mode; editing files is forbidden. Switch permission preset if needed."),
|
|
582
|
+
},
|
|
583
|
+
],
|
|
584
|
+
isError: true,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
if (perm === "workspace-write-never") {
|
|
588
|
+
const p = params?.path ?? "";
|
|
589
|
+
if (!isInsideWorkspaceRoots(p, cwd, getRoots())) {
|
|
590
|
+
return {
|
|
591
|
+
content: [
|
|
592
|
+
{
|
|
593
|
+
type: "text",
|
|
594
|
+
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.`),
|
|
595
|
+
},
|
|
596
|
+
],
|
|
597
|
+
isError: true,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
// 高危修改人机协同审批拦截与规则匹配
|
|
602
|
+
let effectiveParams = params;
|
|
603
|
+
let userEdited = false;
|
|
604
|
+
if (askApproval) {
|
|
605
|
+
const danger = checkDangerousToolCall("edit_soft", params, cwd, getRoots(), getRules?.());
|
|
606
|
+
if (danger.denied) {
|
|
607
|
+
return {
|
|
608
|
+
content: [
|
|
609
|
+
{
|
|
610
|
+
type: "text",
|
|
611
|
+
text: pick(getLang(), `【文件编辑被审批规则直接阻断】${danger.reason ? ` 原因:${danger.reason}` : ""}`, `[File edit blocked by approval rule]${danger.reason ? ` Reason: ${danger.reason}` : ""}`),
|
|
612
|
+
},
|
|
613
|
+
],
|
|
614
|
+
details: { guardDenied: true, ruleDenied: true, reason: danger.reason },
|
|
615
|
+
isError: true,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
if (danger.dangerous) {
|
|
619
|
+
const res = await askApproval(toolCallId, "edit_soft", params, danger.reason, danger.reasonEn, getConversationId?.(), danger.category);
|
|
620
|
+
if (res.decision === "deny") {
|
|
621
|
+
return {
|
|
622
|
+
content: [
|
|
623
|
+
{
|
|
624
|
+
type: "text",
|
|
625
|
+
text: pick(getLang(), `【文件编辑被用户拒绝】${res.reason ? ` 原因:${res.reason}` : ""}`, `[File edit denied by user]${res.reason ? ` Reason: ${res.reason}` : ""}`),
|
|
626
|
+
},
|
|
627
|
+
],
|
|
628
|
+
details: { guardDenied: true, userDenied: true, reason: res.reason },
|
|
629
|
+
isError: true,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
else if (res.decision === "edit") {
|
|
633
|
+
effectiveParams = res.editedParams ?? params;
|
|
634
|
+
userEdited = true;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
const result = (await tool.execute(toolCallId, effectiveParams, signal, onUpdate, ctx));
|
|
639
|
+
if (userEdited && result && typeof result === "object") {
|
|
640
|
+
result.details = {
|
|
641
|
+
...result.details,
|
|
642
|
+
userEdited: true,
|
|
643
|
+
originalParams: params,
|
|
644
|
+
executedParams: effectiveParams,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
return result;
|
|
648
|
+
},
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* 结构化任务执行计划更新工具(plan_update)— Plan Mode / Step State Machine。
|
|
653
|
+
*/
|
|
654
|
+
export function makePlanUpdateTool(planManager, getActiveConvId, emit, flushSnapshot) {
|
|
655
|
+
return {
|
|
656
|
+
name: PLAN_UPDATE_TOOL_NAME,
|
|
657
|
+
label: "plan_update",
|
|
658
|
+
description: "Update the structured task plan / step state machine (Plan Mode): break non-trivial work into decision-ready steps and track progress (pending -> in_progress -> done/failed). Prefer steps that note discovery conclusions, files to be touched, and a rollback strategy.",
|
|
659
|
+
promptSnippet: "update structured task plan with decision-ready steps, file touch list, and live status",
|
|
660
|
+
promptGuidelines: [
|
|
661
|
+
"When executing non-trivial tasks, use plan_update early to outline decision-ready steps before coding: " +
|
|
662
|
+
"specify discovery conclusions, explicitly list files to be touched (File Touch List), and note potential rollback strategies",
|
|
663
|
+
"Keep step status updated as work progresses (pending -> in_progress -> done/failed) so the user has real-time visibility",
|
|
664
|
+
],
|
|
665
|
+
parameters: Type.Object({
|
|
666
|
+
steps: Type.Array(Type.Object({
|
|
667
|
+
id: Type.String({ description: "Unique step ID, e.g. '1', 'step-1'" }),
|
|
668
|
+
title: Type.String({ description: "Short step title" }),
|
|
669
|
+
status: Type.Optional(Type.Union([Type.Literal("pending"), Type.Literal("in_progress"), Type.Literal("done"), Type.Literal("failed")], { description: "Step status: pending | in_progress | done | failed (default: pending)" })),
|
|
670
|
+
description: Type.Optional(Type.String({
|
|
671
|
+
description: "Optional detailed description, acceptance criteria, file touch list, or rollback note.",
|
|
672
|
+
})),
|
|
673
|
+
}), { description: "List of plan steps" }),
|
|
674
|
+
activeStepId: Type.Optional(Type.String({ description: "ID of the step currently being executed" })),
|
|
675
|
+
}),
|
|
676
|
+
execute: async (toolCallId, params) => {
|
|
677
|
+
const convId = getActiveConvId();
|
|
678
|
+
const p = params;
|
|
679
|
+
const plan = planManager.setPlan(convId, p.steps, p.activeStepId);
|
|
680
|
+
emit({
|
|
681
|
+
type: "plan_updated",
|
|
682
|
+
conversationId: convId,
|
|
683
|
+
plan,
|
|
684
|
+
});
|
|
685
|
+
flushSnapshot();
|
|
686
|
+
const summary = planManager.describePlan(convId);
|
|
687
|
+
return {
|
|
688
|
+
content: [{ type: "text", text: `Plan updated successfully.\n\n${summary}` }],
|
|
689
|
+
details: { plan },
|
|
690
|
+
};
|
|
691
|
+
},
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
/** 为 bash 工具包装只读拦截。 */
|
|
695
|
+
function wrapBashToolWithPermission(tool, getPermission, getLang) {
|
|
696
|
+
return {
|
|
697
|
+
...tool,
|
|
698
|
+
execute: async (toolCallId, params, signal, onUpdate, ctx) => {
|
|
699
|
+
const perm = getPermission();
|
|
700
|
+
if (perm === "read-only") {
|
|
701
|
+
return {
|
|
702
|
+
content: [
|
|
703
|
+
{
|
|
704
|
+
type: "text",
|
|
705
|
+
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."),
|
|
706
|
+
},
|
|
707
|
+
],
|
|
708
|
+
isError: true,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
return tool.execute(toolCallId, params, signal, onUpdate, ctx);
|
|
712
|
+
},
|
|
713
|
+
};
|
|
714
|
+
}
|
|
250
715
|
/**
|
|
251
716
|
* 任务列表只读查询工具(todo_list)— 读操作仍走真工具。
|
|
252
717
|
*/
|
|
@@ -254,12 +719,12 @@ function makeMarkersListTool(getActiveId, markerSvc) {
|
|
|
254
719
|
return {
|
|
255
720
|
name: MARKERS_LIST_TOOL_NAME,
|
|
256
721
|
label: "List marker state",
|
|
257
|
-
description: "Read-only query of inline marker state. All WRITE operations must use inline markers ([[todo:new:...]] etc.) in the reply body — never
|
|
722
|
+
description: "Read-only query of inline marker state. All WRITE operations must use inline markers ([[todo:new:...]] etc.) in the reply body — never this tool.",
|
|
258
723
|
parameters: Type.Object({
|
|
259
724
|
action: Type.Unsafe({ enum: ["list"] }),
|
|
260
725
|
tool: Type.Optional(Type.Literal("todo")),
|
|
261
726
|
includeDeleted: Type.Optional(Type.Boolean({
|
|
262
|
-
description: "
|
|
727
|
+
description: "Include deleted tasks (tombstones, todo only).",
|
|
263
728
|
})),
|
|
264
729
|
}),
|
|
265
730
|
execute: async (_id, params) => {
|
|
@@ -292,37 +757,64 @@ export function makeAskUserQuestionTool(clientSession,
|
|
|
292
757
|
/** 本 runtime 所属会话:提问跟着对话走,快照只把当前对话的问卷推给客户端。 */
|
|
293
758
|
ownerId) {
|
|
294
759
|
const QuestionOptionSchema = Type.Object({
|
|
295
|
-
label: Type.String({ description: "Display label for the option" }),
|
|
296
|
-
description: Type.Optional(Type.String({
|
|
760
|
+
label: Type.String({ description: "Display label for the option (1-5 words)" }),
|
|
761
|
+
description: Type.Optional(Type.String({
|
|
762
|
+
description: "One short sentence explaining the impact or tradeoff if selected.",
|
|
763
|
+
})),
|
|
297
764
|
preview: Type.Optional(Type.String({
|
|
298
765
|
description: "Optional preview rendered below when this option is selected (markdown or HTML — use for mockups/code/config).",
|
|
299
766
|
})),
|
|
300
767
|
});
|
|
301
768
|
const QuestionSchema = Type.Object({
|
|
302
|
-
id: Type.String({ description: "Unique identifier for this question" }),
|
|
769
|
+
id: Type.String({ description: "Unique identifier for this question (snake_case)" }),
|
|
303
770
|
question: Type.String({ description: "The full question text to display (markdown/HTML ok)" }),
|
|
304
771
|
detail: Type.Optional(Type.String({ description: "Optional detail/context shown under the question" })),
|
|
305
772
|
header: Type.Optional(Type.String({ description: "Optional short header for this question" })),
|
|
306
|
-
options: Type.Optional(Type.Array(QuestionOptionSchema, {
|
|
773
|
+
options: Type.Optional(Type.Array(QuestionOptionSchema, {
|
|
774
|
+
description: "2-4 mutually exclusive choices. Put the recommended option first when there is a clear default.",
|
|
775
|
+
minItems: 2,
|
|
776
|
+
maxItems: 4,
|
|
777
|
+
})),
|
|
307
778
|
multiSelect: Type.Optional(Type.Boolean({ description: "Allow selecting multiple options (default: false)" })),
|
|
779
|
+
dependsOn: Type.Optional(Type.Object({
|
|
780
|
+
questionId: Type.String({ description: "ID of the prior question this question depends on" }),
|
|
781
|
+
value: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
782
|
+
description: "Show only when the prior answer equals this value (or is in the array). Omit to show whenever answered.",
|
|
783
|
+
})),
|
|
784
|
+
})),
|
|
785
|
+
optionsMap: Type.Optional(Type.Record(Type.String(), Type.Array(QuestionOptionSchema), {
|
|
786
|
+
description: "Dynamic options based on prior question's chosen value: e.g. { 'React': [opts...], 'Vue': [opts...] }",
|
|
787
|
+
})),
|
|
308
788
|
});
|
|
309
789
|
return {
|
|
310
790
|
name: "ask_user_question",
|
|
311
791
|
label: "Ask the user",
|
|
312
|
-
description: "Ask the user focused questions to
|
|
313
|
-
|
|
792
|
+
description: "Ask the user focused questions to clarify ambiguous requirements (clarify the task, confirm decisions, get preferences). " +
|
|
793
|
+
"Strictly ask 1 to 3 questions per call (prefer 1, max 3); provide 2 to 4 mutually exclusive options with the " +
|
|
794
|
+
"recommended option first, and explain impact/tradeoff in each option description. " +
|
|
795
|
+
"Each question renders a browser dialog with markdown/HTML rich text; options may carry a `preview`. Submit or cancel to resume.",
|
|
796
|
+
promptSnippet: "ask the user 1-3 focused questions with recommended options and tradeoffs to clarify requirements",
|
|
314
797
|
promptGuidelines: [
|
|
315
|
-
|
|
316
|
-
|
|
798
|
+
"When requirements are ambiguous, use ask_user_question to clarify instead of guessing: " +
|
|
799
|
+
"ask 1 to 3 focused questions (prefer 1, max 3), provide 2-4 mutually exclusive options with the " +
|
|
800
|
+
"recommended option first, and explain impact/tradeoff in description",
|
|
801
|
+
"A cancelled question comes back as a tool error — respect it and continue without re-asking immediately",
|
|
317
802
|
],
|
|
318
803
|
parameters: Type.Object({
|
|
319
|
-
questions: Type.Array(QuestionSchema, {
|
|
804
|
+
questions: Type.Array(QuestionSchema, {
|
|
805
|
+
description: "Questions to ask the user (strictly 1 to 3 questions; prefer 1).",
|
|
806
|
+
minItems: 1,
|
|
807
|
+
maxItems: 3,
|
|
808
|
+
}),
|
|
320
809
|
}),
|
|
321
810
|
execute: async (_id, params, signal) => {
|
|
322
811
|
const qs = params.questions;
|
|
323
812
|
if (!Array.isArray(qs) || qs.length === 0) {
|
|
324
813
|
throw new Error("ask_user_question requires at least one question");
|
|
325
814
|
}
|
|
815
|
+
if (qs.length > 3) {
|
|
816
|
+
throw new Error("ask_user_question allows at most 3 questions per call to prevent question fatigue (单次提问最多不得超过 3 个问题)");
|
|
817
|
+
}
|
|
326
818
|
const answers = await clientSession.askUser(qs, {
|
|
327
819
|
aborted: signal?.aborted,
|
|
328
820
|
}, ownerId);
|
|
@@ -425,8 +917,8 @@ ownerId) {
|
|
|
425
917
|
name: BROWSER_PAGE_TOOL_NAME,
|
|
426
918
|
label: "Browser page",
|
|
427
919
|
description: [
|
|
428
|
-
'Read or act on a page in the USER\'S OWN browser
|
|
429
|
-
"ops (forwarded to the extension as-is
|
|
920
|
+
'Read or act on a page in the USER\'S OWN browser via the pi-web-ui page-picker extension (the server only forwards). Only pages the user explicitly allowed/paired can be touched. Start with op:"pages" to list available pages, and use ONLY when the user asked you to read or operate a page — never click/type on their pages unprompted.',
|
|
921
|
+
"ops (forwarded to the extension as-is):",
|
|
430
922
|
" pages — no args; lists the pages you may act on",
|
|
431
923
|
' read — { what?: "text" | "html" | "title" | "url" | "query", selector?, all? }',
|
|
432
924
|
" click — { selector, index? }",
|
|
@@ -437,10 +929,12 @@ ownerId) {
|
|
|
437
929
|
" eval — { code } runs JS inside the page (extension-side switch, off by default)",
|
|
438
930
|
"Op options that are not fields of this tool (e.g. read's `limit`) fall back to the extension's defaults. `target` selects the page by origin when more than one is allowed; `timeoutMs` is how long the SERVER waits for the browser (1000-120000, default 30000) before failing the call.",
|
|
439
931
|
].join("\n"),
|
|
440
|
-
promptSnippet:
|
|
932
|
+
promptSnippet: "read or operate a page in the user's browser (page-picker extension; allowed pages only)",
|
|
441
933
|
promptGuidelines: [
|
|
442
|
-
|
|
443
|
-
|
|
934
|
+
"Only use browser_page when the user asked you to read or act on a page in their browser; " +
|
|
935
|
+
"never click or type on their pages on your own initiative",
|
|
936
|
+
'Start with op:"pages" to see which pages are available; ' +
|
|
937
|
+
"the target page must already be allowed in the page-picker extension — when it fails, tell the user what to enable instead of retrying blindly",
|
|
444
938
|
],
|
|
445
939
|
parameters: Type.Object({
|
|
446
940
|
op: Type.String({
|
|
@@ -674,6 +1168,33 @@ function previewToolResult(result) {
|
|
|
674
1168
|
return "[unserializable result]";
|
|
675
1169
|
}
|
|
676
1170
|
}
|
|
1171
|
+
/**
|
|
1172
|
+
* 转录整文件重写的原子落盘:先写同目录临时文件再 rename。转录是 append-only
|
|
1173
|
+
* 的唯一事实源,compaction 收尾/清理的整文件重写若中途被打断(进程被杀/断电),
|
|
1174
|
+
* 原地 writeFileSync 会留下截断的 JSONL;同目录 rename 是原子的,最坏情况是
|
|
1175
|
+
* 旧文件原样保留,绝不会出现半份转录。Windows 下目标被外部占用(阅读器/杀软
|
|
1176
|
+
* 锁)时 rename 可能 EPERM —— 退回原地写保住功能(旧行为),不因小概率锁失败丢更新。
|
|
1177
|
+
*/
|
|
1178
|
+
function atomicWriteFileSync(file, data) {
|
|
1179
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
1180
|
+
try {
|
|
1181
|
+
writeFileSync(tmp, data);
|
|
1182
|
+
try {
|
|
1183
|
+
renameSync(tmp, file);
|
|
1184
|
+
}
|
|
1185
|
+
catch {
|
|
1186
|
+
writeFileSync(file, data);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
finally {
|
|
1190
|
+
try {
|
|
1191
|
+
rmSync(tmp, { force: true });
|
|
1192
|
+
}
|
|
1193
|
+
catch {
|
|
1194
|
+
// 临时文件清不掉只能留给下次(同目录 .tmp,不影响转录本身)
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
677
1198
|
/** Cap on simultaneously open NON-subagent conversations of ONE project (each keeps a full
|
|
678
1199
|
* runtime alive; conversations of other projects keep their own lists).
|
|
679
1200
|
* 子代理不计入:子代理是 inMemory 后台任务,不参与此上限,既不占位也不被此上限拦截。 */
|
|
@@ -759,13 +1280,20 @@ function messageSearchText(m) {
|
|
|
759
1280
|
}
|
|
760
1281
|
return parts.join("\n");
|
|
761
1282
|
}
|
|
1283
|
+
/** 转录全文扫描/整读的大小上限(16MB,与 readHistorySession 一致):
|
|
1284
|
+
* 超限文件同步 readFileSync 会卡事件循环数百毫秒起,搜索不值得。 */
|
|
1285
|
+
const MAX_TRANSCRIPT_SCAN_BYTES = 16 * 1024 * 1024;
|
|
762
1286
|
/** 扫描一个会话转录文件,收集文本命中查询的消息锚点(role + timestamp,
|
|
763
|
-
* 按转录顺序,最多 cap 个)。仅 user/assistant 消息参与,与搜索范围一致。
|
|
1287
|
+
* 按转录顺序,最多 cap 个)。仅 user/assistant 消息参与,与搜索范围一致。
|
|
1288
|
+
* 超过大小上限的转录直接跳过(stat 先行,不整读)—— 全局搜索逐会话同步
|
|
1289
|
+
* 扫描,无上限时一个大转录就能冻住整个事件循环。 */
|
|
764
1290
|
function collectSessionAnchors(filePath, q, cap = 10) {
|
|
765
1291
|
const anchors = [];
|
|
766
1292
|
if (!q)
|
|
767
1293
|
return anchors;
|
|
768
1294
|
try {
|
|
1295
|
+
if (statSync(filePath).size > MAX_TRANSCRIPT_SCAN_BYTES)
|
|
1296
|
+
return anchors;
|
|
769
1297
|
const lines = readFileSync(filePath, "utf8").split("\n");
|
|
770
1298
|
for (const line of lines) {
|
|
771
1299
|
if (!line.trim())
|
|
@@ -890,6 +1418,35 @@ export function sameCwd(a, b) {
|
|
|
890
1418
|
return false;
|
|
891
1419
|
}
|
|
892
1420
|
}
|
|
1421
|
+
/**
|
|
1422
|
+
* 超时杀进程树的决策(纯函数,可单测)。runAsync 的超时兜底用:
|
|
1423
|
+
* - win32:shell:true 实际起的是 cmd.exe,p.kill() 只杀 cmd 本身,npm/git 的
|
|
1424
|
+
* 孙进程照活 —— 必须走 `taskkill /PID <pid> /T /F` 才能整树带走;
|
|
1425
|
+
* - posix:配合 spawn 的 detached:true,向负 pid(整组)发 SIGTERM。
|
|
1426
|
+
* 返回决策而不是直接执行,执行留在调用方(便于注入/测试)。
|
|
1427
|
+
*/
|
|
1428
|
+
export function processTreeKillPlan(platform, pid) {
|
|
1429
|
+
if (!pid || pid <= 0)
|
|
1430
|
+
return { kind: "none" };
|
|
1431
|
+
if (platform === "win32")
|
|
1432
|
+
return { kind: "taskkill", cmd: "taskkill", args: ["/PID", String(pid), "/T", "/F"] };
|
|
1433
|
+
return { kind: "group-signal", signal: "SIGTERM" };
|
|
1434
|
+
}
|
|
1435
|
+
/**
|
|
1436
|
+
* /cwd 目标解析(纯函数,可单测):
|
|
1437
|
+
* - 相对路径以**当前会话 cwd** 为基准(/cwd src = 当前项目的 src),不按 server
|
|
1438
|
+
* 进程 cwd 解析 —— 两者经常不同,按进程 cwd 切必错位。绝对路径不受基准影响
|
|
1439
|
+
* (resolve 遇到绝对段会丢弃前面的基准)。
|
|
1440
|
+
* - win32 裸盘符("C:"):resolve 会按该盘当前目录解析,必须显式指到盘根;仅
|
|
1441
|
+
* win32 生效——posix 下 "C:" 仍是普通相对路径,避免误伤同名目录。
|
|
1442
|
+
*/
|
|
1443
|
+
export function resolveCwdTarget(raw, sessionCwd, platform = process.platform) {
|
|
1444
|
+
const trimmed = String(raw ?? "").trim();
|
|
1445
|
+
if (platform === "win32" && /^[A-Za-z]:$/.test(trimmed)) {
|
|
1446
|
+
return `${trimmed.toUpperCase()}\\`;
|
|
1447
|
+
}
|
|
1448
|
+
return resolve(sessionCwd, trimmed);
|
|
1449
|
+
}
|
|
893
1450
|
export class ClientSession {
|
|
894
1451
|
clientId;
|
|
895
1452
|
/** Set by AgentService.attach: reflects the SERVICE-wide quiesce flag
|
|
@@ -940,7 +1497,7 @@ export class ClientSession {
|
|
|
940
1497
|
emit: (msg) => this.emit(msg),
|
|
941
1498
|
isDisposed: () => this.disposed,
|
|
942
1499
|
getCwd: () => this.cwd,
|
|
943
|
-
getActiveCwd: () => this.
|
|
1500
|
+
getActiveCwd: () => this.convs.get(this.activeId)?.cwd ?? this.cwd,
|
|
944
1501
|
// issue #91:文件服务错误文案按客户端 UI 语言出中英(英文默认)。
|
|
945
1502
|
getLang: () => this.getLang(),
|
|
946
1503
|
});
|
|
@@ -954,6 +1511,9 @@ export class ClientSession {
|
|
|
954
1511
|
/** index.ts 注入(经 AgentService 拷贝到每个新会话):把 SDK 工具执行事件转发给
|
|
955
1512
|
* 插件(PluginManager.emitToolEvent)。未设置时不做任何事。 */
|
|
956
1513
|
onToolEvent = undefined;
|
|
1514
|
+
/** index.ts 注入(P1-5,经 AgentService 拷贝到每个新会话):bash/read 执行前后的
|
|
1515
|
+
* 插件拦截(PluginManager.evaluateToolPre/evaluateToolPost)。未设置时直通。 */
|
|
1516
|
+
toolGuard = undefined;
|
|
957
1517
|
/** index.ts 注入:把运行轨迹事件转发给插件(PluginManager.emitRunEvent,
|
|
958
1518
|
* 轨迹视图插件靠它聚合时间线)。未设置时不做任何事。 */
|
|
959
1519
|
onRunEvent = undefined;
|
|
@@ -993,10 +1553,10 @@ export class ClientSession {
|
|
|
993
1553
|
return this.conv.terminals;
|
|
994
1554
|
}
|
|
995
1555
|
getTerminalManager(conversationId) {
|
|
996
|
-
return (conversationId ? this.convs.get(conversationId) : this.
|
|
1556
|
+
return (conversationId ? this.convs.get(conversationId) : this.convs.get(this.activeId))?.terminals;
|
|
997
1557
|
}
|
|
998
1558
|
getTerminalCwd(conversationId) {
|
|
999
|
-
return (conversationId ? this.convs.get(conversationId) : this.
|
|
1559
|
+
return (conversationId ? this.convs.get(conversationId) : this.convs.get(this.activeId))?.cwd ?? this.cwd;
|
|
1000
1560
|
}
|
|
1001
1561
|
makeTerminalManager(conversationId, cwd) {
|
|
1002
1562
|
const mgr = new TerminalManager((msg) => this.emitTerminal(conversationId, msg), cwd,
|
|
@@ -1077,7 +1637,7 @@ export class ClientSession {
|
|
|
1077
1637
|
if (persist) {
|
|
1078
1638
|
const baseCwdForLimit = parentId ? (this.convs.get(parentId)?.cwd ?? this.cwd) : this.cwd;
|
|
1079
1639
|
const resolvedCwdForLimit = cwd ? resolve(baseCwdForLimit, cwd) : baseCwdForLimit;
|
|
1080
|
-
const openInProject = [...this.convs.values()].filter((c) => c.cwd === resolvedCwdForLimit && !c.isSubagent).length;
|
|
1640
|
+
const openInProject = [...this.convs.values()].filter((c) => c.cwd === resolvedCwdForLimit && !c.isSubagent && !c.isEphemeral).length;
|
|
1081
1641
|
if (openInProject >= MAX_OPEN_CONVERSATIONS) {
|
|
1082
1642
|
throw new Error(pick(this.getLang(), `目标项目运行的普通对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),无法创建持久化对话。请先关闭不需要的对话,或以轻量子代理(persist=false)方式运行`, `The project already has max open regular conversations (${MAX_OPEN_CONVERSATIONS}). Please close some or run as a lightweight subagent (persist=false)`, "agent.conv.limit.reached", { limit: MAX_OPEN_CONVERSATIONS }));
|
|
1083
1643
|
}
|
|
@@ -1099,6 +1659,16 @@ export class ClientSession {
|
|
|
1099
1659
|
const conversationId = persist ? `conv-${randomUUID().slice(0, 8)}` : `sa-${randomUUID().slice(0, 8)}`;
|
|
1100
1660
|
const terminals = this.makeTerminalManager(conversationId, resolvedCwd);
|
|
1101
1661
|
const sessionManager = persist ? SessionManager.create(resolvedCwd) : SessionManager.inMemory(resolvedCwd);
|
|
1662
|
+
if (!persist) {
|
|
1663
|
+
// 为内存子代理提供隔离的临时运行目录(供 SoL-Pi 等依赖 getSessionDir 的扩展正常放置缓存),
|
|
1664
|
+
// 但保持 persist = false(不写 .jsonl 对话文件、不污染历史记录)
|
|
1665
|
+
const ephemeralDir = join(this.agentDir, "subagent-sessions", conversationId);
|
|
1666
|
+
try {
|
|
1667
|
+
mkdirSync(ephemeralDir, { recursive: true });
|
|
1668
|
+
sessionManager.sessionDir = ephemeralDir;
|
|
1669
|
+
}
|
|
1670
|
+
catch { }
|
|
1671
|
+
}
|
|
1102
1672
|
const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, apply, conversationId), {
|
|
1103
1673
|
cwd: resolvedCwd,
|
|
1104
1674
|
agentDir: this.agentDir,
|
|
@@ -1112,6 +1682,9 @@ export class ClientSession {
|
|
|
1112
1682
|
conv.parentId = parentId ?? this.activeId ?? undefined;
|
|
1113
1683
|
conv.subagentType = type;
|
|
1114
1684
|
conv.subagentPrompt = prompt;
|
|
1685
|
+
// 模板快照留在对话上:forceReset 重建 runtime 时工厂要按同一模板组装
|
|
1686
|
+
// (system prompt/技能/扩展白名单),否则重建后回落主会话设置。
|
|
1687
|
+
conv.subagentTemplate = apply;
|
|
1115
1688
|
// 插件工具门与模板扩展白名单对齐:白名单非空时插件/MCP 工具(无 SDK
|
|
1116
1689
|
// extensionKey 身份)不进该会话。工厂期 customTools 不注册 + 下面的
|
|
1117
1690
|
// syncPluginTools 不回补,模板热改不影响已运行的子代理(与 prompt/技能一致)。
|
|
@@ -1136,7 +1709,10 @@ export class ClientSession {
|
|
|
1136
1709
|
// 局部 mock 缺失而崩),但它是 headless 的:UI 输出全部丢弃、弹窗按取消返回,
|
|
1137
1710
|
// 因此既不与主对话的 widget/status 串台,也不会让扩展卡在永远无人应答的弹窗上。
|
|
1138
1711
|
uiContext: WebUIContext.headless(),
|
|
1139
|
-
onError:
|
|
1712
|
+
onError: this.makeExtensionErrorReporter({
|
|
1713
|
+
text: `子代理 ${conversationId}:`,
|
|
1714
|
+
textEn: `Subagent ${conversationId}: `,
|
|
1715
|
+
}),
|
|
1140
1716
|
});
|
|
1141
1717
|
}
|
|
1142
1718
|
catch {
|
|
@@ -1268,6 +1844,8 @@ export class ClientSession {
|
|
|
1268
1844
|
output: conv.session.getLastAssistantText() ?? "",
|
|
1269
1845
|
parentId: conv.parentId,
|
|
1270
1846
|
persisted: isPersisted,
|
|
1847
|
+
handoffTo: conv.peerHandoffTo ? [...conv.peerHandoffTo] : undefined,
|
|
1848
|
+
handoffFrom: conv.peerHandoffFrom ? [...conv.peerHandoffFrom] : undefined,
|
|
1271
1849
|
};
|
|
1272
1850
|
}
|
|
1273
1851
|
/** 子代理最近一次运行的结局:最后一条 assistant 消息的 errorMessage / stopReason。
|
|
@@ -1338,9 +1916,12 @@ export class ClientSession {
|
|
|
1338
1916
|
lastSdkAppendFiles = [];
|
|
1339
1917
|
/** 当前活动会话的工具/资源快照 → composer 输入。cwd 取活动对话的。 */
|
|
1340
1918
|
composeInputs(src) {
|
|
1919
|
+
const preset = src.preset ?? this.conv?.agentPreset ?? this.settingsSvc.current.defaultAgentPreset ?? "standard";
|
|
1341
1920
|
// 技能名录指纹观测(只打日志,不干预组装;预览与 run 共用此入口,
|
|
1342
1921
|
// 变化才记一行,首轮静默)。用未注文的目录(fill 前),全文注入不影响指纹。
|
|
1343
1922
|
this.noteSkillCatalogDigest(src.skills);
|
|
1923
|
+
const showSkills = presetShowsSkillCatalog(preset);
|
|
1924
|
+
const effectiveSkills = showSkills ? this.fillSkillContents(src.skills) : [];
|
|
1344
1925
|
return {
|
|
1345
1926
|
cwd: src.cwd,
|
|
1346
1927
|
systemPromptFile: this.lastBaseSystemPrompt || undefined,
|
|
@@ -1353,15 +1934,15 @@ export class ClientSession {
|
|
|
1353
1934
|
piExamples: PI_DOC_PATHS.examples,
|
|
1354
1935
|
appendFiles: this.lastSdkAppendFiles,
|
|
1355
1936
|
windowsPersona: process.platform === "win32" ? WINDOWS_PERSONA : "",
|
|
1356
|
-
terminalGuidance: isTerminalGuidanceOn(effectiveDisabledAgentTools(this.settingsSvc.current))
|
|
1937
|
+
terminalGuidance: isTerminalGuidanceOn(effectiveDisabledAgentTools(this.settingsSvc.current), preset)
|
|
1357
1938
|
? TERMINAL_TOOLS_GUIDANCE
|
|
1358
1939
|
: "",
|
|
1359
1940
|
markersGuidance: this.markerSvc.buildGuidance(),
|
|
1360
1941
|
// issue #91:组合模板各来源段按客户端 UI 语言渲染(英文默认)。
|
|
1361
1942
|
lang: this.getLang(),
|
|
1362
1943
|
contextFiles: src.contextFiles,
|
|
1363
|
-
skills:
|
|
1364
|
-
skillsFullText: normalizeSkillList(this.settingsSvc.current.skillsFullText),
|
|
1944
|
+
skills: effectiveSkills,
|
|
1945
|
+
skillsFullText: showSkills ? normalizeSkillList(this.settingsSvc.current.skillsFullText) : [],
|
|
1365
1946
|
};
|
|
1366
1947
|
}
|
|
1367
1948
|
/** skill 全文注入({{skills}} 全文模式):最好努力读名单里技能的文件正文。
|
|
@@ -1388,25 +1969,31 @@ export class ClientSession {
|
|
|
1388
1969
|
}
|
|
1389
1970
|
});
|
|
1390
1971
|
}
|
|
1391
|
-
/**
|
|
1392
|
-
*
|
|
1972
|
+
/** 渲染当前组合模板。当存在自定义模板/覆盖,或者当前会话预设非 standard(如 code/minimal/ask/reader),
|
|
1973
|
+
* 或者存在被禁用的工具时,必须渲染完整系统提示词,保证工具门控、技能隐藏与 Guidelines 严格对齐当前预设;
|
|
1974
|
+
* 仅在完全默认且全功能 standard 状态下返回 undefined 让 SDK 拼装。 */
|
|
1393
1975
|
renderMainCompose(src) {
|
|
1394
1976
|
const tpl = (this.settingsSvc.current.promptTemplate ?? "").trim();
|
|
1395
1977
|
const ovs = this.settingsSvc.current.promptOverrides ?? {};
|
|
1396
1978
|
const hasOverride = Object.values(ovs).some((v) => typeof v === "string" && v.trim());
|
|
1397
|
-
|
|
1979
|
+
const preset = src.preset ?? this.conv?.agentPreset ?? this.settingsSvc.current.defaultAgentPreset ?? "standard";
|
|
1980
|
+
const isCustomized = !!tpl || hasOverride || preset !== "standard" || effectiveDisabledAgentTools(this.settingsSvc.current).length > 0;
|
|
1981
|
+
if (!isCustomized)
|
|
1398
1982
|
return undefined;
|
|
1399
1983
|
const texts = resolveSectionTexts(this.composeInputs(src));
|
|
1400
|
-
return renderPromptTemplate(tpl || DEFAULT_PROMPT_TEMPLATE, texts, ovs);
|
|
1984
|
+
return renderPromptTemplate(tpl || DEFAULT_PROMPT_TEMPLATE, texts, hasOverride ? ovs : undefined);
|
|
1401
1985
|
}
|
|
1402
|
-
/**
|
|
1403
|
-
*
|
|
1404
|
-
|
|
1986
|
+
/** 从指定会话(缺省 = 活跃会话)收集工具/资源快照 → 一次算出 ①各来源默认(自动)
|
|
1987
|
+
* 内容 ②实际生效的完整提示词。会话未就绪(或出错)返回 undefined,调用方给空值。
|
|
1988
|
+
* 注意必须传目标 conv:preset/工具 schema 都是按会话走的,拿活跃会话的快照
|
|
1989
|
+
* 算后台会话的基线会串账(lastTurnBaseTokens 跨会话污染)。 */
|
|
1990
|
+
sessionPromptSnapshot(target) {
|
|
1405
1991
|
try {
|
|
1406
|
-
const
|
|
1407
|
-
if (!
|
|
1992
|
+
const conv = target ?? this.convs.get(this.activeId);
|
|
1993
|
+
if (!conv)
|
|
1408
1994
|
return undefined;
|
|
1409
|
-
const
|
|
1995
|
+
const sess = conv.session;
|
|
1996
|
+
const cwd = conv.cwd;
|
|
1410
1997
|
const active = sess.getActiveToolNames();
|
|
1411
1998
|
const snippets = {};
|
|
1412
1999
|
const guidelines = [];
|
|
@@ -1426,6 +2013,7 @@ export class ClientSession {
|
|
|
1426
2013
|
});
|
|
1427
2014
|
}
|
|
1428
2015
|
const loader = sess.resourceLoader;
|
|
2016
|
+
const preset = conv?.agentPreset ?? this.settingsSvc.current.defaultAgentPreset ?? "standard";
|
|
1429
2017
|
const texts = resolveSectionTexts(this.composeInputs({
|
|
1430
2018
|
cwd,
|
|
1431
2019
|
selectedTools: active,
|
|
@@ -1437,13 +2025,14 @@ export class ClientSession {
|
|
|
1437
2025
|
description: s.description ?? "",
|
|
1438
2026
|
filePath: s.filePath ?? "",
|
|
1439
2027
|
})),
|
|
2028
|
+
preset,
|
|
1440
2029
|
}));
|
|
1441
|
-
//
|
|
2030
|
+
// 模板/覆盖渲染(无自定义模板时用默认模板渲染完整提示词,确保与真实 run 规则一致且包含预设过滤)。
|
|
1442
2031
|
const tpl = (this.settingsSvc.current.promptTemplate ?? "").trim();
|
|
1443
2032
|
const ovs = this.settingsSvc.current.promptOverrides ?? {};
|
|
1444
2033
|
const hasOverride = Object.values(ovs).some((v) => typeof v === "string" && v.trim());
|
|
1445
|
-
const rendered =
|
|
1446
|
-
return { texts, full: rendered
|
|
2034
|
+
const rendered = renderPromptTemplate(tpl || DEFAULT_PROMPT_TEMPLATE, texts, hasOverride ? ovs : undefined);
|
|
2035
|
+
return { texts, full: rendered, toolsSchema: buildToolsSchemaText(schemaEntries) };
|
|
1447
2036
|
}
|
|
1448
2037
|
catch {
|
|
1449
2038
|
// Session not ready yet.
|
|
@@ -1455,6 +2044,30 @@ export class ClientSession {
|
|
|
1455
2044
|
promptSnapshot() {
|
|
1456
2045
|
return this.sessionPromptSnapshot() ?? { full: "", texts: {}, toolsSchema: "" };
|
|
1457
2046
|
}
|
|
2047
|
+
/** 会话级 Base Tokens 缓存(避免在节流快照热路径上重复计算正则)。 */
|
|
2048
|
+
cachedBaseTokens = null;
|
|
2049
|
+
/** 指定会话(缺省 = 活跃会话)系统提示词 + 工具 schema 的基础 token 开销
|
|
2050
|
+
* (与设置面板中的「合计」完全一致)。会话未就绪返回 null——调用方必须区分
|
|
2051
|
+
* null 与 0:把 0 写进 lastTurnBaseTokens 会让后续轮次双计全额 base。 */
|
|
2052
|
+
currentBaseTokens(target) {
|
|
2053
|
+
const conv = target ?? this.convs.get(this.activeId);
|
|
2054
|
+
if (!conv)
|
|
2055
|
+
return null;
|
|
2056
|
+
const now = Date.now();
|
|
2057
|
+
if (this.cachedBaseTokens &&
|
|
2058
|
+
this.cachedBaseTokens.convId === conv.id &&
|
|
2059
|
+
now - this.cachedBaseTokens.at < STATS_CACHE_MS) {
|
|
2060
|
+
return this.cachedBaseTokens.tokens;
|
|
2061
|
+
}
|
|
2062
|
+
const snap = this.sessionPromptSnapshot(conv);
|
|
2063
|
+
if (!snap)
|
|
2064
|
+
return null;
|
|
2065
|
+
const prompt = estimatePromptTokens(snap.full);
|
|
2066
|
+
const schema = estimatePromptTokens(snap.toolsSchema);
|
|
2067
|
+
const tokens = prompt + schema;
|
|
2068
|
+
this.cachedBaseTokens = { at: now, convId: conv.id, tokens };
|
|
2069
|
+
return tokens;
|
|
2070
|
+
}
|
|
1458
2071
|
/** Web-facing extension UI context (widgets, notifications). */
|
|
1459
2072
|
webUi = new WebUIContext((msg) => this.emit(msg));
|
|
1460
2073
|
/**
|
|
@@ -1463,6 +2076,96 @@ export class ClientSession {
|
|
|
1463
2076
|
* 历史/resume 列表)、listed=true 出现在左栏「运行的对话」并向用户可见——
|
|
1464
2077
|
* 切换查看 / 输入补充(steer)/ 中止(abort)/ 移出全部复用现有对话机制。
|
|
1465
2078
|
*/
|
|
2079
|
+
subagentHandoffs = [];
|
|
2080
|
+
/**
|
|
2081
|
+
* 多智能体同行协作与直接交接(Peer-to-Peer Subagents & Hand-off):
|
|
2082
|
+
* 将任务产物、分析结果或后续指令直接从一个子代理路由至另一个同行子代理,
|
|
2083
|
+
* 无需主会话反复充当传声筒消耗双倍 token。
|
|
2084
|
+
*/
|
|
2085
|
+
async handoffSubagent(fromRunId, toRunId, payload) {
|
|
2086
|
+
if (fromRunId === toRunId) {
|
|
2087
|
+
throw new Error("Cannot hand off to oneself");
|
|
2088
|
+
}
|
|
2089
|
+
const toConv = this.convs.get(toRunId);
|
|
2090
|
+
if (!toConv) {
|
|
2091
|
+
throw new Error(`Target subagent ${toRunId} not found`);
|
|
2092
|
+
}
|
|
2093
|
+
const fromConv = this.convs.get(fromRunId);
|
|
2094
|
+
const fromType = fromConv?.subagentType ?? "peer";
|
|
2095
|
+
const toType = toConv.subagentType ?? "peer";
|
|
2096
|
+
const handoffMessage = `[Peer Hand-off from ${fromType} subagent (${fromRunId.slice(0, 8)})]:\n\n${payload}`;
|
|
2097
|
+
const record = { fromRunId, toRunId, timestamp: Date.now() };
|
|
2098
|
+
this.subagentHandoffs.push(record);
|
|
2099
|
+
if (!fromConv?.peerHandoffTo) {
|
|
2100
|
+
if (fromConv)
|
|
2101
|
+
fromConv.peerHandoffTo = [];
|
|
2102
|
+
}
|
|
2103
|
+
fromConv?.peerHandoffTo?.push(toRunId);
|
|
2104
|
+
if (!toConv.peerHandoffFrom)
|
|
2105
|
+
toConv.peerHandoffFrom = [];
|
|
2106
|
+
toConv.peerHandoffFrom.push(fromRunId);
|
|
2107
|
+
// 向目标子代理注入对等交接消息
|
|
2108
|
+
await toConv.session.sendUserMessage(handoffMessage, toConv.session.isStreaming ? { deliverAs: "steer" } : undefined);
|
|
2109
|
+
// 广播交接事件
|
|
2110
|
+
this.emit({
|
|
2111
|
+
type: "subagent_handoff",
|
|
2112
|
+
fromRunId,
|
|
2113
|
+
toRunId,
|
|
2114
|
+
payload,
|
|
2115
|
+
timestamp: record.timestamp,
|
|
2116
|
+
});
|
|
2117
|
+
// 向主会话推送通知
|
|
2118
|
+
this.emit({
|
|
2119
|
+
type: "notice",
|
|
2120
|
+
level: "info",
|
|
2121
|
+
text: `子代理 ${fromRunId.slice(0, 8)}(${fromType})已向同行子代理 ${toRunId.slice(0, 8)}(${toType})直接交接`,
|
|
2122
|
+
textEn: `Subagent ${fromRunId.slice(0, 8)} (${fromType}) handed off directly to ${toRunId.slice(0, 8)} (${toType})`,
|
|
2123
|
+
});
|
|
2124
|
+
this.emitConversations();
|
|
2125
|
+
this.flushSnapshot();
|
|
2126
|
+
}
|
|
2127
|
+
/**
|
|
2128
|
+
* 多级分层上下文预算裁剪(Hierarchical Context Budgeting):
|
|
2129
|
+
* 当上下文达到预警水位(例如 70%)时,自动执行第一级(远期工具输出裁剪)和
|
|
2130
|
+
* 第二级(已完成步骤折叠),推迟触发全量 LLM 压缩,保留近期关键代码的细节。
|
|
2131
|
+
*/
|
|
2132
|
+
applyContextBudgetPruning(conv) {
|
|
2133
|
+
if (!conv?.session)
|
|
2134
|
+
return;
|
|
2135
|
+
try {
|
|
2136
|
+
const contextWindow = contextWindowOf(conv.session);
|
|
2137
|
+
if (!contextWindow || contextWindow <= 0)
|
|
2138
|
+
return;
|
|
2139
|
+
const s = this.settingsSvc.current;
|
|
2140
|
+
const modelId = modelKeyOf(conv.session);
|
|
2141
|
+
const cap = effectiveSoftCap(s.softCapTokens, s.softCapByModel, modelId);
|
|
2142
|
+
const reserve = softCapToReserve(contextWindow, cap) ?? DEFAULT_COMPACTION_RESERVE_TOKENS;
|
|
2143
|
+
const messages = conv.session.agent.state.messages;
|
|
2144
|
+
if (!messages || messages.length === 0)
|
|
2145
|
+
return;
|
|
2146
|
+
const result = pruneContextHierarchically(messages, {
|
|
2147
|
+
contextWindow,
|
|
2148
|
+
reserveTokens: reserve,
|
|
2149
|
+
softCap: cap > 0 ? cap : null,
|
|
2150
|
+
});
|
|
2151
|
+
if (result.tier1.trimmedCount > 0 || result.tier2.foldedCount > 0) {
|
|
2152
|
+
conv.session.agent.state.messages = result.messages;
|
|
2153
|
+
const saved = result.tokensBefore - result.tokensAfter;
|
|
2154
|
+
this.emit({
|
|
2155
|
+
type: "notice",
|
|
2156
|
+
level: "info",
|
|
2157
|
+
text: `分层上下文预算裁剪生效:已释放约 ${saved.toLocaleString()} tokens(裁剪远期工具输出/折叠已完成步骤),推迟全量压缩`,
|
|
2158
|
+
textEn: `Hierarchical context pruning active: freed ~${saved.toLocaleString()} tokens (trimmed tool outputs / folded steps), deferring full compaction`,
|
|
2159
|
+
});
|
|
2160
|
+
if (conv.id === this.conv.id) {
|
|
2161
|
+
this.flushSnapshot();
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
catch {
|
|
2166
|
+
/* 预算裁剪尽力而为,不阻断主流程 */
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
1466
2169
|
subagentHost = {
|
|
1467
2170
|
spawnSubagent: (prompt, type, cwd, templateName, model, parentId, persist) => {
|
|
1468
2171
|
// 模板:存在且启用时应用;传了名字但不可用 → 抛错让工具转给 AI。
|
|
@@ -1475,6 +2178,9 @@ export class ClientSession {
|
|
|
1475
2178
|
},
|
|
1476
2179
|
getSubagent: (convId) => this.getSubagentSnapshot(convId),
|
|
1477
2180
|
listSubagents: (scope) => this.listSubagentSnapshots(scope),
|
|
2181
|
+
handoffSubagent: async (fromRunId, toRunId, payload) => {
|
|
2182
|
+
await this.handoffSubagent(fromRunId, toRunId, payload);
|
|
2183
|
+
},
|
|
1478
2184
|
steerSubagent: async (convId, message) => {
|
|
1479
2185
|
const conv = this.convs.get(convId);
|
|
1480
2186
|
if (!conv?.session)
|
|
@@ -1609,11 +2315,18 @@ export class ClientSession {
|
|
|
1609
2315
|
},
|
|
1610
2316
|
readHistorySession: async (path) => {
|
|
1611
2317
|
const all = await SessionManager.listAll(piSessionsRoot());
|
|
1612
|
-
|
|
2318
|
+
// win32 路径大小写不敏感:客户端回传的盘符/目录大小写常与服务端
|
|
2319
|
+
// 列表不一致,严格相等会把合法请求误判为“不在白名单”。大小写
|
|
2320
|
+
// 归一后再比(posix 不受影响)。
|
|
2321
|
+
const norm = (p) => {
|
|
2322
|
+
const r = resolve(p);
|
|
2323
|
+
return process.platform === "win32" ? r.toLowerCase() : r;
|
|
2324
|
+
};
|
|
2325
|
+
const hit = all.find((s) => norm(s.path) === norm(path));
|
|
1613
2326
|
if (!hit)
|
|
1614
2327
|
return undefined;
|
|
1615
2328
|
try {
|
|
1616
|
-
if (statSync(hit.path).size >
|
|
2329
|
+
if (statSync(hit.path).size > MAX_TRANSCRIPT_SCAN_BYTES)
|
|
1617
2330
|
return undefined;
|
|
1618
2331
|
const text = readFileSync(hit.path, "utf8");
|
|
1619
2332
|
return {
|
|
@@ -1651,6 +2364,121 @@ export class ClientSession {
|
|
|
1651
2364
|
store: () => this.getClaimStore?.(),
|
|
1652
2365
|
};
|
|
1653
2366
|
}
|
|
2367
|
+
/** compact_context 工具的数据宿主:提供消息统计用于预检,以及注册 pending 压缩请求。
|
|
2368
|
+
* ownerId 语义同 skill / claimFiles(本 runtime 所属会话,不是派发瞬间 active)。 */
|
|
2369
|
+
compactContextHost(ownerId) {
|
|
2370
|
+
const target = () => {
|
|
2371
|
+
try {
|
|
2372
|
+
return (ownerId ?? "").trim() !== "" ? this.convs.get(ownerId.trim()) : this.convs.get(this.activeId);
|
|
2373
|
+
}
|
|
2374
|
+
catch {
|
|
2375
|
+
return undefined;
|
|
2376
|
+
}
|
|
2377
|
+
};
|
|
2378
|
+
return {
|
|
2379
|
+
conversationId: () => target()?.id ?? this.activeId,
|
|
2380
|
+
getContextStats: () => {
|
|
2381
|
+
const conv = target();
|
|
2382
|
+
if (!conv)
|
|
2383
|
+
return { messageCount: 0, estimatedTokens: 0 };
|
|
2384
|
+
let messages = [];
|
|
2385
|
+
try {
|
|
2386
|
+
messages = conv.session.messages;
|
|
2387
|
+
}
|
|
2388
|
+
catch {
|
|
2389
|
+
messages = [];
|
|
2390
|
+
}
|
|
2391
|
+
let totalChars = 0;
|
|
2392
|
+
for (const m of messages) {
|
|
2393
|
+
if (m && typeof m === "object" && "content" in m) {
|
|
2394
|
+
const content = m.content;
|
|
2395
|
+
if (Array.isArray(content)) {
|
|
2396
|
+
for (const c of content) {
|
|
2397
|
+
if (c &&
|
|
2398
|
+
typeof c === "object" &&
|
|
2399
|
+
"type" in c &&
|
|
2400
|
+
c.type === "text" &&
|
|
2401
|
+
typeof c.text === "string") {
|
|
2402
|
+
totalChars += c.text.length;
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
else if (typeof content === "string") {
|
|
2407
|
+
totalChars += content.length;
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
return {
|
|
2412
|
+
messageCount: messages.length,
|
|
2413
|
+
estimatedTokens: Math.ceil(totalChars / 4),
|
|
2414
|
+
};
|
|
2415
|
+
},
|
|
2416
|
+
scheduleCompaction: (pending) => {
|
|
2417
|
+
const conv = target();
|
|
2418
|
+
if (conv) {
|
|
2419
|
+
conv.pendingCompaction = pending;
|
|
2420
|
+
}
|
|
2421
|
+
},
|
|
2422
|
+
};
|
|
2423
|
+
}
|
|
2424
|
+
/** 执行由 AI 调用 compact_context 安排的上下文压缩(在 agent_settled 阶段调用)。 */
|
|
2425
|
+
async executePendingCompaction(conv, pending) {
|
|
2426
|
+
const instructions = buildCompactionInstructions(pending.focus, pending.summary);
|
|
2427
|
+
const session = conv.session;
|
|
2428
|
+
const model = session.model;
|
|
2429
|
+
let originalKeepRecent;
|
|
2430
|
+
try {
|
|
2431
|
+
originalKeepRecent =
|
|
2432
|
+
session.settingsManager.getCompactionSettings(model)?.keepRecentTokens ?? session.settingsManager.getCompactionSettings().keepRecentTokens;
|
|
2433
|
+
}
|
|
2434
|
+
catch {
|
|
2435
|
+
originalKeepRecent = undefined;
|
|
2436
|
+
}
|
|
2437
|
+
try {
|
|
2438
|
+
if (pending.keepRecentTokens && pending.keepRecentTokens > 0) {
|
|
2439
|
+
session.settingsManager.applyOverrides({
|
|
2440
|
+
compaction: {
|
|
2441
|
+
keepRecentTokens: pending.keepRecentTokens,
|
|
2442
|
+
},
|
|
2443
|
+
});
|
|
2444
|
+
}
|
|
2445
|
+
await session.compact(instructions || undefined);
|
|
2446
|
+
const retainTokens = pending.keepRecentTokens ?? DEFAULT_KEEP_RECENT_TOKENS;
|
|
2447
|
+
this.emit({
|
|
2448
|
+
type: "notice",
|
|
2449
|
+
level: "info",
|
|
2450
|
+
text: `AI 已主动根据当前问题完成上下文压缩(保留 ~${retainTokens.toLocaleString()} tokens 近期上下文,重点保留当前问题相关内容)`,
|
|
2451
|
+
textEn: `Context compacted proactively based on current issue (retained ~${retainTokens.toLocaleString()} tokens, focused on current task)`,
|
|
2452
|
+
});
|
|
2453
|
+
}
|
|
2454
|
+
catch (err) {
|
|
2455
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2456
|
+
this.emit({
|
|
2457
|
+
type: "notice",
|
|
2458
|
+
level: "warning",
|
|
2459
|
+
text: `主动上下文压缩未完成:${msg}`,
|
|
2460
|
+
textEn: `Proactive context compaction did not finish: ${msg}`,
|
|
2461
|
+
});
|
|
2462
|
+
}
|
|
2463
|
+
finally {
|
|
2464
|
+
if (pending.keepRecentTokens && pending.keepRecentTokens > 0) {
|
|
2465
|
+
try {
|
|
2466
|
+
if (originalKeepRecent !== undefined) {
|
|
2467
|
+
session.settingsManager.applyOverrides({
|
|
2468
|
+
compaction: {
|
|
2469
|
+
keepRecentTokens: originalKeepRecent,
|
|
2470
|
+
},
|
|
2471
|
+
});
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
catch {
|
|
2475
|
+
// best effort
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
this.applyCompactionOverrides();
|
|
2479
|
+
this.flushSnapshot();
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
1654
2482
|
/** skill 工具的数据宿主:读所属会话 loader 的实时技能表 + 主会话禁用集过滤
|
|
1655
2483
|
* (与 skillsOverride 主会话语义一致)。ownerId 语义同 browser_page(本
|
|
1656
2484
|
* runtime 所属会话,不是派发瞬间的 active)。失败回空目录,不抛错。 */
|
|
@@ -1742,6 +2570,8 @@ export class ClientSession {
|
|
|
1742
2570
|
watchTimer = null;
|
|
1743
2571
|
/** 子代理模板库(全局共享,<dataDir>/subagent-templates.json)。 */
|
|
1744
2572
|
subagentTemplates;
|
|
2573
|
+
/** 审批规则库(全局共享,<dataDir>/approval-rules.json)。 */
|
|
2574
|
+
approvalRules;
|
|
1745
2575
|
/** 未发送输入框草稿(全局共享,<dataDir>/composer-drafts.json,按 sessionId 键入,见 server/composer-drafts.ts)。 */
|
|
1746
2576
|
drafts;
|
|
1747
2577
|
/** 内置标记服务(todo/notify/svc/rename 等,可全局/分组开关)。 */
|
|
@@ -1756,6 +2586,11 @@ export class ClientSession {
|
|
|
1756
2586
|
/** 待答提问(id → 载荷 + resolve)。一次正常只有一个(agent 阻塞在工具执行);
|
|
1757
2587
|
* conversationId 记录谁问的:看门狗豁免、快照恢复都靠它。 */
|
|
1758
2588
|
pendingQuestions = new Map();
|
|
2589
|
+
/** 任务计划管理器(Plan Mode / Step State Machine)。 */
|
|
2590
|
+
planManager = new PlanManager();
|
|
2591
|
+
approvalSeq = 0;
|
|
2592
|
+
/** 待审批高危工具调用(Human-in-the-Loop: Edit & Run)。 */
|
|
2593
|
+
pendingApprovals = new Map();
|
|
1759
2594
|
// -----------------------------------------------------------------------
|
|
1760
2595
|
// 浏览器页面桥(标准 pi 引擎的 browser_page customTool):模型调工具 → 发
|
|
1761
2596
|
// page_request 给浏览器 → 前端转 page-picker 扩展 → page_response 回到这里
|
|
@@ -1777,6 +2612,7 @@ export class ClientSession {
|
|
|
1777
2612
|
this.stateStore = stateStore;
|
|
1778
2613
|
this.roots = stateStore.getWorkspaceRoots(clientId, cwd);
|
|
1779
2614
|
this.subagentTemplates = new SubagentTemplatesStore(join(stateStore.dataDir, "subagent-templates.json"));
|
|
2615
|
+
this.approvalRules = new ApprovalRulesStore(join(stateStore.dataDir, "approval-rules.json"));
|
|
1780
2616
|
this.drafts = new ComposerDraftsStore(join(stateStore.dataDir, "composer-drafts.json"));
|
|
1781
2617
|
this.markerSvc = new MarkerService({
|
|
1782
2618
|
clientId,
|
|
@@ -1819,20 +2655,21 @@ export class ClientSession {
|
|
|
1819
2655
|
// (含重试次数覆盖)——依次重放:重试覆盖 → 软上限覆盖 → 终端门控。
|
|
1820
2656
|
this.applyRetryOverrides();
|
|
1821
2657
|
this.applyCompactionOverrides();
|
|
1822
|
-
// reload() 会把 custom
|
|
1823
|
-
this.applyToolGating(this.session);
|
|
2658
|
+
// reload() 会把 custom 工具重新加回活跃集——重放当前会话归属预设门控。
|
|
2659
|
+
this.applyToolGating(this.session, this.conv?.agentPreset);
|
|
1824
2660
|
await this.pushSlashCommands();
|
|
1825
2661
|
},
|
|
1826
2662
|
applyRetryOverrides: () => this.applyRetryOverrides(),
|
|
1827
2663
|
applyCompactionOverrides: () => this.applyCompactionOverrides(),
|
|
1828
|
-
applyToolGating: () => this.applyToolGating(this.session),
|
|
2664
|
+
applyToolGating: () => this.applyToolGating(this.session, this.conv?.agentPreset),
|
|
1829
2665
|
promptSnapshot: () => this.promptSnapshot(),
|
|
1830
2666
|
getMarkerState: () => ({
|
|
1831
2667
|
markersEnabled: this.markerSvc.current.markersEnabled,
|
|
1832
2668
|
disabledMarkers: [...this.markerSvc.current.disabledMarkers],
|
|
1833
2669
|
markers: this.markerSvc.listForUi(),
|
|
1834
2670
|
}),
|
|
1835
|
-
|
|
2671
|
+
getApprovalPolicy: () => this.approvalPolicyState(),
|
|
2672
|
+
}, this.subagentTemplates, this.approvalRules);
|
|
1836
2673
|
this.goalSvc = new GoalService({
|
|
1837
2674
|
clientId,
|
|
1838
2675
|
agentDir,
|
|
@@ -1954,7 +2791,7 @@ export class ClientSession {
|
|
|
1954
2791
|
* 应用(prompt replace/append + 白名单),其余(终端接管、Windows persona
|
|
1955
2792
|
* 等)仍跟随主会话设置。undefined = 按主会话设置(普通对话/不选模板的子代理)。
|
|
1956
2793
|
*/
|
|
1957
|
-
makeRuntimeFactory(terminals, apply, ownerId, initialModel) {
|
|
2794
|
+
makeRuntimeFactory(terminals, apply, ownerId, initialModel, targetPreset) {
|
|
1958
2795
|
return async ({ cwd: effectiveCwd, sessionManager }) => {
|
|
1959
2796
|
const services = await createAgentSessionServices({
|
|
1960
2797
|
cwd: effectiveCwd,
|
|
@@ -1998,7 +2835,9 @@ export class ClientSession {
|
|
|
1998
2835
|
// GBK 老中文文件让模型改用终端按正确编码读(iconv/chcp/Get-Content)。
|
|
1999
2836
|
out.push(WINDOWS_PERSONA);
|
|
2000
2837
|
}
|
|
2001
|
-
|
|
2838
|
+
// 终端引导只教「开关开着且预设下仍可用」的工具(见 tool-manager.ts 语义总表)。
|
|
2839
|
+
const presetForGuidance = this.convs.get(ownerId ?? "")?.agentPreset ?? this.settingsSvc.current.defaultAgentPreset ?? "standard";
|
|
2840
|
+
if (isTerminalGuidanceOn(effectiveDisabledAgentTools(this.settingsSvc.current), presetForGuidance)) {
|
|
2002
2841
|
// 终端工具使用引导(全平台):告诉模型什么场景该用持久终端
|
|
2003
2842
|
// 而不是一次性 bash——没有这段模型几乎从不主动选终端工具。
|
|
2004
2843
|
// 组内工具全关时不注入(不教 AI 用不存在的工具)。
|
|
@@ -2011,12 +2850,17 @@ export class ClientSession {
|
|
|
2011
2850
|
out.push(markerGuidance);
|
|
2012
2851
|
return out;
|
|
2013
2852
|
},
|
|
2014
|
-
//
|
|
2853
|
+
// 技能:模板非空白名单时只启用白名单里的(显式配置优先于预设);
|
|
2854
|
+
// 否则按主会话禁用集过滤。预设拿掉 skill 加载器时(minimal/code/ask)
|
|
2855
|
+
// 名录同步隐藏——列出来但调不动只是噪音(见 tool-manager.ts 语义总表)。
|
|
2015
2856
|
skillsOverride: (res) => {
|
|
2016
2857
|
if (apply && apply.enabledSkills.length > 0) {
|
|
2017
2858
|
const set = new Set(apply.enabledSkills);
|
|
2018
2859
|
return { ...res, skills: res.skills.filter((s) => set.has(s.name)) };
|
|
2019
2860
|
}
|
|
2861
|
+
const preset = this.convs.get(ownerId ?? "")?.agentPreset ?? this.settingsSvc.current.defaultAgentPreset ?? "standard";
|
|
2862
|
+
if (!presetShowsSkillCatalog(preset))
|
|
2863
|
+
return { ...res, skills: [] };
|
|
2020
2864
|
return {
|
|
2021
2865
|
...res,
|
|
2022
2866
|
skills: res.skills.filter((s) => !this.settingsSvc.current.disabledSkills.includes(s.name)),
|
|
@@ -2068,19 +2912,40 @@ export class ClientSession {
|
|
|
2068
2912
|
const swapped = tplPrompt + event.systemPrompt.slice(boundary);
|
|
2069
2913
|
return swapped === event.systemPrompt ? undefined : { systemPrompt: swapped };
|
|
2070
2914
|
}
|
|
2071
|
-
//
|
|
2915
|
+
// 主会话:按当前会话归属预设与真正活跃的工具列表组装系统提示词
|
|
2916
|
+
const conv = this.convs.get(ownerId ?? this.activeId) ?? this.conv;
|
|
2917
|
+
const sess = conv?.session;
|
|
2918
|
+
const activeToolNames = sess ? sess.getActiveToolNames() : [];
|
|
2919
|
+
const activeSet = new Set(activeToolNames);
|
|
2920
|
+
const activeSnippets = {};
|
|
2921
|
+
const activeGuidelines = [];
|
|
2922
|
+
if (sess) {
|
|
2923
|
+
for (const name of activeSet) {
|
|
2924
|
+
const def = sess.getToolDefinition(name);
|
|
2925
|
+
if (!def)
|
|
2926
|
+
continue;
|
|
2927
|
+
if (def.promptSnippet && def.promptSnippet.trim()) {
|
|
2928
|
+
activeSnippets[name] = def.promptSnippet.trim();
|
|
2929
|
+
}
|
|
2930
|
+
if (def.promptGuidelines) {
|
|
2931
|
+
activeGuidelines.push(...def.promptGuidelines);
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
const currentPreset = conv?.agentPreset ?? targetPreset ?? this.settingsSvc.current.defaultAgentPreset ?? "standard";
|
|
2072
2936
|
const opts = event.systemPromptOptions;
|
|
2073
2937
|
const rendered = this.renderMainCompose({
|
|
2074
|
-
cwd: typeof opts?.cwd === "string" ? opts.cwd : this.cwd,
|
|
2075
|
-
selectedTools: opts?.selectedTools ?? [],
|
|
2076
|
-
toolSnippets:
|
|
2077
|
-
toolGuidelines:
|
|
2938
|
+
cwd: typeof opts?.cwd === "string" ? opts.cwd : (conv?.cwd ?? effectiveCwd ?? this.cwd),
|
|
2939
|
+
selectedTools: activeToolNames.length > 0 ? activeToolNames : (opts?.selectedTools ?? []),
|
|
2940
|
+
toolSnippets: activeSnippets,
|
|
2941
|
+
toolGuidelines: activeGuidelines,
|
|
2078
2942
|
contextFiles: opts?.contextFiles ?? [],
|
|
2079
2943
|
skills: (opts?.skills ?? []).map((s) => ({
|
|
2080
2944
|
name: s.name,
|
|
2081
2945
|
description: s.description ?? "",
|
|
2082
2946
|
filePath: s.filePath ?? "",
|
|
2083
2947
|
})),
|
|
2948
|
+
preset: currentPreset,
|
|
2084
2949
|
});
|
|
2085
2950
|
return rendered ? { systemPrompt: rendered } : undefined;
|
|
2086
2951
|
});
|
|
@@ -2130,31 +2995,60 @@ export class ClientSession {
|
|
|
2130
2995
|
// 「默认 bash 覆盖」开关(terminalBash)关 → 原生 SDK bash(纯进程、不开终端);
|
|
2131
2996
|
// 开 → 终端接管 bash(persist 决定一次性/持久,可静默自动转后台)。
|
|
2132
2997
|
customTools: [
|
|
2133
|
-
|
|
2998
|
+
// P1-5:bash/read 包插件拦截(pre 拒/问即拦、post 脱敏补上下文;
|
|
2999
|
+
// 未注入 toolGuard 时 withToolGuard 原样返回,零开销)。
|
|
3000
|
+
// 权限沙箱拦截(只读模式拦截终端执行)。
|
|
3001
|
+
wrapBashToolWithPermission(withToolGuard(makeAdaptiveBashTool(
|
|
2134
3002
|
// issue #91:bash 返回按客户端 UI 语言出中英(英文默认)。
|
|
2135
3003
|
makeKillableBashTool(effectiveCwd, this.bashKills, () => this.getLang()), makeTerminalBashTool(terminals, {
|
|
2136
3004
|
cwd: effectiveCwd,
|
|
2137
3005
|
// 设置开 = 用终端;此分支里 persist 未显式给时默认一次性(false)。
|
|
2138
3006
|
defaultPersist: () => false,
|
|
2139
3007
|
idleMs: () => Math.max(0, Math.floor(this.settingsSvc.current.terminalBashIdleMs) || 0),
|
|
3008
|
+
maxForegroundMs: () => Math.max(0, Math.floor(this.settingsSvc.current.terminalBashMaxForegroundMs) || 0),
|
|
2140
3009
|
kills: this.bashKills,
|
|
2141
3010
|
notifyBackgroundDone: (info) => this.notifyTerminalBashDone(terminals, info),
|
|
2142
3011
|
// issue #91:bash 返回按客户端 UI 语言出中英(英文默认)。
|
|
2143
3012
|
lang: () => this.getLang(),
|
|
2144
3013
|
}),
|
|
2145
3014
|
// 设置关 → 原生 bash;开 → 终端 bash。
|
|
2146
|
-
() => this.settingsSvc.current.terminalBash),
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
// 开关是行为开关(read 本体不可关),每次调用实时读设置——不进
|
|
2151
|
-
// tool-manager 的 ActiveSet 目录。DSH 引擎无 customTool 注册面,不接。
|
|
2152
|
-
makeReadDirTool(effectiveCwd, {
|
|
2153
|
-
dirEnabled: () => this.settingsSvc.current.readDirEnabled !== false,
|
|
3015
|
+
() => this.settingsSvc.current.terminalBash), {
|
|
3016
|
+
toolName: "bash",
|
|
3017
|
+
guard: this.toolGuard,
|
|
3018
|
+
conversationId: () => ownerId,
|
|
2154
3019
|
getLang: () => this.getLang(),
|
|
3020
|
+
cwd: effectiveCwd,
|
|
3021
|
+
getRoots: () => this.roots,
|
|
3022
|
+
askApproval: (toolCallId, toolName, params, reason, reasonEn, convId, category) => this.askApproval(toolCallId, toolName, params, reason, reasonEn, convId, category),
|
|
3023
|
+
getRules: () => this.approvalRules.list(),
|
|
3024
|
+
}), () => (ownerId ? this.convs.get(ownerId)?.permissionPreset : undefined) ??
|
|
3025
|
+
this.settingsSvc.current.defaultPermissionPreset ??
|
|
3026
|
+
"workspace-write-never", () => this.getLang()),
|
|
3027
|
+
...makePersistentTerminalTools(terminals, effectiveCwd, () => this.getLang(), {
|
|
3028
|
+
checkSafety: (cmd) => {
|
|
3029
|
+
const perm = (ownerId ? this.convs.get(ownerId)?.permissionPreset : undefined) ??
|
|
3030
|
+
this.settingsSvc.current.defaultPermissionPreset ??
|
|
3031
|
+
"workspace-write-never";
|
|
3032
|
+
if (perm === "read-only") {
|
|
3033
|
+
const danger = checkDangerousToolCall("bash", { command: cmd }, effectiveCwd, this.roots, this.approvalRules.list());
|
|
3034
|
+
if (danger.denied || danger.dangerous) {
|
|
3035
|
+
return { blocked: true, reason: danger.reason || "只读模式禁止执行高危/破坏性命令" };
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
const danger = checkDangerousToolCall("bash", { command: cmd }, effectiveCwd, this.roots, this.approvalRules.list());
|
|
3039
|
+
if (danger.denied) {
|
|
3040
|
+
return { blocked: true, reason: danger.reason || "命中系统阻断规则" };
|
|
3041
|
+
}
|
|
3042
|
+
return {};
|
|
3043
|
+
},
|
|
2155
3044
|
}),
|
|
2156
|
-
//
|
|
2157
|
-
|
|
3045
|
+
// read / write / edit 三处覆盖**不在这里注册**:创建时的 customTools 恒胜、与
|
|
3046
|
+
// 扩展加载顺序无关,直接塞进来会静默顶掉第三方扩展注册的同名工具(见
|
|
3047
|
+
// tool-overrides.ts);它们改在会话建好后由 installToolOverrides 注入。
|
|
3048
|
+
// 不覆盖内置 edit 的独立宽松编辑工具(缩进不敏感匹配;开关看设置;带权限沙箱拦截与人机协同)。
|
|
3049
|
+
wrapEditSoftToolWithPermission(makeEditSoftTool(effectiveCwd, () => this.getLang()), effectiveCwd, () => (ownerId ? this.convs.get(ownerId)?.permissionPreset : undefined) ??
|
|
3050
|
+
this.settingsSvc.current.defaultPermissionPreset ??
|
|
3051
|
+
"workspace-write-never", () => this.roots, () => this.getLang(), (toolCallId, toolName, params, reason, reasonEn, convId, category) => this.askApproval(toolCallId, toolName, params, reason, reasonEn, convId, category), () => ownerId, () => this.approvalRules.list()),
|
|
2158
3052
|
// 插件注册的 AI 工具(创建时刻的实时快照,已按 disabledPluginTools 过滤;
|
|
2159
3053
|
// 后续注册经 refreshPluginTools 动态补入已有会话)。
|
|
2160
3054
|
// 子代理模板带非空扩展白名单时不注入:插件/MCP 工具没有 SDK extensionKey
|
|
@@ -2179,6 +3073,8 @@ export class ClientSession {
|
|
|
2179
3073
|
: [makeDelegateTaskTool(this.subagentHost)]),
|
|
2180
3074
|
// 内置标记只读查询工具(todo/svc 状态查询,写操作走内联标记)。
|
|
2181
3075
|
makeMarkersListTool(() => this.activeId, this.markerSvc),
|
|
3076
|
+
// 结构化任务计划更新(Plan Mode / Step State Machine)。
|
|
3077
|
+
makePlanUpdateTool(this.planManager, () => ownerId ?? this.activeId, (msg) => this.emit(msg), () => this.flushSnapshot()),
|
|
2182
3078
|
// 标准引擎的 ask_user_question:模型调用 → 浏览器富渲染问卷(复用 DSH
|
|
2183
3079
|
// 的 question_pending/question_answer 协议,前端 DshQuestionDialog)。
|
|
2184
3080
|
// DSH 引擎不经此(它走 goal-rpc 的 userQuestions provider)。
|
|
@@ -2218,18 +3114,36 @@ export class ClientSession {
|
|
|
2218
3114
|
// 子代理会话同样注册(owner 即真正派发的父对话,读该会话 loader)。
|
|
2219
3115
|
// DSH 引擎无 customTool 注册面,不接。开关走统一工具 tab。
|
|
2220
3116
|
makeSkillTool(this.skillToolHost(ownerId), () => this.getLang()),
|
|
3117
|
+
// 主动压缩上下文工具(compact_context):AI 主动根据当前问题精简上下文。
|
|
3118
|
+
// 开关走统一工具 tab(ActiveSet 门控)。DSH 引擎无 customTool 注册面,不接。
|
|
3119
|
+
makeCompactContextTool(this.compactContextHost(ownerId), () => this.getLang()),
|
|
2221
3120
|
// 定时唤醒三件套(schedule_task/list/cancel,issue #193):默认绑定
|
|
2222
3121
|
// 发起对话(ownerId,无则活动对话),到期 steer 语义唤醒它;子代理
|
|
2223
3122
|
// 会话同样注册(owner 即真正派发的父对话)。开关走统一工具 tab。
|
|
2224
3123
|
// DSH 引擎无 customTool 注册面,不接。
|
|
2225
3124
|
...makeScheduleTools(this.scheduleToolHost(), ownerId, () => this.getLang()),
|
|
3125
|
+
// 持久代码求值沙箱(eval):开关走统一工具 tab(ActiveSet 门控,默认关)。
|
|
3126
|
+
// ownerId 绑定当前会话;DSH 引擎无 customTool 注册面,不接。
|
|
3127
|
+
makeEvalTool({
|
|
3128
|
+
cwd: effectiveCwd,
|
|
3129
|
+
ownerId,
|
|
3130
|
+
lang: () => this.getLang(),
|
|
3131
|
+
}),
|
|
3132
|
+
// 高可靠行补丁工具(patch,基于内容哈希与语法块级替换)。
|
|
3133
|
+
makePatchTool({ cwd: effectiveCwd, ownerId }),
|
|
3134
|
+
// 原生语言服务器工具(lsp,定义跳转/引用/悬停/诊断)。
|
|
3135
|
+
makeLspTool({ cwd: effectiveCwd, ownerId }),
|
|
2226
3136
|
],
|
|
2227
3137
|
});
|
|
2228
3138
|
// 桥接工具归属锚点:SDK 会话对象在本 runtime 生命周期内稳定,过户只搬对话
|
|
2229
3139
|
// 不改它(见 ClientSession.findConversationHome)。
|
|
2230
3140
|
bridgeAnchor.session = created.session;
|
|
2231
|
-
//
|
|
2232
|
-
|
|
3141
|
+
// read / write / edit 三处覆盖在会话建好后注入(见 tool-overrides.ts):SDK 的合并链是
|
|
3142
|
+
// [...扩展工具, ...customTools] 后写赢 ⇒ 创建时塞进 customTools 会**恒定顶掉**第三方
|
|
3143
|
+
// 扩展注册的同名工具(官方 docs/extensions.md 明写扩展可覆盖 read/write/edit)。
|
|
3144
|
+
installToolOverrides(created.session, this.toolOverrideSpecs(ownerId, effectiveCwd));
|
|
3145
|
+
// 终端工具开关与预设门控从创建起就生效(工具始终注册进注册表,只调活跃集)。
|
|
3146
|
+
this.applyToolGating(created.session, targetPreset);
|
|
2233
3147
|
return {
|
|
2234
3148
|
...created,
|
|
2235
3149
|
services,
|
|
@@ -2256,6 +3170,11 @@ export class ClientSession {
|
|
|
2256
3170
|
session: runtime.session,
|
|
2257
3171
|
cwd: runtime.cwd,
|
|
2258
3172
|
createdAt: Date.now(),
|
|
3173
|
+
agentPreset: this.settingsSvc.current.defaultAgentPreset ?? "standard",
|
|
3174
|
+
presetLocked: false,
|
|
3175
|
+
permissionPreset: readPermissionFromSession(runtime.session.sessionManager) ??
|
|
3176
|
+
this.settingsSvc.current.defaultPermissionPreset ??
|
|
3177
|
+
"workspace-write-never",
|
|
2259
3178
|
// A brand-new conversation is not yet LISTED — it enters the running
|
|
2260
3179
|
// list only when it is displaced to the background while still
|
|
2261
3180
|
// streaming (its runtime is what `listed` protects). A blank chat is
|
|
@@ -2283,6 +3202,7 @@ export class ClientSession {
|
|
|
2283
3202
|
queueFollowUp: [],
|
|
2284
3203
|
toolStartTimes: new Map(),
|
|
2285
3204
|
toolWatchdogs: new Map(),
|
|
3205
|
+
workspaceSnapshots: [],
|
|
2286
3206
|
};
|
|
2287
3207
|
}
|
|
2288
3208
|
/** Summaries of conversations currently streaming — captured at shutdown
|
|
@@ -2341,7 +3261,7 @@ export class ClientSession {
|
|
|
2341
3261
|
textEn: `Restart interrupted "${r.title}" — reopening it and continuing automatically.`,
|
|
2342
3262
|
});
|
|
2343
3263
|
await this.switchSession(r.sessionFile);
|
|
2344
|
-
await this.
|
|
3264
|
+
await this.promptResumedConversation(r.sessionFile, continueText);
|
|
2345
3265
|
}
|
|
2346
3266
|
catch {
|
|
2347
3267
|
// switchSession/prompt already surface failures as notices;
|
|
@@ -2359,6 +3279,50 @@ export class ClientSession {
|
|
|
2359
3279
|
/* staying on the last resumed session is a fine fallback */
|
|
2360
3280
|
}
|
|
2361
3281
|
}
|
|
3282
|
+
/** 找持有指定转录文件的本地对话 id(找不到 = 已被关闭/搬走/还没建好)。 */
|
|
3283
|
+
conversationIdBySessionFile(file) {
|
|
3284
|
+
let abs;
|
|
3285
|
+
try {
|
|
3286
|
+
abs = resolve(file);
|
|
3287
|
+
}
|
|
3288
|
+
catch {
|
|
3289
|
+
return null;
|
|
3290
|
+
}
|
|
3291
|
+
for (const c of this.convs.values()) {
|
|
3292
|
+
try {
|
|
3293
|
+
const f = c.session.sessionFile;
|
|
3294
|
+
if (f && resolve(f) === abs)
|
|
3295
|
+
return c.id;
|
|
3296
|
+
}
|
|
3297
|
+
catch {
|
|
3298
|
+
// session 被替换中 —— 跳过
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3301
|
+
return null;
|
|
3302
|
+
}
|
|
3303
|
+
/**
|
|
3304
|
+
* 恢复流程的「继续」投递(resumeInterrupted 专用)。
|
|
3305
|
+
* 读码结论:prompt() 在**调用时刻**同步捕获 active 对话(进入函数第一行取
|
|
3306
|
+
* this.conv,先于任何 await),调用之后发生的切换不影响投递目标。唯一的错投
|
|
3307
|
+
* 窗口在 switchSession 内部:activeId 置位后还要 await bindSession/恢复模型,
|
|
3308
|
+
* 期间用户的并发切换会把 active 挪走 —— 恢复循环接着调 prompt 就会把「继续」
|
|
3309
|
+
* 投进用户当前对话。因此投递前把目标对话修回 active,并在同一同步执行段内
|
|
3310
|
+
* (上一个 await 恢复点之后、无新 await 处)核对 activeId 后才调用 prompt;
|
|
3311
|
+
* 修不回(再次被抢)就放弃自动继续 —— 宁可少投,不投错对话。
|
|
3312
|
+
*/
|
|
3313
|
+
async promptResumedConversation(sessionFile, text) {
|
|
3314
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
3315
|
+
const targetId = this.conversationIdBySessionFile(sessionFile);
|
|
3316
|
+
if (!targetId)
|
|
3317
|
+
return; // 目标对话已没了(被关/被过户),不投
|
|
3318
|
+
if (this.activeId === targetId) {
|
|
3319
|
+
await this.prompt(text);
|
|
3320
|
+
return;
|
|
3321
|
+
}
|
|
3322
|
+
await this.switchConversation(targetId);
|
|
3323
|
+
}
|
|
3324
|
+
// 两次都没能稳定拿回 active:放弃自动继续,用户可手动点开那条对话。
|
|
3325
|
+
}
|
|
2362
3326
|
/** Add a socket to this client's broadcast set; flushes buffered startup notices. */
|
|
2363
3327
|
attachSink(send) {
|
|
2364
3328
|
this.sinks.add(send);
|
|
@@ -2396,6 +3360,9 @@ export class ClientSession {
|
|
|
2396
3360
|
// Reconnect: push the global default model (the picker's ★ marker +
|
|
2397
3361
|
// "set as global default" button state).
|
|
2398
3362
|
this.pushDefaultModel();
|
|
3363
|
+
// Reconnect: push agent presets and permission presets (align with DSH).
|
|
3364
|
+
this.refreshAgentPresets();
|
|
3365
|
+
this.refreshPermission();
|
|
2399
3366
|
// PTYs are conversation-owned and survive a socket reconnect.
|
|
2400
3367
|
this.pushTerminals();
|
|
2401
3368
|
}
|
|
@@ -2416,6 +3383,34 @@ export class ClientSession {
|
|
|
2416
3383
|
for (const sink of [...this.sinks])
|
|
2417
3384
|
sink(msg);
|
|
2418
3385
|
}
|
|
3386
|
+
/** 扩展错误上报器:同一会话内「扩展 + 事件 + 错误文本」只提示一次,且全量落服务端日志。
|
|
3387
|
+
*
|
|
3388
|
+
* SDK 的 `ExtensionRunner.emitContext()` 在**每次 provider 请求**前都会跑一遍
|
|
3389
|
+
* 扩展的 `context` hook,并对每个 handler 的报错回调 `onError`。in-memory 会话
|
|
3390
|
+
* (子代理 / 无痕会话)取不到会话目录(`SessionManager.inMemory(cwd)` 的
|
|
3391
|
+
* `getSessionDir()` 返回空串),于是「会话目录依赖型」扩展(如 SoL-Pi 的
|
|
3392
|
+
* `runtimeRoot()`)每轮都抛同一个错——原样广播就等于按轮数刷屏(issue #298)。
|
|
3393
|
+
*
|
|
3394
|
+
* `prefix` 给 notice 带上会话归属:用户一眼能看出是后台会话的问题,
|
|
3395
|
+
* 而不是当前对话坏了(与同函数内其它子代理通知的口径一致)。 */
|
|
3396
|
+
makeExtensionErrorReporter(prefix) {
|
|
3397
|
+
const seen = new Set();
|
|
3398
|
+
return (err) => {
|
|
3399
|
+
const message = err?.error ?? String(err);
|
|
3400
|
+
const where = [err?.extensionPath, err?.event].filter(Boolean).join(" · ");
|
|
3401
|
+
console.error(`[extension] ${prefix?.text ?? "当前对话"}${where ? ` (${where})` : ""}: ${message}${err?.stack ? `\n${err.stack}` : ""}`);
|
|
3402
|
+
const key = `${err?.extensionPath ?? ""}|${err?.event ?? ""}|${message}`;
|
|
3403
|
+
if (seen.has(key))
|
|
3404
|
+
return;
|
|
3405
|
+
seen.add(key);
|
|
3406
|
+
this.emit({
|
|
3407
|
+
type: "notice",
|
|
3408
|
+
level: "error",
|
|
3409
|
+
text: prefix ? `${prefix.text}扩展报错:${message}` : message,
|
|
3410
|
+
textEn: prefix ? `${prefix.textEn} Extension error: ${message}` : message,
|
|
3411
|
+
});
|
|
3412
|
+
};
|
|
3413
|
+
}
|
|
2419
3414
|
/** (Re)attach event plumbing to the ACTIVE conversation's session. */
|
|
2420
3415
|
async bindSession() {
|
|
2421
3416
|
const conv = this.conv;
|
|
@@ -2424,9 +3419,7 @@ export class ClientSession {
|
|
|
2424
3419
|
await conv.session.bindExtensions({
|
|
2425
3420
|
mode: "rpc",
|
|
2426
3421
|
uiContext: this.webUi,
|
|
2427
|
-
onError: (
|
|
2428
|
-
this.emit({ type: "notice", level: "error", text: err.error, textEn: err.error });
|
|
2429
|
-
},
|
|
3422
|
+
onError: this.makeExtensionErrorReporter(conv.isEphemeral ? { text: `临时对话 ${conv.id}:`, textEn: `Ephemeral chat ${conv.id}: ` } : undefined),
|
|
2430
3423
|
});
|
|
2431
3424
|
conv.unsubscribe = conv.session.subscribe((event) => this.onEvent(conv, event));
|
|
2432
3425
|
// 新会话 / 切换会话 / 强杀重建的必经之路:刚创建的 runtime 用的是 SDK
|
|
@@ -3152,6 +4145,7 @@ export class ClientSession {
|
|
|
3152
4145
|
if (stopNotice) {
|
|
3153
4146
|
this.emit({ type: "notice", level: "warning", text: stopNotice.text, textEn: stopNotice.textEn });
|
|
3154
4147
|
}
|
|
4148
|
+
this.emitConversations();
|
|
3155
4149
|
break;
|
|
3156
4150
|
}
|
|
3157
4151
|
// 子代理运行报错(provider 400 / 超时等)→ 通知主对话,让用户/AI 知道
|
|
@@ -3208,6 +4202,33 @@ export class ClientSession {
|
|
|
3208
4202
|
catch {
|
|
3209
4203
|
// sidecar 只是加速 + 防压缩丢失,失败了下次重算
|
|
3210
4204
|
}
|
|
4205
|
+
// AI 主动上下文压缩:若本轮调过 compact_context,在回合结算后异步执行压缩
|
|
4206
|
+
if (!event.willRetry && !aborted && conv.pendingCompaction && !this.disposed) {
|
|
4207
|
+
const pending = conv.pendingCompaction;
|
|
4208
|
+
conv.pendingCompaction = null;
|
|
4209
|
+
setTimeout(() => {
|
|
4210
|
+
if (!this.disposed) {
|
|
4211
|
+
void this.executePendingCompaction(conv, pending);
|
|
4212
|
+
}
|
|
4213
|
+
}, 50);
|
|
4214
|
+
}
|
|
4215
|
+
// 本轮真正结束且不再重试:立即向客户端广播最新会话状态(流式状态及时复位)
|
|
4216
|
+
if (!event.willRetry) {
|
|
4217
|
+
this.emitConversations();
|
|
4218
|
+
}
|
|
4219
|
+
break;
|
|
4220
|
+
}
|
|
4221
|
+
case "agent_settled": {
|
|
4222
|
+
// 记录本会话自己的 Base 基线:currentBaseTokens 必须传 conv(否则串成
|
|
4223
|
+
// 活跃会话的基线);快照未就绪时保持 undefined,宁可少补偿也不把 0 钉死。
|
|
4224
|
+
const settledBaseTokens = this.currentBaseTokens(conv);
|
|
4225
|
+
if (settledBaseTokens != null)
|
|
4226
|
+
conv.lastTurnBaseTokens = settledBaseTokens;
|
|
4227
|
+
if (conv.pendingCompaction && !this.disposed) {
|
|
4228
|
+
const pending = conv.pendingCompaction;
|
|
4229
|
+
conv.pendingCompaction = null;
|
|
4230
|
+
void this.executePendingCompaction(conv, pending);
|
|
4231
|
+
}
|
|
3211
4232
|
break;
|
|
3212
4233
|
}
|
|
3213
4234
|
case "entry_appended": {
|
|
@@ -3485,14 +4506,42 @@ export class ClientSession {
|
|
|
3485
4506
|
tokens: conv.lastCompactionTokens,
|
|
3486
4507
|
contextWindow: cu.contextWindow,
|
|
3487
4508
|
percent: (conv.lastCompactionTokens / cu.contextWindow) * 100,
|
|
3488
|
-
estimated:
|
|
4509
|
+
estimated: false,
|
|
4510
|
+
softCap: this.activeSoftCap(cu.contextWindow),
|
|
4511
|
+
};
|
|
4512
|
+
}
|
|
4513
|
+
// 展示用兜底 0(会话未就绪时旧行为也是 0);落账 lastTurnBaseTokens 的路径
|
|
4514
|
+
// 在 agent_settled 处对 null 跳过,不受此兜底影响。
|
|
4515
|
+
const baseTokens = this.currentBaseTokens(conv) ?? 0;
|
|
4516
|
+
// 空白会话(尚未发言,SDK 报 0 或 null):真实反映当前模式/工具配置下的 Base 开销。
|
|
4517
|
+
if (cu.contextWindow > 0 && (this.isBlankConversation(conv) || cu.tokens == null || cu.tokens === 0)) {
|
|
4518
|
+
return {
|
|
4519
|
+
tokens: baseTokens,
|
|
4520
|
+
contextWindow: cu.contextWindow,
|
|
4521
|
+
percent: (baseTokens / cu.contextWindow) * 100,
|
|
4522
|
+
estimated: false,
|
|
4523
|
+
softCap: this.activeSoftCap(cu.contextWindow),
|
|
4524
|
+
};
|
|
4525
|
+
}
|
|
4526
|
+
// 首轮对话中(尚未有定稿 assistant 消息,SDK cu.tokens 仅为当前消息估算):叠加 Base 开销
|
|
4527
|
+
if (conv.lastTurnBaseTokens == null && cu.contextWindow > 0) {
|
|
4528
|
+
const total = baseTokens + (cu.tokens ?? 0);
|
|
4529
|
+
return {
|
|
4530
|
+
tokens: total,
|
|
4531
|
+
contextWindow: cu.contextWindow,
|
|
4532
|
+
percent: (total / cu.contextWindow) * 100,
|
|
4533
|
+
estimated: false,
|
|
3489
4534
|
softCap: this.activeSoftCap(cu.contextWindow),
|
|
3490
4535
|
};
|
|
3491
4536
|
}
|
|
4537
|
+
// 已有多轮对话历史:若在对话间歇切预设或开关工具,叠加当前 Base 开销相比上一轮定稿时的差额
|
|
4538
|
+
const baseDelta = baseTokens - (conv.lastTurnBaseTokens ?? baseTokens);
|
|
4539
|
+
const effectiveTokens = Math.max(0, (cu.tokens ?? 0) + baseDelta);
|
|
3492
4540
|
return {
|
|
3493
|
-
tokens:
|
|
4541
|
+
tokens: effectiveTokens,
|
|
3494
4542
|
contextWindow: cu.contextWindow,
|
|
3495
|
-
percent: cu.percent,
|
|
4543
|
+
percent: cu.contextWindow > 0 ? (effectiveTokens / cu.contextWindow) * 100 : cu.percent,
|
|
4544
|
+
estimated: false,
|
|
3496
4545
|
softCap: this.activeSoftCap(cu.contextWindow),
|
|
3497
4546
|
};
|
|
3498
4547
|
})(),
|
|
@@ -3519,6 +4568,12 @@ export class ClientSession {
|
|
|
3519
4568
|
sessionId: this.session.sessionId,
|
|
3520
4569
|
sessionFile: this.session.sessionFile,
|
|
3521
4570
|
conversationId: this.activeId,
|
|
4571
|
+
// 临时对话标记(issue #285):提示条/转正按钮跟当前对话走。
|
|
4572
|
+
// 不能只靠 conversations 列表 —— 空白的临时对话不在运行列表里(shownInRunningList
|
|
4573
|
+
// 只列有内容的),刚新建时列表里查不到它,提示条就永远不出现。
|
|
4574
|
+
// 必须**恒存在**(不能只在 true 时展开):转正后它由 true→false,而增量快照是
|
|
4575
|
+
// `{...ui, ...d.state}` 浅合并,缺字段会把 true 残留下来。
|
|
4576
|
+
isEphemeral: !!this.conv?.isEphemeral,
|
|
3522
4577
|
rev,
|
|
3523
4578
|
streamingMessage,
|
|
3524
4579
|
isStreaming: this.session.isStreaming,
|
|
@@ -3539,6 +4594,19 @@ export class ClientSession {
|
|
|
3539
4594
|
retry: conv.retryState ?? null,
|
|
3540
4595
|
compaction: conv.compactionState ?? null,
|
|
3541
4596
|
pendingQuestion: this.pendingQuestionForSnapshot(),
|
|
4597
|
+
pendingApproval: this.pendingApprovalForSnapshot(),
|
|
4598
|
+
plan: this.planManager.getPlan(this.activeId),
|
|
4599
|
+
subagentHandoffs: this.subagentHandoffs.length > 0 ? [...this.subagentHandoffs] : undefined,
|
|
4600
|
+
agentPreset: conv
|
|
4601
|
+
? {
|
|
4602
|
+
id: conv.agentPreset ?? "standard",
|
|
4603
|
+
name: PI_AGENT_PRESETS.find((p) => p.id === conv.agentPreset)?.name ?? "全功能",
|
|
4604
|
+
locked: conv.presetLocked || !this.isBlankConversation(conv),
|
|
4605
|
+
}
|
|
4606
|
+
: null,
|
|
4607
|
+
permission: conv
|
|
4608
|
+
? (conv.permissionPreset ?? this.settingsSvc.current.defaultPermissionPreset ?? "workspace-write-never")
|
|
4609
|
+
: null,
|
|
3542
4610
|
// 未发送草稿只跟全量快照走(切会话/new_chat/get_state):增量 delta 里
|
|
3543
4611
|
// 这个 key 必须整个缺席(不能是显式的 undefined——前端 delta 合并是
|
|
3544
4612
|
// {...ui, ...d.state} 整批覆盖,显式 undefined 会把上次全量带回的草稿洗掉)。
|
|
@@ -3715,6 +4783,10 @@ export class ClientSession {
|
|
|
3715
4783
|
if (p.conversationId === undefined || p.conversationId === conversationId)
|
|
3716
4784
|
return true;
|
|
3717
4785
|
}
|
|
4786
|
+
for (const a of this.pendingApprovals.values()) {
|
|
4787
|
+
if (a.conversationId === undefined || a.conversationId === conversationId)
|
|
4788
|
+
return true;
|
|
4789
|
+
}
|
|
3718
4790
|
return false;
|
|
3719
4791
|
}
|
|
3720
4792
|
/** 标准引擎的 question_answer 路由入口(index.ts 经 cs.answerQuestion?. 转发)。
|
|
@@ -3733,27 +4805,241 @@ export class ClientSession {
|
|
|
3733
4805
|
}
|
|
3734
4806
|
return undefined;
|
|
3735
4807
|
}
|
|
3736
|
-
/** 取某对话的等答复问卷简要信息(用于会话列表与横幅展示)。 */
|
|
3737
|
-
getPendingQuestionForConv(convId) {
|
|
3738
|
-
for (const [id, p] of this.pendingQuestions) {
|
|
3739
|
-
if (p.conversationId === undefined || p.conversationId === convId) {
|
|
3740
|
-
const q0 = p.questions[0];
|
|
3741
|
-
const title = q0?.header?.trim() || q0?.question?.trim() || undefined;
|
|
3742
|
-
return { id, title };
|
|
3743
|
-
}
|
|
4808
|
+
/** 取某对话的等答复问卷简要信息(用于会话列表与横幅展示)。 */
|
|
4809
|
+
getPendingQuestionForConv(convId) {
|
|
4810
|
+
for (const [id, p] of this.pendingQuestions) {
|
|
4811
|
+
if (p.conversationId === undefined || p.conversationId === convId) {
|
|
4812
|
+
const q0 = p.questions[0];
|
|
4813
|
+
const title = q0?.header?.trim() || q0?.question?.trim() || undefined;
|
|
4814
|
+
return { id, title };
|
|
4815
|
+
}
|
|
4816
|
+
}
|
|
4817
|
+
return undefined;
|
|
4818
|
+
}
|
|
4819
|
+
/** 跨页作答:把别处问卷的原文推给本页弹框(回答经 answerElsewhereQuestion 回去)。 */
|
|
4820
|
+
pushElsewhereQuestion(owner, convId, q) {
|
|
4821
|
+
this.emit({
|
|
4822
|
+
type: "elsewhere_question",
|
|
4823
|
+
owner,
|
|
4824
|
+
convId,
|
|
4825
|
+
id: q.id,
|
|
4826
|
+
questions: q.questions,
|
|
4827
|
+
...(q.conversationTitle ? { conversationTitle: q.conversationTitle } : {}),
|
|
4828
|
+
});
|
|
4829
|
+
}
|
|
4830
|
+
// -----------------------------------------------------------------------
|
|
4831
|
+
// 人机协同工具审批桥(Tool Approval: Human-in-the-Loop Edit & Run)
|
|
4832
|
+
// -----------------------------------------------------------------------
|
|
4833
|
+
/**
|
|
4834
|
+
* 弹审批(Human-in-the-Loop)。**三档放行门禁在入口**(策略纯函数见
|
|
4835
|
+
* server/tool-approval.ts 的 approvalSuppressionReason):
|
|
4836
|
+
* - 全局开关关(设置 →「工具」页)→ 直接批准(不弹);
|
|
4837
|
+
* - 本对话「全部允许」/ 已记住该同类 → 直接批准(不弹)。
|
|
4838
|
+
* 门禁都返回已 resolve 的 Promise,调用方(withToolGuard / 三个权限包装)
|
|
4839
|
+
* 无需关心是否真的弹过窗。
|
|
4840
|
+
*/
|
|
4841
|
+
askApproval(toolCallId, toolName, params, reason, reasonEn, conversationId, category) {
|
|
4842
|
+
return new Promise((resolve) => {
|
|
4843
|
+
if (this.disposed) {
|
|
4844
|
+
resolve({ decision: "deny", reason: "会话已关闭" });
|
|
4845
|
+
return;
|
|
4846
|
+
}
|
|
4847
|
+
const id = `appr-${++this.approvalSeq}`;
|
|
4848
|
+
const conv = conversationId ? this.convs.get(conversationId) : this.conv;
|
|
4849
|
+
const suppression = approvalSuppressionReason(conv?.approvalPolicy, this.settingsSvc.current.toolApprovalEnabled !== false, category?.id);
|
|
4850
|
+
if (suppression) {
|
|
4851
|
+
// 放行但不弹窗:不记 pending,不发消息,模型继续跑。
|
|
4852
|
+
resolve({ decision: "approve" });
|
|
4853
|
+
return;
|
|
4854
|
+
}
|
|
4855
|
+
const conversationTitle = conv?.title;
|
|
4856
|
+
const entry = {
|
|
4857
|
+
id,
|
|
4858
|
+
toolCallId,
|
|
4859
|
+
toolName,
|
|
4860
|
+
params: params,
|
|
4861
|
+
reason,
|
|
4862
|
+
reasonEn,
|
|
4863
|
+
...(category ? { category } : {}),
|
|
4864
|
+
conversationId,
|
|
4865
|
+
conversationTitle,
|
|
4866
|
+
resolve,
|
|
4867
|
+
createdAt: Date.now(),
|
|
4868
|
+
};
|
|
4869
|
+
this.pendingApprovals.set(id, entry);
|
|
4870
|
+
this.emit({
|
|
4871
|
+
type: "tool_approval_pending",
|
|
4872
|
+
id,
|
|
4873
|
+
toolCallId,
|
|
4874
|
+
toolName,
|
|
4875
|
+
params: params,
|
|
4876
|
+
reason,
|
|
4877
|
+
reasonEn,
|
|
4878
|
+
...(category ? { category } : {}),
|
|
4879
|
+
...(conversationId !== undefined ? { conversationId } : {}),
|
|
4880
|
+
...(conversationTitle ? { conversationTitle } : {}),
|
|
4881
|
+
});
|
|
4882
|
+
this.flushSnapshot();
|
|
4883
|
+
});
|
|
4884
|
+
}
|
|
4885
|
+
/**
|
|
4886
|
+
* 审批答复。scope(仅 approve 有效)是「不再问」的两档记忆:
|
|
4887
|
+
* - "category":记住本对话的该同类档位;
|
|
4888
|
+
* - "all":本对话后续全部允许。
|
|
4889
|
+
* 记住后,同一对话里**已被覆盖的其它待审批项一并放行**(否则用户点了
|
|
4890
|
+
* 「全部允许」却还有几张弹窗挂着等点),并推一次设置面板(撤销区)。
|
|
4891
|
+
*/
|
|
4892
|
+
resolveToolApproval(id, decision, editedParams, reason, scope) {
|
|
4893
|
+
const pending = this.pendingApprovals.get(id);
|
|
4894
|
+
if (!pending)
|
|
4895
|
+
return false;
|
|
4896
|
+
const convId = pending.conversationId ?? this.activeId;
|
|
4897
|
+
const conv = this.convs.get(convId);
|
|
4898
|
+
this.pendingApprovals.delete(id);
|
|
4899
|
+
pending.resolve({ decision, editedParams, reason });
|
|
4900
|
+
this.emit({ type: "tool_approval_resolved", id });
|
|
4901
|
+
if (decision === "approve" && scope && scope !== "once" && conv) {
|
|
4902
|
+
const policy = this.ensureApprovalPolicy(conv);
|
|
4903
|
+
if (scope === "all")
|
|
4904
|
+
policy.allowAll = true;
|
|
4905
|
+
else if (pending.category)
|
|
4906
|
+
policy.categories.set(pending.category.id, pending.category);
|
|
4907
|
+
this.emitApprovalPolicyNotice(scope, pending.category, this.approveCoveredPending(convId, policy));
|
|
4908
|
+
this.settingsSvc.push();
|
|
4909
|
+
}
|
|
4910
|
+
this.flushSnapshot();
|
|
4911
|
+
return true;
|
|
4912
|
+
}
|
|
4913
|
+
/** 取出(或建出)对话的审批策略对象。 */
|
|
4914
|
+
ensureApprovalPolicy(conv) {
|
|
4915
|
+
if (!conv.approvalPolicy)
|
|
4916
|
+
conv.approvalPolicy = { allowAll: false, categories: new Map() };
|
|
4917
|
+
return conv.approvalPolicy;
|
|
4918
|
+
}
|
|
4919
|
+
/** 把某对话里已被策略覆盖的其它待审批项一并放行,返回放行条数。 */
|
|
4920
|
+
approveCoveredPending(convId, policy) {
|
|
4921
|
+
let n = 0;
|
|
4922
|
+
// eslint-disable-next-line unicorn/no-useless-spread -- 快照:循环里会从 map 删项(迭代中改集合)
|
|
4923
|
+
for (const [oid, o] of [...this.pendingApprovals]) {
|
|
4924
|
+
if ((o.conversationId ?? this.activeId) !== convId)
|
|
4925
|
+
continue;
|
|
4926
|
+
if (!approvalSuppressionReason(policy, true, o.category?.id))
|
|
4927
|
+
continue;
|
|
4928
|
+
this.pendingApprovals.delete(oid);
|
|
4929
|
+
o.resolve({ decision: "approve" });
|
|
4930
|
+
this.emit({ type: "tool_approval_resolved", id: oid });
|
|
4931
|
+
n++;
|
|
4932
|
+
}
|
|
4933
|
+
return n;
|
|
4934
|
+
}
|
|
4935
|
+
/** 记住策略后给用户一句回执(文本双语,前端按 locale 自选)。 */
|
|
4936
|
+
emitApprovalPolicyNotice(scope, category, auto = 0) {
|
|
4937
|
+
const tailZh = auto > 0 ? `,并已自动放行另外 ${auto} 项待审批` : "";
|
|
4938
|
+
const tailEn = auto > 0 ? ` (auto-approved ${auto} other pending request(s))` : "";
|
|
4939
|
+
if (scope === "all") {
|
|
4940
|
+
this.emit({
|
|
4941
|
+
type: "notice",
|
|
4942
|
+
level: "info",
|
|
4943
|
+
text: `本对话已允许全部工具审批,后续高危操作不再询问${tailZh};可在 设置 →「工具」页撤销。`,
|
|
4944
|
+
textEn: `All tool approvals are now allowed in this conversation${tailEn}; revoke it under Settings → Tools.`,
|
|
4945
|
+
});
|
|
4946
|
+
return;
|
|
4947
|
+
}
|
|
4948
|
+
const label = category?.label ?? "同类";
|
|
4949
|
+
const labelEn = category?.labelEn ?? "this category";
|
|
4950
|
+
this.emit({
|
|
4951
|
+
type: "notice",
|
|
4952
|
+
level: "info",
|
|
4953
|
+
text: `本对话已允许「${label}」类操作,后续同类不再询问${tailZh};可在 设置 →「工具」页撤销。`,
|
|
4954
|
+
textEn: `Allowed "${labelEn}" in this conversation${tailEn}; revoke it under Settings → Tools.`,
|
|
4955
|
+
});
|
|
4956
|
+
}
|
|
4957
|
+
/** 全局审批开关被关掉时:挂着的待审批全部按批准放行(否则弹窗还在等人点)。 */
|
|
4958
|
+
autoApprovePendingApprovals(reasonZh, reasonEn) {
|
|
4959
|
+
if (this.pendingApprovals.size === 0)
|
|
4960
|
+
return;
|
|
4961
|
+
let n = 0;
|
|
4962
|
+
// eslint-disable-next-line unicorn/no-useless-spread -- 快照:循环里会从 map 删项(迭代中改集合)
|
|
4963
|
+
for (const [oid, o] of [...this.pendingApprovals]) {
|
|
4964
|
+
this.pendingApprovals.delete(oid);
|
|
4965
|
+
o.resolve({ decision: "approve" });
|
|
4966
|
+
this.emit({ type: "tool_approval_resolved", id: oid });
|
|
4967
|
+
n++;
|
|
4968
|
+
}
|
|
4969
|
+
this.emit({
|
|
4970
|
+
type: "notice",
|
|
4971
|
+
level: "info",
|
|
4972
|
+
text: `${reasonZh},已自动放行 ${n} 项待审批。`,
|
|
4973
|
+
textEn: `${reasonEn} — auto-approved ${n} pending request(s).`,
|
|
4974
|
+
});
|
|
4975
|
+
this.flushSnapshot();
|
|
4976
|
+
}
|
|
4977
|
+
/**
|
|
4978
|
+
* 设置面板撤销区用:当前对话的审批策略(allowAll + 已记住的同类档位)。
|
|
4979
|
+
* 只回当前对话——撤销本来就是「在这里关掉这里的东西」,别的对话要撤销
|
|
4980
|
+
* 就切过去(策略跟对话走,见 Conversation.approvalPolicy)。
|
|
4981
|
+
*/
|
|
4982
|
+
approvalPolicyState() {
|
|
4983
|
+
const conv = this.convs.get(this.activeId);
|
|
4984
|
+
const policy = conv?.approvalPolicy;
|
|
4985
|
+
return {
|
|
4986
|
+
conversationId: this.activeId,
|
|
4987
|
+
allowAll: policy?.allowAll ?? false,
|
|
4988
|
+
categories: policy ? [...policy.categories.values()] : [],
|
|
4989
|
+
};
|
|
4990
|
+
}
|
|
4991
|
+
/**
|
|
4992
|
+
* 设置某对话的审批策略(设置面板撤销用)。只应用给出的字段:allowAll 直接赋值;
|
|
4993
|
+
* categories 给出时作为保留名单整体替换(空数组 = 清掉全部同类记忆)。
|
|
4994
|
+
*/
|
|
4995
|
+
setApprovalPolicy(partial) {
|
|
4996
|
+
const convId = partial.conversationId ?? this.activeId;
|
|
4997
|
+
const conv = this.convs.get(convId);
|
|
4998
|
+
if (!conv)
|
|
4999
|
+
return;
|
|
5000
|
+
const cur = conv.approvalPolicy;
|
|
5001
|
+
const next = {
|
|
5002
|
+
allowAll: partial.allowAll ?? cur?.allowAll ?? false,
|
|
5003
|
+
categories: new Map(cur?.categories ?? []),
|
|
5004
|
+
};
|
|
5005
|
+
if (partial.categories) {
|
|
5006
|
+
const keep = new Set(partial.categories);
|
|
5007
|
+
// eslint-disable-next-line unicorn/no-useless-spread -- 快照:循环里会从 map 删项(迭代中改集合)
|
|
5008
|
+
for (const key of [...next.categories.keys()])
|
|
5009
|
+
if (!keep.has(key))
|
|
5010
|
+
next.categories.delete(key);
|
|
5011
|
+
}
|
|
5012
|
+
conv.approvalPolicy = isApprovalPolicyEmpty(next) ? undefined : next;
|
|
5013
|
+
this.settingsSvc.push();
|
|
5014
|
+
this.flushSnapshot();
|
|
5015
|
+
}
|
|
5016
|
+
pendingApprovalForSnapshot() {
|
|
5017
|
+
for (const [id, p] of this.pendingApprovals) {
|
|
5018
|
+
if (p.conversationId !== undefined && p.conversationId !== this.activeId)
|
|
5019
|
+
continue;
|
|
5020
|
+
return {
|
|
5021
|
+
id,
|
|
5022
|
+
toolCallId: p.toolCallId,
|
|
5023
|
+
toolName: p.toolName,
|
|
5024
|
+
params: p.params,
|
|
5025
|
+
reason: p.reason,
|
|
5026
|
+
reasonEn: p.reasonEn,
|
|
5027
|
+
...(p.category ? { category: p.category } : {}),
|
|
5028
|
+
conversationId: p.conversationId,
|
|
5029
|
+
conversationTitle: p.conversationTitle,
|
|
5030
|
+
};
|
|
3744
5031
|
}
|
|
3745
|
-
return
|
|
5032
|
+
return null;
|
|
3746
5033
|
}
|
|
3747
|
-
|
|
3748
|
-
|
|
5034
|
+
updatePlan(steps, activeStepId, conversationId) {
|
|
5035
|
+
const convId = conversationId ?? this.activeId;
|
|
5036
|
+
const plan = this.planManager.setPlan(convId, steps, activeStepId);
|
|
3749
5037
|
this.emit({
|
|
3750
|
-
type: "
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
id: q.id,
|
|
3754
|
-
questions: q.questions,
|
|
3755
|
-
...(q.conversationTitle ? { conversationTitle: q.conversationTitle } : {}),
|
|
5038
|
+
type: "plan_updated",
|
|
5039
|
+
conversationId: convId,
|
|
5040
|
+
plan,
|
|
3756
5041
|
});
|
|
5042
|
+
this.flushSnapshot();
|
|
3757
5043
|
}
|
|
3758
5044
|
/** 关闭所有挂起提问(dispose 时清理):以「取消」解析,避免模型挂死。 */
|
|
3759
5045
|
cancelPendingQuestions() {
|
|
@@ -3762,6 +5048,20 @@ export class ClientSession {
|
|
|
3762
5048
|
}
|
|
3763
5049
|
this.pendingQuestions.clear();
|
|
3764
5050
|
}
|
|
5051
|
+
/** 关闭所有挂起审批(dispose 时清理):以「拒绝」解析,避免等答复的
|
|
5052
|
+
* Promise 与对应 runtime 泄漏(会话没了,审批弹窗永远不会有人点)。 */
|
|
5053
|
+
cancelPendingApprovals() {
|
|
5054
|
+
for (const [id, a] of this.pendingApprovals) {
|
|
5055
|
+
this.pendingApprovals.delete(id);
|
|
5056
|
+
try {
|
|
5057
|
+
a.resolve({ decision: "deny", reason: "会话已关闭" });
|
|
5058
|
+
}
|
|
5059
|
+
catch {
|
|
5060
|
+
// 单个 resolve 异常不影响其余清理
|
|
5061
|
+
}
|
|
5062
|
+
this.emit({ type: "tool_approval_resolved", id });
|
|
5063
|
+
}
|
|
5064
|
+
}
|
|
3765
5065
|
// -----------------------------------------------------------------------
|
|
3766
5066
|
// 浏览器页面桥(标准 pi 引擎 browser_page customTool)
|
|
3767
5067
|
// -----------------------------------------------------------------------
|
|
@@ -3945,6 +5245,10 @@ export class ClientSession {
|
|
|
3945
5245
|
// Windows: npm and friends are .cmd shims — Node can only exec
|
|
3946
5246
|
// them through the shell (otherwise spawn npm → ENOENT).
|
|
3947
5247
|
shell: process.platform === "win32",
|
|
5248
|
+
// posix 下让子进程自成进程组:超时时能整组杀掉(孙进程不残留)。
|
|
5249
|
+
// 代价是父进程退出后子进程可能短暂存活——这些都是带超时的短命令,
|
|
5250
|
+
// 可接受;win32 不需要(走 taskkill /T)。
|
|
5251
|
+
detached: process.platform !== "win32",
|
|
3948
5252
|
});
|
|
3949
5253
|
}
|
|
3950
5254
|
catch (err) {
|
|
@@ -3960,7 +5264,27 @@ export class ClientSession {
|
|
|
3960
5264
|
clearTimeout(t);
|
|
3961
5265
|
resolve({ code, out: text ?? out });
|
|
3962
5266
|
};
|
|
3963
|
-
const t = setTimeout(() =>
|
|
5267
|
+
const t = setTimeout(() => {
|
|
5268
|
+
// 只 p.kill() 杀不掉整棵树(win32 下杀的是 cmd.exe 壳,posix 下
|
|
5269
|
+
// 杀不到孙进程)—— 按平台走整树杀,失败再退回 p.kill 兜底。
|
|
5270
|
+
const plan = processTreeKillPlan(process.platform, p.pid);
|
|
5271
|
+
try {
|
|
5272
|
+
if (plan.kind === "taskkill") {
|
|
5273
|
+
// taskkill 独立进程执行:父 cmd 死活不影响命中目标树。
|
|
5274
|
+
spawn(plan.cmd, plan.args, { stdio: "ignore", windowsHide: true });
|
|
5275
|
+
}
|
|
5276
|
+
else if (plan.kind === "group-signal") {
|
|
5277
|
+
process.kill(-p.pid, plan.signal);
|
|
5278
|
+
}
|
|
5279
|
+
else {
|
|
5280
|
+
p.kill();
|
|
5281
|
+
}
|
|
5282
|
+
}
|
|
5283
|
+
catch {
|
|
5284
|
+
// 进程组已不在(正常退出竞态)等 —— 退回直接杀。
|
|
5285
|
+
p.kill();
|
|
5286
|
+
}
|
|
5287
|
+
}, timeoutMs);
|
|
3964
5288
|
p.stdout?.on("data", (d) => (out += d.toString()));
|
|
3965
5289
|
p.stderr?.on("data", (d) => (out += d.toString()));
|
|
3966
5290
|
p.on("error", (err) => done(-1, String(err)));
|
|
@@ -4075,26 +5399,176 @@ export class ClientSession {
|
|
|
4075
5399
|
/** Cache window for the all-source check: 30 minutes. */
|
|
4076
5400
|
static UPDATE_ALL_CACHE_MS = 30 * 60_000;
|
|
4077
5401
|
updatesAllCache = null;
|
|
5402
|
+
notifiedPluginUpdates = new Set();
|
|
5403
|
+
lastPluginUpdates = [];
|
|
5404
|
+
/**
|
|
5405
|
+
* 主动检查已安装界面插件(<dataDir>/plugins)的更新状态并向客户端推送。
|
|
5406
|
+
*/
|
|
5407
|
+
async checkPluginUpdates(manual = false) {
|
|
5408
|
+
const lang = () => this.getLang();
|
|
5409
|
+
try {
|
|
5410
|
+
const updates = await checkPluginUpdates(this.stateStore.dataDir, undefined, lang);
|
|
5411
|
+
const list = updates.map((p) => ({
|
|
5412
|
+
id: p.id,
|
|
5413
|
+
name: p.name,
|
|
5414
|
+
version: p.version,
|
|
5415
|
+
latestVersion: p.latestVersion ?? null,
|
|
5416
|
+
source: p.source,
|
|
5417
|
+
localSha: p.localSha,
|
|
5418
|
+
remoteSha: p.remoteSha,
|
|
5419
|
+
updatable: p.updatable,
|
|
5420
|
+
builtin: p.builtin,
|
|
5421
|
+
error: p.error,
|
|
5422
|
+
}));
|
|
5423
|
+
this.lastPluginUpdates = list;
|
|
5424
|
+
this.emit({ type: "plugin_updates", updates: list });
|
|
5425
|
+
const updatableBuiltins = list.filter((p) => p.builtin && p.updatable);
|
|
5426
|
+
if (manual) {
|
|
5427
|
+
if (updatableBuiltins.length > 0) {
|
|
5428
|
+
const names = updatableBuiltins.map((p) => p.name || p.id).join(", ");
|
|
5429
|
+
this.emit({
|
|
5430
|
+
type: "notice",
|
|
5431
|
+
level: "info",
|
|
5432
|
+
text: `发现 ${updatableBuiltins.length} 个内置插件有更新:${names}`,
|
|
5433
|
+
textEn: `Update available for ${updatableBuiltins.length} built-in plugin(s): ${names}`,
|
|
5434
|
+
});
|
|
5435
|
+
}
|
|
5436
|
+
else {
|
|
5437
|
+
const allUpdatable = list.filter((p) => p.updatable);
|
|
5438
|
+
if (allUpdatable.length > 0) {
|
|
5439
|
+
const names = allUpdatable.map((p) => p.name || p.id).join(", ");
|
|
5440
|
+
this.emit({
|
|
5441
|
+
type: "notice",
|
|
5442
|
+
level: "info",
|
|
5443
|
+
text: `发现 ${allUpdatable.length} 个插件有更新:${names}`,
|
|
5444
|
+
textEn: `Update available for ${allUpdatable.length} plugin(s): ${names}`,
|
|
5445
|
+
});
|
|
5446
|
+
}
|
|
5447
|
+
else {
|
|
5448
|
+
this.emit({
|
|
5449
|
+
type: "notice",
|
|
5450
|
+
level: "info",
|
|
5451
|
+
text: "所有插件均为最新版本。",
|
|
5452
|
+
textEn: "All plugins are up to date.",
|
|
5453
|
+
});
|
|
5454
|
+
}
|
|
5455
|
+
}
|
|
5456
|
+
}
|
|
5457
|
+
else {
|
|
5458
|
+
const newUpdatables = updatableBuiltins.filter((p) => {
|
|
5459
|
+
const key = `${p.id}@${p.latestVersion || p.remoteSha || "upd"}`;
|
|
5460
|
+
if (this.notifiedPluginUpdates.has(key))
|
|
5461
|
+
return false;
|
|
5462
|
+
this.notifiedPluginUpdates.add(key);
|
|
5463
|
+
return true;
|
|
5464
|
+
});
|
|
5465
|
+
if (newUpdatables.length > 0) {
|
|
5466
|
+
const names = newUpdatables.map((p) => p.name || p.id).join(", ");
|
|
5467
|
+
this.emit({
|
|
5468
|
+
type: "notice",
|
|
5469
|
+
level: "info",
|
|
5470
|
+
text: `发现 ${newUpdatables.length} 个内置插件有更新可用:${names},可前往设置或更新面板中更新。`,
|
|
5471
|
+
textEn: `Update available for ${newUpdatables.length} built-in plugin(s): ${names}. You can update in Settings or the Updates panel.`,
|
|
5472
|
+
});
|
|
5473
|
+
}
|
|
5474
|
+
}
|
|
5475
|
+
}
|
|
5476
|
+
catch (err) {
|
|
5477
|
+
if (manual) {
|
|
5478
|
+
this.emit({
|
|
5479
|
+
type: "notice",
|
|
5480
|
+
level: "error",
|
|
5481
|
+
text: `检查插件更新失败:${err.message}`,
|
|
5482
|
+
textEn: `Failed to check plugin updates: ${err.message}`,
|
|
5483
|
+
});
|
|
5484
|
+
}
|
|
5485
|
+
}
|
|
5486
|
+
}
|
|
4078
5487
|
/**
|
|
4079
5488
|
* All-source update check: pi-web-ui + the pi core + direct pi extensions
|
|
4080
|
-
* from the agent manifest (fallback: raw walk). Re-emits the cached list
|
|
5489
|
+
* from the agent manifest (fallback: raw walk) + installed UI plugins. Re-emits the cached list
|
|
4081
5490
|
* within UPDATE_ALL_CACHE_MS; pass force=true (explicit refresh) to bypass.
|
|
4082
5491
|
*/
|
|
4083
5492
|
async checkUpdatesAll(force = false) {
|
|
5493
|
+
// issue #321:pi SDK 副本状态随结果下发(运行中是哪份 / 是否自带 / 机器上有没有
|
|
5494
|
+
// 更新的),UI 据此在更新面板亮「重启跟上」与「安装全局引擎并切换」入口。
|
|
5495
|
+
// 缓存命中也现算:用户升级全局 pi 不经本服务,缓存的 items 不影响这个判据,
|
|
5496
|
+
// 探针本身有 10s memoize,重算便宜。
|
|
5497
|
+
const piSdk = {
|
|
5498
|
+
running: VERSION,
|
|
5499
|
+
bundledInUse: isBundledInUse(sdkCopies(), VERSION),
|
|
5500
|
+
newerInstalled: detectPiSdkSplit(VERSION)?.installed ?? null,
|
|
5501
|
+
};
|
|
4084
5502
|
if (!force && this.updatesAllCache && Date.now() - this.updatesAllCache.at < ClientSession.UPDATE_ALL_CACHE_MS) {
|
|
4085
5503
|
this.emit({
|
|
4086
5504
|
type: "update_status_all",
|
|
4087
5505
|
items: this.updatesAllCache.items,
|
|
5506
|
+
piSdk,
|
|
4088
5507
|
});
|
|
5508
|
+
if (this.lastPluginUpdates.length > 0) {
|
|
5509
|
+
this.emit({ type: "plugin_updates", updates: this.lastPluginUpdates });
|
|
5510
|
+
}
|
|
4089
5511
|
return;
|
|
4090
5512
|
}
|
|
4091
5513
|
try {
|
|
4092
5514
|
const targets = collectTargets(this.agentDir, ClientSession.currentAppVersion(), undefined, {
|
|
4093
|
-
projectCwd: this.
|
|
5515
|
+
projectCwd: this.convs.get(this.activeId)?.cwd ?? this.cwd,
|
|
4094
5516
|
});
|
|
4095
|
-
const items =
|
|
4096
|
-
|
|
4097
|
-
|
|
5517
|
+
const items = await checkAllUpdates(targets, undefined, () => this.getLang(), resolveNpmRegistry(this.agentDir));
|
|
5518
|
+
let pluginItems = [];
|
|
5519
|
+
try {
|
|
5520
|
+
const pluginUpdates = await checkPluginUpdates(this.stateStore.dataDir, undefined, () => this.getLang());
|
|
5521
|
+
const list = pluginUpdates.map((p) => ({
|
|
5522
|
+
id: p.id,
|
|
5523
|
+
name: p.name,
|
|
5524
|
+
version: p.version,
|
|
5525
|
+
latestVersion: p.latestVersion ?? null,
|
|
5526
|
+
source: p.source,
|
|
5527
|
+
localSha: p.localSha,
|
|
5528
|
+
remoteSha: p.remoteSha,
|
|
5529
|
+
updatable: p.updatable,
|
|
5530
|
+
builtin: p.builtin,
|
|
5531
|
+
error: p.error,
|
|
5532
|
+
}));
|
|
5533
|
+
this.lastPluginUpdates = list;
|
|
5534
|
+
this.emit({ type: "plugin_updates", updates: list });
|
|
5535
|
+
pluginItems = pluginUpdates.map((p) => ({
|
|
5536
|
+
name: p.name ? `${p.name} (${p.id})` : p.id,
|
|
5537
|
+
kind: "plugin",
|
|
5538
|
+
current: p.version ? `v${p.version}` : (p.localSha ?? "unknown"),
|
|
5539
|
+
latest: p.latestVersion ? `v${p.latestVersion}` : (p.remoteSha ?? null),
|
|
5540
|
+
latestPublishedAt: null,
|
|
5541
|
+
upToDate: !p.updatable,
|
|
5542
|
+
error: p.error,
|
|
5543
|
+
source: p.source,
|
|
5544
|
+
pluginId: p.id,
|
|
5545
|
+
builtin: p.builtin,
|
|
5546
|
+
}));
|
|
5547
|
+
const newUpdatables = pluginUpdates
|
|
5548
|
+
.filter((p) => p.builtin && p.updatable)
|
|
5549
|
+
.filter((p) => {
|
|
5550
|
+
const key = `${p.id}@${p.latestVersion || p.remoteSha || "upd"}`;
|
|
5551
|
+
if (this.notifiedPluginUpdates.has(key))
|
|
5552
|
+
return false;
|
|
5553
|
+
this.notifiedPluginUpdates.add(key);
|
|
5554
|
+
return true;
|
|
5555
|
+
});
|
|
5556
|
+
if (newUpdatables.length > 0) {
|
|
5557
|
+
const names = newUpdatables.map((p) => p.name || p.id).join(", ");
|
|
5558
|
+
this.emit({
|
|
5559
|
+
type: "notice",
|
|
5560
|
+
level: "info",
|
|
5561
|
+
text: `发现 ${newUpdatables.length} 个内置插件有更新可用:${names},可前往设置或更新面板中更新。`,
|
|
5562
|
+
textEn: `Update available for ${newUpdatables.length} built-in plugin(s): ${names}. You can update in Settings or the Updates panel.`,
|
|
5563
|
+
});
|
|
5564
|
+
}
|
|
5565
|
+
}
|
|
5566
|
+
catch (err) {
|
|
5567
|
+
console.warn("[agent-service] 检查插件更新失败:", err);
|
|
5568
|
+
}
|
|
5569
|
+
const allItems = sortUpdateItems([...items, ...pluginItems]);
|
|
5570
|
+
this.updatesAllCache = { at: Date.now(), items: allItems };
|
|
5571
|
+
this.emit({ type: "update_status_all", items: allItems, piSdk });
|
|
4098
5572
|
}
|
|
4099
5573
|
catch (err) {
|
|
4100
5574
|
// checkAll degrades per-item; only local enumeration blowing up lands
|
|
@@ -4229,7 +5703,7 @@ export class ClientSession {
|
|
|
4229
5703
|
// /reload 同样重读磁盘 settings.json——重放重试覆盖 + 软上限覆盖 + 终端门控。
|
|
4230
5704
|
this.applyRetryOverrides();
|
|
4231
5705
|
this.applyCompactionOverrides();
|
|
4232
|
-
this.applyToolGating(this.session);
|
|
5706
|
+
this.applyToolGating(this.session, this.conv?.agentPreset);
|
|
4233
5707
|
},
|
|
4234
5708
|
pluginCommands: () => this.pluginCommandsProvider?.() ?? [],
|
|
4235
5709
|
execPluginCommand: async (name, args) => {
|
|
@@ -4324,8 +5798,11 @@ export class ClientSession {
|
|
|
4324
5798
|
reloadModelsConfig() {
|
|
4325
5799
|
return this.modelAdmin.reloadModelsConfig();
|
|
4326
5800
|
}
|
|
4327
|
-
fetchModelsList(reqId, baseUrl, apiKey, authHeader, api) {
|
|
4328
|
-
return this.modelAdmin.fetchModelsList(reqId, baseUrl, apiKey, authHeader, api, () => this.getLang());
|
|
5801
|
+
fetchModelsList(reqId, baseUrl, apiKey, authHeader, api, providerId) {
|
|
5802
|
+
return this.modelAdmin.fetchModelsList(reqId, baseUrl, apiKey, authHeader, api, () => this.getLang(), providerId);
|
|
5803
|
+
}
|
|
5804
|
+
testModelConnection(reqId, baseUrl, apiKey, authHeader, api, providerId) {
|
|
5805
|
+
return this.modelAdmin.testConnection(reqId, baseUrl, apiKey, authHeader, api, () => this.getLang(), providerId);
|
|
4329
5806
|
}
|
|
4330
5807
|
refreshProviderModels(providerId, reqId) {
|
|
4331
5808
|
return this.modelAdmin.refreshProviderModels(providerId, reqId, () => this.getLang());
|
|
@@ -4591,6 +6068,11 @@ export class ClientSession {
|
|
|
4591
6068
|
markerChanged = true;
|
|
4592
6069
|
}
|
|
4593
6070
|
await this.settingsSvc.set(rest);
|
|
6071
|
+
// 审批总开关被关掉 → 挂着的待审批全部按批准放行(否则弹窗还在等人点,
|
|
6072
|
+
// 而用户已经明确表示「不要再问我了」)。
|
|
6073
|
+
if (rest.toolApprovalEnabled === false) {
|
|
6074
|
+
this.autoApprovePendingApprovals("审批已全局关闭", "Tool approval was disabled globally");
|
|
6075
|
+
}
|
|
4594
6076
|
if (rest.disabledPluginTools !== undefined) {
|
|
4595
6077
|
this.refreshPluginTools();
|
|
4596
6078
|
this.flushSnapshot();
|
|
@@ -4605,7 +6087,7 @@ export class ClientSession {
|
|
|
4605
6087
|
await this.session.reload();
|
|
4606
6088
|
this.applyRetryOverrides();
|
|
4607
6089
|
this.applyCompactionOverrides();
|
|
4608
|
-
this.applyToolGating(this.session);
|
|
6090
|
+
this.applyToolGating(this.session, this.conv?.agentPreset);
|
|
4609
6091
|
await this.pushSlashCommands();
|
|
4610
6092
|
this.pushSettings();
|
|
4611
6093
|
}
|
|
@@ -4637,6 +6119,22 @@ export class ClientSession {
|
|
|
4637
6119
|
async saveSubagentTemplate(template) {
|
|
4638
6120
|
return this.settingsSvc.saveTemplate(template);
|
|
4639
6121
|
}
|
|
6122
|
+
/** 保存一条审批规则(全局共享)。 */
|
|
6123
|
+
async saveApprovalRule(rule) {
|
|
6124
|
+
return this.settingsSvc.saveApprovalRule(rule);
|
|
6125
|
+
}
|
|
6126
|
+
/** 批量保存审批规则(全局共享)。 */
|
|
6127
|
+
async saveApprovalRules(rules) {
|
|
6128
|
+
return this.settingsSvc.saveApprovalRules(rules);
|
|
6129
|
+
}
|
|
6130
|
+
/** 删除一条自定义审批规则。 */
|
|
6131
|
+
async deleteApprovalRule(id) {
|
|
6132
|
+
return this.settingsSvc.deleteApprovalRule(id);
|
|
6133
|
+
}
|
|
6134
|
+
/** 恢复某条内置审批规则到系统默认。 */
|
|
6135
|
+
async resetBuiltinApprovalRule(id) {
|
|
6136
|
+
return this.settingsSvc.resetBuiltinApprovalRule(id);
|
|
6137
|
+
}
|
|
4640
6138
|
/** 删除一个子代理模板。 */
|
|
4641
6139
|
async deleteSubagentTemplate(name) {
|
|
4642
6140
|
return this.settingsSvc.deleteTemplate(name);
|
|
@@ -4645,13 +6143,65 @@ export class ClientSession {
|
|
|
4645
6143
|
async applyRuntimeSettings() {
|
|
4646
6144
|
return this.settingsSvc.applyRuntime();
|
|
4647
6145
|
}
|
|
6146
|
+
/** 按会话对象反查所属对话的预设(创建早期对话还没进 map 时返回 undefined,
|
|
6147
|
+
* 调用方回落默认预设;比按 activeId 猜更准——后台对话的门控不再吃当前页的预设)。 */
|
|
6148
|
+
presetOfSession(session) {
|
|
6149
|
+
for (const c of this.convs.values())
|
|
6150
|
+
if (c.session === session)
|
|
6151
|
+
return c.agentPreset;
|
|
6152
|
+
return undefined;
|
|
6153
|
+
}
|
|
6154
|
+
/**
|
|
6155
|
+
* read / write / edit 三处覆盖的注入规格:基底 = 扩展注册的同名工具优先,否则 SDK 内置实现
|
|
6156
|
+
* (见 tool-overrides.ts —— 这三处覆盖不能塞进创建时的 `customTools`,那会静默顶掉扩展的
|
|
6157
|
+
* 同名工具,而官方 docs/extensions.md 明写扩展可覆盖 read/write/edit)。
|
|
6158
|
+
*/
|
|
6159
|
+
toolOverrideSpecs(ownerId, cwd) {
|
|
6160
|
+
const currentPermission = () => (ownerId ? this.convs.get(ownerId)?.permissionPreset : undefined) ??
|
|
6161
|
+
this.settingsSvc.current.defaultPermissionPreset ??
|
|
6162
|
+
"workspace-write-never";
|
|
6163
|
+
const approve = (toolCallId, toolName, params, reason, reasonEn, convId, category) => this.askApproval(toolCallId, toolName, params, reason, reasonEn, convId, category);
|
|
6164
|
+
// 行为开关(read 本体不可关):每次调用实时读设置 —— 不进 tool-manager 的 ActiveSet 目录。
|
|
6165
|
+
const readDirOptions = {
|
|
6166
|
+
dirEnabled: () => this.settingsSvc.current.readDirEnabled !== false,
|
|
6167
|
+
getLang: () => this.getLang(),
|
|
6168
|
+
};
|
|
6169
|
+
const readGuardOptions = {
|
|
6170
|
+
toolName: "read",
|
|
6171
|
+
guard: this.toolGuard,
|
|
6172
|
+
conversationId: () => ownerId,
|
|
6173
|
+
getLang: () => this.getLang(),
|
|
6174
|
+
cwd,
|
|
6175
|
+
getRoots: () => this.roots,
|
|
6176
|
+
askApproval: approve,
|
|
6177
|
+
getRules: () => this.approvalRules.list(),
|
|
6178
|
+
};
|
|
6179
|
+
// 写/编的权限沙箱包装:同一个函数,有扩展同名工具时把它的定义当基底(末参)。
|
|
6180
|
+
const composeWrite = (base) => wrapWriteToolWithPermission(cwd, currentPermission, () => this.roots, () => this.getLang(), approve, () => ownerId, () => this.approvalRules.list(), base);
|
|
6181
|
+
const composeEdit = (base) => wrapEditToolWithPermission(cwd, currentPermission, () => this.roots, () => this.getLang(), approve, () => ownerId, () => this.approvalRules.list(), base);
|
|
6182
|
+
return [
|
|
6183
|
+
{
|
|
6184
|
+
name: "read",
|
|
6185
|
+
// 没有扩展 read:完整覆盖(内置基底 + 英文描述 + file_path 别名)。
|
|
6186
|
+
fallback: () => withToolGuard(makeReadDirTool(cwd, readDirOptions), readGuardOptions),
|
|
6187
|
+
// 有扩展 read:只叠「目录列条目」,它的锚协议/独有参数/渲染全保留(行为委托它)。
|
|
6188
|
+
composeWith: (base) => withToolGuard(withReadDirSupport(base, cwd, readDirOptions), readGuardOptions),
|
|
6189
|
+
},
|
|
6190
|
+
{ name: "write", fallback: () => composeWrite(), composeWith: (base) => composeWrite(base) },
|
|
6191
|
+
{ name: "edit", fallback: () => composeEdit(), composeWith: (base) => composeEdit(base) },
|
|
6192
|
+
];
|
|
6193
|
+
}
|
|
4648
6194
|
/** 统一工具门控(tool_manage 唯一落点):按 disabledAgentTools 把目录内工具
|
|
4649
6195
|
* 逐个加回/剔除活跃集(工具仍留在注册表,重开可直接加回;live 生效无需
|
|
4650
|
-
* reload
|
|
6196
|
+
* reload)。支持按当前会话预设(preset)进行工具过滤。
|
|
6197
|
+
* session.reload() 与新会话创建都会把 custom 工具加回活跃集,
|
|
4651
6198
|
* 所以这两条路径之后都要重放本方法(见 reloadSession/创建处)。 */
|
|
4652
|
-
applyToolGating(session) {
|
|
4653
|
-
|
|
4654
|
-
this.
|
|
6199
|
+
applyToolGating(session, preset) {
|
|
6200
|
+
const targetPreset = preset ?? this.presetOfSession(session) ?? this.settingsSvc.current.defaultAgentPreset ?? "standard";
|
|
6201
|
+
applyAgentToolsGating(session, effectiveDisabledAgentTools(this.settingsSvc.current), targetPreset);
|
|
6202
|
+
this.syncPluginTools(session, targetPreset);
|
|
6203
|
+
this.sessionStatsCache = null;
|
|
6204
|
+
this.cachedBaseTokens = null;
|
|
4655
6205
|
// SDK 的 setActiveToolsByName 只改 agent.state.tools,不派发任何事件——门控后
|
|
4656
6206
|
// 主动推一次快照,否则快照里的 tools 要等下一个 SDK 事件才对齐(会话空闲时永远
|
|
4657
6207
|
// 等不到;回归:tests/terminal-smoke-test.mjs「agent exposes persistent terminal tools」)。
|
|
@@ -4660,9 +6210,153 @@ export class ClientSession {
|
|
|
4660
6210
|
if (active && active.session === session)
|
|
4661
6211
|
this.flushSnapshot();
|
|
4662
6212
|
}
|
|
6213
|
+
/** 判断是否为空白对话(无消息、无队列、未在流式生成)。 */
|
|
6214
|
+
isBlankConversation(c) {
|
|
6215
|
+
try {
|
|
6216
|
+
return (c.session.state.messages.length === 0 &&
|
|
6217
|
+
c.queueSteering.length === 0 &&
|
|
6218
|
+
c.queueFollowUp.length === 0 &&
|
|
6219
|
+
!c.session.isStreaming);
|
|
6220
|
+
}
|
|
6221
|
+
catch {
|
|
6222
|
+
return false;
|
|
6223
|
+
}
|
|
6224
|
+
}
|
|
6225
|
+
/** 推送 Agent 预设名录(UI 预设条用)。 */
|
|
6226
|
+
async refreshAgentPresets() {
|
|
6227
|
+
this.emit({
|
|
6228
|
+
type: "dsh_presets",
|
|
6229
|
+
presets: PI_AGENT_PRESETS,
|
|
6230
|
+
defaultPreset: this.settingsSvc.current.defaultAgentPreset ?? "standard",
|
|
6231
|
+
});
|
|
6232
|
+
}
|
|
6233
|
+
/** 切换当前空白会话的预设。 */
|
|
6234
|
+
async selectAgentPreset(preset) {
|
|
6235
|
+
const conv = this.conv;
|
|
6236
|
+
if (conv.presetLocked || !this.isBlankConversation(conv)) {
|
|
6237
|
+
conv.presetLocked = true;
|
|
6238
|
+
this.emit({
|
|
6239
|
+
type: "notice",
|
|
6240
|
+
level: "warning",
|
|
6241
|
+
text: `会话已开始,预设锁定为「${PI_AGENT_PRESETS.find((p) => p.id === conv.agentPreset)?.name ?? conv.agentPreset}」(空白会话可切换)`,
|
|
6242
|
+
textEn: `Session already started; preset locked to "${conv.agentPreset}" (only blank sessions can switch)`,
|
|
6243
|
+
});
|
|
6244
|
+
this.flushSnapshot();
|
|
6245
|
+
return;
|
|
6246
|
+
}
|
|
6247
|
+
const hit = PI_AGENT_PRESETS.find((p) => p.id === preset);
|
|
6248
|
+
if (!hit) {
|
|
6249
|
+
this.emit({
|
|
6250
|
+
type: "notice",
|
|
6251
|
+
level: "warning",
|
|
6252
|
+
text: `未知预设「${preset}」`,
|
|
6253
|
+
textEn: `Unknown preset "${preset}"`,
|
|
6254
|
+
});
|
|
6255
|
+
return;
|
|
6256
|
+
}
|
|
6257
|
+
conv.agentPreset = hit.id;
|
|
6258
|
+
this.applyToolGating(conv.session, hit.id);
|
|
6259
|
+
this.sessionStatsCache = null;
|
|
6260
|
+
this.cachedBaseTokens = null;
|
|
6261
|
+
// 技能名录段/终端引导是按 run 组装的提示词:重载 resourceLoader 让新预设
|
|
6262
|
+
// 即时生效(只有空白会话能切到这里,无历史可丢)。
|
|
6263
|
+
try {
|
|
6264
|
+
await conv.session.resourceLoader.reload();
|
|
6265
|
+
}
|
|
6266
|
+
catch {
|
|
6267
|
+
// loader 未就绪——首轮 run 组装时自然读到新预设。
|
|
6268
|
+
}
|
|
6269
|
+
this.emit({
|
|
6270
|
+
type: "notice",
|
|
6271
|
+
level: "info",
|
|
6272
|
+
text: `已切换为「${hit.name}」预设`,
|
|
6273
|
+
textEn: `Switched to preset "${hit.name}"`,
|
|
6274
|
+
});
|
|
6275
|
+
this.pushSettings();
|
|
6276
|
+
this.flushSnapshot();
|
|
6277
|
+
}
|
|
6278
|
+
/** 设置新会话默认预设。 */
|
|
6279
|
+
async setDefaultAgentPreset(preset) {
|
|
6280
|
+
const hit = PI_AGENT_PRESETS.find((p) => p.id === preset);
|
|
6281
|
+
if (!hit) {
|
|
6282
|
+
this.emit({
|
|
6283
|
+
type: "notice",
|
|
6284
|
+
level: "warning",
|
|
6285
|
+
text: `未知预设「${preset}」,默认预设未改`,
|
|
6286
|
+
textEn: `Unknown preset "${preset}"; default preset unchanged`,
|
|
6287
|
+
});
|
|
6288
|
+
return;
|
|
6289
|
+
}
|
|
6290
|
+
this.settingsSvc.current.defaultAgentPreset = hit.id;
|
|
6291
|
+
this.stateStore.saveSettings(this.clientId, { defaultAgentPreset: hit.id });
|
|
6292
|
+
this.pushSettings();
|
|
6293
|
+
this.refreshAgentPresets();
|
|
6294
|
+
this.flushSnapshot();
|
|
6295
|
+
}
|
|
6296
|
+
/** 推送权限选项表 + 默认权限。 */
|
|
6297
|
+
async refreshPermission() {
|
|
6298
|
+
this.emit({
|
|
6299
|
+
type: "dsh_permission",
|
|
6300
|
+
options: PI_PERMISSION_OPTIONS,
|
|
6301
|
+
defaultPreset: this.settingsSvc.current.defaultPermissionPreset ?? "workspace-write-never",
|
|
6302
|
+
});
|
|
6303
|
+
}
|
|
6304
|
+
/** 切换当前会话的权限预设(热生效)。 */
|
|
6305
|
+
async setPermissionPreset(preset) {
|
|
6306
|
+
const hit = PI_PERMISSION_OPTIONS.find((p) => p.value === preset);
|
|
6307
|
+
if (!hit) {
|
|
6308
|
+
this.emit({
|
|
6309
|
+
type: "notice",
|
|
6310
|
+
level: "warning",
|
|
6311
|
+
text: `未知权限预设「${preset}」`,
|
|
6312
|
+
textEn: `Unknown permission preset "${preset}"`,
|
|
6313
|
+
});
|
|
6314
|
+
return;
|
|
6315
|
+
}
|
|
6316
|
+
if (this.conv.permissionPreset === hit.value) {
|
|
6317
|
+
return;
|
|
6318
|
+
}
|
|
6319
|
+
this.conv.permissionPreset = hit.value;
|
|
6320
|
+
try {
|
|
6321
|
+
// 持久会话:将权限变更写入会话转录日志,切会话/切项目重载时不丢失
|
|
6322
|
+
const sm = this.conv.session.sessionManager;
|
|
6323
|
+
sm?.appendCustomEntry?.("permission/preset", { preset: hit.value });
|
|
6324
|
+
}
|
|
6325
|
+
catch {
|
|
6326
|
+
// best effort for in-memory sessions
|
|
6327
|
+
}
|
|
6328
|
+
this.emit({
|
|
6329
|
+
type: "notice",
|
|
6330
|
+
level: "info",
|
|
6331
|
+
text: `当前会话权限已切换为「${hit.name}」`,
|
|
6332
|
+
textEn: `Current session permission switched to "${hit.name}"`,
|
|
6333
|
+
});
|
|
6334
|
+
this.flushSnapshot();
|
|
6335
|
+
}
|
|
6336
|
+
/** 设置新会话默认权限预设。 */
|
|
6337
|
+
async setDefaultPermissionPreset(preset) {
|
|
6338
|
+
const hit = PI_PERMISSION_OPTIONS.find((p) => p.value === preset);
|
|
6339
|
+
if (!hit) {
|
|
6340
|
+
this.emit({
|
|
6341
|
+
type: "notice",
|
|
6342
|
+
level: "warning",
|
|
6343
|
+
text: `未知权限预设「${preset}」,默认未改`,
|
|
6344
|
+
textEn: `Unknown permission preset "${preset}"; default unchanged`,
|
|
6345
|
+
});
|
|
6346
|
+
return;
|
|
6347
|
+
}
|
|
6348
|
+
this.settingsSvc.current.defaultPermissionPreset = hit.value;
|
|
6349
|
+
this.stateStore.saveSettings(this.clientId, { defaultPermissionPreset: hit.value });
|
|
6350
|
+
this.refreshPermission();
|
|
6351
|
+
this.flushSnapshot();
|
|
6352
|
+
}
|
|
4663
6353
|
/** 当前启用的插件 AI 工具定义(provider 快照按 disabledPluginTools 过滤;
|
|
4664
|
-
* 未知/已卸载插件的禁用条目保留但不影响现有工具)。
|
|
4665
|
-
|
|
6354
|
+
* 未知/已卸载插件的禁用条目保留但不影响现有工具)。
|
|
6355
|
+
* 预设是第二层门控(见 tool-manager.ts 语义总表):非 standard 预设下插件工具
|
|
6356
|
+
* 一律不可用(读写未知,保守处理),此时返回空表,调用方负责从会话移除。 */
|
|
6357
|
+
enabledPluginToolDefs(preset) {
|
|
6358
|
+
if (!presetAllowsPluginTools(preset))
|
|
6359
|
+
return [];
|
|
4666
6360
|
const off = new Set(normalizeDisabledPluginTools(this.settingsSvc.current.disabledPluginTools));
|
|
4667
6361
|
return (this.pluginToolsProvider?.() ?? []).filter((t) => !off.has(t.name)).map(pluginToolToDefinition);
|
|
4668
6362
|
}
|
|
@@ -4670,14 +6364,24 @@ export class ClientSession {
|
|
|
4670
6364
|
* 实际 diff 逻辑在 plugins.ts 的 syncPluginToolsIntoSession(可单测)。
|
|
4671
6365
|
* 模板白名单的子代理(subagentBarsPluginTools)跳过:工厂期就没注册,这里
|
|
4672
6366
|
* 不回补,否则白名单等于没关门。 */
|
|
4673
|
-
syncPluginTools(session) {
|
|
6367
|
+
syncPluginTools(session, preset) {
|
|
6368
|
+
let targetPreset = preset;
|
|
4674
6369
|
for (const conv of this.convs.values()) {
|
|
4675
|
-
if (conv.session
|
|
6370
|
+
if (conv.session !== session)
|
|
6371
|
+
continue;
|
|
6372
|
+
if (conv.subagentBarsPluginTools)
|
|
4676
6373
|
return;
|
|
6374
|
+
targetPreset ??= conv.agentPreset;
|
|
4677
6375
|
}
|
|
4678
6376
|
try {
|
|
4679
|
-
const defs = this.enabledPluginToolDefs();
|
|
4680
|
-
|
|
6377
|
+
const defs = this.enabledPluginToolDefs(targetPreset);
|
|
6378
|
+
// 移除口径 = 已同步过的 ∪ 全量插件宇宙:创建时工厂直接注册进
|
|
6379
|
+
// _customTools 的工具不在 applied 表里,首轮同步(defs 为空时)否则删不掉。
|
|
6380
|
+
const universe = new Set([
|
|
6381
|
+
...this.appliedPluginToolNames,
|
|
6382
|
+
...(this.pluginToolsProvider?.() ?? []).map((t) => t.name),
|
|
6383
|
+
]);
|
|
6384
|
+
const next = syncPluginToolsIntoSession(session, defs, universe);
|
|
4681
6385
|
if (next)
|
|
4682
6386
|
this.appliedPluginToolNames = new Set(next);
|
|
4683
6387
|
}
|
|
@@ -4935,6 +6639,8 @@ export class ClientSession {
|
|
|
4935
6639
|
// switch/new_chat while prompt() is in flight must never target a
|
|
4936
6640
|
// different conversation.
|
|
4937
6641
|
const conv = this.conv;
|
|
6642
|
+
const promptAc = new AbortController();
|
|
6643
|
+
conv.activePromptAc = promptAc;
|
|
4938
6644
|
// 输入框内容被消费(发送/斜杠执行)→ 清掉该会话存过的草稿(best-effort)。
|
|
4939
6645
|
// 快捷短语发送(不碰输入框)同样清:客户端发送成功后会把当前草稿重存回来。
|
|
4940
6646
|
// clear() 同时记录 clear 时间戳水位:清掉之后才 landing 的旧 draft_update
|
|
@@ -4960,6 +6666,89 @@ export class ClientSession {
|
|
|
4960
6666
|
// is refused until admission reopens.
|
|
4961
6667
|
if (this.quiesceBlocked())
|
|
4962
6668
|
return;
|
|
6669
|
+
// 首轮用户发言后锁定当前会话的预设(对齐 DSH 预设语义)
|
|
6670
|
+
conv.presetLocked = true;
|
|
6671
|
+
// #280:悬空 toolCall 守卫——转录尾是「有调用、无结果」时直接 prompt
|
|
6672
|
+
// 会把非法链喂给 provider(有发起迹象但零落盘、零报错的黑洞)。
|
|
6673
|
+
// 非流式时先补合成结果再继续;补不上则响亮拒绝。
|
|
6674
|
+
if (conv.transcriptBlocked && !s.isStreaming) {
|
|
6675
|
+
this.emit({
|
|
6676
|
+
type: "notice",
|
|
6677
|
+
level: "error",
|
|
6678
|
+
text: `发送已拒绝:该对话的记录尾是一个没有结果的工具调用(上次运行被强制终止),且自动修复失败。请从历史记录重新打开该对话,或新建对话后重试。`,
|
|
6679
|
+
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.`,
|
|
6680
|
+
});
|
|
6681
|
+
this.flushSnapshot();
|
|
6682
|
+
return;
|
|
6683
|
+
}
|
|
6684
|
+
if (!s.isStreaming) {
|
|
6685
|
+
try {
|
|
6686
|
+
const live = s.agent.state.messages;
|
|
6687
|
+
const dangling = Array.isArray(live) ? findDanglingToolCalls(live) : [];
|
|
6688
|
+
if (dangling.length > 0) {
|
|
6689
|
+
let healed = 0;
|
|
6690
|
+
try {
|
|
6691
|
+
const sm = s.sessionManager;
|
|
6692
|
+
if (typeof sm?.appendMessage === "function") {
|
|
6693
|
+
for (const d of dangling) {
|
|
6694
|
+
sm.appendMessage({
|
|
6695
|
+
role: "toolResult",
|
|
6696
|
+
toolCallId: d.toolCallId,
|
|
6697
|
+
toolName: d.toolName,
|
|
6698
|
+
content: [
|
|
6699
|
+
{
|
|
6700
|
+
type: "text",
|
|
6701
|
+
text: `${DANGLING_TOOL_RESULT_TEXT}\n${DANGLING_TOOL_RESULT_TEXT_EN}`,
|
|
6702
|
+
},
|
|
6703
|
+
],
|
|
6704
|
+
isError: true,
|
|
6705
|
+
timestamp: Date.now(),
|
|
6706
|
+
});
|
|
6707
|
+
healed += 1;
|
|
6708
|
+
}
|
|
6709
|
+
}
|
|
6710
|
+
}
|
|
6711
|
+
catch {
|
|
6712
|
+
// 落盘失败走下面的拒绝分支。
|
|
6713
|
+
}
|
|
6714
|
+
if (healed === dangling.length && healed > 0) {
|
|
6715
|
+
conv.transcriptBlocked = false;
|
|
6716
|
+
try {
|
|
6717
|
+
await s.reload();
|
|
6718
|
+
this.applyRetryOverrides();
|
|
6719
|
+
this.applyCompactionOverrides();
|
|
6720
|
+
this.applyToolGating(s, conv.agentPreset);
|
|
6721
|
+
}
|
|
6722
|
+
catch {
|
|
6723
|
+
// reload 失败兜底
|
|
6724
|
+
}
|
|
6725
|
+
this.emit({
|
|
6726
|
+
type: "notice",
|
|
6727
|
+
level: "warning",
|
|
6728
|
+
text: `检测到上次运行残留的 ${dangling.length} 个无结果工具调用,已自动填入合成结果后继续。如任务未完成请重新执行该工具。`,
|
|
6729
|
+
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.`,
|
|
6730
|
+
});
|
|
6731
|
+
}
|
|
6732
|
+
else {
|
|
6733
|
+
conv.transcriptBlocked = true;
|
|
6734
|
+
this.emit({
|
|
6735
|
+
type: "notice",
|
|
6736
|
+
level: "error",
|
|
6737
|
+
text: `发送已拒绝:该对话的记录尾是一个没有结果的工具调用(上次运行被强制终止),且自动修复失败。请从历史记录重新打开该对话,或新建对话后重试。`,
|
|
6738
|
+
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.`,
|
|
6739
|
+
});
|
|
6740
|
+
this.flushSnapshot();
|
|
6741
|
+
return;
|
|
6742
|
+
}
|
|
6743
|
+
}
|
|
6744
|
+
else {
|
|
6745
|
+
conv.transcriptBlocked = false;
|
|
6746
|
+
}
|
|
6747
|
+
}
|
|
6748
|
+
catch {
|
|
6749
|
+
// 守卫本身绝不挡发送:查不到就按原路径走。
|
|
6750
|
+
}
|
|
6751
|
+
}
|
|
4963
6752
|
// issue #145:发之前再查一次同文件持有者 —— 拦住「打开时空闲、发送时在跑」的竞态。
|
|
4964
6753
|
// 没有第二个 writer,就不可能有看不见的第二个 agent。
|
|
4965
6754
|
const activeFile = this.activeSessionFileResolved();
|
|
@@ -5075,16 +6864,23 @@ export class ClientSession {
|
|
|
5075
6864
|
.filter((r) => r.hits.length > 0);
|
|
5076
6865
|
const extNoteEn = externalRunners.length > 0 ? ` Touched files of external run(s) are unknown.` : "";
|
|
5077
6866
|
const extNoteZh = externalRunners.length > 0 ? `外部运行的文件触碰未知。` : "";
|
|
6867
|
+
// 预设拿掉问卷工具时(minimal/code/ask),提醒文案不点不存在的工具名,
|
|
6868
|
+
// 改走正文提问(见 tool-manager.ts 语义总表;认领信息本身照常有用)。
|
|
6869
|
+
const canAsk = presetHasQuestionnaire(conv.agentPreset ?? this.settingsSvc.current.defaultAgentPreset ?? "standard");
|
|
6870
|
+
const askClauseEn = canAsk
|
|
6871
|
+
? "use ask_user_question when unsure "
|
|
6872
|
+
: "ask the user in your reply text when unsure ";
|
|
6873
|
+
const askClauseZh = canAsk ? "拿不准就用 ask_user_question 让用户选择:" : "拿不准就在回复正文里直接问用户:";
|
|
5078
6874
|
let aiReminder;
|
|
5079
6875
|
if (clashes.length === 0 && myClaimed.length === 0) {
|
|
5080
6876
|
aiReminder =
|
|
5081
6877
|
`(System reminder: ${aiItems.length} other run(s) [${aiItems.join("; ")}] ` +
|
|
5082
6878
|
`are currently running in the same project directory. No file written by both you and them was detected, ` +
|
|
5083
6879
|
`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
|
|
6880
|
+
`commands, assess the conflict risk first, and ${askClauseEn}` +
|
|
5085
6881
|
`(continue in parallel / wait / watch read-only).${extNoteEn}${claimsSummaryEn})\n` +
|
|
5086
6882
|
`(系统提醒:同一项目另有 ${aiItems.length} 处运行(${shown}${more})。未发现双方都写过的文件,` +
|
|
5087
|
-
|
|
6883
|
+
`改不同文件可并行;动同一文件或跑全局命令前先评估冲突,${askClauseZh}` +
|
|
5088
6884
|
`并行 / 等它跑完 / 只读围观。${extNoteZh}${claimsSummaryZh})`;
|
|
5089
6885
|
}
|
|
5090
6886
|
else {
|
|
@@ -5102,10 +6898,10 @@ export class ClientSession {
|
|
|
5102
6898
|
aiReminder =
|
|
5103
6899
|
`(System reminder: ${aiItems.length} other run(s) [${aiItems.join("; ")}] ` +
|
|
5104
6900
|
`are currently running in the same project directory. ⚠ ${clashEn} — re-read these files before ` +
|
|
5105
|
-
`touching them again, and
|
|
6901
|
+
`touching them again, and ${askClauseEn}` +
|
|
5106
6902
|
`(continue in parallel / wait / watch read-only).${extNoteEn})\n` +
|
|
5107
6903
|
`(系统提醒:同一项目另有 ${aiItems.length} 处运行(${shown}${more})。` +
|
|
5108
|
-
`${clashZh} ——
|
|
6904
|
+
`${clashZh} —— 再动这些文件前先读最新内容,${askClauseZh}` +
|
|
5109
6905
|
`并行 / 等它跑完 / 只读围观。${extNoteZh})`;
|
|
5110
6906
|
}
|
|
5111
6907
|
try {
|
|
@@ -5157,9 +6953,35 @@ export class ClientSession {
|
|
|
5157
6953
|
// issue #91:附件/视觉桥文案按客户端 UI 语言出中英(英文默认)。
|
|
5158
6954
|
getLang: () => this.getLang(),
|
|
5159
6955
|
}, attachments);
|
|
6956
|
+
if (promptAc.signal.aborted) {
|
|
6957
|
+
conv.activePromptAc = undefined;
|
|
6958
|
+
return;
|
|
6959
|
+
}
|
|
5160
6960
|
for (const aside of asides) {
|
|
5161
|
-
|
|
6961
|
+
if (s.isStreaming) {
|
|
6962
|
+
await s.sendCustomMessage(aside.message, { deliverAs: "nextTurn" });
|
|
6963
|
+
}
|
|
6964
|
+
else {
|
|
6965
|
+
await s.sendCustomMessage(aside.message);
|
|
6966
|
+
}
|
|
6967
|
+
}
|
|
6968
|
+
// 附件处理完毕后立即刷新快照,让引用的文件卡片在等待模型首字前瞬间出现在界面上
|
|
6969
|
+
if (asides.length > 0) {
|
|
6970
|
+
this.flushSnapshot();
|
|
6971
|
+
}
|
|
6972
|
+
// 创建工作区版本影子快照(Dual-State Rollback)
|
|
6973
|
+
let snapshotRef = null;
|
|
6974
|
+
try {
|
|
6975
|
+
snapshotRef = await createWorkspaceSnapshot(conv.cwd);
|
|
6976
|
+
}
|
|
6977
|
+
catch {
|
|
6978
|
+
// best-effort
|
|
5162
6979
|
}
|
|
6980
|
+
if (promptAc.signal.aborted) {
|
|
6981
|
+
conv.activePromptAc = undefined;
|
|
6982
|
+
return;
|
|
6983
|
+
}
|
|
6984
|
+
conv.activePromptAc = undefined;
|
|
5163
6985
|
if (s.isStreaming) {
|
|
5164
6986
|
// queue=true (补充 button) → followUp: the message is delivered only
|
|
5165
6987
|
// after the whole run finishes — the agent finishes what it started,
|
|
@@ -5178,8 +7000,29 @@ export class ClientSession {
|
|
|
5178
7000
|
else {
|
|
5179
7001
|
await s.prompt(text);
|
|
5180
7002
|
}
|
|
7003
|
+
// 关联快照与本次 prompt 产生的用户消息 entry
|
|
7004
|
+
if (snapshotRef) {
|
|
7005
|
+
try {
|
|
7006
|
+
const entries = conv.session.sessionManager.buildContextEntries();
|
|
7007
|
+
const lastUserEntry = [...entries]
|
|
7008
|
+
.reverse()
|
|
7009
|
+
.find((e) => e.type === "message" && e.message?.role === "user");
|
|
7010
|
+
conv.workspaceSnapshots.push({
|
|
7011
|
+
entryId: lastUserEntry?.id,
|
|
7012
|
+
timestamp: Date.now(),
|
|
7013
|
+
snapshotRef,
|
|
7014
|
+
});
|
|
7015
|
+
if (conv.workspaceSnapshots.length > 50) {
|
|
7016
|
+
conv.workspaceSnapshots.shift();
|
|
7017
|
+
}
|
|
7018
|
+
}
|
|
7019
|
+
catch {
|
|
7020
|
+
// best-effort
|
|
7021
|
+
}
|
|
7022
|
+
}
|
|
5181
7023
|
}
|
|
5182
7024
|
catch (err) {
|
|
7025
|
+
conv.activePromptAc = undefined;
|
|
5183
7026
|
this.emit({
|
|
5184
7027
|
type: "notice",
|
|
5185
7028
|
level: "error",
|
|
@@ -5201,15 +7044,12 @@ export class ClientSession {
|
|
|
5201
7044
|
/**
|
|
5202
7045
|
* Turn attached files into custom-message payloads.
|
|
5203
7046
|
*
|
|
5204
|
-
*
|
|
5205
|
-
*
|
|
5206
|
-
*
|
|
5207
|
-
*
|
|
5208
|
-
*
|
|
5209
|
-
*
|
|
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).
|
|
7047
|
+
* 一律只给路径引用:文本文件(无论大小)都只发 `<file path="..." />`,模型用
|
|
7048
|
+
* 自己的 read 工具按需读(自带截断/分页)——文件内容永不进 prompt。行范围模式
|
|
7049
|
+
* (mode "lines")在引用上带 lines 属性,告诉模型用户选的是哪几行。
|
|
7050
|
+
* 图片始终作为 image 内容发送。粘贴/拖入/上传的原始图片(attachment.imageData)
|
|
7051
|
+
* 不走工作区路径,直接进模型;上传文件(attachment.fileData)落在
|
|
7052
|
+
* <dataDir>/uploads/ 下,以绝对路径引用。
|
|
5213
7053
|
*/
|
|
5214
7054
|
/**
|
|
5215
7055
|
* Hard-abort the running agent (Stop button / global 中断). Tries
|
|
@@ -5220,6 +7060,15 @@ export class ClientSession {
|
|
|
5220
7060
|
* overnight. The notice fires only on the forced-reset path.
|
|
5221
7061
|
*/
|
|
5222
7062
|
async abort() {
|
|
7063
|
+
if (this.conv.activePromptAc) {
|
|
7064
|
+
this.conv.activePromptAc.abort();
|
|
7065
|
+
this.conv.activePromptAc = undefined;
|
|
7066
|
+
}
|
|
7067
|
+
const isRunning = this.conversationStreaming(this.conv) || !this.conv.session.isIdle;
|
|
7068
|
+
if (!isRunning) {
|
|
7069
|
+
this.flushSnapshot();
|
|
7070
|
+
return;
|
|
7071
|
+
}
|
|
5223
7072
|
// 只停止智能体运行本身;AI 在后台启动的服务由「后台任务」面板单独
|
|
5224
7073
|
// 管理(可逐个停止或全部关闭),不会在停止对话时被连带杀掉。
|
|
5225
7074
|
await this.interruptRun(this.conv, "已停止");
|
|
@@ -5476,6 +7325,9 @@ export class ClientSession {
|
|
|
5476
7325
|
}
|
|
5477
7326
|
/** Interrupt a run: abort, with a force-reset fallback on timeout. */
|
|
5478
7327
|
async interruptRun(conv, reason) {
|
|
7328
|
+
if (!this.conversationStreaming(conv) && conv.session.isIdle) {
|
|
7329
|
+
return;
|
|
7330
|
+
}
|
|
5479
7331
|
// The run is only truly stopped when its agent_end event arrives:
|
|
5480
7332
|
// session.abort() can return without stopping anything when the run is
|
|
5481
7333
|
// stuck before the agent even started (e.g. a model stream that never
|
|
@@ -5513,14 +7365,14 @@ export class ClientSession {
|
|
|
5513
7365
|
textEn: `Abort failed: ${err.message}`,
|
|
5514
7366
|
});
|
|
5515
7367
|
}
|
|
5516
|
-
// 3) abort returned but no agent_end within the settle window
|
|
5517
|
-
//
|
|
5518
|
-
if (!ended) {
|
|
7368
|
+
// 3) abort returned but no agent_end within the settle window:
|
|
7369
|
+
// 仅在 session 依然处于非 idle 状态(即真正卡住)时才等待并强制重置
|
|
7370
|
+
if (!ended && !conv.session.isIdle) {
|
|
5519
7371
|
await new Promise((r) => setTimeout(r, ClientSession.HARD_ABORT_SETTLE_MS));
|
|
5520
7372
|
}
|
|
5521
7373
|
clearTimeout(abortTimer);
|
|
5522
7374
|
off();
|
|
5523
|
-
if (!ended)
|
|
7375
|
+
if (!ended && !conv.session.isIdle)
|
|
5524
7376
|
force();
|
|
5525
7377
|
}
|
|
5526
7378
|
/** Force-reset a conversation: dispose the stuck runtime (kills the hung
|
|
@@ -5528,18 +7380,54 @@ export class ClientSession {
|
|
|
5528
7380
|
* persisted session. The conversation record itself is kept (same id,
|
|
5529
7381
|
* same cwd, same serialization caches), so the UI stays attached. */
|
|
5530
7382
|
async forceResetConversation(conv, reason) {
|
|
7383
|
+
// #280:先记下本次对话自己的会话文件——重建必须回到同一个文件,
|
|
7384
|
+
// 不能用 continueRecent(cwd) 按 mtime 取「最近」(同 cwd 多会话时会接错文件)。
|
|
7385
|
+
const ownFile = (() => {
|
|
7386
|
+
try {
|
|
7387
|
+
const f = conv.session.sessionFile;
|
|
7388
|
+
return typeof f === "string" && f ? f : undefined;
|
|
7389
|
+
}
|
|
7390
|
+
catch {
|
|
7391
|
+
return undefined;
|
|
7392
|
+
}
|
|
7393
|
+
})();
|
|
5531
7394
|
try {
|
|
5532
7395
|
conv.unsubscribe?.();
|
|
5533
7396
|
conv.unsubscribe = undefined;
|
|
5534
7397
|
this.clearAllToolWatchdogs(conv);
|
|
5535
7398
|
conv.toolStartTimes.clear();
|
|
7399
|
+
disposeEvalSession(conv.id);
|
|
5536
7400
|
await conv.runtime.dispose();
|
|
5537
|
-
// #
|
|
5538
|
-
|
|
7401
|
+
// #280:dispose 丢弃了内存里的在飞状态(未落盘的工具结果蒸发),
|
|
7402
|
+
// 文件尾可能留下一个悬空 toolCall——先补合成 toolResult 再重建,
|
|
7403
|
+
// 否则重建后的 prompt 会把非法转录链喂给 provider(零落盘黑洞)。
|
|
7404
|
+
let healedCount = 0;
|
|
7405
|
+
if (ownFile && existsSync(ownFile)) {
|
|
7406
|
+
try {
|
|
7407
|
+
const n = healDanglingToolCallFile(ownFile);
|
|
7408
|
+
if (n > 0)
|
|
7409
|
+
healedCount = n;
|
|
7410
|
+
}
|
|
7411
|
+
catch {
|
|
7412
|
+
// best-effort:修不好就按原路径重建,下面的守卫会在 prompt 前再拦。
|
|
7413
|
+
}
|
|
7414
|
+
}
|
|
7415
|
+
// #280 & #335:转录链损坏时修一次再试(见 openManagerAndRuntime)。
|
|
7416
|
+
// 严禁在 ownFile 不存在时回退到 continueRecent(conv.cwd) 或按 mtime list 历史文件,
|
|
7417
|
+
// 否则会直接接错并顶替同项目的其它历史会话,污染别人的转录记录。
|
|
7418
|
+
const opened = await this.openManagerAndRuntime(() => {
|
|
7419
|
+
if (ownFile && existsSync(ownFile)) {
|
|
7420
|
+
return SessionManager.open(ownFile);
|
|
7421
|
+
}
|
|
7422
|
+
return conv.isEphemeral ? SessionManager.inMemory(conv.cwd) : SessionManager.create(conv.cwd);
|
|
7423
|
+
}, (m) =>
|
|
7424
|
+
// 子代理带模板时按原模板重建(conv.subagentTemplate 是派发时工厂
|
|
7425
|
+
// 用的同一快照);普通对话 undefined,行为不变。
|
|
7426
|
+
createAgentSessionRuntime(this.makeRuntimeFactory(conv.terminals, conv.subagentTemplate, conv.id), {
|
|
5539
7427
|
cwd: conv.cwd,
|
|
5540
7428
|
agentDir: this.agentDir,
|
|
5541
7429
|
sessionManager: m,
|
|
5542
|
-
}), async () => (
|
|
7430
|
+
}), async () => (ownFile && existsSync(ownFile) ? ownFile : undefined));
|
|
5543
7431
|
const runtime = opened.runtime;
|
|
5544
7432
|
if (opened.repair) {
|
|
5545
7433
|
for (const n of this.transcriptRepairNotices(opened.repair))
|
|
@@ -5547,6 +7435,27 @@ export class ClientSession {
|
|
|
5547
7435
|
}
|
|
5548
7436
|
conv.runtime = runtime;
|
|
5549
7437
|
conv.session = runtime.session;
|
|
7438
|
+
if (healedCount > 0) {
|
|
7439
|
+
this.emit({
|
|
7440
|
+
type: "notice",
|
|
7441
|
+
level: "warning",
|
|
7442
|
+
text: `上次运行被强制终止,${healedCount} 个无结果的工具调用已自动填入合成结果(转录链已修复)。建议检查任务状态,必要时重新执行该工具。`,
|
|
7443
|
+
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.`,
|
|
7444
|
+
});
|
|
7445
|
+
}
|
|
7446
|
+
// #280:重建后复查——新 runtime 仍以悬空 toolCall 开头说明修复没落盘
|
|
7447
|
+
// (文件被删/只读等),此时响亮拒绝后续 prompt 而不是静默黑洞。
|
|
7448
|
+
try {
|
|
7449
|
+
const msgs = conv.session.agent.state.messages;
|
|
7450
|
+
const still = Array.isArray(msgs) ? findDanglingToolCalls(msgs) : [];
|
|
7451
|
+
if (still.length > 0)
|
|
7452
|
+
conv.transcriptBlocked = true;
|
|
7453
|
+
else
|
|
7454
|
+
conv.transcriptBlocked = false;
|
|
7455
|
+
}
|
|
7456
|
+
catch {
|
|
7457
|
+
// ignore:守卫是兜底,查不到就让 prompt 路径再查。
|
|
7458
|
+
}
|
|
5550
7459
|
this.emit({
|
|
5551
7460
|
type: "notice",
|
|
5552
7461
|
level: "warning",
|
|
@@ -5570,8 +7479,7 @@ export class ClientSession {
|
|
|
5570
7479
|
* 空白新对话」——/new <prompt> 只在 true 时投递首条提示;false 表示没能进入
|
|
5571
7480
|
* 新对话(准入关闭 / 同项目对话数达上限 / runtime 创建失败),此时照发会把
|
|
5572
7481
|
* 首条提示投进用户原本正在用的那个对话里。 */
|
|
5573
|
-
async newChat(_preset) {
|
|
5574
|
-
// _preset: DSH Agent 预设(pi 引擎无此概念,忽略;wire 统一见 protocol new_chat)。
|
|
7482
|
+
async newChat(_preset, ephemeral) {
|
|
5575
7483
|
if (this.quiesceBlocked())
|
|
5576
7484
|
return false;
|
|
5577
7485
|
// Reuse an already-open blank conversation instead of piling up new ones
|
|
@@ -5589,31 +7497,43 @@ export class ClientSession {
|
|
|
5589
7497
|
}
|
|
5590
7498
|
};
|
|
5591
7499
|
const active = this.conv;
|
|
5592
|
-
if (active && isBlank(active)) {
|
|
5593
|
-
|
|
7500
|
+
if (!ephemeral && active && isBlank(active)) {
|
|
7501
|
+
if (_preset)
|
|
7502
|
+
await this.selectAgentPreset(_preset);
|
|
7503
|
+
else {
|
|
7504
|
+
this.pushSettings();
|
|
7505
|
+
this.flushSnapshot();
|
|
7506
|
+
}
|
|
5594
7507
|
return true;
|
|
5595
7508
|
}
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
7509
|
+
if (!ephemeral) {
|
|
7510
|
+
for (const conv of this.convs.values()) {
|
|
7511
|
+
if (conv.id === this.activeId)
|
|
7512
|
+
continue;
|
|
7513
|
+
if (isBlank(conv)) {
|
|
7514
|
+
await this.switchConversation(conv.id);
|
|
7515
|
+
if (_preset)
|
|
7516
|
+
await this.selectAgentPreset(_preset);
|
|
7517
|
+
else
|
|
7518
|
+
this.flushSnapshot();
|
|
7519
|
+
return true;
|
|
7520
|
+
}
|
|
5603
7521
|
}
|
|
5604
7522
|
}
|
|
5605
7523
|
// Cap is per project — conversations of other projects keep their own
|
|
5606
7524
|
// lists and don't consume this project's slots. Subagents don't count
|
|
5607
|
-
// (inMemory
|
|
5608
|
-
|
|
5609
|
-
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
|
|
5616
|
-
|
|
7525
|
+
// (inMemory 后台任务,不占位)。临时会话也不占名额。
|
|
7526
|
+
if (!ephemeral) {
|
|
7527
|
+
const openInProject = [...this.convs.values()].filter((c) => c.cwd === this.cwd && !c.isSubagent && !c.isEphemeral).length;
|
|
7528
|
+
if (openInProject >= MAX_OPEN_CONVERSATIONS) {
|
|
7529
|
+
this.emit({
|
|
7530
|
+
type: "notice",
|
|
7531
|
+
level: "warning",
|
|
7532
|
+
text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先打开某个对话并离开(不继续对话)以移出列表`,
|
|
7533
|
+
textEn: `This project already has the max open conversations (${MAX_OPEN_CONVERSATIONS}). Open one and leave it (without continuing) to remove it from the list.`,
|
|
7534
|
+
});
|
|
7535
|
+
return false;
|
|
7536
|
+
}
|
|
5617
7537
|
}
|
|
5618
7538
|
// The outgoing conversation is left behind — apply the running-list
|
|
5619
7539
|
// lifecycle. Removal is deferred until the new chat exists so the active
|
|
@@ -5627,14 +7547,50 @@ export class ClientSession {
|
|
|
5627
7547
|
try {
|
|
5628
7548
|
const conversationId = this.nextConversationId();
|
|
5629
7549
|
const terminals = this.makeTerminalManager(conversationId, this.cwd);
|
|
5630
|
-
|
|
7550
|
+
let sessionManager;
|
|
7551
|
+
if (ephemeral) {
|
|
7552
|
+
sessionManager = SessionManager.inMemory(this.cwd);
|
|
7553
|
+
// 为无痕临时会话提供隔离的临时运行目录(供 SoL-Pi 等依赖 getSessionDir 的扩展正常放置缓存),
|
|
7554
|
+
// 但保持 persist = false(不写 .jsonl 对话文件、不污染历史记录)
|
|
7555
|
+
const ephemeralDir = join(this.agentDir, "ephemeral-sessions", conversationId);
|
|
7556
|
+
try {
|
|
7557
|
+
mkdirSync(ephemeralDir, { recursive: true });
|
|
7558
|
+
sessionManager.sessionDir = ephemeralDir;
|
|
7559
|
+
}
|
|
7560
|
+
catch { }
|
|
7561
|
+
}
|
|
7562
|
+
else {
|
|
7563
|
+
sessionManager = SessionManager.create(this.cwd);
|
|
7564
|
+
}
|
|
7565
|
+
const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, undefined, conversationId, prevModel ?? undefined, _preset), {
|
|
5631
7566
|
cwd: this.cwd,
|
|
5632
7567
|
agentDir: this.agentDir,
|
|
5633
|
-
sessionManager
|
|
7568
|
+
sessionManager,
|
|
5634
7569
|
});
|
|
5635
7570
|
const conv = this.makeConversation(runtime, conversationId, terminals);
|
|
7571
|
+
if (ephemeral)
|
|
7572
|
+
conv.isEphemeral = true;
|
|
7573
|
+
if (_preset) {
|
|
7574
|
+
const hit = PI_AGENT_PRESETS.find((p) => p.id === _preset);
|
|
7575
|
+
if (hit)
|
|
7576
|
+
conv.agentPreset = hit.id;
|
|
7577
|
+
}
|
|
7578
|
+
// 创建即按归属预设门控:工厂内只能按 active/默认兜底(conv 还没进 map),
|
|
7579
|
+
// 默认预设非 standard 时那个结果是错的,这里用 conv 自身预设显式重放一次。
|
|
7580
|
+
// conv 尚未进 map,preset 必须显式传,插件同步才能正确剥离。
|
|
7581
|
+
this.applyToolGating(conv.session, conv.agentPreset);
|
|
5636
7582
|
this.convs.set(conv.id, conv);
|
|
5637
7583
|
this.activeId = conv.id;
|
|
7584
|
+
if (_preset && conv.agentPreset !== (this.settingsSvc.current.defaultAgentPreset ?? "standard")) {
|
|
7585
|
+
// 显式预设与默认不一致:工厂组装提示词时用的是默认视图,重载一次对齐
|
|
7586
|
+
// (技能名录/终端引导;此时 conv 已进 map,override 能读到归属预设)。
|
|
7587
|
+
try {
|
|
7588
|
+
await conv.session.resourceLoader.reload();
|
|
7589
|
+
}
|
|
7590
|
+
catch {
|
|
7591
|
+
// loader 未就绪——首轮 run 组装时自然对齐。
|
|
7592
|
+
}
|
|
7593
|
+
}
|
|
5638
7594
|
if (displaced)
|
|
5639
7595
|
this.removeConversation(displaced.id);
|
|
5640
7596
|
await this.bindSession();
|
|
@@ -5682,6 +7638,7 @@ export class ClientSession {
|
|
|
5682
7638
|
void this.pushSlashCommands();
|
|
5683
7639
|
// 新对话即当前打开 → 插件重拉(轨迹视图跟随)。
|
|
5684
7640
|
this.notifyConversationChanged();
|
|
7641
|
+
this.pushSettings();
|
|
5685
7642
|
ready = true;
|
|
5686
7643
|
}
|
|
5687
7644
|
catch (err) {
|
|
@@ -5788,7 +7745,7 @@ export class ClientSession {
|
|
|
5788
7745
|
};
|
|
5789
7746
|
lines[idx] = JSON.stringify(done);
|
|
5790
7747
|
}
|
|
5791
|
-
|
|
7748
|
+
atomicWriteFileSync(file, lines.join("\n"));
|
|
5792
7749
|
}
|
|
5793
7750
|
catch {
|
|
5794
7751
|
// Best-effort, same as the write path.
|
|
@@ -5809,7 +7766,7 @@ export class ClientSession {
|
|
|
5809
7766
|
if (!raw.includes('"pi-web-ui/compaction-pending"'))
|
|
5810
7767
|
return;
|
|
5811
7768
|
const kept = raw.split("\n").filter((line) => !line.includes("pi-web-ui/compaction-pending"));
|
|
5812
|
-
|
|
7769
|
+
atomicWriteFileSync(file, kept.join("\n"));
|
|
5813
7770
|
this.emitCompactionInterruptedNotice();
|
|
5814
7771
|
}
|
|
5815
7772
|
catch {
|
|
@@ -5848,6 +7805,7 @@ export class ClientSession {
|
|
|
5848
7805
|
/**
|
|
5849
7806
|
* #235:已知路径先修后开(openConversation 走这条——单文件预扫描零负担)。
|
|
5850
7807
|
* 返回 null = 文件健康或无需处理;返回 repair = 修过,调用方弹提示。
|
|
7808
|
+
* #280:顺带修悬空 toolCall(强制重置/崩溃残留的有调用无结果),修过同样弹提示。
|
|
5851
7809
|
*/
|
|
5852
7810
|
repairTranscriptFileBeforeOpen(filePath) {
|
|
5853
7811
|
let repair = null;
|
|
@@ -5857,8 +7815,27 @@ export class ClientSession {
|
|
|
5857
7815
|
catch {
|
|
5858
7816
|
return null;
|
|
5859
7817
|
}
|
|
5860
|
-
if (!repair?.changed)
|
|
7818
|
+
if (!repair?.changed) {
|
|
7819
|
+
// #280:压缩链健康时仍要查悬空 toolCall(崩溃/强制重置残留)。
|
|
7820
|
+
let healed = 0;
|
|
7821
|
+
try {
|
|
7822
|
+
const n = healDanglingToolCallFile(filePath);
|
|
7823
|
+
if (n > 0)
|
|
7824
|
+
healed = n;
|
|
7825
|
+
}
|
|
7826
|
+
catch {
|
|
7827
|
+
// best-effort
|
|
7828
|
+
}
|
|
7829
|
+
if (healed <= 0)
|
|
7830
|
+
return null;
|
|
7831
|
+
this.emit({
|
|
7832
|
+
type: "notice",
|
|
7833
|
+
level: "warning",
|
|
7834
|
+
text: `该对话上次运行残留 ${healed} 个无结果的工具调用,已自动填入合成结果。如任务未完成请重新执行该工具。`,
|
|
7835
|
+
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.`,
|
|
7836
|
+
});
|
|
5861
7837
|
return null;
|
|
7838
|
+
}
|
|
5862
7839
|
for (const n of this.transcriptRepairNotices(repair))
|
|
5863
7840
|
this.emit(n);
|
|
5864
7841
|
return repair;
|
|
@@ -5950,9 +7927,26 @@ export class ClientSession {
|
|
|
5950
7927
|
}
|
|
5951
7928
|
this.convs.delete(id);
|
|
5952
7929
|
this.clearAllToolWatchdogs(conv);
|
|
7930
|
+
// 关对话 → 连它的 eval 内核(Python/Node 子进程 + 临时沙箱目录)一起回收:
|
|
7931
|
+
// 这些进程是 detached 进程组,父进程退出不会自动带走它们。
|
|
7932
|
+
disposeEvalSession(id);
|
|
5953
7933
|
conv.terminals.killAll();
|
|
5954
7934
|
conv.unsubscribe?.();
|
|
5955
7935
|
conv.unsubscribe = undefined;
|
|
7936
|
+
if (conv.isSubagent) {
|
|
7937
|
+
const ephemeralDir = join(this.agentDir, "subagent-sessions", conv.id);
|
|
7938
|
+
try {
|
|
7939
|
+
rmSync(ephemeralDir, { recursive: true, force: true });
|
|
7940
|
+
}
|
|
7941
|
+
catch { }
|
|
7942
|
+
}
|
|
7943
|
+
if (conv.isEphemeral) {
|
|
7944
|
+
const ephemeralDir = join(this.agentDir, "ephemeral-sessions", conv.id);
|
|
7945
|
+
try {
|
|
7946
|
+
rmSync(ephemeralDir, { recursive: true, force: true });
|
|
7947
|
+
}
|
|
7948
|
+
catch { }
|
|
7949
|
+
}
|
|
5956
7950
|
void conv.runtime.dispose().catch(() => { });
|
|
5957
7951
|
}
|
|
5958
7952
|
/** 本会话是否持有这个 SDK 会话对应的对话;命中则给出它的**当前** id
|
|
@@ -5972,6 +7966,7 @@ export class ClientSession {
|
|
|
5972
7966
|
cwd: c.cwd,
|
|
5973
7967
|
...(c.parentId ? { parentId: c.parentId } : {}),
|
|
5974
7968
|
isSubagent: c.isSubagent,
|
|
7969
|
+
isEphemeral: !!c.isEphemeral,
|
|
5975
7970
|
}));
|
|
5976
7971
|
}
|
|
5977
7972
|
/**
|
|
@@ -6020,9 +8015,30 @@ export class ClientSession {
|
|
|
6020
8015
|
pageCalls.push({ resolve: p.resolve, req: p.req, timeoutMs: p.timeoutMs, conversationId: p.conversationId });
|
|
6021
8016
|
}
|
|
6022
8017
|
}
|
|
8018
|
+
// 待审批跟对话走:留在源会话就是幽灵弹窗(id 失效点不动,runtime 已搬走,
|
|
8019
|
+
// 永远等不到答复)。只搬归属明确的(conversationId 对得上,与问卷/页调用
|
|
8020
|
+
// 同一规则);目标侧按新 id 重发弹窗,等待中的 Promise 原样过户不 resolve。
|
|
8021
|
+
const approvals = [];
|
|
8022
|
+
for (const [aid, a] of this.pendingApprovals) {
|
|
8023
|
+
if (a.conversationId !== undefined && set.has(a.conversationId)) {
|
|
8024
|
+
this.pendingApprovals.delete(aid);
|
|
8025
|
+
approvals.push({
|
|
8026
|
+
resolve: a.resolve,
|
|
8027
|
+
toolCallId: a.toolCallId,
|
|
8028
|
+
toolName: a.toolName,
|
|
8029
|
+
params: a.params,
|
|
8030
|
+
reason: a.reason,
|
|
8031
|
+
reasonEn: a.reasonEn,
|
|
8032
|
+
...(a.category ? { category: a.category } : {}),
|
|
8033
|
+
conversationId: a.conversationId,
|
|
8034
|
+
...(a.conversationTitle ? { conversationTitle: a.conversationTitle } : {}),
|
|
8035
|
+
createdAt: a.createdAt,
|
|
8036
|
+
});
|
|
8037
|
+
}
|
|
8038
|
+
}
|
|
6023
8039
|
this.emitConversations();
|
|
6024
8040
|
this.flushSnapshot();
|
|
6025
|
-
return { ok: true, payload: { convs, questions, pageCalls } };
|
|
8041
|
+
return { ok: true, payload: { convs, questions, pageCalls, approvals } };
|
|
6026
8042
|
}
|
|
6027
8043
|
/**
|
|
6028
8044
|
* 过户转入:把另一会话摘除的对话整体接过来,返回主对话的新 id。
|
|
@@ -6103,8 +8119,65 @@ export class ClientSession {
|
|
|
6103
8119
|
timeoutMs: p.timeoutMs,
|
|
6104
8120
|
});
|
|
6105
8121
|
}
|
|
8122
|
+
// 迁入的待审批按新 id 重新挂上(等待中的 Promise 未动,模型还在等)。
|
|
8123
|
+
// 主对话的立即弹窗(子代理的靠角标与快照呈现,与问卷同一口径)。
|
|
8124
|
+
for (const a of payload.approvals) {
|
|
8125
|
+
const nid = `appr-${++this.approvalSeq}`;
|
|
8126
|
+
const aConvId = fix(a.conversationId);
|
|
8127
|
+
this.pendingApprovals.set(nid, {
|
|
8128
|
+
id: nid,
|
|
8129
|
+
toolCallId: a.toolCallId,
|
|
8130
|
+
toolName: a.toolName,
|
|
8131
|
+
params: a.params,
|
|
8132
|
+
reason: a.reason,
|
|
8133
|
+
reasonEn: a.reasonEn,
|
|
8134
|
+
...(a.category ? { category: a.category } : {}),
|
|
8135
|
+
conversationId: aConvId,
|
|
8136
|
+
...(a.conversationTitle ? { conversationTitle: a.conversationTitle } : {}),
|
|
8137
|
+
resolve: a.resolve,
|
|
8138
|
+
createdAt: a.createdAt,
|
|
8139
|
+
});
|
|
8140
|
+
if (aConvId === mainId) {
|
|
8141
|
+
this.emit({
|
|
8142
|
+
type: "tool_approval_pending",
|
|
8143
|
+
id: nid,
|
|
8144
|
+
toolCallId: a.toolCallId,
|
|
8145
|
+
toolName: a.toolName,
|
|
8146
|
+
params: a.params,
|
|
8147
|
+
reason: a.reason,
|
|
8148
|
+
reasonEn: a.reasonEn,
|
|
8149
|
+
...(a.category ? { category: a.category } : {}),
|
|
8150
|
+
conversationId: aConvId,
|
|
8151
|
+
...(a.conversationTitle ? { conversationTitle: a.conversationTitle } : {}),
|
|
8152
|
+
});
|
|
8153
|
+
}
|
|
8154
|
+
}
|
|
6106
8155
|
return mainId;
|
|
6107
8156
|
}
|
|
8157
|
+
/**
|
|
8158
|
+
* 切到新工作目录后的“跟随面”刷新(roots / 插件钩子 / 最近项目 / 历史列表 /
|
|
8159
|
+
* 文件树 / 命令目录)。调用方先把 this.cwd 改好再调本函数。
|
|
8160
|
+
* switchConversation 跨项目切换、set_cwd、switchSession 打开别项目的历史
|
|
8161
|
+
* 会话三处共用 —— 之前 switchSession 只改了 cwd 没同步 roots,文件树/
|
|
8162
|
+
* 工作区插件/历史列表都还停在旧项目。集中一处避免再漏。
|
|
8163
|
+
*/
|
|
8164
|
+
applyCwdSideEffects(abs) {
|
|
8165
|
+
this.roots = this.stateStore.getWorkspaceRoots(this.clientId, abs);
|
|
8166
|
+
// 工作区跟随型插件(编辑器文件树等)同步切根。
|
|
8167
|
+
try {
|
|
8168
|
+
this.onCwdChanged?.(abs, this.roots);
|
|
8169
|
+
}
|
|
8170
|
+
catch {
|
|
8171
|
+
/* 钩子异常不影响主流程 */
|
|
8172
|
+
}
|
|
8173
|
+
// Remember the new workspace (restore target + recent-project entry).
|
|
8174
|
+
this.stateStore.remember(this.clientId, abs);
|
|
8175
|
+
void this.pushProjects();
|
|
8176
|
+
this.refreshSessionsOnSwitch();
|
|
8177
|
+
void this.listFiles(undefined);
|
|
8178
|
+
// Commands are per-project (.pi/commands.json in the current cwd).
|
|
8179
|
+
void this.listCommands();
|
|
8180
|
+
}
|
|
6108
8181
|
/** Switch the ACTIVE conversation without interrupting any other chat. */
|
|
6109
8182
|
async switchConversation(id) {
|
|
6110
8183
|
if (!this.convs.has(id) || id === this.activeId)
|
|
@@ -6129,7 +8202,6 @@ export class ClientSession {
|
|
|
6129
8202
|
void this.pushSlashCommands();
|
|
6130
8203
|
if (cwdChanged) {
|
|
6131
8204
|
this.cwd = newCwd;
|
|
6132
|
-
this.roots = this.stateStore.getWorkspaceRoots(this.clientId, newCwd);
|
|
6133
8205
|
// 模型/key 恢复不挡快照:后台做,带代际 guard(用户又切走就跳过),
|
|
6134
8206
|
// 做完补一次 flush 刷新模型栏。
|
|
6135
8207
|
{
|
|
@@ -6151,20 +8223,11 @@ export class ClientSession {
|
|
|
6151
8223
|
}
|
|
6152
8224
|
// Mirror set_cwd's project-switch side-effects so the whole UI follows
|
|
6153
8225
|
// the new workspace, not just the chat pane.
|
|
6154
|
-
|
|
6155
|
-
this.onCwdChanged?.(newCwd, this.roots);
|
|
6156
|
-
}
|
|
6157
|
-
catch {
|
|
6158
|
-
/* hook failure must not break the switch */
|
|
6159
|
-
}
|
|
6160
|
-
this.stateStore.remember(this.clientId, newCwd);
|
|
6161
|
-
void this.pushProjects();
|
|
6162
|
-
this.refreshSessionsOnSwitch();
|
|
6163
|
-
void this.listFiles(undefined);
|
|
6164
|
-
void this.listCommands();
|
|
8226
|
+
this.applyCwdSideEffects(newCwd);
|
|
6165
8227
|
}
|
|
6166
8228
|
// 当前打开对话变了 → 插件重拉(轨迹视图切会话后即刷新,不等轮询)。
|
|
6167
8229
|
this.notifyConversationChanged();
|
|
8230
|
+
this.pushSettings();
|
|
6168
8231
|
this.flushSnapshot();
|
|
6169
8232
|
}
|
|
6170
8233
|
/** 左栏「运行的对话」的展示口径(issue #140)。
|
|
@@ -6228,6 +8291,7 @@ export class ClientSession {
|
|
|
6228
8291
|
messageCount,
|
|
6229
8292
|
isStreaming,
|
|
6230
8293
|
isSubagent: !!conv.isSubagent,
|
|
8294
|
+
...(conv.isEphemeral ? { isEphemeral: true } : {}),
|
|
6231
8295
|
// 落盘会话才有文件(inMemory 子代理缺省):右键复制路径 / AI 按 path 读历史时用。
|
|
6232
8296
|
...(() => {
|
|
6233
8297
|
try {
|
|
@@ -6246,6 +8310,7 @@ export class ClientSession {
|
|
|
6246
8310
|
return pq ? { hasQuestion: true, questionId: pq.id, questionTitle: pq.title } : {};
|
|
6247
8311
|
})(),
|
|
6248
8312
|
parentId: conv.parentId,
|
|
8313
|
+
...(conv.forkFrom ? { forkFrom: conv.forkFrom } : {}),
|
|
6249
8314
|
});
|
|
6250
8315
|
}
|
|
6251
8316
|
// issue #145:流式集合签名变化 → 通知其他客户端重推(左栏「另一处正在运行」近实时)。
|
|
@@ -6455,6 +8520,23 @@ export class ClientSession {
|
|
|
6455
8520
|
return;
|
|
6456
8521
|
}
|
|
6457
8522
|
}
|
|
8523
|
+
// #145 同款守卫:删之前查一遍其他客户端是否持有这份转录 ——
|
|
8524
|
+
// 对端 runtime 还开着时硬删等于把文件从活 writer 身下抽走(跑着时
|
|
8525
|
+
// 更是两支并发写)。被持有就拒绝删除;本客户端自己的持有已在上面处理。
|
|
8526
|
+
const otherOwner = this.findSessionOwner?.(abs);
|
|
8527
|
+
if (otherOwner) {
|
|
8528
|
+
this.emit({
|
|
8529
|
+
type: "notice",
|
|
8530
|
+
level: "warning",
|
|
8531
|
+
text: otherOwner.isStreaming
|
|
8532
|
+
? `该对话正在另一处运行中(「${otherOwner.title}」),已停止删除;请先回到原窗口停止或关闭它`
|
|
8533
|
+
: `该对话在另一处仍开着(「${otherOwner.title}」),已停止删除;请先在原窗口关闭它再删`,
|
|
8534
|
+
textEn: otherOwner.isStreaming
|
|
8535
|
+
? `This conversation is running in another window ("${otherOwner.title}"); delete aborted — stop or close it there first`
|
|
8536
|
+
: `This conversation is still open in another window ("${otherOwner.title}"); delete aborted — close it there first`,
|
|
8537
|
+
});
|
|
8538
|
+
return;
|
|
8539
|
+
}
|
|
6458
8540
|
rmSync(abs, { force: true });
|
|
6459
8541
|
// 转录删了,sidecar 再留着就是孤儿,一起清掉(不存在不报错)。
|
|
6460
8542
|
removeTouchSidecar(abs);
|
|
@@ -6584,8 +8666,8 @@ export class ClientSession {
|
|
|
6584
8666
|
});
|
|
6585
8667
|
}
|
|
6586
8668
|
/**
|
|
6587
|
-
*
|
|
6588
|
-
* 写入磁盘 .jsonl 会话文件,清除 isSubagent 标记,使它进入历史会话列表并长久保留。
|
|
8669
|
+
* 将内存会话(inMemory 子代理 / 临时对话)固化为普通持久化对话:
|
|
8670
|
+
* 写入磁盘 .jsonl 会话文件,清除 isSubagent / isEphemeral 标记,使它进入历史会话列表并长久保留。
|
|
6589
8671
|
*/
|
|
6590
8672
|
async persistConversation(id) {
|
|
6591
8673
|
const conv = this.convs.get(id);
|
|
@@ -6599,7 +8681,7 @@ export class ClientSession {
|
|
|
6599
8681
|
return;
|
|
6600
8682
|
}
|
|
6601
8683
|
const sm = conv.session.sessionManager;
|
|
6602
|
-
if (!conv.isSubagent && sm?.isPersisted?.()) {
|
|
8684
|
+
if (!conv.isSubagent && !conv.isEphemeral && sm?.isPersisted?.()) {
|
|
6603
8685
|
this.emit({
|
|
6604
8686
|
type: "notice",
|
|
6605
8687
|
level: "info",
|
|
@@ -6608,6 +8690,8 @@ export class ClientSession {
|
|
|
6608
8690
|
});
|
|
6609
8691
|
return;
|
|
6610
8692
|
}
|
|
8693
|
+
// 固化对象是临时对话时文案换一套(用户看到的是「临时对话」而非「子代理」)。
|
|
8694
|
+
const wasEphemeral = !!conv.isEphemeral;
|
|
6611
8695
|
try {
|
|
6612
8696
|
const cwd = conv.cwd || this.cwd;
|
|
6613
8697
|
const sampleSm = SessionManager.create(cwd);
|
|
@@ -6641,14 +8725,26 @@ export class ClientSession {
|
|
|
6641
8725
|
sm.flushed = true;
|
|
6642
8726
|
}
|
|
6643
8727
|
conv.isSubagent = false;
|
|
8728
|
+
conv.isEphemeral = false;
|
|
8729
|
+
if (wasEphemeral) {
|
|
8730
|
+
const ephemeralDir = join(this.agentDir, "ephemeral-sessions", conv.id);
|
|
8731
|
+
try {
|
|
8732
|
+
rmSync(ephemeralDir, { recursive: true, force: true });
|
|
8733
|
+
}
|
|
8734
|
+
catch { }
|
|
8735
|
+
}
|
|
6644
8736
|
this.emitConversations();
|
|
6645
8737
|
await this.pushProjects();
|
|
6646
8738
|
this.flushSnapshot();
|
|
6647
8739
|
this.emit({
|
|
6648
8740
|
type: "notice",
|
|
6649
8741
|
level: "info",
|
|
6650
|
-
text:
|
|
6651
|
-
|
|
8742
|
+
text: wasEphemeral
|
|
8743
|
+
? `已将临时对话「${conv.title}」保存为正式对话,并存入历史记录`
|
|
8744
|
+
: `已将子代理「${conv.title}」固化为普通对话,并保存至历史记录`,
|
|
8745
|
+
textEn: wasEphemeral
|
|
8746
|
+
? `Saved ephemeral conversation "${conv.title}" as a regular conversation in history`
|
|
8747
|
+
: `Solidified subagent "${conv.title}" into a regular conversation saved to history`,
|
|
6652
8748
|
});
|
|
6653
8749
|
}
|
|
6654
8750
|
catch (err) {
|
|
@@ -6656,8 +8752,8 @@ export class ClientSession {
|
|
|
6656
8752
|
this.emit({
|
|
6657
8753
|
type: "notice",
|
|
6658
8754
|
level: "error",
|
|
6659
|
-
text:
|
|
6660
|
-
textEn: `Failed to persist
|
|
8755
|
+
text: `固化失败:${msg}`,
|
|
8756
|
+
textEn: `Failed to persist conversation: ${msg}`,
|
|
6661
8757
|
});
|
|
6662
8758
|
}
|
|
6663
8759
|
}
|
|
@@ -7123,9 +9219,9 @@ export class ClientSession {
|
|
|
7123
9219
|
// is known-good. This keeps a failed history open entirely non-destructive.
|
|
7124
9220
|
const oldListed = this.conv.listed;
|
|
7125
9221
|
const displaced = this.displaceActive();
|
|
7126
|
-
const openInProject = [...this.convs.values()].filter((c) => c.cwd === targetCwd && !c.isSubagent).length +
|
|
9222
|
+
const openInProject = [...this.convs.values()].filter((c) => c.cwd === targetCwd && !c.isSubagent && !c.isEphemeral).length +
|
|
7127
9223
|
1 -
|
|
7128
|
-
(displaced?.cwd === targetCwd && !displaced?.isSubagent ? 1 : 0);
|
|
9224
|
+
(displaced?.cwd === targetCwd && !displaced?.isSubagent && !displaced?.isEphemeral ? 1 : 0);
|
|
7129
9225
|
if (openInProject > MAX_OPEN_CONVERSATIONS) {
|
|
7130
9226
|
// displaceActive() may have promoted a streaming conversation into the
|
|
7131
9227
|
// running list. Roll that presentation-only mutation back because no
|
|
@@ -7156,6 +9252,9 @@ export class ClientSession {
|
|
|
7156
9252
|
this.removeConversation(displaced.id);
|
|
7157
9253
|
await this.bindSession();
|
|
7158
9254
|
this.cwd = targetCwd;
|
|
9255
|
+
// 打开的历史会话可能属于另一个项目 —— 工作区跟随面(roots/文件树/
|
|
9256
|
+
// 历史列表/命令目录/最近项目)必须跟着切,否则 UI 停在旧项目。
|
|
9257
|
+
this.applyCwdSideEffects(targetCwd);
|
|
7159
9258
|
await this.restoreProjectProviderKeysForCwd(targetCwd);
|
|
7160
9259
|
await this.restoreProjectModelForCwd(targetCwd);
|
|
7161
9260
|
this.conv.lastActiveAt = Date.now();
|
|
@@ -7167,6 +9266,7 @@ export class ClientSession {
|
|
|
7167
9266
|
void this.pushSlashCommands();
|
|
7168
9267
|
// 切历史会话成功 → 插件重拉(轨迹视图立即显示该会话时间线)。
|
|
7169
9268
|
this.notifyConversationChanged();
|
|
9269
|
+
this.pushSettings();
|
|
7170
9270
|
}
|
|
7171
9271
|
catch (err) {
|
|
7172
9272
|
openedTerminals?.killAll();
|
|
@@ -7208,6 +9308,280 @@ export class ClientSession {
|
|
|
7208
9308
|
}
|
|
7209
9309
|
return null;
|
|
7210
9310
|
}
|
|
9311
|
+
/**
|
|
9312
|
+
* Map any rendered message id (u-*, a-*, t-*, b-*, c-*, or raw entry id)
|
|
9313
|
+
* back to its append-only session entry in the given conversation.
|
|
9314
|
+
*/
|
|
9315
|
+
resolveMessageEntry(conv, messageId) {
|
|
9316
|
+
// The matcher re-derives rendered ids with the SAME derivation
|
|
9317
|
+
// serializeCachedFor() used to hand them to the browser (uiMessageId +
|
|
9318
|
+
// the counter below). Historically this recomputed ids with its own
|
|
9319
|
+
// numbering (per-timestamp assistant seq, branch-entry global seq),
|
|
9320
|
+
// which never matched what was rendered — fork/rollback on any
|
|
9321
|
+
// assistant bubble failed 100% of the time (issue #381).
|
|
9322
|
+
const entries = conv.session.sessionManager.buildContextEntries();
|
|
9323
|
+
const found = findEntryByUiId(entries, messageId, (m) => this.uiMessageKey(conv, m).n);
|
|
9324
|
+
// 兜底:getEntry 查整棵树
|
|
9325
|
+
return found ?? conv.session.sessionManager.getEntry(messageId) ?? null;
|
|
9326
|
+
}
|
|
9327
|
+
/**
|
|
9328
|
+
* Fork a NEW branch conversation from a specific historical message position.
|
|
9329
|
+
* Truncates the transcript before (or at) that message as the context of the
|
|
9330
|
+
* new conversation, allowing the user to explore alternative lines of thought
|
|
9331
|
+
* without affecting the original conversation.
|
|
9332
|
+
*/
|
|
9333
|
+
async forkSession(messageId, position = "before", targetConvId) {
|
|
9334
|
+
if (this.quiesceBlocked())
|
|
9335
|
+
return;
|
|
9336
|
+
const targetConv = (targetConvId ? this.convs.get(targetConvId) : this.conv) ?? this.conv;
|
|
9337
|
+
const entry = this.resolveMessageEntry(targetConv, messageId);
|
|
9338
|
+
if (!entry) {
|
|
9339
|
+
this.emit({
|
|
9340
|
+
type: "notice",
|
|
9341
|
+
level: "error",
|
|
9342
|
+
text: "找不到指定的消息节点(可能已被压缩或不在当前分支)",
|
|
9343
|
+
textEn: "Message node to fork from not found (may have been compacted or is on another branch)",
|
|
9344
|
+
});
|
|
9345
|
+
this.flushSnapshot();
|
|
9346
|
+
return;
|
|
9347
|
+
}
|
|
9348
|
+
const targetLeafId = position === "at" ? entry.id : entry.parentId;
|
|
9349
|
+
try {
|
|
9350
|
+
const currentSessionFile = targetConv.session.sessionFile;
|
|
9351
|
+
const isPersisted = targetConv.session.sessionManager.isPersisted() && currentSessionFile && existsSync(currentSessionFile);
|
|
9352
|
+
const prevModel = targetConv.session.agent.state.model ?? null;
|
|
9353
|
+
const prevThinking = targetConv.session.thinkingLevel ?? null;
|
|
9354
|
+
const conversationId = this.nextConversationId();
|
|
9355
|
+
const terminals = this.makeTerminalManager(conversationId, targetConv.cwd);
|
|
9356
|
+
let forkedManager;
|
|
9357
|
+
if (isPersisted) {
|
|
9358
|
+
const sessionDir = targetConv.session.sessionManager.getSessionDir();
|
|
9359
|
+
let forkedSessionPath;
|
|
9360
|
+
if (!targetLeafId) {
|
|
9361
|
+
const sm = SessionManager.create(targetConv.cwd, sessionDir);
|
|
9362
|
+
sm.newSession({ parentSession: currentSessionFile });
|
|
9363
|
+
forkedSessionPath = sm.getSessionFile();
|
|
9364
|
+
}
|
|
9365
|
+
else {
|
|
9366
|
+
const sm = SessionManager.open(currentSessionFile, sessionDir);
|
|
9367
|
+
forkedSessionPath = sm.createBranchedSession(targetLeafId);
|
|
9368
|
+
}
|
|
9369
|
+
if (!forkedSessionPath) {
|
|
9370
|
+
throw new Error("Failed to create forked session file");
|
|
9371
|
+
}
|
|
9372
|
+
forkedManager = SessionManager.open(forkedSessionPath, sessionDir);
|
|
9373
|
+
}
|
|
9374
|
+
else {
|
|
9375
|
+
forkedManager = SessionManager.create(targetConv.cwd);
|
|
9376
|
+
if (targetLeafId) {
|
|
9377
|
+
const branch = targetConv.session.sessionManager.getBranch(targetLeafId);
|
|
9378
|
+
for (const e of branch) {
|
|
9379
|
+
if (e.type === "message") {
|
|
9380
|
+
try {
|
|
9381
|
+
forkedManager.appendMessage(e.message);
|
|
9382
|
+
}
|
|
9383
|
+
catch {
|
|
9384
|
+
// skip malformed/unsupported message entries in memory mode
|
|
9385
|
+
}
|
|
9386
|
+
}
|
|
9387
|
+
}
|
|
9388
|
+
}
|
|
9389
|
+
}
|
|
9390
|
+
const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, undefined, conversationId, prevModel ?? undefined), {
|
|
9391
|
+
cwd: targetConv.cwd,
|
|
9392
|
+
agentDir: this.agentDir,
|
|
9393
|
+
sessionManager: forkedManager,
|
|
9394
|
+
});
|
|
9395
|
+
const newConv = this.makeConversation(runtime, conversationId, terminals);
|
|
9396
|
+
if (targetConv.agentPreset)
|
|
9397
|
+
newConv.agentPreset = targetConv.agentPreset;
|
|
9398
|
+
if (targetConv.permissionPreset)
|
|
9399
|
+
newConv.permissionPreset = targetConv.permissionPreset;
|
|
9400
|
+
newConv.forkFrom = {
|
|
9401
|
+
conversationId: targetConv.id,
|
|
9402
|
+
messageId,
|
|
9403
|
+
title: targetConv.title,
|
|
9404
|
+
};
|
|
9405
|
+
// 与 newChat/switchSession 同一护栏:fork 出的对话也占项目名额,被换下
|
|
9406
|
+
// 的旧 active 也要走运行列表生命周期(该保留的保留、该释放的释放)。
|
|
9407
|
+
// 之前两者皆无 —— 可以无限 fork 堆爆名额,旧对话也永远留在列表里。
|
|
9408
|
+
// 顺序照 switchSession:新 runtime 建好才 displace,失败不伤现有对话。
|
|
9409
|
+
const oldListed = this.conv.listed;
|
|
9410
|
+
const displaced = this.displaceActive();
|
|
9411
|
+
const openInProject = [...this.convs.values()].filter((c) => c.cwd === targetConv.cwd && !c.isSubagent && !c.isEphemeral).length +
|
|
9412
|
+
1 -
|
|
9413
|
+
(displaced?.cwd === targetConv.cwd && !displaced?.isSubagent && !displaced?.isEphemeral ? 1 : 0);
|
|
9414
|
+
if (openInProject > MAX_OPEN_CONVERSATIONS) {
|
|
9415
|
+
// displaceActive() 可能只是把旧对话标成后台展示(presentation-only);
|
|
9416
|
+
// 没有切换发生时回滚该标记,新 fork 的 runtime/终端直接释放。
|
|
9417
|
+
this.conv.listed = oldListed;
|
|
9418
|
+
terminals.killAll();
|
|
9419
|
+
await runtime.dispose();
|
|
9420
|
+
this.emit({
|
|
9421
|
+
type: "notice",
|
|
9422
|
+
level: "warning",
|
|
9423
|
+
text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先打开某个对话并离开(不继续对话)以移出列表`,
|
|
9424
|
+
textEn: `This project already has the max open conversations (${MAX_OPEN_CONVERSATIONS}). Open one and leave it (without continuing) to remove it from the list.`,
|
|
9425
|
+
});
|
|
9426
|
+
return;
|
|
9427
|
+
}
|
|
9428
|
+
this.convs.set(conversationId, newConv);
|
|
9429
|
+
this.activeId = conversationId;
|
|
9430
|
+
if (displaced)
|
|
9431
|
+
this.removeConversation(displaced.id);
|
|
9432
|
+
await this.bindSession();
|
|
9433
|
+
if (prevModel && this.sharedModelRuntime) {
|
|
9434
|
+
try {
|
|
9435
|
+
const pm = prevModel;
|
|
9436
|
+
await this.restoreKeyForModel(`${pm.provider}/${pm.id}`, targetConv.cwd);
|
|
9437
|
+
await this.session.setModel(prevModel);
|
|
9438
|
+
}
|
|
9439
|
+
catch {
|
|
9440
|
+
// keep default
|
|
9441
|
+
}
|
|
9442
|
+
}
|
|
9443
|
+
if (prevThinking) {
|
|
9444
|
+
try {
|
|
9445
|
+
this.session.setThinkingLevel(prevThinking);
|
|
9446
|
+
}
|
|
9447
|
+
catch {
|
|
9448
|
+
// keep default
|
|
9449
|
+
}
|
|
9450
|
+
}
|
|
9451
|
+
this.emit({
|
|
9452
|
+
type: "notice",
|
|
9453
|
+
level: "info",
|
|
9454
|
+
text: "🌱 已派生新分支会话并自动切换(原对话保持不变)",
|
|
9455
|
+
textEn: "🌱 Forked new branch session and switched to it (original preserved)",
|
|
9456
|
+
});
|
|
9457
|
+
}
|
|
9458
|
+
catch (err) {
|
|
9459
|
+
this.emit({
|
|
9460
|
+
type: "notice",
|
|
9461
|
+
level: "error",
|
|
9462
|
+
text: `派生分支会话失败:${err.message}`,
|
|
9463
|
+
textEn: `Failed to fork branch session: ${err.message}`,
|
|
9464
|
+
});
|
|
9465
|
+
}
|
|
9466
|
+
this.flushSnapshot();
|
|
9467
|
+
}
|
|
9468
|
+
/**
|
|
9469
|
+
* 回滚会话至指定消息检查点(Checkpoint Rollback):
|
|
9470
|
+
* 丢弃该消息之后的所有内容,并就地重置 session 状态,用户可直接在此继续提问。
|
|
9471
|
+
*/
|
|
9472
|
+
async rollbackSession(messageId, targetConvId, restoreWorkspace) {
|
|
9473
|
+
if (this.quiesceBlocked())
|
|
9474
|
+
return;
|
|
9475
|
+
const targetConv = (targetConvId ? this.convs.get(targetConvId) : this.conv) ?? this.conv;
|
|
9476
|
+
const entry = this.resolveMessageEntry(targetConv, messageId);
|
|
9477
|
+
if (!entry) {
|
|
9478
|
+
this.emit({
|
|
9479
|
+
type: "notice",
|
|
9480
|
+
level: "error",
|
|
9481
|
+
text: "找不到指定的回滚检查点(可能已被压缩或不存在)",
|
|
9482
|
+
textEn: "Rollback checkpoint not found (may have been compacted or not exist)",
|
|
9483
|
+
});
|
|
9484
|
+
this.flushSnapshot();
|
|
9485
|
+
return;
|
|
9486
|
+
}
|
|
9487
|
+
try {
|
|
9488
|
+
if (targetConv.session.isStreaming) {
|
|
9489
|
+
await targetConv.session.abort();
|
|
9490
|
+
}
|
|
9491
|
+
// 重置分支 leaf 到目标 entry.id
|
|
9492
|
+
targetConv.session.sessionManager.branch(entry.id);
|
|
9493
|
+
const branchedContext = targetConv.session.sessionManager.buildSessionContext();
|
|
9494
|
+
targetConv.session.agent.state.messages = [...branchedContext.messages];
|
|
9495
|
+
if (branchedContext.thinkingLevel) {
|
|
9496
|
+
try {
|
|
9497
|
+
targetConv.session.setThinkingLevel(branchedContext.thinkingLevel);
|
|
9498
|
+
}
|
|
9499
|
+
catch {
|
|
9500
|
+
// ignore
|
|
9501
|
+
}
|
|
9502
|
+
}
|
|
9503
|
+
await targetConv.session.reload();
|
|
9504
|
+
// 重新应用设置和门控
|
|
9505
|
+
this.applyRetryOverrides();
|
|
9506
|
+
this.applyCompactionOverrides();
|
|
9507
|
+
this.applyToolGating(targetConv.session, targetConv.agentPreset);
|
|
9508
|
+
// 清理该会话的 UI 消息缓存和序列化映射
|
|
9509
|
+
targetConv.uiMessageCache.clear();
|
|
9510
|
+
targetConv.msgIds.clear();
|
|
9511
|
+
targetConv.userSeqByTs.clear();
|
|
9512
|
+
targetConv.nextMsgId = 1;
|
|
9513
|
+
targetConv.lastMessagesSig = "";
|
|
9514
|
+
targetConv.lastMessagesArray = [];
|
|
9515
|
+
targetConv.queueSteering = [];
|
|
9516
|
+
targetConv.queueFollowUp = [];
|
|
9517
|
+
try {
|
|
9518
|
+
targetConv.session.clearQueue?.();
|
|
9519
|
+
}
|
|
9520
|
+
catch {
|
|
9521
|
+
// best effort
|
|
9522
|
+
}
|
|
9523
|
+
// 联动还原物理工作区文件(Dual-State Rollback)
|
|
9524
|
+
let workspaceRestored = false;
|
|
9525
|
+
if (restoreWorkspace) {
|
|
9526
|
+
const entryTs = entry.message?.timestamp ??
|
|
9527
|
+
entry.timestamp ??
|
|
9528
|
+
0;
|
|
9529
|
+
// 查找最贴近该 entry 的快照(按 entryId 或 <= entryTs 的最后一份快照)
|
|
9530
|
+
const snapshots = targetConv.workspaceSnapshots ?? [];
|
|
9531
|
+
let matched = snapshots.find((s) => s.entryId === entry.id);
|
|
9532
|
+
if (!matched) {
|
|
9533
|
+
const eligible = snapshots.filter((s) => entryTs === 0 || s.timestamp <= entryTs + 2000);
|
|
9534
|
+
matched = eligible[eligible.length - 1];
|
|
9535
|
+
}
|
|
9536
|
+
if (!matched && snapshots.length > 0) {
|
|
9537
|
+
matched = snapshots[0];
|
|
9538
|
+
}
|
|
9539
|
+
if (matched) {
|
|
9540
|
+
const res = await restoreWorkspaceSnapshot(targetConv.cwd, matched.snapshotRef);
|
|
9541
|
+
if (res.success) {
|
|
9542
|
+
workspaceRestored = true;
|
|
9543
|
+
}
|
|
9544
|
+
else {
|
|
9545
|
+
this.emit({
|
|
9546
|
+
type: "notice",
|
|
9547
|
+
level: "warning",
|
|
9548
|
+
text: `会话已回滚,但工作区物理文件还原失败:${res.error}`,
|
|
9549
|
+
textEn: `Session rolled back, but workspace file restore failed: ${res.error}`,
|
|
9550
|
+
});
|
|
9551
|
+
}
|
|
9552
|
+
}
|
|
9553
|
+
else {
|
|
9554
|
+
this.emit({
|
|
9555
|
+
type: "notice",
|
|
9556
|
+
level: "warning",
|
|
9557
|
+
text: "未找到对应检查点的工作区快照(可能非 Git 仓库或尚未记录),工作区文件未改动。",
|
|
9558
|
+
textEn: "No workspace snapshot found for this checkpoint (not a Git repo or snapshot unavailable); files left untouched.",
|
|
9559
|
+
});
|
|
9560
|
+
}
|
|
9561
|
+
}
|
|
9562
|
+
this.emit({
|
|
9563
|
+
type: "notice",
|
|
9564
|
+
level: "info",
|
|
9565
|
+
text: workspaceRestored
|
|
9566
|
+
? "已回滚至选定消息,工作区物理文件已联动还原至该检查点时刻。"
|
|
9567
|
+
: "已回滚至选定消息,后续内容已丢弃,可直接继续对话。",
|
|
9568
|
+
textEn: workspaceRestored
|
|
9569
|
+
? "Rolled back to selected message; workspace physical files restored to checkpoint state."
|
|
9570
|
+
: "Rolled back to selected message; subsequent content discarded, ready to continue.",
|
|
9571
|
+
});
|
|
9572
|
+
this.emittedMessages = null;
|
|
9573
|
+
this.emitConversations();
|
|
9574
|
+
this.flushSnapshot(true);
|
|
9575
|
+
}
|
|
9576
|
+
catch (err) {
|
|
9577
|
+
this.emit({
|
|
9578
|
+
type: "notice",
|
|
9579
|
+
level: "error",
|
|
9580
|
+
text: `回滚失败:${err.message}`,
|
|
9581
|
+
textEn: `Rollback failed: ${err.message}`,
|
|
9582
|
+
});
|
|
9583
|
+
}
|
|
9584
|
+
}
|
|
7211
9585
|
/**
|
|
7212
9586
|
* Edit a past user question and re-ask it: forks a NEW session file that
|
|
7213
9587
|
* keeps everything up to (but not including) that question, then sends the
|
|
@@ -7328,7 +9702,7 @@ export class ClientSession {
|
|
|
7328
9702
|
const run = (async () => {
|
|
7329
9703
|
try {
|
|
7330
9704
|
const saved = this.stateStore.get(this.clientId);
|
|
7331
|
-
const removedProjects = new Set(this.stateStore.getRemovedProjects(this.clientId));
|
|
9705
|
+
const removedProjects = new Set(this.stateStore.getRemovedProjects(this.clientId).map(normalizePathKey));
|
|
7332
9706
|
const map = new Map();
|
|
7333
9707
|
for (const p of saved.projects)
|
|
7334
9708
|
map.set(p.path, p.lastUsed);
|
|
@@ -7345,7 +9719,7 @@ export class ClientSession {
|
|
|
7345
9719
|
// is useless in the picker. Tombstoned entries (explicitly removed by
|
|
7346
9720
|
// the user) stay hidden even though session files still mention them.
|
|
7347
9721
|
const projects = [...map.entries()]
|
|
7348
|
-
.filter(([path]) => !removedProjects.has(path) && existsSync(path))
|
|
9722
|
+
.filter(([path]) => !removedProjects.has(normalizePathKey(path)) && existsSync(path))
|
|
7349
9723
|
.map(([path, lastUsed]) => ({ path, lastUsed }))
|
|
7350
9724
|
.sort((a, b) => b.lastUsed - a.lastUsed)
|
|
7351
9725
|
.slice(0, 20);
|
|
@@ -7365,11 +9739,17 @@ export class ClientSession {
|
|
|
7365
9739
|
await run;
|
|
7366
9740
|
}
|
|
7367
9741
|
/** 缓存命中时把当前 cwd 并进去:命中则刷新 lastUsed 重排,未命中则补到首位
|
|
7368
|
-
* (remember 刚写入的新项目在 TTL 窗口内也可见,不必等下一次扫盘)。
|
|
9742
|
+
* (remember 刚写入的新项目在 TTL 窗口内也可见,不必等下一次扫盘)。
|
|
9743
|
+
* 若当前工作区已被显式移出,则不强行塞回最近列表。 */
|
|
7369
9744
|
withCurrentCwd(projects, now) {
|
|
7370
|
-
|
|
9745
|
+
const currentKey = normalizePathKey(this.cwd);
|
|
9746
|
+
const removedKeys = new Set(this.stateStore.getRemovedProjects(this.clientId).map(normalizePathKey));
|
|
9747
|
+
if (removedKeys.has(currentKey)) {
|
|
9748
|
+
return projects;
|
|
9749
|
+
}
|
|
9750
|
+
if (projects.some((p) => normalizePathKey(p.path) === currentKey)) {
|
|
7371
9751
|
return projects
|
|
7372
|
-
.map((p) => (p.path ===
|
|
9752
|
+
.map((p) => (normalizePathKey(p.path) === currentKey && p.lastUsed < now ? { ...p, lastUsed: now } : p))
|
|
7373
9753
|
.sort((a, b) => b.lastUsed - a.lastUsed);
|
|
7374
9754
|
}
|
|
7375
9755
|
return [{ path: this.cwd, lastUsed: now }, ...projects].slice(0, 20);
|
|
@@ -7432,7 +9812,7 @@ export class ClientSession {
|
|
|
7432
9812
|
*/
|
|
7433
9813
|
async scmGenCommitMessage(reqId) {
|
|
7434
9814
|
const lang = this.getLang();
|
|
7435
|
-
const cwd = this.
|
|
9815
|
+
const cwd = this.convs.get(this.activeId)?.cwd ?? this.cwd;
|
|
7436
9816
|
const reply = (ok, extra) => {
|
|
7437
9817
|
this.emit({ type: "scm_data", reqId, kind: "commitmsg", ok, ...extra });
|
|
7438
9818
|
};
|
|
@@ -7599,7 +9979,7 @@ export class ClientSession {
|
|
|
7599
9979
|
}
|
|
7600
9980
|
async setCwd(newCwd) {
|
|
7601
9981
|
try {
|
|
7602
|
-
const { resolve
|
|
9982
|
+
const { resolve } = await import("node:path");
|
|
7603
9983
|
this.files.unwatchGit(); // stale repo's watcher must not fire across projects
|
|
7604
9984
|
const fs = await import("node:fs/promises");
|
|
7605
9985
|
const trimmed = newCwd.trim();
|
|
@@ -7613,11 +9993,9 @@ export class ClientSession {
|
|
|
7613
9993
|
});
|
|
7614
9994
|
return;
|
|
7615
9995
|
}
|
|
7616
|
-
// Windows 裸盘符("C:"
|
|
7617
|
-
//
|
|
7618
|
-
const abs =
|
|
7619
|
-
? `${trimmed.toUpperCase()}${sep}`
|
|
7620
|
-
: resolve(trimmed);
|
|
9996
|
+
// Windows 裸盘符("C:")与相对路径基准的处理收敛到 resolveCwdTarget
|
|
9997
|
+
// (纯函数,含 win32/posix 差异说明与单测)。
|
|
9998
|
+
const abs = resolveCwdTarget(trimmed, this.cwd);
|
|
7621
9999
|
const st = await fs.stat(abs);
|
|
7622
10000
|
if (!st.isDirectory()) {
|
|
7623
10001
|
throw new Error("路径不是目录");
|
|
@@ -7727,7 +10105,6 @@ export class ClientSession {
|
|
|
7727
10105
|
this.conv.promptedSinceActive = false;
|
|
7728
10106
|
this.conv.lastActiveAt = Date.now();
|
|
7729
10107
|
this.cwd = abs;
|
|
7730
|
-
this.roots = this.stateStore.getWorkspaceRoots(this.clientId, abs);
|
|
7731
10108
|
// 模型/key 恢复不挡快照:后台做,带切换代际 guard(用户又切走就跳过,
|
|
7732
10109
|
// 否则会把旧项目的 key 套到新对话上),做完补一次 flush 刷新模型栏。
|
|
7733
10110
|
{
|
|
@@ -7747,16 +10124,7 @@ export class ClientSession {
|
|
|
7747
10124
|
}
|
|
7748
10125
|
})();
|
|
7749
10126
|
}
|
|
7750
|
-
|
|
7751
|
-
try {
|
|
7752
|
-
this.onCwdChanged?.(abs, this.roots);
|
|
7753
|
-
}
|
|
7754
|
-
catch {
|
|
7755
|
-
/* 钩子异常不影响主流程 */
|
|
7756
|
-
}
|
|
7757
|
-
// Remember the new workspace (restore target + recent-project entry).
|
|
7758
|
-
this.stateStore.remember(this.clientId, abs);
|
|
7759
|
-
void this.pushProjects();
|
|
10127
|
+
this.applyCwdSideEffects(abs);
|
|
7760
10128
|
this.webUi.refresh();
|
|
7761
10129
|
this.emitConversations();
|
|
7762
10130
|
this.goalSvc.emitGoalStatus();
|
|
@@ -7768,12 +10136,9 @@ export class ClientSession {
|
|
|
7768
10136
|
text: `已切换到工作目录:${abs}`,
|
|
7769
10137
|
textEn: `Switched to directory: ${abs}`,
|
|
7770
10138
|
});
|
|
7771
|
-
this.refreshSessionsOnSwitch();
|
|
7772
|
-
void this.listFiles(undefined);
|
|
7773
|
-
// Commands are per-project (.pi/commands.json in the current cwd).
|
|
7774
|
-
void this.listCommands();
|
|
7775
10139
|
// 切项目即换了当前打开对话 → 插件重拉。
|
|
7776
10140
|
this.notifyConversationChanged();
|
|
10141
|
+
this.pushSettings();
|
|
7777
10142
|
}
|
|
7778
10143
|
catch (err) {
|
|
7779
10144
|
this.emit({
|
|
@@ -7839,14 +10204,30 @@ export class ClientSession {
|
|
|
7839
10204
|
async clearGoal() {
|
|
7840
10205
|
return this.goalSvc.clearGoal();
|
|
7841
10206
|
}
|
|
7842
|
-
/**
|
|
7843
|
-
*
|
|
10207
|
+
/**
|
|
10208
|
+
* Run a git diff (unstaged + staged) in a conversation's workspace, or
|
|
10209
|
+
* "" when not a repo.
|
|
10210
|
+
*
|
|
10211
|
+
* 返回值是 goal 审查用的「变更指纹」,构造规则(截断与等值比较的相互作用)
|
|
10212
|
+
* 见 buildDiffFingerprint:diff 为空时以排序后的 `git status --porcelain`
|
|
10213
|
+
* 兜底(未跟踪文件也算变更),diff 非空时正文截断后拼 [diff-meta] 尾段,
|
|
10214
|
+
* 避免大 diff 截断后两轮前缀相同被误判成停滞。
|
|
10215
|
+
*/
|
|
7844
10216
|
async gitDiff(cwd) {
|
|
7845
10217
|
try {
|
|
7846
10218
|
const { code, out } = await this.runAsync("git", ["diff", "HEAD"], 10_000, cwd);
|
|
7847
10219
|
if (code !== 0)
|
|
7848
10220
|
return "";
|
|
7849
|
-
|
|
10221
|
+
let status = "";
|
|
10222
|
+
try {
|
|
10223
|
+
const st = await this.runAsync("git", ["status", "--porcelain"], 10_000, cwd);
|
|
10224
|
+
if (st.code === 0)
|
|
10225
|
+
status = st.out;
|
|
10226
|
+
}
|
|
10227
|
+
catch {
|
|
10228
|
+
// status 拍不到 → 指纹退化为纯 diff,行为与旧版一致
|
|
10229
|
+
}
|
|
10230
|
+
return buildDiffFingerprint(out, status);
|
|
7850
10231
|
}
|
|
7851
10232
|
catch {
|
|
7852
10233
|
return "";
|
|
@@ -8058,9 +10439,13 @@ export class ClientSession {
|
|
|
8058
10439
|
this.cancelPendingQuestions();
|
|
8059
10440
|
// 同理关闭挂起的页面调用(以失败解析:对面是扩展,没有答可等)。
|
|
8060
10441
|
this.cancelPendingPageCalls();
|
|
10442
|
+
// 挂起的工具审批一并清掉(以「拒绝」解析,防 Promise/runtime 泄漏)。
|
|
10443
|
+
this.cancelPendingApprovals();
|
|
8061
10444
|
this.bg.stop();
|
|
8062
10445
|
for (const conv of this.convs.values()) {
|
|
8063
10446
|
this.clearAllToolWatchdogs(conv);
|
|
10447
|
+
// 逐个对话回收 eval 内核;下面的兜底再清一次表(含已 delete 的残留)。
|
|
10448
|
+
disposeEvalSession(conv.id);
|
|
8064
10449
|
conv.unsubscribe?.();
|
|
8065
10450
|
try {
|
|
8066
10451
|
await conv.runtime.dispose();
|
|
@@ -8069,6 +10454,7 @@ export class ClientSession {
|
|
|
8069
10454
|
// best effort
|
|
8070
10455
|
}
|
|
8071
10456
|
}
|
|
10457
|
+
disposeAllEvalKernels();
|
|
8072
10458
|
}
|
|
8073
10459
|
}
|
|
8074
10460
|
/** issue #226:插件无头调用的工作目录校验(纯函数,可单测)。
|
|
@@ -8111,6 +10497,8 @@ export class AgentService {
|
|
|
8111
10497
|
cwd;
|
|
8112
10498
|
/** index.ts 注入:SDK 工具执行事件的插件转发钩子,attach 时拷贝到每个新会话。 */
|
|
8113
10499
|
onToolEvent = undefined;
|
|
10500
|
+
/** index.ts 注入:bash/read 插件拦截钩子,attach 时拷贝到每个新会话。 */
|
|
10501
|
+
toolGuard = undefined;
|
|
8114
10502
|
/** index.ts 注入:运行轨迹事件的插件转发钩子,attach 时拷贝到每个新会话。 */
|
|
8115
10503
|
onRunEvent = undefined;
|
|
8116
10504
|
/** index.ts 注入:对话切换通知钩子,attach 时拷贝到每个新会话。 */
|
|
@@ -8418,12 +10806,34 @@ export class AgentService {
|
|
|
8418
10806
|
for (const [clientId, cs] of this.clients) {
|
|
8419
10807
|
if (clientId === excludeClientId)
|
|
8420
10808
|
continue;
|
|
10809
|
+
// issue #291:跳过无浏览器连接的残骸(非伪客户端 sinkCount=0 = 断连留存)。
|
|
10810
|
+
// 伪客户端(scheduler:/plugin:)不走浏览器,sinkCount 永远为 0,保留。
|
|
10811
|
+
if (!AgentService.isPseudoClientId(clientId) && cs.sinkCount() === 0)
|
|
10812
|
+
continue;
|
|
8421
10813
|
for (const r of cs.streamingSummariesAll())
|
|
8422
10814
|
out.push({ ...r, owner: clientId });
|
|
8423
10815
|
}
|
|
8424
10816
|
return out;
|
|
8425
10817
|
}
|
|
8426
|
-
/** issue #
|
|
10818
|
+
/** issue #291:删除定时任务后回收对应伪客户端,避免残留在 elsewhere 列表。 */
|
|
10819
|
+
releaseSchedulerClient(taskId) {
|
|
10820
|
+
const safe = String(taskId ?? "").replace(/[^A-Za-z0-9_-]/g, "") || "task";
|
|
10821
|
+
const clientId = `scheduler:${safe}`;
|
|
10822
|
+
const cs = this.clients.get(clientId);
|
|
10823
|
+
if (!cs)
|
|
10824
|
+
return;
|
|
10825
|
+
// 伪客户端常驻一个 noop sink(chatFromScheduler 的 attach),所以不能按
|
|
10826
|
+
// sinkCount 判断「有没有浏览器」—— 它永远为 1。这里就是它的回收点。
|
|
10827
|
+
this.clients.delete(clientId);
|
|
10828
|
+
// 先摘出 map 再异步 dispose:只删 map 的话,对话的 runtime/终端/看门狗
|
|
10829
|
+
// 计时器全都留在内存里(泄漏),转录还挂着活 writer(同文件双写者风险)。
|
|
10830
|
+
void cs.dispose().catch(() => {
|
|
10831
|
+
// 回收失败不拦主流程:尽力而为,进程退出时 OS 兜底
|
|
10832
|
+
});
|
|
10833
|
+
// 通知其他客户端刷新 elsewhere 列表。
|
|
10834
|
+
this.pokeExternalRunning(clientId);
|
|
10835
|
+
}
|
|
10836
|
+
/** issue #145: 某客户端流式集合变化 → 其他客户端重推 conversations。 */
|
|
8427
10837
|
pokeExternalRunning(excludeClientId) {
|
|
8428
10838
|
for (const [clientId, cs] of this.clients) {
|
|
8429
10839
|
if (clientId === excludeClientId)
|
|
@@ -8798,9 +11208,9 @@ export class AgentService {
|
|
|
8798
11208
|
}
|
|
8799
11209
|
const moveIds = [convId, ...collectSubagentDescendantIds(briefs, convId)];
|
|
8800
11210
|
const moveSet = new Set(moveIds);
|
|
8801
|
-
// 容量:与 switchSession
|
|
8802
|
-
const movedMains = briefs.filter((b) => moveSet.has(b.id) && !b.isSubagent).length;
|
|
8803
|
-
const openInProject = target.takeoverBriefs().filter((b) => b.cwd === main.cwd && !b.isSubagent).length + movedMains;
|
|
11211
|
+
// 容量:与 switchSession 同口径(目标项目非子代理且非临时会话 8 个)。
|
|
11212
|
+
const movedMains = briefs.filter((b) => moveSet.has(b.id) && !b.isSubagent && !b.isEphemeral).length;
|
|
11213
|
+
const openInProject = target.takeoverBriefs().filter((b) => b.cwd === main.cwd && !b.isSubagent && !b.isEphemeral).length + movedMains;
|
|
8804
11214
|
if (openInProject > MAX_OPEN_CONVERSATIONS) {
|
|
8805
11215
|
fail(`目标项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先打开某个对话并离开(不继续对话)以移出列表`, `This project already has the max open conversations (${MAX_OPEN_CONVERSATIONS}). Open one and leave it (without continuing) to remove it from the list.`);
|
|
8806
11216
|
return;
|
|
@@ -8904,7 +11314,9 @@ export class AgentService {
|
|
|
8904
11314
|
// 流式增量经尾部 attachSink 直接推给新 socket)。
|
|
8905
11315
|
// 有其他在线标签时不认领(issue #10 隔离优先);quiesce 排空期也放行
|
|
8906
11316
|
// (这是重连既有工作,不是新工作)。本段无 await,并发 attach 原子。
|
|
8907
|
-
|
|
11317
|
+
// 伪客户端(scheduler:/plugin:,定时任务/插件的无头调用)绝不认领:
|
|
11318
|
+
// 否则无头会话会改键劫持用户关浏览器留下的残会话。
|
|
11319
|
+
const orphan = AgentService.isPseudoClientId(clientId) ? null : this.findAdoptableOrphan(clientId);
|
|
8908
11320
|
if (orphan) {
|
|
8909
11321
|
this.clients.delete(orphan.oldId);
|
|
8910
11322
|
this.clients.set(clientId, orphan.cs);
|
|
@@ -8932,11 +11344,15 @@ export class AgentService {
|
|
|
8932
11344
|
const saved = this.stateStore.get(clientId);
|
|
8933
11345
|
if (saved.lastCwd && saved.lastCwd !== this.cwd) {
|
|
8934
11346
|
try {
|
|
8935
|
-
|
|
11347
|
+
// issue #295:异步 stat —— 同步 stat 落在坏挂载(已卸载的外部卷/
|
|
11348
|
+
// autofs 触发点)上会在内核里挂起,冻住整个事件循环(含控制
|
|
11349
|
+
// socket 与其他客户端的心跳);异步版本只挡本连接,超时提示照发。
|
|
11350
|
+
const { stat } = await import("node:fs/promises");
|
|
11351
|
+
if ((await stat(saved.lastCwd)).isDirectory())
|
|
8936
11352
|
cwd = saved.lastCwd;
|
|
8937
11353
|
}
|
|
8938
11354
|
catch {
|
|
8939
|
-
// gone (unmounted drive / deleted) — fall back to the default
|
|
11355
|
+
// gone (unmounted drive / deleted / hanging mount) — fall back to the default
|
|
8940
11356
|
}
|
|
8941
11357
|
}
|
|
8942
11358
|
// Sessions use the SDK default per-project dir — no per-client dir.
|
|
@@ -8988,6 +11404,7 @@ export class AgentService {
|
|
|
8988
11404
|
// Forward hooks (set once by index.ts) to every session.
|
|
8989
11405
|
cs.onQuit = this.onQuit;
|
|
8990
11406
|
cs.onToolEvent = this.onToolEvent;
|
|
11407
|
+
cs.toolGuard = this.toolGuard;
|
|
8991
11408
|
cs.onRunEvent = this.onRunEvent;
|
|
8992
11409
|
cs.onConversationChanged = () => this.onConversationChanged?.();
|
|
8993
11410
|
cs.pluginToolsProvider = this.pluginToolsProvider;
|
|
@@ -9040,7 +11457,14 @@ export class AgentService {
|
|
|
9040
11457
|
}
|
|
9041
11458
|
/** Remove a socket from a client's broadcast set (called on socket close). */
|
|
9042
11459
|
detach(clientId, send) {
|
|
9043
|
-
this.clients.get(clientId)
|
|
11460
|
+
const cs = this.clients.get(clientId);
|
|
11461
|
+
cs?.detachSink(send);
|
|
11462
|
+
// issue #291:最后一个 sink 断开 = 该客户端不再在线 → 它的对话不该再出现在
|
|
11463
|
+
// 别人的 elsewhere 列表(listExternalRunning 已跳过 sinkCount=0 的非伪客户端,
|
|
11464
|
+
// 但列表是推过去的,得让其他客户端重推一次才能立刻消失)。
|
|
11465
|
+
if (cs && !AgentService.isPseudoClientId(clientId) && cs.sinkCount() === 0) {
|
|
11466
|
+
this.pokeExternalRunning(clientId);
|
|
11467
|
+
}
|
|
9044
11468
|
}
|
|
9045
11469
|
get(clientId) {
|
|
9046
11470
|
return this.clients.get(clientId);
|