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