pi-web-ui 0.96.0 → 0.97.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 +69 -2
- package/README.md +26 -0
- package/README.zh-CN.md +25 -0
- package/bin/pi-web-ui.mjs +11 -2
- package/dist/server/agent-service.js +1195 -120
- package/dist/server/client-state.js +89 -1
- package/dist/server/compacted-history.js +146 -0
- package/dist/server/delegate-mode.js +149 -0
- package/dist/server/delegate-task.js +10 -10
- package/dist/server/dsh/dsh-agent-service.js +35 -2
- package/dist/server/dsh/dsh-client.js +3 -1
- package/dist/server/eval-tool.js +3 -1
- package/dist/server/goal-review-gate.js +263 -0
- package/dist/server/goal-service.js +1225 -445
- package/dist/server/index.js +79 -20
- package/dist/server/lsp-tool.js +13 -10
- package/dist/server/markers/builtins/todo.js +0 -2
- package/dist/server/mcp-bridge.js +13 -0
- package/dist/server/patch-tool.js +1 -0
- package/dist/server/patch-turn-end-boundary.js +77 -0
- package/dist/server/permission-preset.js +44 -0
- package/dist/server/plan-mode.js +376 -0
- package/dist/server/plugin-facilities.js +36 -3
- package/dist/server/plugin-installer.js +30 -0
- package/dist/server/plugins.js +6 -3
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/serialize.js +11 -0
- package/dist/server/settings-service.js +20 -8
- package/dist/server/subagents.js +360 -408
- package/dist/server/text-sniff.js +15 -9
- package/dist/server/tool-manager.js +48 -4
- package/dist/server/update-check.js +11 -3
- package/dist/server/wait-subscription-scan.js +5 -1
- package/package.json +3 -3
- package/themes/zhupi-dark.css +79 -44
- package/themes/zhupi.css +79 -44
- package/web/dist/assets/{TerminalPanel-DDChcYOh.js → TerminalPanel-CITBqQrb.js} +1 -1
- package/web/dist/assets/index-DwToli0J.css +1 -0
- package/web/dist/assets/index-oI7UKOqJ.js +368 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-Do9RgJC3.js +0 -366
- package/web/dist/assets/index-DryOsILO.css +0 -1
|
@@ -14,9 +14,11 @@
|
|
|
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";
|
|
21
|
+
import * as fsPromises from "node:fs/promises";
|
|
20
22
|
import { appendFileSync, existsSync, readFileSync, renameSync, 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";
|
|
@@ -34,7 +36,7 @@ import { removeQueuedByIndexOrText } from "./queue-utils.js";
|
|
|
34
36
|
import { syncPluginToolsIntoSession } from "./plugins.js";
|
|
35
37
|
import { denialText, } from "./plugin-tool-guard.js";
|
|
36
38
|
import { SettingsService } from "./settings-service.js";
|
|
37
|
-
import { GoalService, buildDiffFingerprint } from "./goal-service.js";
|
|
39
|
+
import { GoalService, buildDiffFingerprint, extractErrorSnippetFromSession, lastToolNameOfSession, } from "./goal-service.js";
|
|
38
40
|
import { MarkerService } from "./marker-service.js";
|
|
39
41
|
import { SlashCommandsService, parseSlash } from "./slash-commands.js";
|
|
40
42
|
import { ModelAdminService } from "./model-admin.js";
|
|
@@ -49,8 +51,13 @@ import { createWorkspaceSnapshot, restoreWorkspaceSnapshot } from "./workspace-s
|
|
|
49
51
|
import { isPathInsideRoot } from "./approval-rules.js";
|
|
50
52
|
import { approvalSuppressionReason, checkDangerousToolCall, isApprovalPolicyEmpty, pluginApprovalCategory, } from "./tool-approval.js";
|
|
51
53
|
import { PlanManager } from "./plan-manager.js";
|
|
54
|
+
import { buildPlanModePrompt, planModeDenial, planModeNoticeText, PLAN_MODE_BLOCKED_TOOL_NAMES } from "./plan-mode.js";
|
|
55
|
+
import { DELEGATION_SYSTEM_PROMPT, delegationDenial, delegateNoticeText } from "./delegate-mode.js";
|
|
56
|
+
import { goalReviewDenial, shouldDeferPromptForReview } from "./goal-review-gate.js";
|
|
57
|
+
import { readPlanModeFromSession } from "./permission-preset.js";
|
|
58
|
+
import { readDelegateModeFromSession } from "./permission-preset.js";
|
|
52
59
|
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";
|
|
60
|
+
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
61
|
import { WebUIContext } from "./webui-context.js";
|
|
55
62
|
import { DEFAULT_COMPACTION_RESERVE_TOKENS, effectiveSoftCap, softCapToReserve } from "./soft-cap.js";
|
|
56
63
|
import { pruneContextHierarchically } from "./context-budget.js";
|
|
@@ -75,6 +82,7 @@ import { extractTouches, formatTouchesCompact, intersectTouches } from "./conver
|
|
|
75
82
|
import { ClaimStore, matchClaims, mergeTouchSidecar, readTouchSidecar, removeTouchSidecar } from "./claim-store.js";
|
|
76
83
|
import { makeClaimFilesTool } from "./claim-files-tool.js";
|
|
77
84
|
import { makeSkillTool } from "./skill-tool.js";
|
|
85
|
+
import { getCompactedMessages } from "./compacted-history.js";
|
|
78
86
|
import { makeScheduleTools } from "./schedule-agent-tool.js";
|
|
79
87
|
import { makePatchTool } from "./patch-tool.js";
|
|
80
88
|
import { makeLspTool } from "./lsp-tool.js";
|
|
@@ -266,6 +274,85 @@ export function makeAdaptiveBashTool(killable, terminalBacked, useTerminal) {
|
|
|
266
274
|
},
|
|
267
275
|
};
|
|
268
276
|
}
|
|
277
|
+
/**
|
|
278
|
+
* 计划模式闸门装饰(通用):套在任何工具定义外层,只在**执行前**判定一次。
|
|
279
|
+
* 与权限沙箱/插件守卫平行且更靠外 —— 计划模式是会话级硬约束,不给任何
|
|
280
|
+
* 「记住同类/全部允许」的口子(允许了就等于放开了实施)。
|
|
281
|
+
* 关闭时零开销(返回原定义,不包 execute)。
|
|
282
|
+
*/
|
|
283
|
+
export function withPlanModeGate(def, planMode, getLang) {
|
|
284
|
+
return {
|
|
285
|
+
...def,
|
|
286
|
+
execute: (async (...args) => {
|
|
287
|
+
if (planMode()) {
|
|
288
|
+
const params = args[1];
|
|
289
|
+
const denied = planModeDenial(def.name, params);
|
|
290
|
+
if (denied) {
|
|
291
|
+
const reason = pick(getLang(), denied.reason, denied.reasonEn);
|
|
292
|
+
const err = new Error(reason);
|
|
293
|
+
err.details = {
|
|
294
|
+
guardDenied: true,
|
|
295
|
+
planModeDenied: true,
|
|
296
|
+
kind: denied.kind,
|
|
297
|
+
};
|
|
298
|
+
throw err;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return def.execute(...args);
|
|
302
|
+
}),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* 目标审查回合闸门(server/goal-review-gate.ts):主对话在 `awaitingVerdict`
|
|
307
|
+
* 置位期间是审查者 —— 写类工具、非常规 bash、派发类工具、向用户提问一律拒。
|
|
308
|
+
* 形状与 withPlanModeGate / withDelegationGate 同构,串在最外层(理由最贴合
|
|
309
|
+
* 此刻:跟模型说「这是审查回合」比「这是计划/审查者模式」更有用)。闸门无状态
|
|
310
|
+
* (每次执行只读 `awaitingVerdict`),verdict 落定即自动恢复,不存在钉死。
|
|
311
|
+
*/
|
|
312
|
+
export function withGoalReviewGate(def, reviewTurn, getLang) {
|
|
313
|
+
return {
|
|
314
|
+
...def,
|
|
315
|
+
execute: (async (...args) => {
|
|
316
|
+
if (reviewTurn()) {
|
|
317
|
+
const params = args[1];
|
|
318
|
+
const denied = goalReviewDenial(def.name, params);
|
|
319
|
+
if (denied) {
|
|
320
|
+
return {
|
|
321
|
+
content: [{ type: "text", text: pick(getLang(), denied.reason, denied.reasonEn) }],
|
|
322
|
+
details: { guardDenied: true, goalReviewDenied: true, kind: denied.kind },
|
|
323
|
+
isError: true,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return def.execute(...args);
|
|
328
|
+
}),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* 审查者模式闸门(server/delegate-mode.ts):主对话只审阅不施工 ——
|
|
333
|
+
* 写类工具、非常规 bash、派发类工具一律拒。形状与 withPlanModeGate 同构,
|
|
334
|
+
* 串在它**外层**(两者都开时,先问计划模式还是审查者模式都不影响结果:
|
|
335
|
+
* 都是拒,只是理由不同)。
|
|
336
|
+
*/
|
|
337
|
+
export function withDelegationGate(def, delegateMode, getLang) {
|
|
338
|
+
return {
|
|
339
|
+
...def,
|
|
340
|
+
execute: (async (...args) => {
|
|
341
|
+
if (delegateMode()) {
|
|
342
|
+
const params = args[1];
|
|
343
|
+
const denied = delegationDenial(def.name, params);
|
|
344
|
+
if (denied) {
|
|
345
|
+
return {
|
|
346
|
+
content: [{ type: "text", text: pick(getLang(), denied.reason, denied.reasonEn) }],
|
|
347
|
+
details: { guardDenied: true, delegateDenied: true, kind: denied.kind },
|
|
348
|
+
isError: true,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return def.execute(...args);
|
|
353
|
+
}),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
269
356
|
/**
|
|
270
357
|
* 给已接管工具(bash/read)包上拦截守卫与人机协同审批:
|
|
271
358
|
* 1. pre guard 命中 deny → 直接阻断;
|
|
@@ -666,7 +753,11 @@ export function makePlanUpdateTool(planManager, getActiveConvId, emit, flushSnap
|
|
|
666
753
|
steps: Type.Array(Type.Object({
|
|
667
754
|
id: Type.String({ description: "Unique step ID, e.g. '1', 'step-1'" }),
|
|
668
755
|
title: Type.String({ description: "Short step title" }),
|
|
669
|
-
status: Type.Optional(Type.
|
|
756
|
+
status: Type.Optional(Type.Unsafe({
|
|
757
|
+
type: "string",
|
|
758
|
+
enum: ["pending", "in_progress", "done", "failed"],
|
|
759
|
+
description: "Step status: pending | in_progress | done | failed (default: pending)",
|
|
760
|
+
})),
|
|
670
761
|
description: Type.Optional(Type.String({
|
|
671
762
|
description: "Optional detailed description, acceptance criteria, file touch list, or rollback note.",
|
|
672
763
|
})),
|
|
@@ -718,6 +809,7 @@ function wrapBashToolWithPermission(tool, getPermission, getLang) {
|
|
|
718
809
|
function makeMarkersListTool(getActiveId, markerSvc) {
|
|
719
810
|
return {
|
|
720
811
|
name: MARKERS_LIST_TOOL_NAME,
|
|
812
|
+
promptSnippet: "list current inline-marker tasks (read-only — writes go through [[todo:...]] inline markers)",
|
|
721
813
|
label: "List marker state",
|
|
722
814
|
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
815
|
parameters: Type.Object({
|
|
@@ -757,52 +849,50 @@ export function makeAskUserQuestionTool(clientSession,
|
|
|
757
849
|
/** 本 runtime 所属会话:提问跟着对话走,快照只把当前对话的问卷推给客户端。 */
|
|
758
850
|
ownerId) {
|
|
759
851
|
const QuestionOptionSchema = Type.Object({
|
|
760
|
-
label: Type.String({ description: "Display label
|
|
852
|
+
label: Type.String({ description: "Display label (1-5 words)" }),
|
|
761
853
|
description: Type.Optional(Type.String({
|
|
762
|
-
description: "
|
|
854
|
+
description: "Short explanation of impact/tradeoff",
|
|
763
855
|
})),
|
|
764
856
|
preview: Type.Optional(Type.String({
|
|
765
|
-
description: "Optional preview
|
|
857
|
+
description: "Optional preview (markdown/HTML/code)",
|
|
766
858
|
})),
|
|
767
859
|
});
|
|
768
860
|
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
|
|
861
|
+
id: Type.String({ description: "Unique identifier (snake_case)" }),
|
|
862
|
+
question: Type.String({ description: "Question text (markdown/HTML ok)" }),
|
|
863
|
+
detail: Type.Optional(Type.String({ description: "Optional context under question" })),
|
|
864
|
+
header: Type.Optional(Type.String({ description: "Optional short header" })),
|
|
773
865
|
options: Type.Optional(Type.Array(QuestionOptionSchema, {
|
|
774
|
-
description: "2-4
|
|
866
|
+
description: "2-4 choices (recommended option first)",
|
|
775
867
|
minItems: 2,
|
|
776
868
|
maxItems: 4,
|
|
777
869
|
})),
|
|
778
|
-
multiSelect: Type.Optional(Type.Boolean({ description: "Allow
|
|
870
|
+
multiSelect: Type.Optional(Type.Boolean({ description: "Allow multiple choices (default: false)" })),
|
|
779
871
|
dependsOn: Type.Optional(Type.Object({
|
|
780
|
-
questionId: Type.String({ description: "
|
|
872
|
+
questionId: Type.String({ description: "Prior question ID" }),
|
|
781
873
|
value: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
782
|
-
description: "Show
|
|
874
|
+
description: "Show when prior answer matches",
|
|
783
875
|
})),
|
|
784
876
|
})),
|
|
785
877
|
optionsMap: Type.Optional(Type.Record(Type.String(), Type.Array(QuestionOptionSchema), {
|
|
786
|
-
description: "Dynamic options
|
|
878
|
+
description: "Dynamic options keyed by prior answer",
|
|
787
879
|
})),
|
|
788
880
|
});
|
|
789
881
|
return {
|
|
790
882
|
name: "ask_user_question",
|
|
791
883
|
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.",
|
|
884
|
+
description: "Ask the user 1-3 focused questions to clarify requirements, confirm decisions, or choose options. " +
|
|
885
|
+
"Provide 2-4 choices (recommended first) with concise tradeoffs. " +
|
|
886
|
+
"Renders a rich browser dialog; resumes on submit or cancel.",
|
|
796
887
|
promptSnippet: "ask the user 1-3 focused questions with recommended options and tradeoffs to clarify requirements",
|
|
797
888
|
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",
|
|
889
|
+
"When requirements are ambiguous, use ask_user_question to clarify: " +
|
|
890
|
+
"strictly 1 to 3 focused questions (prefer 1), 2-4 choices with recommended option first, and explain impact/tradeoff concisely",
|
|
891
|
+
"A cancelled question comes back as a tool error — respect it without immediately re-asking",
|
|
802
892
|
],
|
|
803
893
|
parameters: Type.Object({
|
|
804
894
|
questions: Type.Array(QuestionSchema, {
|
|
805
|
-
description: "
|
|
895
|
+
description: "1-3 questions to ask (prefer 1)",
|
|
806
896
|
minItems: 1,
|
|
807
897
|
maxItems: 3,
|
|
808
898
|
}),
|
|
@@ -1361,6 +1451,238 @@ export function isInsideSessionsDir(agentDir, targetPath) {
|
|
|
1361
1451
|
roots.push(resolve(extra));
|
|
1362
1452
|
return roots.some((root) => abs.startsWith(root + sep));
|
|
1363
1453
|
}
|
|
1454
|
+
/**
|
|
1455
|
+
* 从会话文件首行读取 cwd(轻量探测,最多读取 2KB,绝不完整解析整个 jsonl 消息历史)。
|
|
1456
|
+
*/
|
|
1457
|
+
async function readCwdFromSessionHeader(filePath) {
|
|
1458
|
+
let handle;
|
|
1459
|
+
try {
|
|
1460
|
+
handle = await fsPromises.open(filePath, "r");
|
|
1461
|
+
const buf = Buffer.alloc(2048);
|
|
1462
|
+
const { bytesRead } = await handle.read(buf, 0, 2048, 0);
|
|
1463
|
+
const text = buf.toString("utf8", 0, bytesRead);
|
|
1464
|
+
const nl = text.indexOf("\n");
|
|
1465
|
+
const firstLine = nl !== -1 ? text.slice(0, nl) : text;
|
|
1466
|
+
if (!firstLine.trim())
|
|
1467
|
+
return null;
|
|
1468
|
+
const parsed = JSON.parse(firstLine);
|
|
1469
|
+
return typeof parsed.cwd === "string" && parsed.cwd.trim() ? parsed.cwd : null;
|
|
1470
|
+
}
|
|
1471
|
+
catch {
|
|
1472
|
+
return null;
|
|
1473
|
+
}
|
|
1474
|
+
finally {
|
|
1475
|
+
if (handle)
|
|
1476
|
+
await handle.close().catch(() => { });
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* 快速轻量地从 SDK 会话目录中发现项目路径(仅读取会话文件的第一行头部获取 cwd,
|
|
1481
|
+
* 绝不使用 SessionManager.listAll 遍历解析全部会话文本,避免大历史时阻塞数秒到数十秒)。
|
|
1482
|
+
*/
|
|
1483
|
+
async function discoverRecentProjectsFromDisk(sessionRoots, limit = 30) {
|
|
1484
|
+
const projects = new Map();
|
|
1485
|
+
for (const root of sessionRoots) {
|
|
1486
|
+
if (projects.size >= limit)
|
|
1487
|
+
break;
|
|
1488
|
+
try {
|
|
1489
|
+
if (!existsSync(root))
|
|
1490
|
+
continue;
|
|
1491
|
+
const entries = await fsPromises.readdir(root, { withFileTypes: true });
|
|
1492
|
+
// 1. 扁平布局(PI_CODING_AGENT_SESSION_DIR):根目录下直接是 .jsonl
|
|
1493
|
+
const flatFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl"));
|
|
1494
|
+
if (flatFiles.length > 0) {
|
|
1495
|
+
const fileStats = await Promise.all(flatFiles.map(async (f) => {
|
|
1496
|
+
const fp = join(root, f.name);
|
|
1497
|
+
try {
|
|
1498
|
+
const st = await fsPromises.stat(fp);
|
|
1499
|
+
return { path: fp, mtime: st.mtimeMs };
|
|
1500
|
+
}
|
|
1501
|
+
catch {
|
|
1502
|
+
return null;
|
|
1503
|
+
}
|
|
1504
|
+
}));
|
|
1505
|
+
fileStats.sort((a, b) => (b?.mtime ?? 0) - (a?.mtime ?? 0));
|
|
1506
|
+
for (const s of fileStats) {
|
|
1507
|
+
if (!s || projects.size >= limit)
|
|
1508
|
+
break;
|
|
1509
|
+
const cwd = await readCwdFromSessionHeader(s.path);
|
|
1510
|
+
if (cwd) {
|
|
1511
|
+
const key = normalizePathKey(cwd);
|
|
1512
|
+
if (!projects.has(key)) {
|
|
1513
|
+
try {
|
|
1514
|
+
if (existsSync(cwd))
|
|
1515
|
+
projects.set(key, { path: cwd, lastUsed: s.mtime });
|
|
1516
|
+
}
|
|
1517
|
+
catch { }
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
// 2. 默认子目录布局(<agentDir>/sessions/--<cwd>--/):每 cwd 一个子目录
|
|
1523
|
+
const subdirs = entries.filter((e) => e.isDirectory() || e.isSymbolicLink()).map((e) => join(root, e.name));
|
|
1524
|
+
if (subdirs.length > 0 && projects.size < limit) {
|
|
1525
|
+
const CHUNK = 64;
|
|
1526
|
+
const dirStats = [];
|
|
1527
|
+
for (let i = 0; i < subdirs.length; i += CHUNK) {
|
|
1528
|
+
const chunk = subdirs.slice(i, i + CHUNK);
|
|
1529
|
+
const stats = await Promise.all(chunk.map(async (dir) => {
|
|
1530
|
+
try {
|
|
1531
|
+
const st = await fsPromises.stat(dir);
|
|
1532
|
+
return { path: dir, mtime: st.mtimeMs };
|
|
1533
|
+
}
|
|
1534
|
+
catch {
|
|
1535
|
+
return null;
|
|
1536
|
+
}
|
|
1537
|
+
}));
|
|
1538
|
+
for (const s of stats)
|
|
1539
|
+
if (s)
|
|
1540
|
+
dirStats.push(s);
|
|
1541
|
+
}
|
|
1542
|
+
dirStats.sort((a, b) => b.mtime - a.mtime);
|
|
1543
|
+
for (let i = 0; i < dirStats.length; i += 16) {
|
|
1544
|
+
if (projects.size >= limit)
|
|
1545
|
+
break;
|
|
1546
|
+
const batch = dirStats.slice(i, i + 16);
|
|
1547
|
+
const batchRes = await Promise.all(batch.map(async (d) => {
|
|
1548
|
+
try {
|
|
1549
|
+
const files = await fsPromises.readdir(d.path);
|
|
1550
|
+
const jsonls = files.filter((f) => f.endsWith(".jsonl"));
|
|
1551
|
+
if (jsonls.length === 0)
|
|
1552
|
+
return null;
|
|
1553
|
+
let newestFile = "";
|
|
1554
|
+
let newestMtime = 0;
|
|
1555
|
+
for (const f of jsonls) {
|
|
1556
|
+
const fp = join(d.path, f);
|
|
1557
|
+
try {
|
|
1558
|
+
const st = await fsPromises.stat(fp);
|
|
1559
|
+
if (st.mtimeMs > newestMtime) {
|
|
1560
|
+
newestMtime = st.mtimeMs;
|
|
1561
|
+
newestFile = fp;
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
catch { }
|
|
1565
|
+
}
|
|
1566
|
+
if (!newestFile)
|
|
1567
|
+
return null;
|
|
1568
|
+
const cwd = await readCwdFromSessionHeader(newestFile);
|
|
1569
|
+
if (!cwd)
|
|
1570
|
+
return null;
|
|
1571
|
+
return { path: cwd, lastUsed: newestMtime };
|
|
1572
|
+
}
|
|
1573
|
+
catch {
|
|
1574
|
+
return null;
|
|
1575
|
+
}
|
|
1576
|
+
}));
|
|
1577
|
+
for (const r of batchRes) {
|
|
1578
|
+
if (!r)
|
|
1579
|
+
continue;
|
|
1580
|
+
const key = normalizePathKey(r.path);
|
|
1581
|
+
if (!projects.has(key)) {
|
|
1582
|
+
try {
|
|
1583
|
+
if (existsSync(r.path)) {
|
|
1584
|
+
projects.set(key, r);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
catch { }
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
catch {
|
|
1594
|
+
/* best effort */
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
return [...projects.values()].sort((a, b) => b.lastUsed - a.lastUsed);
|
|
1598
|
+
}
|
|
1599
|
+
/**
|
|
1600
|
+
* 快速解析单个会话文件(提取 id/cwd/name/parentSessionPath/firstMessage/messageCount/modified/allMessagesText)。
|
|
1601
|
+
* 仅用于会话列表发现与搜索,避免调用 SDK 重型的全量树解析。
|
|
1602
|
+
*/
|
|
1603
|
+
async function parseSessionInfoFast(filePath, fileMtime) {
|
|
1604
|
+
try {
|
|
1605
|
+
const content = await fsPromises.readFile(filePath, "utf8");
|
|
1606
|
+
const lines = content.split("\n");
|
|
1607
|
+
let id = "";
|
|
1608
|
+
let cwd = "";
|
|
1609
|
+
let name;
|
|
1610
|
+
let parentSessionPath;
|
|
1611
|
+
let firstMessage = "";
|
|
1612
|
+
let messageCount = 0;
|
|
1613
|
+
let lastActivityTime = 0;
|
|
1614
|
+
let headerTime = 0;
|
|
1615
|
+
const allMessages = [];
|
|
1616
|
+
for (const line of lines) {
|
|
1617
|
+
if (!line.trim())
|
|
1618
|
+
continue;
|
|
1619
|
+
let entry;
|
|
1620
|
+
try {
|
|
1621
|
+
entry = JSON.parse(line);
|
|
1622
|
+
}
|
|
1623
|
+
catch {
|
|
1624
|
+
continue;
|
|
1625
|
+
}
|
|
1626
|
+
const rec = entry;
|
|
1627
|
+
if (!rec)
|
|
1628
|
+
continue;
|
|
1629
|
+
if (rec.type === "session") {
|
|
1630
|
+
id = typeof rec.id === "string" ? rec.id : "";
|
|
1631
|
+
cwd = typeof rec.cwd === "string" ? rec.cwd : "";
|
|
1632
|
+
if (typeof rec.timestamp === "string")
|
|
1633
|
+
headerTime = new Date(rec.timestamp).getTime();
|
|
1634
|
+
if (typeof rec.parentSession === "string")
|
|
1635
|
+
parentSessionPath = rec.parentSession;
|
|
1636
|
+
}
|
|
1637
|
+
else if (rec.type === "session_info") {
|
|
1638
|
+
if (typeof rec.name === "string" && rec.name.trim())
|
|
1639
|
+
name = rec.name.trim();
|
|
1640
|
+
}
|
|
1641
|
+
else if (rec.type === "message") {
|
|
1642
|
+
messageCount++;
|
|
1643
|
+
const msg = rec.message;
|
|
1644
|
+
if (msg) {
|
|
1645
|
+
if (typeof msg.timestamp === "number")
|
|
1646
|
+
lastActivityTime = Math.max(lastActivityTime, msg.timestamp);
|
|
1647
|
+
let text = "";
|
|
1648
|
+
if (typeof msg.content === "string") {
|
|
1649
|
+
text = msg.content;
|
|
1650
|
+
}
|
|
1651
|
+
else if (Array.isArray(msg.content)) {
|
|
1652
|
+
for (const part of msg.content) {
|
|
1653
|
+
if (part && typeof part.text === "string") {
|
|
1654
|
+
text += (text ? " " : "") + part.text;
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
if (text) {
|
|
1659
|
+
if (!firstMessage && msg.role === "user")
|
|
1660
|
+
firstMessage = text;
|
|
1661
|
+
allMessages.push(text);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
if (!id)
|
|
1667
|
+
return null;
|
|
1668
|
+
const modified = lastActivityTime > 0 ? lastActivityTime : headerTime > 0 ? headerTime : fileMtime;
|
|
1669
|
+
return {
|
|
1670
|
+
path: filePath,
|
|
1671
|
+
id,
|
|
1672
|
+
cwd,
|
|
1673
|
+
name,
|
|
1674
|
+
parentSessionPath,
|
|
1675
|
+
created: new Date(headerTime || fileMtime),
|
|
1676
|
+
modified: new Date(modified),
|
|
1677
|
+
messageCount,
|
|
1678
|
+
firstMessage: firstMessage || "(no messages)",
|
|
1679
|
+
allMessagesText: allMessages.join(" "),
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
catch {
|
|
1683
|
+
return null;
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1364
1686
|
/** 会话当前模型的 "provider/id"(无模型时 null;软上限按模型覆盖用,issue #229)。 */
|
|
1365
1687
|
function modelKeyOf(session) {
|
|
1366
1688
|
const m = session?.model;
|
|
@@ -1470,6 +1792,9 @@ export class ClientSession {
|
|
|
1470
1792
|
* top bar applies to every chat, not just the one that set it. Seeded by
|
|
1471
1793
|
* the first conversation and reused by later ones. */
|
|
1472
1794
|
sharedModelRuntime;
|
|
1795
|
+
/** 目标模式「委托执行」(Plan A)的「本轮结束」等待者:convId → 回调集合。
|
|
1796
|
+
* agent_end 到达时唤醒(事件驱动,绝不轮询);对话被移出时按 gone 收。 */
|
|
1797
|
+
turnEndWaiters = new Map();
|
|
1473
1798
|
// -----------------------------------------------------------------------
|
|
1474
1799
|
// Goal / review / wizard —— 自包含模块,见 goal-service.ts。每个对话有独立
|
|
1475
1800
|
// 的 GoalStatus,审查可并发;宿主回调在构造函数里接入。
|
|
@@ -1631,7 +1956,7 @@ export class ClientSession {
|
|
|
1631
1956
|
* `model`(可选):"provider/id",显式指定子代理模型。不传时由调用方决定是否
|
|
1632
1957
|
* 回退到模板模型 / 设置面板默认模型;null = 跟随主对话当前模型(默认行为,
|
|
1633
1958
|
* runtime 重建时会继承共享 ModelRuntime 的当前默认)。 */
|
|
1634
|
-
async spawnSubagentConversation(prompt, type, cwd, apply, model, parentId, persist) {
|
|
1959
|
+
async spawnSubagentConversation(prompt, type, cwd, apply, model, parentId, persist, title) {
|
|
1635
1960
|
// 数量上限先行:每个子代理都是完整 runtime + TerminalManager,无上限时一次
|
|
1636
1961
|
// 并行派发几十个会把服务进程拖垮。持久化普通对话则受项目会话上限限制。
|
|
1637
1962
|
if (persist) {
|
|
@@ -1690,7 +2015,7 @@ export class ClientSession {
|
|
|
1690
2015
|
// syncPluginTools 不回补,模板热改不影响已运行的子代理(与 prompt/技能一致)。
|
|
1691
2016
|
conv.subagentBarsPluginTools = !!apply && apply.enabledExtensions.length > 0;
|
|
1692
2017
|
conv.listed = true;
|
|
1693
|
-
conv.title = subagentTitle(prompt);
|
|
2018
|
+
conv.title = title ?? subagentTitle(prompt);
|
|
1694
2019
|
this.convs.set(conv.id, conv);
|
|
1695
2020
|
// 子代理会话同样订阅 SDK 事件:否则 onEvent 永不触发,点开查看时没有
|
|
1696
2021
|
// message_delta 流式增量、快照也不刷新,只能靠切走切回时的 flushSnapshot
|
|
@@ -1778,8 +2103,9 @@ export class ClientSession {
|
|
|
1778
2103
|
});
|
|
1779
2104
|
}
|
|
1780
2105
|
}
|
|
1781
|
-
//
|
|
1782
|
-
|
|
2106
|
+
// 触发回合(后台执行;失败转识为通知)。把 Promise 挂在对话上:目标模式的
|
|
2107
|
+
// 「等本回合结束」要先等它落定,才能区分「还没开跑」与「已跑完」。
|
|
2108
|
+
conv.kickoff = conv.session.sendUserMessage(prompt).catch((err) => {
|
|
1783
2109
|
this.emit({
|
|
1784
2110
|
type: "notice",
|
|
1785
2111
|
level: "error",
|
|
@@ -1877,6 +2203,92 @@ export class ClientSession {
|
|
|
1877
2203
|
}
|
|
1878
2204
|
return {};
|
|
1879
2205
|
}
|
|
2206
|
+
/**
|
|
2207
|
+
* 事件驱动地等某对话「当前回合结束」(目标模式委托执行用)。
|
|
2208
|
+
* 若当前并未在跑则立即返回既有结局;超时返回 "timeout";对话已消失返回 "gone"。
|
|
2209
|
+
* 不轮询:靠 agent_end 的 notifyTurnEnd / removeConversation 唤醒。
|
|
2210
|
+
*/
|
|
2211
|
+
async waitConversationTurnEnd(convId, timeoutMs) {
|
|
2212
|
+
const isStreaming = (c) => {
|
|
2213
|
+
try {
|
|
2214
|
+
return c.session.isStreaming;
|
|
2215
|
+
}
|
|
2216
|
+
catch {
|
|
2217
|
+
return false;
|
|
2218
|
+
}
|
|
2219
|
+
};
|
|
2220
|
+
let conv = this.convs.get(convId);
|
|
2221
|
+
if (!conv)
|
|
2222
|
+
return Promise.resolve("gone");
|
|
2223
|
+
// spawn 的首回合是 fire-and-forget 投递的:先等它落定,否则「还没开跑」会被
|
|
2224
|
+
// 误判成「已跑完」(空取样 → 下一轮派活撞在一起)。
|
|
2225
|
+
if (conv.kickoff) {
|
|
2226
|
+
await conv.kickoff.catch(() => { });
|
|
2227
|
+
conv = this.convs.get(convId);
|
|
2228
|
+
if (!conv)
|
|
2229
|
+
return "gone";
|
|
2230
|
+
}
|
|
2231
|
+
// 极短的启动宽限:投递刚返回时 isStreaming 可能还差一拍。只在这里等,
|
|
2232
|
+
// 一旦开跑就交给事件驱动(agent_end),不是长轮询。
|
|
2233
|
+
if (!isStreaming(conv)) {
|
|
2234
|
+
const graceEnd = Date.now() + Math.min(3000, Math.max(0, timeoutMs));
|
|
2235
|
+
while (!isStreaming(conv) && Date.now() < graceEnd) {
|
|
2236
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2237
|
+
const again = this.convs.get(convId);
|
|
2238
|
+
if (!again)
|
|
2239
|
+
return "gone";
|
|
2240
|
+
conv = again;
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
if (!isStreaming(conv))
|
|
2244
|
+
return this.roleOutcomeOf(conv);
|
|
2245
|
+
return new Promise((resolve) => {
|
|
2246
|
+
const set = this.turnEndWaiters.get(convId) ?? new Set();
|
|
2247
|
+
this.turnEndWaiters.set(convId, set);
|
|
2248
|
+
let settled = false;
|
|
2249
|
+
let timer;
|
|
2250
|
+
const finish = (o) => {
|
|
2251
|
+
if (settled)
|
|
2252
|
+
return;
|
|
2253
|
+
settled = true;
|
|
2254
|
+
if (timer)
|
|
2255
|
+
clearTimeout(timer);
|
|
2256
|
+
set.delete(finish);
|
|
2257
|
+
if (set.size === 0)
|
|
2258
|
+
this.turnEndWaiters.delete(convId);
|
|
2259
|
+
resolve(o);
|
|
2260
|
+
};
|
|
2261
|
+
timer = setTimeout(() => finish("timeout"), Math.max(1000, timeoutMs));
|
|
2262
|
+
timer.unref?.();
|
|
2263
|
+
set.add(finish);
|
|
2264
|
+
// 注册后复检:agent_end 可能恰好在 isStreaming 检查与注册之间到达。
|
|
2265
|
+
const still = this.convs.get(convId);
|
|
2266
|
+
if (!still) {
|
|
2267
|
+
finish("gone");
|
|
2268
|
+
return;
|
|
2269
|
+
}
|
|
2270
|
+
if (!isStreaming(still))
|
|
2271
|
+
finish(this.roleOutcomeOf(still));
|
|
2272
|
+
});
|
|
2273
|
+
}
|
|
2274
|
+
/** 对话结局 → 角色轮结局(报错 > 中止 > 正常)。 */
|
|
2275
|
+
roleOutcomeOf(conv) {
|
|
2276
|
+
const { error, canceled } = this.subagentRunOutcome(conv);
|
|
2277
|
+
if (error)
|
|
2278
|
+
return "error";
|
|
2279
|
+
if (canceled)
|
|
2280
|
+
return "canceled";
|
|
2281
|
+
return "done";
|
|
2282
|
+
}
|
|
2283
|
+
/** 某对话回合结束 → 唤醒它的等待者(agent_end / 中止路径都调)。 */
|
|
2284
|
+
notifyTurnEnd(conv) {
|
|
2285
|
+
const set = this.turnEndWaiters.get(conv.id);
|
|
2286
|
+
if (!set || set.size === 0)
|
|
2287
|
+
return;
|
|
2288
|
+
const outcome = this.roleOutcomeOf(conv);
|
|
2289
|
+
for (const fn of set)
|
|
2290
|
+
fn(outcome);
|
|
2291
|
+
}
|
|
1880
2292
|
emitTerminal(conversationId, msg) {
|
|
1881
2293
|
// Background conversations keep collecting output in their own PTY buffer.
|
|
1882
2294
|
// Do not stream it into the active xterm; push the retained window on switch.
|
|
@@ -2661,7 +3073,11 @@ export class ClientSession {
|
|
|
2661
3073
|
},
|
|
2662
3074
|
applyRetryOverrides: () => this.applyRetryOverrides(),
|
|
2663
3075
|
applyCompactionOverrides: () => this.applyCompactionOverrides(),
|
|
2664
|
-
applyToolGating: () =>
|
|
3076
|
+
applyToolGating: () => {
|
|
3077
|
+
for (const conv of this.convs.values()) {
|
|
3078
|
+
this.applyToolGating(conv.session, conv.agentPreset);
|
|
3079
|
+
}
|
|
3080
|
+
},
|
|
2665
3081
|
promptSnapshot: () => this.promptSnapshot(),
|
|
2666
3082
|
getMarkerState: () => ({
|
|
2667
3083
|
markersEnabled: this.markerSvc.current.markersEnabled,
|
|
@@ -2669,6 +3085,10 @@ export class ClientSession {
|
|
|
2669
3085
|
markers: this.markerSvc.listForUi(),
|
|
2670
3086
|
}),
|
|
2671
3087
|
getApprovalPolicy: () => this.approvalPolicyState(),
|
|
3088
|
+
// 目标模式总开关关闭 → 在飞的目标/调研立即停(幂等;无在飞目标时无声)。
|
|
3089
|
+
onGoalModeDisabled: () => {
|
|
3090
|
+
void this.goalSvc.stopAllGoals();
|
|
3091
|
+
},
|
|
2672
3092
|
}, this.subagentTemplates, this.approvalRules);
|
|
2673
3093
|
this.goalSvc = new GoalService({
|
|
2674
3094
|
clientId,
|
|
@@ -2687,8 +3107,122 @@ export class ClientSession {
|
|
|
2687
3107
|
activeConv: () => this.conv,
|
|
2688
3108
|
getConv: (id) => this.convs.get(id),
|
|
2689
3109
|
cwd: () => this.cwd,
|
|
2690
|
-
reviewSettings: () => this.settingsSvc.reviewPrefs,
|
|
2691
3110
|
gitDiff: (dir) => this.gitDiff(dir),
|
|
3111
|
+
// 任务计划看板(#389 目标与计划联动):审查者核验步骤看板,向导产物注入看板
|
|
3112
|
+
getPlan: (convId) => this.planManager.getPlan(convId),
|
|
3113
|
+
describePlan: (convId) => this.planManager.describePlan(convId),
|
|
3114
|
+
setPlan: (convId, steps) => {
|
|
3115
|
+
this.updatePlan(steps, undefined, convId);
|
|
3116
|
+
},
|
|
3117
|
+
// 工作区 git 可用性(目标模式停滞判定的可信信号开关):非仓库目录下
|
|
3118
|
+
// git diff 恒为空,取样会跳过停滞计数,纯问答类目标不再被误熔断。
|
|
3119
|
+
isGitRepo: async (cwd) => {
|
|
3120
|
+
try {
|
|
3121
|
+
const r = await this.runAsync("git", ["rev-parse", "--is-inside-work-tree"], 5_000, cwd || this.cwd);
|
|
3122
|
+
return r.code === 0;
|
|
3123
|
+
}
|
|
3124
|
+
catch {
|
|
3125
|
+
return false;
|
|
3126
|
+
}
|
|
3127
|
+
},
|
|
3128
|
+
// ---- 目标模式 2.0 的角色对话桥(唯一审查/执行路径)----
|
|
3129
|
+
// 复用子代理通道(同一套模板/模型/思考强度/配额/左栏展示),但角色对话**落盘**
|
|
3130
|
+
// (persist=true):转录进历史、服务重启后仍可打开回看。
|
|
3131
|
+
// 代价:落盘对话 isSubagent=false → 占「每项目 8 个普通对话」名额之一
|
|
3132
|
+
// (spawnSubagentConversation 满员时抛错 → GoalService 降级回 self 并提示),
|
|
3133
|
+
// 且左栏不再有「子代理」徽标 —— 故给它一个带前缀的标题保持可辨识。
|
|
3134
|
+
spawnRoleAgent: async ({ role, prompt, cwd, model, parentId, title }) => {
|
|
3135
|
+
const baseCwd = cwd || (parentId ? this.convs.get(parentId)?.cwd : undefined) || this.cwd;
|
|
3136
|
+
const convId = await this.spawnSubagentConversation(prompt, role === "executor" ? "goal-executor" : "goal-reviewer", baseCwd, undefined, model ?? null, parentId, true, title);
|
|
3137
|
+
if (title) {
|
|
3138
|
+
const conv = this.convs.get(convId);
|
|
3139
|
+
if (conv)
|
|
3140
|
+
conv.title = title;
|
|
3141
|
+
}
|
|
3142
|
+
return convId;
|
|
3143
|
+
},
|
|
3144
|
+
waitRoleAgent: (convId, timeoutMs) => this.waitConversationTurnEnd(convId, timeoutMs),
|
|
3145
|
+
sendRoleAgent: async (convId, message, deliverAs) => {
|
|
3146
|
+
const conv = this.convs.get(convId);
|
|
3147
|
+
if (!conv?.session)
|
|
3148
|
+
return false;
|
|
3149
|
+
await conv.session.sendUserMessage(message, deliverAs ? { deliverAs } : conv.session.isStreaming ? { deliverAs: "steer" } : undefined);
|
|
3150
|
+
return true;
|
|
3151
|
+
},
|
|
3152
|
+
readRoleAgent: (convId) => {
|
|
3153
|
+
const conv = this.convs.get(convId);
|
|
3154
|
+
if (!conv?.session)
|
|
3155
|
+
return undefined;
|
|
3156
|
+
let text = "";
|
|
3157
|
+
try {
|
|
3158
|
+
text = conv.session.getLastAssistantText() ?? "";
|
|
3159
|
+
}
|
|
3160
|
+
catch {
|
|
3161
|
+
text = "";
|
|
3162
|
+
}
|
|
3163
|
+
// 执行者 vitals(目标条实时进度 + 用量累计用;轮次边界才读一次,
|
|
3164
|
+
// getSessionStats 会遍历转写,不在高频路径上调)。
|
|
3165
|
+
let streaming;
|
|
3166
|
+
let lastTool;
|
|
3167
|
+
let usage;
|
|
3168
|
+
try {
|
|
3169
|
+
streaming = conv.session.isStreaming;
|
|
3170
|
+
}
|
|
3171
|
+
catch {
|
|
3172
|
+
streaming = undefined;
|
|
3173
|
+
}
|
|
3174
|
+
try {
|
|
3175
|
+
lastTool = lastToolNameOfSession(conv.session);
|
|
3176
|
+
}
|
|
3177
|
+
catch {
|
|
3178
|
+
lastTool = undefined;
|
|
3179
|
+
}
|
|
3180
|
+
try {
|
|
3181
|
+
const t = conv.session.getSessionStats()?.tokens;
|
|
3182
|
+
if (t && typeof t.input === "number" && typeof t.output === "number") {
|
|
3183
|
+
usage = { input: t.input, output: t.output };
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
catch {
|
|
3187
|
+
usage = undefined;
|
|
3188
|
+
}
|
|
3189
|
+
return {
|
|
3190
|
+
text,
|
|
3191
|
+
errorSnippet: extractErrorSnippetFromSession(conv.session, text),
|
|
3192
|
+
streaming,
|
|
3193
|
+
lastTool,
|
|
3194
|
+
usage,
|
|
3195
|
+
};
|
|
3196
|
+
},
|
|
3197
|
+
stopRoleAgent: async (convId) => {
|
|
3198
|
+
const conv = this.convs.get(convId);
|
|
3199
|
+
if (!conv)
|
|
3200
|
+
return;
|
|
3201
|
+
try {
|
|
3202
|
+
if (conv.session.isStreaming || !conv.session.isIdle) {
|
|
3203
|
+
await this.interruptRun(conv, pick(this.getLang(), "目标模式停止角色对话", "Goal mode stopped a role conversation", "agent.role.stop"));
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
catch {
|
|
3207
|
+
// best-effort
|
|
3208
|
+
}
|
|
3209
|
+
},
|
|
3210
|
+
dismissRoleAgent: async (convId) => {
|
|
3211
|
+
const conv = this.convs.get(convId);
|
|
3212
|
+
if (!conv)
|
|
3213
|
+
return;
|
|
3214
|
+
// 用户正看着这个角色对话时不要把他弹走(对话留着,用户可自行关闭)。
|
|
3215
|
+
if (convId === this.activeId)
|
|
3216
|
+
return;
|
|
3217
|
+
try {
|
|
3218
|
+
await this.dismissConversation(convId, true, true);
|
|
3219
|
+
}
|
|
3220
|
+
catch {
|
|
3221
|
+
// best-effort
|
|
3222
|
+
}
|
|
3223
|
+
},
|
|
3224
|
+
hasConv: (convId) => this.convs.has(convId),
|
|
3225
|
+
roleDeadlineMs: () => this.getBaseToolWatchdogTimeoutMs(),
|
|
2692
3226
|
});
|
|
2693
3227
|
this.modelAdmin = new ModelAdminService({
|
|
2694
3228
|
agentDir,
|
|
@@ -2829,6 +3363,21 @@ export class ClientSession {
|
|
|
2829
3363
|
out.push(pickTemplatePrompt(apply, this.getLang()));
|
|
2830
3364
|
}
|
|
2831
3365
|
// 主会话自定义「追加」已并入组合模板的 {{append}} 覆盖,不再在此注入。
|
|
3366
|
+
// 计划模式:只规划不实施 —— 硬闸门(server/plan-mode.ts 拒写类/旁路
|
|
3367
|
+
// 工具与非常规 bash)之外再补一段软约束,每轮重建提示词时读当前
|
|
3368
|
+
// 会话状态,切换后下一次 reload 生效。提示词正文可在设置面板改
|
|
3369
|
+
// (追加/替换内置默认,见 buildPlanModePrompt)。
|
|
3370
|
+
// ⚠️ 这一段与平台无关(曾在 win32 分支里被误嵌套,导致非 Windows
|
|
3371
|
+
// 上计划模式的软约束从来没进过提示词,只有硬闸门在挡)。
|
|
3372
|
+
if (this.planModeOf(ownerId)) {
|
|
3373
|
+
const planPrefs = this.settingsSvc.current;
|
|
3374
|
+
out.push(buildPlanModePrompt(planPrefs.planModePromptMode, planPrefs.planModePrompt));
|
|
3375
|
+
}
|
|
3376
|
+
// 审查者模式(自动委派):本对话只审阅,活由服务端派给常驻执行对话。
|
|
3377
|
+
// 与计划模式**互斥优先级**:计划模式开着时以计划模式为准,不派活。
|
|
3378
|
+
if (this.delegateModeOf(ownerId) && !this.planModeOf(ownerId)) {
|
|
3379
|
+
out.push(DELEGATION_SYSTEM_PROMPT);
|
|
3380
|
+
}
|
|
2832
3381
|
if (process.platform === "win32") {
|
|
2833
3382
|
// Windows 专属 persona:bash 工具跑 Git Bash 且无默认超时、终端
|
|
2834
3383
|
// 是交互式 TTY——注入约束避免 heredoc/交互/长驻命令挂死整个会话;
|
|
@@ -2994,6 +3543,8 @@ export class ClientSession {
|
|
|
2994
3543
|
// 覆盖 SDK 内置 bash(customTools 按 name 覆盖)。双实现分流:
|
|
2995
3544
|
// 「默认 bash 覆盖」开关(terminalBash)关 → 原生 SDK bash(纯进程、不开终端);
|
|
2996
3545
|
// 开 → 终端接管 bash(persist 决定一次性/持久,可静默自动转后台)。
|
|
3546
|
+
// 整列工具统一过计划模式闸门(只规划不实施,写类/旁路工具执行前拒),
|
|
3547
|
+
// read/write/edit 三处覆盖在会话建好后另注入(见 installToolOverrides)。
|
|
2997
3548
|
customTools: [
|
|
2998
3549
|
// P1-5:bash/read 包插件拦截(pre 拒/问即拦、post 脱敏补上下文;
|
|
2999
3550
|
// 未注入 toolGuard 时 withToolGuard 原样返回,零开销)。
|
|
@@ -3026,6 +3577,18 @@ export class ClientSession {
|
|
|
3026
3577
|
"workspace-write-never", () => this.getLang()),
|
|
3027
3578
|
...makePersistentTerminalTools(terminals, effectiveCwd, () => this.getLang(), {
|
|
3028
3579
|
checkSafety: (cmd) => {
|
|
3580
|
+
// 计划模式:终端只放行只读命令(常驻终端也走这条,与 bash 覆盖同口径)。
|
|
3581
|
+
if (this.planModeOf(ownerId)) {
|
|
3582
|
+
const denied = planModeDenial("bash", { command: cmd });
|
|
3583
|
+
if (denied)
|
|
3584
|
+
return { blocked: true, reason: denied.reason };
|
|
3585
|
+
}
|
|
3586
|
+
// 目标审查回合:常驻终端同样只放只读命令(审查者跑测试可以,改文件不行)。
|
|
3587
|
+
if (this.goalReviewTurnOf(ownerId)) {
|
|
3588
|
+
const denied = goalReviewDenial("bash", { command: cmd });
|
|
3589
|
+
if (denied)
|
|
3590
|
+
return { blocked: true, reason: denied.reason };
|
|
3591
|
+
}
|
|
3029
3592
|
const perm = (ownerId ? this.convs.get(ownerId)?.permissionPreset : undefined) ??
|
|
3030
3593
|
this.settingsSvc.current.defaultPermissionPreset ??
|
|
3031
3594
|
"workspace-write-never";
|
|
@@ -3046,9 +3609,10 @@ export class ClientSession {
|
|
|
3046
3609
|
// 扩展加载顺序无关,直接塞进来会静默顶掉第三方扩展注册的同名工具(见
|
|
3047
3610
|
// tool-overrides.ts);它们改在会话建好后由 installToolOverrides 注入。
|
|
3048
3611
|
// 不覆盖内置 edit 的独立宽松编辑工具(缩进不敏感匹配;开关看设置;带权限沙箱拦截与人机协同)。
|
|
3049
|
-
|
|
3612
|
+
// 目标审查闸门包在最外层:审查回合的理由最贴合此刻(跟模型说「这是审查回合」)。
|
|
3613
|
+
withGoalReviewGate(withDelegationGate(withPlanModeGate(wrapEditSoftToolWithPermission(makeEditSoftTool(effectiveCwd, () => this.getLang()), effectiveCwd, () => (ownerId ? this.convs.get(ownerId)?.permissionPreset : undefined) ??
|
|
3050
3614
|
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()),
|
|
3615
|
+
"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
3616
|
// 插件注册的 AI 工具(创建时刻的实时快照,已按 disabledPluginTools 过滤;
|
|
3053
3617
|
// 后续注册经 refreshPluginTools 动态补入已有会话)。
|
|
3054
3618
|
// 子代理模板带非空扩展白名单时不注入:插件/MCP 工具没有 SDK extensionKey
|
|
@@ -3133,7 +3697,11 @@ export class ClientSession {
|
|
|
3133
3697
|
makePatchTool({ cwd: effectiveCwd, ownerId }),
|
|
3134
3698
|
// 原生语言服务器工具(lsp,定义跳转/引用/悬停/诊断)。
|
|
3135
3699
|
makeLspTool({ cwd: effectiveCwd, ownerId }),
|
|
3136
|
-
]
|
|
3700
|
+
].map((t) =>
|
|
3701
|
+
// 目标审查闸门在最外层(理由最贴合此刻);插件工具也在这个数组里,
|
|
3702
|
+
// 创建时注册的同样被闸门覆盖(后续动态补入的走 syncPluginTools,与
|
|
3703
|
+
// 计划/审查者闸门同口径不在覆盖面,见 goal-review-gate.ts 头注)。
|
|
3704
|
+
withGoalReviewGate(withDelegationGate(withPlanModeGate(t, () => this.planModeOf(ownerId), () => this.getLang()), () => this.delegateModeOf(ownerId), () => this.getLang()), () => this.goalReviewTurnOf(ownerId), () => this.getLang())),
|
|
3137
3705
|
});
|
|
3138
3706
|
// 桥接工具归属锚点:SDK 会话对象在本 runtime 生命周期内稳定,过户只搬对话
|
|
3139
3707
|
// 不改它(见 ClientSession.findConversationHome)。
|
|
@@ -3175,6 +3743,8 @@ export class ClientSession {
|
|
|
3175
3743
|
permissionPreset: readPermissionFromSession(runtime.session.sessionManager) ??
|
|
3176
3744
|
this.settingsSvc.current.defaultPermissionPreset ??
|
|
3177
3745
|
"workspace-write-never",
|
|
3746
|
+
planMode: readPlanModeFromSession(runtime.session.sessionManager) === true,
|
|
3747
|
+
delegateMode: readDelegateModeFromSession(runtime.session.sessionManager) === true,
|
|
3178
3748
|
// A brand-new conversation is not yet LISTED — it enters the running
|
|
3179
3749
|
// list only when it is displaced to the background while still
|
|
3180
3750
|
// streaming (its runtime is what `listed` protects). A blank chat is
|
|
@@ -3188,7 +3758,6 @@ export class ClientSession {
|
|
|
3188
3758
|
stallNoticed: false,
|
|
3189
3759
|
goal: this.makeGoalStatus(),
|
|
3190
3760
|
goalGeneration: 0,
|
|
3191
|
-
goalReviewGeneration: 0,
|
|
3192
3761
|
wizardRunning: false,
|
|
3193
3762
|
deltaSeq: 0,
|
|
3194
3763
|
terminals,
|
|
@@ -3598,6 +4167,9 @@ export class ClientSession {
|
|
|
3598
4167
|
const modelId = curModel ? `${curModel.provider}/${curModel.id}` : undefined;
|
|
3599
4168
|
return {
|
|
3600
4169
|
conversationId: target.id,
|
|
4170
|
+
sessionId: target.session.sessionId,
|
|
4171
|
+
sessionFile: target.session.sessionFile,
|
|
4172
|
+
sessionDir: target.session.sessionManager?.getSessionDir?.(),
|
|
3601
4173
|
title: target.title,
|
|
3602
4174
|
model: modelId,
|
|
3603
4175
|
at: target.lastActiveAt,
|
|
@@ -3885,6 +4457,11 @@ export class ClientSession {
|
|
|
3885
4457
|
// Any SDK event proves the run is alive — feeds the stall watchdog below.
|
|
3886
4458
|
conv.lastSdkEventAt = Date.now();
|
|
3887
4459
|
conv.stallNoticed = false;
|
|
4460
|
+
// 模型可能在 pi-web-ui 之外被换(SDK 从会话历史恢复、/model 命令等),
|
|
4461
|
+
// 而软上限 reserve 是按注入那一刻的窗口换算的固定值——模型一变必须重算,
|
|
4462
|
+
// 否则大窗口算出的 reserve 泄漏到小窗口模型上,触发点被压成负数,
|
|
4463
|
+
// 上下文刚过几万 token 就反复触发压缩。热路径只做一次字符串比对。
|
|
4464
|
+
this.reapplySoftCapIfModelChanged(conv);
|
|
3888
4465
|
switch (event.type) {
|
|
3889
4466
|
case "bash_execution_update": {
|
|
3890
4467
|
if (event.id) {
|
|
@@ -4141,6 +4718,8 @@ export class ClientSession {
|
|
|
4141
4718
|
return a.role === "assistant" && a.stopReason === "aborted";
|
|
4142
4719
|
});
|
|
4143
4720
|
if (aborted) {
|
|
4721
|
+
// 角色轮等待者先唤醒(中止也算「本轮结束」),再让 GoalService 作废目标。
|
|
4722
|
+
this.notifyTurnEnd(conv);
|
|
4144
4723
|
const stopNotice = this.goalSvc.onAgentEnd(conv, true);
|
|
4145
4724
|
if (stopNotice) {
|
|
4146
4725
|
this.emit({ type: "notice", level: "warning", text: stopNotice.text, textEn: stopNotice.textEn });
|
|
@@ -4169,6 +4748,9 @@ export class ClientSession {
|
|
|
4169
4748
|
this.emitConversations();
|
|
4170
4749
|
}
|
|
4171
4750
|
// Goal review hook lives in GoalService.onAgentEnd(conv, false).
|
|
4751
|
+
// 先唤醒角色轮等待者:委托执行(Plan A)下服务端正阻塞在「等主对话给 verdict」
|
|
4752
|
+
// 或「等对话空闲」上,而 onAgentEnd 随后会把它需要的 verdict 交回。
|
|
4753
|
+
this.notifyTurnEnd(conv);
|
|
4172
4754
|
this.goalSvc.onAgentEnd(conv, false);
|
|
4173
4755
|
// Deferred settings reload: settings (system prompt / skills /
|
|
4174
4756
|
// extensions) changed while the run was streaming — applying now
|
|
@@ -4596,11 +5178,17 @@ export class ClientSession {
|
|
|
4596
5178
|
pendingQuestion: this.pendingQuestionForSnapshot(),
|
|
4597
5179
|
pendingApproval: this.pendingApprovalForSnapshot(),
|
|
4598
5180
|
plan: this.planManager.getPlan(this.activeId),
|
|
5181
|
+
// 计划模式是**会话级**开关:快照恒给布尔(不用 undefined),否则
|
|
5182
|
+
// snapshot_delta 里 key 缺席 → 前端 spread 合并会残留上一对话的 true。
|
|
5183
|
+
planMode: conv?.planMode === true,
|
|
5184
|
+
// 审查者模式同样是**会话级**:布尔恒给(理由同 planMode),另带执行对话 id。
|
|
5185
|
+
delegateMode: conv?.delegateMode === true,
|
|
5186
|
+
delegateConvId: conv?.delegateConvId ?? null,
|
|
4599
5187
|
subagentHandoffs: this.subagentHandoffs.length > 0 ? [...this.subagentHandoffs] : undefined,
|
|
4600
5188
|
agentPreset: conv
|
|
4601
5189
|
? {
|
|
4602
5190
|
id: conv.agentPreset ?? "standard",
|
|
4603
|
-
name: PI_AGENT_PRESETS.find((p) => p.id === conv.agentPreset)
|
|
5191
|
+
name: localizedName(PI_AGENT_PRESETS.find((p) => p.id === conv.agentPreset), this.getLang(), "全功能"),
|
|
4604
5192
|
locked: conv.presetLocked || !this.isBlankConversation(conv),
|
|
4605
5193
|
}
|
|
4606
5194
|
: null,
|
|
@@ -6018,24 +6606,50 @@ export class ClientSession {
|
|
|
6018
6606
|
* 合并视图,SDK 每次自动压缩检查前都重读 getCompactionSettings(),
|
|
6019
6607
|
* 无需 reload;软上限关闭时回填 SDK 默认 reserve(不让旧覆盖泄漏)。
|
|
6020
6608
|
* 窗口未知(会话未就绪/无模型)的会话跳过——创建/就绪/换模型路径
|
|
6021
|
-
* 会重放(见各 applyRetryOverrides
|
|
6609
|
+
* 会重放(见各 applyRetryOverrides 调用点);此外 onEvent 按模型
|
|
6610
|
+
* key 变化兜底重放(reapplySoftCapIfModelChanged),保证会话模型
|
|
6611
|
+
* 在 pi-web-ui 之外被换(如 SDK 从会话历史恢复)后覆盖不泄漏。 */
|
|
6022
6612
|
applyCompactionOverrides() {
|
|
6023
6613
|
const s = this.settingsSvc.current;
|
|
6024
6614
|
for (const c of this.convs.values()) {
|
|
6025
6615
|
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
|
-
});
|
|
6616
|
+
this.applyCompactionOverrideForConv(c, s);
|
|
6033
6617
|
}
|
|
6034
6618
|
catch {
|
|
6035
6619
|
// 会话未就绪或已释放 → 其 runtime 创建时统一注入。
|
|
6036
6620
|
}
|
|
6037
6621
|
}
|
|
6038
6622
|
}
|
|
6623
|
+
/** 单会话换算并注入软上限 reserve(applyCompactionOverrides 的循环体;
|
|
6624
|
+
* reapplySoftCapIfModelChanged 单会话重放也复用)。抛错交调用方处置。 */
|
|
6625
|
+
applyCompactionOverrideForConv(c, s) {
|
|
6626
|
+
const modelId = modelKeyOf(c.session);
|
|
6627
|
+
const contextWindow = contextWindowOf(c.session);
|
|
6628
|
+
const cap = effectiveSoftCap(s.softCapTokens, s.softCapByModel, modelId);
|
|
6629
|
+
const reserve = softCapToReserve(contextWindow, cap);
|
|
6630
|
+
c.session.settingsManager.applyOverrides({
|
|
6631
|
+
compaction: { reserveTokens: reserve ?? DEFAULT_COMPACTION_RESERVE_TOKENS },
|
|
6632
|
+
});
|
|
6633
|
+
}
|
|
6634
|
+
/** 会话模型变化时重放该会话的软上限覆盖。SDK 恢复历史会话的模型
|
|
6635
|
+
* (findInitialModel)与 /model 命令都不经过 pi-web-ui 的 setModel
|
|
6636
|
+
* 路径,注入过的 reserve 会停留在旧模型窗口的换算值上——例如默认
|
|
6637
|
+
* 模型(1M 窗口)算出 748576,泄漏到 500K 窗口的会话后压缩触发点
|
|
6638
|
+
* 变成负数,上下文几万 token 就被反复压缩。onEvent 每个事件比对
|
|
6639
|
+
* 一次 model key(O(1) 字符串比较),变化才重放。 */
|
|
6640
|
+
reapplySoftCapIfModelChanged(conv) {
|
|
6641
|
+
try {
|
|
6642
|
+
const key = modelKeyOf(conv.session);
|
|
6643
|
+
if (conv.lastModelKey === key)
|
|
6644
|
+
return;
|
|
6645
|
+
conv.lastModelKey = key;
|
|
6646
|
+
if (key)
|
|
6647
|
+
this.applyCompactionOverrideForConv(conv, this.settingsSvc.current);
|
|
6648
|
+
}
|
|
6649
|
+
catch {
|
|
6650
|
+
// 会话未就绪或已释放 → 留给创建/换模型路径与后续事件重试。
|
|
6651
|
+
}
|
|
6652
|
+
}
|
|
6039
6653
|
/** 当前活动对话的生效软上限(快照底栏标记线用;null = 关闭/未知)。 */
|
|
6040
6654
|
activeSoftCap(contextWindow) {
|
|
6041
6655
|
try {
|
|
@@ -6151,6 +6765,12 @@ export class ClientSession {
|
|
|
6151
6765
|
return c.agentPreset;
|
|
6152
6766
|
return undefined;
|
|
6153
6767
|
}
|
|
6768
|
+
convOfSession(session) {
|
|
6769
|
+
for (const c of this.convs.values())
|
|
6770
|
+
if (c.session === session)
|
|
6771
|
+
return c;
|
|
6772
|
+
return undefined;
|
|
6773
|
+
}
|
|
6154
6774
|
/**
|
|
6155
6775
|
* read / write / edit 三处覆盖的注入规格:基底 = 扩展注册的同名工具优先,否则 SDK 内置实现
|
|
6156
6776
|
* (见 tool-overrides.ts —— 这三处覆盖不能塞进创建时的 `customTools`,那会静默顶掉扩展的
|
|
@@ -6177,8 +6797,10 @@ export class ClientSession {
|
|
|
6177
6797
|
getRules: () => this.approvalRules.list(),
|
|
6178
6798
|
};
|
|
6179
6799
|
// 写/编的权限沙箱包装:同一个函数,有扩展同名工具时把它的定义当基底(末参)。
|
|
6180
|
-
|
|
6181
|
-
const
|
|
6800
|
+
// 目标审查闸门包在**最外层**(权限沙箱之前):审查回合只读核实,写类直接拒。
|
|
6801
|
+
const planGate = (def) => withGoalReviewGate(withDelegationGate(withPlanModeGate(def, () => this.planModeOf(ownerId), () => this.getLang()), () => this.delegateModeOf(ownerId), () => this.getLang()), () => this.goalReviewTurnOf(ownerId), () => this.getLang());
|
|
6802
|
+
const composeWrite = (base) => planGate(wrapWriteToolWithPermission(cwd, currentPermission, () => this.roots, () => this.getLang(), approve, () => ownerId, () => this.approvalRules.list(), base));
|
|
6803
|
+
const composeEdit = (base) => planGate(wrapEditToolWithPermission(cwd, currentPermission, () => this.roots, () => this.getLang(), approve, () => ownerId, () => this.approvalRules.list(), base));
|
|
6182
6804
|
return [
|
|
6183
6805
|
{
|
|
6184
6806
|
name: "read",
|
|
@@ -6197,8 +6819,20 @@ export class ClientSession {
|
|
|
6197
6819
|
* session.reload() 与新会话创建都会把 custom 工具加回活跃集,
|
|
6198
6820
|
* 所以这两条路径之后都要重放本方法(见 reloadSession/创建处)。 */
|
|
6199
6821
|
applyToolGating(session, preset) {
|
|
6200
|
-
const
|
|
6201
|
-
|
|
6822
|
+
const conv = this.convOfSession(session);
|
|
6823
|
+
const targetPreset = preset ??
|
|
6824
|
+
conv?.agentPreset ??
|
|
6825
|
+
this.presetOfSession(session) ??
|
|
6826
|
+
this.settingsSvc.current.defaultAgentPreset ??
|
|
6827
|
+
"standard";
|
|
6828
|
+
const isPlanMode = conv?.planMode === true;
|
|
6829
|
+
const disabled = new Set(effectiveDisabledAgentTools(this.settingsSvc.current));
|
|
6830
|
+
if (isPlanMode) {
|
|
6831
|
+
for (const toolName of PLAN_MODE_BLOCKED_TOOL_NAMES) {
|
|
6832
|
+
disabled.add(toolName);
|
|
6833
|
+
}
|
|
6834
|
+
}
|
|
6835
|
+
applyAgentToolsGating(session, [...disabled], targetPreset);
|
|
6202
6836
|
this.syncPluginTools(session, targetPreset);
|
|
6203
6837
|
this.sessionStatsCache = null;
|
|
6204
6838
|
this.cachedBaseTokens = null;
|
|
@@ -6269,8 +6903,8 @@ export class ClientSession {
|
|
|
6269
6903
|
this.emit({
|
|
6270
6904
|
type: "notice",
|
|
6271
6905
|
level: "info",
|
|
6272
|
-
text: `已切换为「${hit.
|
|
6273
|
-
textEn: `Switched to preset "${hit.
|
|
6906
|
+
text: `已切换为「${localizedName(hit, this.getLang())}」预设`,
|
|
6907
|
+
textEn: `Switched to preset "${localizedName(hit, this.getLang())}"`,
|
|
6274
6908
|
});
|
|
6275
6909
|
this.pushSettings();
|
|
6276
6910
|
this.flushSnapshot();
|
|
@@ -6328,10 +6962,207 @@ export class ClientSession {
|
|
|
6328
6962
|
this.emit({
|
|
6329
6963
|
type: "notice",
|
|
6330
6964
|
level: "info",
|
|
6331
|
-
text: `当前会话权限已切换为「${hit.
|
|
6332
|
-
textEn: `Current session permission switched to "${hit.
|
|
6965
|
+
text: `当前会话权限已切换为「${localizedName(hit, this.getLang())}」`,
|
|
6966
|
+
textEn: `Current session permission switched to "${localizedName(hit, this.getLang())}"`,
|
|
6967
|
+
});
|
|
6968
|
+
this.flushSnapshot();
|
|
6969
|
+
}
|
|
6970
|
+
/** 该会话是否处于计划模式(ownerId 缺省 = 当前对话)。 */
|
|
6971
|
+
planModeOf(ownerId) {
|
|
6972
|
+
const id = ownerId ?? this.activeId;
|
|
6973
|
+
return this.convs.get(id)?.planMode === true;
|
|
6974
|
+
}
|
|
6975
|
+
/** 审查者模式(自动委派)是否对该 runtime 所属会话开着。 */
|
|
6976
|
+
delegateModeOf(ownerId) {
|
|
6977
|
+
const id = ownerId ?? this.activeId;
|
|
6978
|
+
return this.convs.get(id)?.delegateMode === true;
|
|
6979
|
+
}
|
|
6980
|
+
/** 该会话是否正在跑目标审查回合(awaitingVerdict 置位):是则写类 / 派发类工具
|
|
6981
|
+
* 走目标审查闸门(server/goal-review-gate.ts)。ownerId 恒为会话 id(创建时传入),
|
|
6982
|
+
* 缺省才回退活动对话 —— 与 planModeOf / delegateModeOf 同口径。 */
|
|
6983
|
+
goalReviewTurnOf(ownerId) {
|
|
6984
|
+
const id = ownerId ?? this.activeId;
|
|
6985
|
+
return this.convs.get(id)?.awaitingVerdict != null;
|
|
6986
|
+
}
|
|
6987
|
+
/**
|
|
6988
|
+
* 切换审查者模式(会话级,默认关)。
|
|
6989
|
+
* 开启后:① 本对话只审阅(写类/派发类工具硬闸门 + 提示词段);② 用户发的每条
|
|
6990
|
+
* prompt 由服务端转给一个**常驻落盘执行对话**执行。计划模式优先(两者同开不派活)。
|
|
6991
|
+
*/
|
|
6992
|
+
async setDelegateMode(enabled, conversationId) {
|
|
6993
|
+
const target = conversationId ? this.convs.get(conversationId) : this.conv;
|
|
6994
|
+
if (!target)
|
|
6995
|
+
return;
|
|
6996
|
+
if (target.delegateMode === enabled) {
|
|
6997
|
+
this.flushSnapshot();
|
|
6998
|
+
return;
|
|
6999
|
+
}
|
|
7000
|
+
target.delegateMode = enabled;
|
|
7001
|
+
if (!enabled)
|
|
7002
|
+
target.delegateConvId = null; // 关闭后不再指向旧执行对话
|
|
7003
|
+
try {
|
|
7004
|
+
// 与 plan/mode 同口径写进转录:切会话/重载能回放(布尔),执行对话 id 不落盘。
|
|
7005
|
+
const sm = target.session.sessionManager;
|
|
7006
|
+
sm?.appendCustomEntry?.("delegate/mode", { enabled });
|
|
7007
|
+
}
|
|
7008
|
+
catch {
|
|
7009
|
+
// best effort for in-memory sessions
|
|
7010
|
+
}
|
|
7011
|
+
const notice = delegateNoticeText(enabled);
|
|
7012
|
+
this.emit({ type: "notice", level: "info", text: notice.text, textEn: notice.textEn });
|
|
7013
|
+
this.flushSnapshot();
|
|
7014
|
+
// 提示词段随之增减(下一轮生效);失败不阻断(硬闸门仍拦得住)。
|
|
7015
|
+
try {
|
|
7016
|
+
await target.session.reload?.();
|
|
7017
|
+
}
|
|
7018
|
+
catch {
|
|
7019
|
+
// best effort
|
|
7020
|
+
}
|
|
7021
|
+
}
|
|
7022
|
+
/**
|
|
7023
|
+
* 自动路由:把本轮用户输入转给常驻执行对话执行(审查者模式的干活路径)。
|
|
7024
|
+
* 返回 true = 已接管(调用方不要再跑主会话的模型);false = 没收走(走正常路径)。
|
|
7025
|
+
*
|
|
7026
|
+
* 与计划模式互斥:计划模式开着时**不派活**(那边已把 spawn/旁路工具全拒,
|
|
7027
|
+
* 再自动派活就是死锁),由计划模式那一轮自己在主对话里出计划。
|
|
7028
|
+
*/
|
|
7029
|
+
async dispatchToDelegate(conv, text) {
|
|
7030
|
+
if (conv.delegateMode !== true)
|
|
7031
|
+
return false;
|
|
7032
|
+
if (conv.planMode === true)
|
|
7033
|
+
return false; // 计划模式优先
|
|
7034
|
+
if (this.quiesceBlocked())
|
|
7035
|
+
return false;
|
|
7036
|
+
const trimmed = text.trim();
|
|
7037
|
+
if (!trimmed)
|
|
7038
|
+
return false;
|
|
7039
|
+
let execId = conv.delegateConvId;
|
|
7040
|
+
if (execId && !this.convs.get(execId)?.session)
|
|
7041
|
+
execId = null;
|
|
7042
|
+
// 首轮:第一条 prompt 直接交给 spawnSubagentConversation(它会跑起这一轮)——
|
|
7043
|
+
// **不要再 sendUserMessage 一次**,否则执行对话正在处理中,第二次投递会被
|
|
7044
|
+
// SDK 拒("Agent is already processing a prompt")。之后每轮才走追加投递。
|
|
7045
|
+
const firstTurn = !execId;
|
|
7046
|
+
if (firstTurn) {
|
|
7047
|
+
try {
|
|
7048
|
+
// 常驻执行对话:落盘普通对话(进历史、可续聊),与目标模式的执行对话同一通道。
|
|
7049
|
+
// 满员(项目 8 个普通对话)→ 抛错 → 下面提示用户,不会静默把活留在主对话。
|
|
7050
|
+
execId = await this.spawnSubagentConversation(trimmed, "delegate-executor", conv.cwd ?? this.cwd, undefined, null, conv.id, true, pick(this.getLang(), "委派执行", "Delegate executor"));
|
|
7051
|
+
const execConv = this.convs.get(execId);
|
|
7052
|
+
if (execConv)
|
|
7053
|
+
execConv.title = pick(this.getLang(), "委派执行", "Delegate executor");
|
|
7054
|
+
conv.delegateConvId = execId;
|
|
7055
|
+
}
|
|
7056
|
+
catch (err) {
|
|
7057
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
7058
|
+
this.emit({
|
|
7059
|
+
type: "notice",
|
|
7060
|
+
level: "error",
|
|
7061
|
+
text: `审查者模式:无法创建执行对话(${msg})。请关闭审查者模式,或先释放一些对话名额。`,
|
|
7062
|
+
textEn: `Reviewer mode: could not create the executor conversation (${msg}). Turn reviewer mode off, or free up some conversation slots first.`,
|
|
7063
|
+
});
|
|
7064
|
+
this.flushSnapshot();
|
|
7065
|
+
return false;
|
|
7066
|
+
}
|
|
7067
|
+
}
|
|
7068
|
+
else {
|
|
7069
|
+
const execConv = this.convs.get(execId);
|
|
7070
|
+
if (!execConv?.session)
|
|
7071
|
+
return false;
|
|
7072
|
+
try {
|
|
7073
|
+
await execConv.session.sendUserMessage(trimmed, execConv.session.isStreaming ? { deliverAs: "steer" } : undefined);
|
|
7074
|
+
}
|
|
7075
|
+
catch (err) {
|
|
7076
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
7077
|
+
this.emit({
|
|
7078
|
+
type: "notice",
|
|
7079
|
+
level: "error",
|
|
7080
|
+
text: `审查者模式:派活失败(${msg})`,
|
|
7081
|
+
textEn: `Reviewer mode: dispatch failed (${msg})`,
|
|
7082
|
+
});
|
|
7083
|
+
this.flushSnapshot();
|
|
7084
|
+
return false;
|
|
7085
|
+
}
|
|
7086
|
+
}
|
|
7087
|
+
this.emit({
|
|
7088
|
+
type: "notice",
|
|
7089
|
+
level: "info",
|
|
7090
|
+
text: `🔎 已派给执行对话:${trimmed.slice(0, 120)}${trimmed.length > 120 ? "…" : ""}(本对话只审阅)`,
|
|
7091
|
+
textEn: `🔎 Dispatched to the executor conversation: ${trimmed.slice(0, 120)}${trimmed.length > 120 ? "…" : ""} (this conversation reviews only)`,
|
|
6333
7092
|
});
|
|
6334
7093
|
this.flushSnapshot();
|
|
7094
|
+
// 执行对话跑完 → 回报主对话(只通知,不自动验收:验收由人/主对话下一轮做)。
|
|
7095
|
+
void this.notifyWhenDelegateFinishes(conv, execId);
|
|
7096
|
+
return true;
|
|
7097
|
+
}
|
|
7098
|
+
/** 等常驻执行对话这一轮结束,回主对话一条通知(带它的最后一条回复摘要)。 */
|
|
7099
|
+
async notifyWhenDelegateFinishes(conv, execId) {
|
|
7100
|
+
try {
|
|
7101
|
+
await this.waitConversationTurnEnd(execId, 30 * 60 * 1000);
|
|
7102
|
+
}
|
|
7103
|
+
catch {
|
|
7104
|
+
// 超时/取消:仍给一条「执行对话还没结束」的弱提示,避免主对话永远静默。
|
|
7105
|
+
}
|
|
7106
|
+
const execConv = this.convs.get(execId);
|
|
7107
|
+
if (!execConv)
|
|
7108
|
+
return;
|
|
7109
|
+
const last = [...execConv.session.agent.state.messages]
|
|
7110
|
+
.reverse()
|
|
7111
|
+
.find((m) => m.role === "assistant");
|
|
7112
|
+
const summary = typeof last?.content === "string" ? last.content : JSON.stringify(last?.content ?? "").slice(0, 400);
|
|
7113
|
+
this.emit({
|
|
7114
|
+
type: "notice",
|
|
7115
|
+
level: "info",
|
|
7116
|
+
text: `✅ 执行对话已交付,请审阅:${summary.slice(0, 300)}${summary.length > 300 ? "…" : ""}(左栏「委派执行」可打开看全文)`,
|
|
7117
|
+
textEn: `✅ The executor conversation delivered — review it: ${summary.slice(0, 300)}${summary.length > 300 ? "…" : ""} (open “Delegate executor” in the sidebar for the full transcript)`,
|
|
7118
|
+
});
|
|
7119
|
+
this.flushSnapshot();
|
|
7120
|
+
}
|
|
7121
|
+
/**
|
|
7122
|
+
* 切换计划模式(会话级,只规划不实施)。
|
|
7123
|
+
* 软约束(系统提示词段)+ 硬闸门(写类/旁路工具与非常规 bash 直接拒)双管:
|
|
7124
|
+
* 状态随会话转录落盘(切会话/重载恢复),并即时提示用户当前口径。
|
|
7125
|
+
*/
|
|
7126
|
+
async setPlanMode(enabled, conversationId) {
|
|
7127
|
+
const target = conversationId ? this.convs.get(conversationId) : this.conv;
|
|
7128
|
+
if (!target)
|
|
7129
|
+
return;
|
|
7130
|
+
if (target.planMode === enabled) {
|
|
7131
|
+
this.flushSnapshot();
|
|
7132
|
+
return;
|
|
7133
|
+
}
|
|
7134
|
+
target.planMode = enabled;
|
|
7135
|
+
try {
|
|
7136
|
+
// 持久会话:写入转录日志,切会话/重载后回放恢复(与 permission/preset 同口径)。
|
|
7137
|
+
const sm = target.session.sessionManager;
|
|
7138
|
+
sm?.appendCustomEntry?.("plan/mode", { enabled });
|
|
7139
|
+
}
|
|
7140
|
+
catch {
|
|
7141
|
+
// best effort for in-memory sessions
|
|
7142
|
+
}
|
|
7143
|
+
// 动态应用工具门控:开启计划模式时剔除写类工具,关闭时恢复写类工具
|
|
7144
|
+
this.applyToolGating(target.session);
|
|
7145
|
+
// 计划模式有会话准入校验(空白对话也能开,但快照要立刻反映按钮态)。
|
|
7146
|
+
const notice = planModeNoticeText(enabled);
|
|
7147
|
+
this.emit({ type: "notice", level: "info", text: notice.text, textEn: notice.textEn });
|
|
7148
|
+
this.flushSnapshot();
|
|
7149
|
+
// 提示词段随之增减:重建资源让下一轮就带上/去掉计划模式约束。
|
|
7150
|
+
void this.reloadPromptForPlanMode(target);
|
|
7151
|
+
}
|
|
7152
|
+
/**
|
|
7153
|
+
* 提示词重建:切换计划模式后需要让**下一轮**带上/去掉计划模式段
|
|
7154
|
+
* (appendSystemPromptOverride 在资源加载重放时取值)。会话空闲时即时
|
|
7155
|
+
* 重建,用户下一句就生效;重建失败不阻断(硬闸门仍然拦得住)。
|
|
7156
|
+
*/
|
|
7157
|
+
async reloadPromptForPlanMode(conv) {
|
|
7158
|
+
try {
|
|
7159
|
+
await conv.session.reload?.();
|
|
7160
|
+
// reload() 重新加载扩展时可能会将工具加回活跃集,重跑一次门控确保写工具被持续剔除
|
|
7161
|
+
this.applyToolGating(conv.session);
|
|
7162
|
+
}
|
|
7163
|
+
catch {
|
|
7164
|
+
// best effort:某些引擎/临时态不支持 reload
|
|
7165
|
+
}
|
|
6335
7166
|
}
|
|
6336
7167
|
/** 设置新会话默认权限预设。 */
|
|
6337
7168
|
async setDefaultPermissionPreset(preset) {
|
|
@@ -6350,7 +7181,7 @@ export class ClientSession {
|
|
|
6350
7181
|
this.refreshPermission();
|
|
6351
7182
|
this.flushSnapshot();
|
|
6352
7183
|
}
|
|
6353
|
-
/** 当前启用的插件 AI 工具定义(provider 快照按 disabledPluginTools 过滤;
|
|
7184
|
+
/** 当前启用的插件 AI 工具定义(provider 快照按 disabledPlugins 与 disabledPluginTools 过滤;
|
|
6354
7185
|
* 未知/已卸载插件的禁用条目保留但不影响现有工具)。
|
|
6355
7186
|
* 预设是第二层门控(见 tool-manager.ts 语义总表):非 standard 预设下插件工具
|
|
6356
7187
|
* 一律不可用(读写未知,保守处理),此时返回空表,调用方负责从会话移除。 */
|
|
@@ -6358,7 +7189,14 @@ export class ClientSession {
|
|
|
6358
7189
|
if (!presetAllowsPluginTools(preset))
|
|
6359
7190
|
return [];
|
|
6360
7191
|
const off = new Set(normalizeDisabledPluginTools(this.settingsSvc.current.disabledPluginTools));
|
|
6361
|
-
|
|
7192
|
+
const disabledPlugins = new Set(this.settingsSvc.current.disabledPlugins ?? []);
|
|
7193
|
+
return (this.pluginToolsProvider?.() ?? [])
|
|
7194
|
+
.filter((t) => {
|
|
7195
|
+
if (t.pluginId && disabledPlugins.has(t.pluginId))
|
|
7196
|
+
return false;
|
|
7197
|
+
return !off.has(t.name);
|
|
7198
|
+
})
|
|
7199
|
+
.map(pluginToolToDefinition);
|
|
6362
7200
|
}
|
|
6363
7201
|
/** 把插件 AI 工具同步进一个已存在的会话(新增/更新/移除;禁用工具同步移除)。
|
|
6364
7202
|
* 实际 diff 逻辑在 plugins.ts 的 syncPluginToolsIntoSession(可单测)。
|
|
@@ -6634,6 +7472,18 @@ export class ClientSession {
|
|
|
6634
7472
|
* after the current turn settles, skipping remaining planned tool calls.
|
|
6635
7473
|
*/
|
|
6636
7474
|
queue = false) {
|
|
7475
|
+
const trimmedText = (text ?? "").trim();
|
|
7476
|
+
const hasAttachments = Boolean(attachments && attachments.length > 0);
|
|
7477
|
+
if (!trimmedText && !hasAttachments) {
|
|
7478
|
+
this.emit({
|
|
7479
|
+
type: "notice",
|
|
7480
|
+
level: "warning",
|
|
7481
|
+
text: "发送已忽略:提示词为空且未附带文件或上下文引用。",
|
|
7482
|
+
textEn: "Prompt ignored: text is empty and no attachments were provided.",
|
|
7483
|
+
});
|
|
7484
|
+
this.flushSnapshot();
|
|
7485
|
+
return;
|
|
7486
|
+
}
|
|
6637
7487
|
// Captured at the START (before any await): the conversation being
|
|
6638
7488
|
// addressed by this prompt. See the naming block below — a concurrent
|
|
6639
7489
|
// switch/new_chat while prompt() is in flight must never target a
|
|
@@ -6641,6 +7491,54 @@ export class ClientSession {
|
|
|
6641
7491
|
const conv = this.conv;
|
|
6642
7492
|
const promptAc = new AbortController();
|
|
6643
7493
|
conv.activePromptAc = promptAc;
|
|
7494
|
+
if (conv.planMode === true) {
|
|
7495
|
+
this.applyToolGating(conv.session);
|
|
7496
|
+
}
|
|
7497
|
+
// 审查者模式(自动委派):本对话只审阅 —— 用户这条 prompt 直接转给常驻执行
|
|
7498
|
+
// 对话执行,主会话这一轮**不跑模型**。接在 promptAc 之后、draft 清理之前:
|
|
7499
|
+
// 转走的内容不该把主对话的草稿也清掉(用户可能还想在主对话里追一句)。
|
|
7500
|
+
// 计划模式优先(那边直接返回 false)。
|
|
7501
|
+
if (await this.dispatchToDelegate(conv, text)) {
|
|
7502
|
+
conv.presetLocked = true;
|
|
7503
|
+
return;
|
|
7504
|
+
}
|
|
7505
|
+
// 目标审查回合进行中:纯文本插话顺延到 verdict 落定后(steer 进去会污染
|
|
7506
|
+
// verdict,一次插话烧掉整个目标;followUp 由 SDK 排在整轮结束后才送达,
|
|
7507
|
+
// 本来就安全所以只拦 steer)。斜杠命令直通(原生配置类不进模型);带附件 /
|
|
7508
|
+
// 图片的不顺延(附件引用只在发送瞬间有效)→ 响亮拒绝,草稿保留在输入框。
|
|
7509
|
+
// 判定抽成纯函数(server/goal-review-gate.ts),单测见 goal-review-gate.test.ts。
|
|
7510
|
+
if (shouldDeferPromptForReview({
|
|
7511
|
+
queue,
|
|
7512
|
+
text,
|
|
7513
|
+
hasAttachments: !!attachments && attachments.length > 0,
|
|
7514
|
+
awaitingVerdict: conv?.awaitingVerdict != null,
|
|
7515
|
+
})) {
|
|
7516
|
+
const gc = conv;
|
|
7517
|
+
(gc.deferredPrompts ??= []).push(text);
|
|
7518
|
+
this.emit({
|
|
7519
|
+
type: "notice",
|
|
7520
|
+
level: "info",
|
|
7521
|
+
text: "审查回合进行中,你的消息已排队(审查结束后自动发送,不会打断审查)。",
|
|
7522
|
+
textEn: "A review round is running; your message is queued and will be sent automatically when it ends (without interrupting the review).",
|
|
7523
|
+
});
|
|
7524
|
+
this.flushSnapshot();
|
|
7525
|
+
return;
|
|
7526
|
+
}
|
|
7527
|
+
if (!queue &&
|
|
7528
|
+
!text.trim().startsWith("/") &&
|
|
7529
|
+
!!attachments &&
|
|
7530
|
+
attachments.length > 0 &&
|
|
7531
|
+
conv?.awaitingVerdict != null) {
|
|
7532
|
+
// 带附件的插话不顺延也不 steer:直接拒绝,用户稍后重发(草稿还在输入框)。
|
|
7533
|
+
this.emit({
|
|
7534
|
+
type: "notice",
|
|
7535
|
+
level: "warning",
|
|
7536
|
+
text: "审查回合进行中:带附件/图片的消息请等审查结束后再发(文本草稿已保留)。",
|
|
7537
|
+
textEn: "A review round is running; please resend messages with attachments/images after it ends (your draft is kept).",
|
|
7538
|
+
});
|
|
7539
|
+
this.flushSnapshot();
|
|
7540
|
+
return;
|
|
7541
|
+
}
|
|
6644
7542
|
// 输入框内容被消费(发送/斜杠执行)→ 清掉该会话存过的草稿(best-effort)。
|
|
6645
7543
|
// 快捷短语发送(不碰输入框)同样清:客户端发送成功后会把当前草稿重存回来。
|
|
6646
7544
|
// clear() 同时记录 clear 时间戳水位:清掉之后才 landing 的旧 draft_update
|
|
@@ -6942,6 +7840,12 @@ export class ClientSession {
|
|
|
6942
7840
|
// 立刻要有它(此刻还在流式输出,不能等 agent_end 的防抖刷新)。
|
|
6943
7841
|
this.emitConversations();
|
|
6944
7842
|
}
|
|
7843
|
+
// 置换守卫:从这一刻起进入异步前置(附件构建 + 影子快照)——在它们完成前
|
|
7844
|
+
// 对话既不 streaming 也没有新消息落盘,displaceActive() 会把它误判成
|
|
7845
|
+
// 空闲对话并销毁 runtime,导致投递中的消息被静默丢弃。放在所有同步校验 /
|
|
7846
|
+
// 原生斜杠命令拦截之后:原生命令(/new, /cwd 等)不进投递流程且可能
|
|
7847
|
+
// 立即切换会话,若提前置位会被 displaceActive 误判为有消息投递而报错通知。
|
|
7848
|
+
conv.promptInFlight = true;
|
|
6945
7849
|
// Attach files as independent nextTurn context messages (asides) so the
|
|
6946
7850
|
// user message stays clean; they render as separate attachment cards.
|
|
6947
7851
|
const asides = await buildAttachmentMessages({
|
|
@@ -6982,6 +7886,16 @@ export class ClientSession {
|
|
|
6982
7886
|
return;
|
|
6983
7887
|
}
|
|
6984
7888
|
conv.activePromptAc = undefined;
|
|
7889
|
+
// 前置阶段(附件 / 影子快照)期间用户可能已经切走或新建了对话:消息仍然
|
|
7890
|
+
// 落在本对话里,指给用户看,免得「发出去的消息不见了」。
|
|
7891
|
+
if (conv.id !== this.activeId) {
|
|
7892
|
+
this.emit({
|
|
7893
|
+
type: "notice",
|
|
7894
|
+
level: "info",
|
|
7895
|
+
text: `消息已投递到「${conv.title}」——你刚切换了对话,它不在当前对话里`,
|
|
7896
|
+
textEn: `Message delivered to "${conv.title}" — you switched conversations before it landed`,
|
|
7897
|
+
});
|
|
7898
|
+
}
|
|
6985
7899
|
if (s.isStreaming) {
|
|
6986
7900
|
// queue=true (补充 button) → followUp: the message is delivered only
|
|
6987
7901
|
// after the whole run finishes — the agent finishes what it started,
|
|
@@ -7030,6 +7944,11 @@ export class ClientSession {
|
|
|
7030
7944
|
textEn: `Failed to send prompt: ${err.message}`,
|
|
7031
7945
|
});
|
|
7032
7946
|
}
|
|
7947
|
+
finally {
|
|
7948
|
+
// 无论走哪条出口(斜杠命令 / quiesce 拒绝 / 中止 / 抛错 / 正常落定),
|
|
7949
|
+
// 投递窗口都结束了;留着 true 会让这条对话永远不被置换。
|
|
7950
|
+
conv.promptInFlight = false;
|
|
7951
|
+
}
|
|
7033
7952
|
// The active conversation (captured at prompt start — see above) has been
|
|
7034
7953
|
// continued since it was opened — it must not be dismissed when the user
|
|
7035
7954
|
// switches away. (Also bumps the per-project "most recently active"
|
|
@@ -7529,8 +8448,8 @@ export class ClientSession {
|
|
|
7529
8448
|
this.emit({
|
|
7530
8449
|
type: "notice",
|
|
7531
8450
|
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.`,
|
|
8451
|
+
text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先移出不需要的对话(打开后离开不继续对话即移出;钉住的对话需先取消钉住)`,
|
|
8452
|
+
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
8453
|
});
|
|
7535
8454
|
return false;
|
|
7536
8455
|
}
|
|
@@ -7901,11 +8820,22 @@ export class ClientSession {
|
|
|
7901
8820
|
openTerminals: conv.terminals.countBlockingLive(),
|
|
7902
8821
|
listed: conv.listed,
|
|
7903
8822
|
promptedSinceActive: conv.promptedSinceActive,
|
|
8823
|
+
pinned: conv.pinned,
|
|
8824
|
+
// 投递中的消息(附件构建 / 影子快照阶段):见 Conversation.promptInFlight。
|
|
8825
|
+
promptInFlight: Boolean(conv.promptInFlight),
|
|
7904
8826
|
hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
|
|
7905
8827
|
hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
|
|
7906
8828
|
});
|
|
7907
8829
|
if (retained) {
|
|
7908
8830
|
conv.listed = true;
|
|
8831
|
+
if (conv.promptInFlight && !conv.session.isStreaming) {
|
|
8832
|
+
this.emit({
|
|
8833
|
+
type: "notice",
|
|
8834
|
+
level: "info",
|
|
8835
|
+
text: `「${conv.title}」的消息正在投递,已为你保留该对话`,
|
|
8836
|
+
textEn: `"${conv.title}" is still sending your message — kept in the running list`,
|
|
8837
|
+
});
|
|
8838
|
+
}
|
|
7909
8839
|
return null;
|
|
7910
8840
|
}
|
|
7911
8841
|
return conv;
|
|
@@ -7917,6 +8847,20 @@ export class ClientSession {
|
|
|
7917
8847
|
const conv = this.convs.get(id);
|
|
7918
8848
|
if (!conv || id === this.activeId)
|
|
7919
8849
|
return;
|
|
8850
|
+
// 正在投递的消息(附件构建 / 影子快照阶段)必须先打断:否则 prompt() 醒来
|
|
8851
|
+
// 后会把用户消息写进已销毁的 runtime,静默丢失。
|
|
8852
|
+
if (conv.activePromptAc) {
|
|
8853
|
+
conv.activePromptAc.abort();
|
|
8854
|
+
conv.activePromptAc = undefined;
|
|
8855
|
+
}
|
|
8856
|
+
// 角色轮等待者:对话被移出 → 等它的循环收到 gone(否则要等到超时)。
|
|
8857
|
+
const waiters = this.turnEndWaiters.get(id);
|
|
8858
|
+
if (waiters) {
|
|
8859
|
+
this.turnEndWaiters.delete(id);
|
|
8860
|
+
// 先摘表再逐个唤醒:回调里的自删不会弄脏迭代。
|
|
8861
|
+
for (const fn of waiters)
|
|
8862
|
+
fn("gone");
|
|
8863
|
+
}
|
|
7920
8864
|
// 对话真关闭(dismiss/释放)→ 放掉它的认领。过户不走这里(对话换个会话
|
|
7921
8865
|
// 继续,owner 不变,认领继续有效),所以只在此处释放。
|
|
7922
8866
|
try {
|
|
@@ -8310,6 +9254,7 @@ export class ClientSession {
|
|
|
8310
9254
|
return pq ? { hasQuestion: true, questionId: pq.id, questionTitle: pq.title } : {};
|
|
8311
9255
|
})(),
|
|
8312
9256
|
parentId: conv.parentId,
|
|
9257
|
+
...(conv.pinned ? { pinned: true } : {}),
|
|
8313
9258
|
...(conv.forkFrom ? { forkFrom: conv.forkFrom } : {}),
|
|
8314
9259
|
});
|
|
8315
9260
|
}
|
|
@@ -8355,30 +9300,97 @@ export class ClientSession {
|
|
|
8355
9300
|
* pushSessions() and searchSessions() share this fridge — opening the
|
|
8356
9301
|
* panel warms it, then every keystroke inside the TTL is free.
|
|
8357
9302
|
*/
|
|
8358
|
-
|
|
8359
|
-
static
|
|
8360
|
-
|
|
8361
|
-
|
|
8362
|
-
|
|
8363
|
-
|
|
8364
|
-
|
|
8365
|
-
|
|
9303
|
+
/** 最近项目列表缓存:跨客户端共享静态缓存,TTL 窗口内直接复用。 */
|
|
9304
|
+
static projectsCache = null;
|
|
9305
|
+
static PROJECTS_CACHE_TTL = 60_000;
|
|
9306
|
+
static projectsInFlight = null;
|
|
9307
|
+
/** 会话单文件 stat 缓存(path -> { mtime, size, info }):避免对未修改的会话文件重复读取与 JSON 解析。 */
|
|
9308
|
+
static sessionFileCache = new Map();
|
|
9309
|
+
/** 会话列表缓存(按 cwdKey 隔离,30s TTL)。 */
|
|
9310
|
+
static sessionInfosCache = new Map();
|
|
9311
|
+
static sessionInfosInFlight = new Map();
|
|
9312
|
+
static SESSION_INFO_CACHE_TTL = 30_000;
|
|
8366
9313
|
async loadSessionInfos() {
|
|
8367
9314
|
const now = Date.now();
|
|
8368
|
-
const
|
|
8369
|
-
|
|
9315
|
+
const cwdKey = normalizePathKey(this.cwd);
|
|
9316
|
+
const c = ClientSession.sessionInfosCache.get(cwdKey);
|
|
9317
|
+
if (c && now - c.at < ClientSession.SESSION_INFO_CACHE_TTL) {
|
|
8370
9318
|
return c.infos;
|
|
8371
9319
|
}
|
|
8372
|
-
const
|
|
8373
|
-
|
|
8374
|
-
|
|
9320
|
+
const inFlight = ClientSession.sessionInfosInFlight.get(cwdKey);
|
|
9321
|
+
if (inFlight)
|
|
9322
|
+
return inFlight;
|
|
9323
|
+
const run = (async () => {
|
|
9324
|
+
try {
|
|
9325
|
+
const sessionDir = piSessionsRoot()
|
|
9326
|
+
? resolve(piSessionsRoot())
|
|
9327
|
+
: SessionManager.create(this.cwd).getSessionDir();
|
|
9328
|
+
if (!existsSync(sessionDir))
|
|
9329
|
+
return [];
|
|
9330
|
+
const dirEntries = await fsPromises.readdir(sessionDir);
|
|
9331
|
+
const files = dirEntries.filter((f) => f.endsWith(".jsonl"));
|
|
9332
|
+
if (files.length === 0)
|
|
9333
|
+
return [];
|
|
9334
|
+
// Stat files to detect modified/new files (takes only ~8ms for 600+ files)
|
|
9335
|
+
const stats = await Promise.all(files.map(async (f) => {
|
|
9336
|
+
const fp = join(sessionDir, f);
|
|
9337
|
+
try {
|
|
9338
|
+
const st = await fsPromises.stat(fp);
|
|
9339
|
+
return { path: fp, mtime: st.mtimeMs, size: st.size };
|
|
9340
|
+
}
|
|
9341
|
+
catch {
|
|
9342
|
+
return null;
|
|
9343
|
+
}
|
|
9344
|
+
}));
|
|
9345
|
+
const validStats = stats.filter((s) => s !== null);
|
|
9346
|
+
validStats.sort((a, b) => b.mtime - a.mtime);
|
|
9347
|
+
const candidates = validStats.slice(0, 200);
|
|
9348
|
+
const results = await Promise.all(candidates.map(async (file) => {
|
|
9349
|
+
const cached = ClientSession.sessionFileCache.get(file.path);
|
|
9350
|
+
if (cached && cached.mtime === file.mtime && cached.size === file.size) {
|
|
9351
|
+
return cached.info;
|
|
9352
|
+
}
|
|
9353
|
+
const info = await parseSessionInfoFast(file.path, file.mtime);
|
|
9354
|
+
if (info) {
|
|
9355
|
+
ClientSession.sessionFileCache.set(file.path, {
|
|
9356
|
+
mtime: file.mtime,
|
|
9357
|
+
size: file.size,
|
|
9358
|
+
info,
|
|
9359
|
+
});
|
|
9360
|
+
}
|
|
9361
|
+
return info;
|
|
9362
|
+
}));
|
|
9363
|
+
const validInfos = results.filter((info) => info !== null);
|
|
9364
|
+
validInfos.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
9365
|
+
ClientSession.sessionInfosCache.set(cwdKey, { infos: validInfos, at: Date.now() });
|
|
9366
|
+
return validInfos;
|
|
9367
|
+
}
|
|
9368
|
+
catch {
|
|
9369
|
+
try {
|
|
9370
|
+
const fallback = await SessionManager.list(this.cwd, piSessionsRoot());
|
|
9371
|
+
ClientSession.sessionInfosCache.set(cwdKey, { infos: fallback, at: Date.now() });
|
|
9372
|
+
return fallback;
|
|
9373
|
+
}
|
|
9374
|
+
catch {
|
|
9375
|
+
return [];
|
|
9376
|
+
}
|
|
9377
|
+
}
|
|
9378
|
+
finally {
|
|
9379
|
+
ClientSession.sessionInfosInFlight.delete(cwdKey);
|
|
9380
|
+
}
|
|
9381
|
+
})();
|
|
9382
|
+
ClientSession.sessionInfosInFlight.set(cwdKey, run);
|
|
9383
|
+
return run;
|
|
8375
9384
|
}
|
|
8376
9385
|
/** Session files on disk changed (delete / new-transcript) — drop the brief
|
|
8377
9386
|
* TTL fridge so the NEXT listing re-reads the directory instead of serving
|
|
8378
9387
|
* the pre-mutation snapshot (delete-then-refresh commonly runs inside the
|
|
8379
9388
|
* window, which would re-push the just-removed session). */
|
|
8380
|
-
invalidateSessionInfos() {
|
|
8381
|
-
|
|
9389
|
+
invalidateSessionInfos(filePath) {
|
|
9390
|
+
ClientSession.sessionInfosCache.clear();
|
|
9391
|
+
if (filePath) {
|
|
9392
|
+
ClientSession.sessionFileCache.delete(resolve(filePath));
|
|
9393
|
+
}
|
|
8382
9394
|
}
|
|
8383
9395
|
/** Push the persisted session list to the client (client-requested). */
|
|
8384
9396
|
async refreshSessions() {
|
|
@@ -8482,7 +9494,7 @@ export class ClientSession {
|
|
|
8482
9494
|
}
|
|
8483
9495
|
if (holder) {
|
|
8484
9496
|
// Same source the history panel uses (refreshSessions): newest first.
|
|
8485
|
-
const infos = await
|
|
9497
|
+
const infos = await this.loadSessionInfos();
|
|
8486
9498
|
const next = infos
|
|
8487
9499
|
.filter((s) => resolve(s.path) !== abs)
|
|
8488
9500
|
.sort((a, b) => b.modified.getTime() - a.modified.getTime())[0];
|
|
@@ -8550,7 +9562,7 @@ export class ClientSession {
|
|
|
8550
9562
|
// Bust the brief session-info fridge: refreshSessions() below usually
|
|
8551
9563
|
// lands inside its 3s TTL and would otherwise re-serve a listing that
|
|
8552
9564
|
// still contains the deleted transcript.
|
|
8553
|
-
this.invalidateSessionInfos();
|
|
9565
|
+
this.invalidateSessionInfos(abs);
|
|
8554
9566
|
await this.refreshSessions();
|
|
8555
9567
|
}
|
|
8556
9568
|
catch (err) {
|
|
@@ -8583,7 +9595,7 @@ export class ClientSession {
|
|
|
8583
9595
|
const mgr = SessionManager.open(abs);
|
|
8584
9596
|
mgr.appendSessionInfo(trimmed);
|
|
8585
9597
|
this.setConversationTitleForFile(abs, trimmed);
|
|
8586
|
-
this.invalidateSessionInfos();
|
|
9598
|
+
this.invalidateSessionInfos(abs);
|
|
8587
9599
|
await this.refreshSessions();
|
|
8588
9600
|
}
|
|
8589
9601
|
catch (err) {
|
|
@@ -8595,6 +9607,55 @@ export class ClientSession {
|
|
|
8595
9607
|
});
|
|
8596
9608
|
}
|
|
8597
9609
|
}
|
|
9610
|
+
/** 钉住 / 取消钉住某个对话(左栏右键菜单)。钉住后切走也不从「运行的对话」
|
|
9611
|
+
* 释放(见 shouldRetainActive 的 pinned 判据,最高优先级);取消钉住不立即
|
|
9612
|
+
* 释放,下一次自然置换时按常规规则处理。*/
|
|
9613
|
+
async setConversationPinned(id, pinned) {
|
|
9614
|
+
const conv = this.convs.get(id);
|
|
9615
|
+
if (!conv) {
|
|
9616
|
+
this.emit({
|
|
9617
|
+
type: "notice",
|
|
9618
|
+
level: "warning",
|
|
9619
|
+
text: "该对话不存在或已关闭",
|
|
9620
|
+
textEn: "This conversation does not exist or is already closed",
|
|
9621
|
+
});
|
|
9622
|
+
return;
|
|
9623
|
+
}
|
|
9624
|
+
// 子代理本来就永久豁免置换(displaceActive 开头即返回),钉子对它们无意义。
|
|
9625
|
+
if (conv.isSubagent) {
|
|
9626
|
+
this.emit({
|
|
9627
|
+
type: "notice",
|
|
9628
|
+
level: "info",
|
|
9629
|
+
text: "子代理对话始终保留在运行列表,无需钉住",
|
|
9630
|
+
textEn: "Subagent chats always stay in the running list — no need to pin",
|
|
9631
|
+
});
|
|
9632
|
+
return;
|
|
9633
|
+
}
|
|
9634
|
+
if (pinned === !!conv.pinned) {
|
|
9635
|
+
this.emitConversations();
|
|
9636
|
+
return;
|
|
9637
|
+
}
|
|
9638
|
+
if (pinned) {
|
|
9639
|
+
conv.pinned = true;
|
|
9640
|
+
// 立即进入运行列表(含空白对话):listed 一旦置位,shownInRunningList 即放行。
|
|
9641
|
+
conv.listed = true;
|
|
9642
|
+
}
|
|
9643
|
+
else {
|
|
9644
|
+
delete conv.pinned;
|
|
9645
|
+
}
|
|
9646
|
+
this.emitConversations();
|
|
9647
|
+
this.flushSnapshot();
|
|
9648
|
+
this.emit({
|
|
9649
|
+
type: "notice",
|
|
9650
|
+
level: "info",
|
|
9651
|
+
text: pinned
|
|
9652
|
+
? `已钉住对话「${conv.title}」,切换其他对话不会将它移出运行列表`
|
|
9653
|
+
: `已取消钉住对话「${conv.title}」,下次切换离开时按常规规则处理`,
|
|
9654
|
+
textEn: pinned
|
|
9655
|
+
? `Pinned "${conv.title}" — switching away keeps it in the running list`
|
|
9656
|
+
: `Unpinned "${conv.title}" — it will be handled by the usual rules on the next switch`,
|
|
9657
|
+
});
|
|
9658
|
+
}
|
|
8598
9659
|
/** Rename a live conversation by id: retitle in memory AND persist a
|
|
8599
9660
|
* session_info entry to its transcript so History matches immediately. */
|
|
8600
9661
|
async renameConversation(id, name) {
|
|
@@ -8660,6 +9721,7 @@ export class ClientSession {
|
|
|
8660
9721
|
// Dismiss 口径:只看“用过”的用户终端(AI bash 不钉住,见上)。
|
|
8661
9722
|
openTerminals: conv.terminals.countUserBlockingLive(),
|
|
8662
9723
|
listed: false,
|
|
9724
|
+
promptInFlight: Boolean(conv.promptInFlight),
|
|
8663
9725
|
promptedSinceActive: false,
|
|
8664
9726
|
hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
|
|
8665
9727
|
hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
|
|
@@ -8842,6 +9904,7 @@ export class ClientSession {
|
|
|
8842
9904
|
openTerminals: 0,
|
|
8843
9905
|
listed: conv.listed,
|
|
8844
9906
|
promptedSinceActive: conv.promptedSinceActive,
|
|
9907
|
+
promptInFlight: Boolean(conv.promptInFlight),
|
|
8845
9908
|
hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
|
|
8846
9909
|
hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
|
|
8847
9910
|
})) {
|
|
@@ -9090,6 +10153,7 @@ export class ClientSession {
|
|
|
9090
10153
|
// Dismiss 口径:只看“用过”的用户终端(issue #181,AI bash 不钉住)。
|
|
9091
10154
|
openTerminals: conv.terminals.countUserBlockingLive(),
|
|
9092
10155
|
listed: false,
|
|
10156
|
+
promptInFlight: Boolean(conv.promptInFlight),
|
|
9093
10157
|
promptedSinceActive: false,
|
|
9094
10158
|
hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
|
|
9095
10159
|
hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
|
|
@@ -9234,8 +10298,8 @@ export class ClientSession {
|
|
|
9234
10298
|
this.emit({
|
|
9235
10299
|
type: "notice",
|
|
9236
10300
|
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.`,
|
|
10301
|
+
text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先移出不需要的对话(打开后离开不继续对话即移出;钉住的对话需先取消钉住)`,
|
|
10302
|
+
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
10303
|
});
|
|
9240
10304
|
return;
|
|
9241
10305
|
}
|
|
@@ -9324,6 +10388,21 @@ export class ClientSession {
|
|
|
9324
10388
|
// 兜底:getEntry 查整棵树
|
|
9325
10389
|
return found ?? conv.session.sessionManager.getEntry(messageId) ?? null;
|
|
9326
10390
|
}
|
|
10391
|
+
/**
|
|
10392
|
+
* 获取被某个压缩卡片折叠的历史消息(issue #398,按需惰性加载)。
|
|
10393
|
+
*/
|
|
10394
|
+
getCompactedMessages(compactionMessageId, targetConvId) {
|
|
10395
|
+
const targetConv = (targetConvId ? this.convs.get(targetConvId) : this.conv) ?? this.conv;
|
|
10396
|
+
const sm = targetConv.session.sessionManager;
|
|
10397
|
+
const result = getCompactedMessages(sm, compactionMessageId, (m) => this.uiMessageKey(targetConv, m).n);
|
|
10398
|
+
this.emit({
|
|
10399
|
+
type: "compacted_messages_result",
|
|
10400
|
+
compactionMessageId,
|
|
10401
|
+
conversationId: targetConv.id,
|
|
10402
|
+
messages: result.messages,
|
|
10403
|
+
error: result.error,
|
|
10404
|
+
});
|
|
10405
|
+
}
|
|
9327
10406
|
/**
|
|
9328
10407
|
* Fork a NEW branch conversation from a specific historical message position.
|
|
9329
10408
|
* Truncates the transcript before (or at) that message as the context of the
|
|
@@ -9420,8 +10499,8 @@ export class ClientSession {
|
|
|
9420
10499
|
this.emit({
|
|
9421
10500
|
type: "notice",
|
|
9422
10501
|
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.`,
|
|
10502
|
+
text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先移出不需要的对话(打开后离开不继续对话即移出;钉住的对话需先取消钉住)`,
|
|
10503
|
+
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
10504
|
});
|
|
9426
10505
|
return;
|
|
9427
10506
|
}
|
|
@@ -9681,16 +10760,16 @@ export class ClientSession {
|
|
|
9681
10760
|
*/
|
|
9682
10761
|
async pushProjects() {
|
|
9683
10762
|
const now = Date.now();
|
|
9684
|
-
const cached =
|
|
10763
|
+
const cached = ClientSession.projectsCache;
|
|
9685
10764
|
// TTL 命中:直接复用(把当前 cwd 合并进去,刚 remember 的新项目也可见)。
|
|
9686
10765
|
if (cached && now - cached.at < ClientSession.PROJECTS_CACHE_TTL) {
|
|
9687
10766
|
this.emit({ type: "projects", projects: this.withCurrentCwd(cached.projects, now) });
|
|
9688
10767
|
return;
|
|
9689
10768
|
}
|
|
9690
10769
|
// 已有扫描在跑:搭车等它,不要并发扫两遍盘。
|
|
9691
|
-
if (
|
|
10770
|
+
if (ClientSession.projectsInFlight) {
|
|
9692
10771
|
try {
|
|
9693
|
-
const projects = await
|
|
10772
|
+
const projects = await ClientSession.projectsInFlight;
|
|
9694
10773
|
if (projects)
|
|
9695
10774
|
this.emit({ type: "projects", projects: this.withCurrentCwd(projects, Date.now()) });
|
|
9696
10775
|
}
|
|
@@ -9699,43 +10778,31 @@ export class ClientSession {
|
|
|
9699
10778
|
}
|
|
9700
10779
|
return;
|
|
9701
10780
|
}
|
|
10781
|
+
// 执行轻量磁盘发现(仅读会话文件首行头部获取 cwd,不解析全部消息历史)
|
|
10782
|
+
const sessionRoots = [resolve(this.agentDir, "sessions")];
|
|
10783
|
+
const extra = piSessionsRoot();
|
|
10784
|
+
if (extra)
|
|
10785
|
+
sessionRoots.push(resolve(extra));
|
|
9702
10786
|
const run = (async () => {
|
|
9703
10787
|
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;
|
|
10788
|
+
const discovered = await discoverRecentProjectsFromDisk(sessionRoots, 30);
|
|
10789
|
+
this.stateStore.mergeDiscoveredProjects(discovered);
|
|
10790
|
+
const merged = this.stateStore.getRecentProjects(this.clientId);
|
|
10791
|
+
ClientSession.projectsCache = { at: Date.now(), projects: merged };
|
|
10792
|
+
const result = this.withCurrentCwd(merged, Date.now());
|
|
10793
|
+
this.emit({ type: "projects", projects: result });
|
|
10794
|
+
return merged;
|
|
9729
10795
|
}
|
|
9730
10796
|
catch {
|
|
9731
|
-
this.
|
|
9732
|
-
|
|
10797
|
+
const fallback = this.withCurrentCwd(this.stateStore.getRecentProjects(this.clientId), Date.now());
|
|
10798
|
+
this.emit({ type: "projects", projects: fallback });
|
|
10799
|
+
return fallback;
|
|
9733
10800
|
}
|
|
9734
10801
|
finally {
|
|
9735
|
-
|
|
10802
|
+
ClientSession.projectsInFlight = null;
|
|
9736
10803
|
}
|
|
9737
10804
|
})();
|
|
9738
|
-
|
|
10805
|
+
ClientSession.projectsInFlight = run;
|
|
9739
10806
|
await run;
|
|
9740
10807
|
}
|
|
9741
10808
|
/** 缓存命中时把当前 cwd 并进去:命中则刷新 lastUsed 重排,未命中则补到首位
|
|
@@ -9756,7 +10823,7 @@ export class ClientSession {
|
|
|
9756
10823
|
}
|
|
9757
10824
|
/** 最近项目缓存失效(用户显式移除项目后,下一次推送必须重扫)。 */
|
|
9758
10825
|
invalidateProjectsCache() {
|
|
9759
|
-
|
|
10826
|
+
ClientSession.projectsCache = null;
|
|
9760
10827
|
}
|
|
9761
10828
|
/** List a workspace directory (relative to the configured cwd). */
|
|
9762
10829
|
async listFiles(relPath) {
|
|
@@ -10493,8 +11560,13 @@ export function checkPluginCwd(cwd) {
|
|
|
10493
11560
|
}
|
|
10494
11561
|
return { ok: true, abs };
|
|
10495
11562
|
}
|
|
11563
|
+
/** 插件/调度伪客户端:sink 常驻(fire-and-forget 的空函数),不能按浏览器存活判断。 */
|
|
11564
|
+
export function isPseudoClientId(id) {
|
|
11565
|
+
return id.startsWith("plugin:") || id.startsWith("scheduler:");
|
|
11566
|
+
}
|
|
10496
11567
|
export class AgentService {
|
|
10497
11568
|
cwd;
|
|
11569
|
+
static isPseudoClientId = isPseudoClientId;
|
|
10498
11570
|
/** index.ts 注入:SDK 工具执行事件的插件转发钩子,attach 时拷贝到每个新会话。 */
|
|
10499
11571
|
onToolEvent = undefined;
|
|
10500
11572
|
/** index.ts 注入:bash/read 插件拦截钩子,attach 时拷贝到每个新会话。 */
|
|
@@ -10808,10 +11880,16 @@ export class AgentService {
|
|
|
10808
11880
|
continue;
|
|
10809
11881
|
// issue #291:跳过无浏览器连接的残骸(非伪客户端 sinkCount=0 = 断连留存)。
|
|
10810
11882
|
// 伪客户端(scheduler:/plugin:)不走浏览器,sinkCount 永远为 0,保留。
|
|
10811
|
-
|
|
11883
|
+
const isPseudo = AgentService.isPseudoClientId(clientId);
|
|
11884
|
+
if (!isPseudo && cs.sinkCount() === 0)
|
|
10812
11885
|
continue;
|
|
10813
|
-
for (const r of cs.streamingSummariesAll())
|
|
10814
|
-
out.push({
|
|
11886
|
+
for (const r of cs.streamingSummariesAll()) {
|
|
11887
|
+
out.push({
|
|
11888
|
+
...r,
|
|
11889
|
+
owner: clientId,
|
|
11890
|
+
...(isPseudo ? { pseudo: true } : {}),
|
|
11891
|
+
});
|
|
11892
|
+
}
|
|
10815
11893
|
}
|
|
10816
11894
|
return out;
|
|
10817
11895
|
}
|
|
@@ -11086,10 +12164,7 @@ export class AgentService {
|
|
|
11086
12164
|
service: toServiceInfo(launchOrigin()),
|
|
11087
12165
|
};
|
|
11088
12166
|
}
|
|
11089
|
-
|
|
11090
|
-
static isPseudoClientId(id) {
|
|
11091
|
-
return id.startsWith("plugin:") || id.startsWith("scheduler:");
|
|
11092
|
-
}
|
|
12167
|
+
// (原 private static isPseudoClientId 已抽到模块级导出并挂为 static 属性)
|
|
11093
12168
|
/**
|
|
11094
12169
|
* 浏览器重启认领:给 fresh clientId 找一个可接管的断开残留会话(返回旧 id)。
|
|
11095
12170
|
* 有别的在线浏览器时返回 null(issue #10 隔离优先)。
|
|
@@ -11186,7 +12261,7 @@ export class AgentService {
|
|
|
11186
12261
|
return;
|
|
11187
12262
|
}
|
|
11188
12263
|
if (AgentService.isPseudoClientId(ownerId)) {
|
|
11189
|
-
fail("定时任务/插件会话不支持过户", "Scheduler/plugin sessions cannot be taken over.");
|
|
12264
|
+
fail("定时任务/插件会话不支持过户 —— 报告会自动落回绑定的对话,可在后台任务面板查看进度", "Scheduler/plugin sessions cannot be taken over — reports are saved to the bound conversation automatically; check progress in the Background Tasks panel.");
|
|
11190
12265
|
return;
|
|
11191
12266
|
}
|
|
11192
12267
|
const source = this.clients.get(ownerId);
|
|
@@ -11212,7 +12287,7 @@ export class AgentService {
|
|
|
11212
12287
|
const movedMains = briefs.filter((b) => moveSet.has(b.id) && !b.isSubagent && !b.isEphemeral).length;
|
|
11213
12288
|
const openInProject = target.takeoverBriefs().filter((b) => b.cwd === main.cwd && !b.isSubagent && !b.isEphemeral).length + movedMains;
|
|
11214
12289
|
if (openInProject > MAX_OPEN_CONVERSATIONS) {
|
|
11215
|
-
fail(`目标项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS}
|
|
12290
|
+
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
12291
|
return;
|
|
11217
12292
|
}
|
|
11218
12293
|
try {
|
|
@@ -11266,7 +12341,7 @@ export class AgentService {
|
|
|
11266
12341
|
if (ownerId === targetId)
|
|
11267
12342
|
return; // 自己的问卷走本地通道,不需要预告
|
|
11268
12343
|
if (AgentService.isPseudoClientId(ownerId)) {
|
|
11269
|
-
fail("定时任务/插件会话的问卷不支持跨页作答", "Scheduler/plugin session questions cannot be answered cross-page.");
|
|
12344
|
+
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
12345
|
return;
|
|
11271
12346
|
}
|
|
11272
12347
|
const source = this.clients.get(ownerId);
|