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