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.
Files changed (42) hide show
  1. package/CHANGELOG.md +69 -2
  2. package/README.md +26 -0
  3. package/README.zh-CN.md +25 -0
  4. package/bin/pi-web-ui.mjs +11 -2
  5. package/dist/server/agent-service.js +1195 -120
  6. package/dist/server/client-state.js +89 -1
  7. package/dist/server/compacted-history.js +146 -0
  8. package/dist/server/delegate-mode.js +149 -0
  9. package/dist/server/delegate-task.js +10 -10
  10. package/dist/server/dsh/dsh-agent-service.js +35 -2
  11. package/dist/server/dsh/dsh-client.js +3 -1
  12. package/dist/server/eval-tool.js +3 -1
  13. package/dist/server/goal-review-gate.js +263 -0
  14. package/dist/server/goal-service.js +1225 -445
  15. package/dist/server/index.js +79 -20
  16. package/dist/server/lsp-tool.js +13 -10
  17. package/dist/server/markers/builtins/todo.js +0 -2
  18. package/dist/server/mcp-bridge.js +13 -0
  19. package/dist/server/patch-tool.js +1 -0
  20. package/dist/server/patch-turn-end-boundary.js +77 -0
  21. package/dist/server/permission-preset.js +44 -0
  22. package/dist/server/plan-mode.js +376 -0
  23. package/dist/server/plugin-facilities.js +36 -3
  24. package/dist/server/plugin-installer.js +30 -0
  25. package/dist/server/plugins.js +6 -3
  26. package/dist/server/protocol-version.js +1 -1
  27. package/dist/server/serialize.js +11 -0
  28. package/dist/server/settings-service.js +20 -8
  29. package/dist/server/subagents.js +360 -408
  30. package/dist/server/text-sniff.js +15 -9
  31. package/dist/server/tool-manager.js +48 -4
  32. package/dist/server/update-check.js +11 -3
  33. package/dist/server/wait-subscription-scan.js +5 -1
  34. package/package.json +3 -3
  35. package/themes/zhupi-dark.css +79 -44
  36. package/themes/zhupi.css +79 -44
  37. package/web/dist/assets/{TerminalPanel-DDChcYOh.js → TerminalPanel-CITBqQrb.js} +1 -1
  38. package/web/dist/assets/index-DwToli0J.css +1 -0
  39. package/web/dist/assets/index-oI7UKOqJ.js +368 -0
  40. package/web/dist/index.html +2 -2
  41. package/web/dist/assets/index-Do9RgJC3.js +0 -366
  42. package/web/dist/assets/index-DryOsILO.css +0 -1
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * 从 agent-service.ts 抽出,行为保持不变。
8
8
  */
9
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
10
  import { dirname, isAbsolute, resolve } from "node:path";
11
11
  import { normalizeSoftCapByModel, normalizeSoftCapTokens } from "./soft-cap.js";
12
12
  import { deriveLegacy, legacyToDisabled, normalizeDisabledAgentTools } from "./tool-manager.js";
@@ -353,6 +353,12 @@ export class ClientStateStore {
353
353
  { path: cwd, lastUsed: now },
354
354
  ...state.projects.filter((p) => normalizePathKey(p.path) !== targetKey),
355
355
  ].slice(0, 30);
356
+ // Also persist in global settings so new tabs and sessions inherit it immediately
357
+ const globalState = (all[ClientStateStore.GLOBAL_SETTINGS_KEY] ??= { projects: [] });
358
+ globalState.projects = [
359
+ { path: cwd, lastUsed: now },
360
+ ...(globalState.projects ?? []).filter((p) => normalizePathKey(p.path) !== targetKey),
361
+ ].slice(0, 50);
356
362
  // Opening the workspace again clears its removal tombstone across all clients and global settings.
357
363
  for (const cState of Object.values(all)) {
358
364
  if (cState.removedProjects?.length) {
@@ -409,6 +415,82 @@ export class ClientStateStore {
409
415
  }
410
416
  return result;
411
417
  }
418
+ /**
419
+ * Get recent projects merged across the client's own history, global settings,
420
+ * and other clients in this store, filtering out tombstoned paths and non-existent paths.
421
+ */
422
+ getRecentProjects(clientId) {
423
+ const all = this.load();
424
+ const removedKeys = new Set(this.getRemovedProjects(clientId).map(normalizePathKey));
425
+ const map = new Map();
426
+ const merge = (list) => {
427
+ if (!list)
428
+ return;
429
+ for (const p of list) {
430
+ if (!p?.path)
431
+ continue;
432
+ const key = normalizePathKey(p.path);
433
+ if (removedKeys.has(key))
434
+ continue;
435
+ const existing = map.get(key);
436
+ if (!existing || p.lastUsed > existing.lastUsed) {
437
+ map.set(key, { path: p.path, lastUsed: p.lastUsed });
438
+ }
439
+ }
440
+ };
441
+ // Merge client's own projects first, then global settings, then all other clients
442
+ merge(all[clientId]?.projects);
443
+ merge(all[ClientStateStore.GLOBAL_SETTINGS_KEY]?.projects);
444
+ for (const [id, cState] of Object.entries(all)) {
445
+ if (id !== clientId && id !== ClientStateStore.GLOBAL_SETTINGS_KEY) {
446
+ merge(cState.projects);
447
+ }
448
+ }
449
+ return [...map.values()]
450
+ .filter((p) => {
451
+ try {
452
+ return existsSync(p.path);
453
+ }
454
+ catch {
455
+ return false;
456
+ }
457
+ })
458
+ .sort((a, b) => b.lastUsed - a.lastUsed)
459
+ .slice(0, 30);
460
+ }
461
+ /** Record discovered projects into global settings cache (without clobbering tombstones). */
462
+ mergeDiscoveredProjects(projects) {
463
+ if (!projects.length)
464
+ return;
465
+ const all = this.load();
466
+ const globalState = (all[ClientStateStore.GLOBAL_SETTINGS_KEY] ??= { projects: [] });
467
+ const removedKeys = new Set((globalState.removedProjects ?? []).map(normalizePathKey));
468
+ const map = new Map();
469
+ for (const p of globalState.projects ?? []) {
470
+ const key = normalizePathKey(p.path);
471
+ if (!removedKeys.has(key))
472
+ map.set(key, p);
473
+ }
474
+ let changed = false;
475
+ for (const p of projects) {
476
+ const key = normalizePathKey(p.path);
477
+ if (removedKeys.has(key))
478
+ continue;
479
+ const existing = map.get(key);
480
+ if (!existing) {
481
+ map.set(key, p);
482
+ changed = true;
483
+ }
484
+ else if (p.lastUsed > existing.lastUsed) {
485
+ existing.lastUsed = p.lastUsed;
486
+ changed = true;
487
+ }
488
+ }
489
+ if (changed) {
490
+ globalState.projects = [...map.values()].sort((a, b) => b.lastUsed - a.lastUsed).slice(0, 50);
491
+ this.save();
492
+ }
493
+ }
412
494
  /** Last-used goal/review prefs for a client, or undefined if never set. */
413
495
  getGoalPrefs(clientId) {
414
496
  const s = this.load()[clientId];
@@ -418,6 +500,7 @@ export class ClientStateStore {
418
500
  reviewModel: s.goalPrefs.reviewModel ?? null,
419
501
  maxRounds: s.goalPrefs.maxRounds ?? 0,
420
502
  locked: s.goalPrefs.locked ?? true,
503
+ execModel: s.goalPrefs.execModel ?? null,
421
504
  };
422
505
  }
423
506
  /** 某项目当前的额外工作区根(空数组 = 单根)。 */
@@ -461,6 +544,7 @@ export class ClientStateStore {
461
544
  reviewModel: prefs?.reviewModel ?? null,
462
545
  maxRounds: prefs?.maxRounds ?? 0,
463
546
  locked: prefs?.locked ?? true,
547
+ execModel: prefs?.execModel ?? null,
464
548
  };
465
549
  this.save();
466
550
  }
@@ -542,6 +626,8 @@ export class ClientStateStore {
542
626
  visionBridgePrompt: stored?.visionBridgePrompt ?? "",
543
627
  scmCommitMsgPromptMode: stored?.scmCommitMsgPromptMode === "replace" ? "replace" : "append",
544
628
  scmCommitMsgPrompt: stored?.scmCommitMsgPrompt ?? "",
629
+ planModePromptMode: stored?.planModePromptMode === "replace" ? "replace" : "append",
630
+ planModePrompt: stored?.planModePrompt ?? "",
545
631
  subagentDefaultModel: stored?.subagentDefaultModel ?? null,
546
632
  retryMaxAttempts: normalizeRetryMaxAttempts(stored?.retryMaxAttempts),
547
633
  softCapTokens: normalizeSoftCapTokens(stored?.softCapTokens),
@@ -606,6 +692,8 @@ export class ClientStateStore {
606
692
  visionBridgePrompt: settings.visionBridgePrompt ?? cur.visionBridgePrompt ?? "",
607
693
  scmCommitMsgPromptMode: settings.scmCommitMsgPromptMode ?? cur.scmCommitMsgPromptMode ?? "append",
608
694
  scmCommitMsgPrompt: settings.scmCommitMsgPrompt ?? cur.scmCommitMsgPrompt ?? "",
695
+ planModePromptMode: settings.planModePromptMode ?? cur.planModePromptMode ?? "append",
696
+ planModePrompt: settings.planModePrompt ?? cur.planModePrompt ?? "",
609
697
  reviewPrompt: settings.reviewPrompt ?? cur.reviewPrompt ?? "",
610
698
  reviewDisabledSkills: settings.reviewDisabledSkills ?? cur.reviewDisabledSkills ?? [],
611
699
  disabledPlugins: settings.disabledPlugins ?? cur.disabledPlugins ?? [],
@@ -0,0 +1,146 @@
1
+ /**
2
+ * compacted-history.ts — 提取被上下文压缩折叠的历史消息(issue #398)。
3
+ *
4
+ * 核心原则:严格解耦「UI 展示流」与「LLM 推理视窗」。
5
+ * 当用户在聊天界面点击压缩卡片(CompactionCard)上的「展开查看被折叠的历史」时,
6
+ * 本模块根据 compactionMessageId 纯只读地从会话 DAG 祖先链中还原被该节点折叠的
7
+ * 历史消息,转换为 UiMessage[] 供前端展示,绝不重新送入后续大模型推理。
8
+ */
9
+ import { findEntryByUiId, serializeMessage } from "./serialize.js";
10
+ /**
11
+ * 在任意 entries 集合中按 parentId 回溯构造从根到 targetId 的祖先链。
12
+ * 纯函数,防循环引用,用于 getBranch 不可用或单测环境下的兜底。
13
+ */
14
+ export function walkAncestors(targetId, allEntries) {
15
+ const byId = new Map();
16
+ for (const e of allEntries)
17
+ byId.set(e.id, e);
18
+ const path = [];
19
+ let curId = targetId;
20
+ const visited = new Set();
21
+ while (curId && byId.has(curId) && !visited.has(curId)) {
22
+ visited.add(curId);
23
+ const entry = byId.get(curId);
24
+ path.push(entry);
25
+ curId = entry.parentId;
26
+ }
27
+ return path.reverse();
28
+ }
29
+ /**
30
+ * 根据 Compaction 节点切分出被该节点折叠的历史 entries。
31
+ * 纯函数。
32
+ *
33
+ * 切分规则(遵循 Pi 内核 CompactionEntry 规范):
34
+ * - 结束边界(keptIndex):当前 compaction 的 firstKeptEntryId 所在位置。
35
+ * 若为 retain-none(firstKeptEntryId === id),则结束边界为压缩节点自身。
36
+ * - 起始边界(startIndex):若前序存在更早的 compaction 节点,则从其 firstKeptEntryId
37
+ * (或前序节点之后)开始,避免与前一张压缩卡片折叠的内容重叠;若无前序,则从 0 开始。
38
+ */
39
+ export function sliceCompactedEntries(targetCompaction, branch) {
40
+ const targetIdx = branch.findIndex((e) => e.id === targetCompaction.id);
41
+ if (targetIdx === -1)
42
+ return [];
43
+ // 1. 确定保留起点边界 keptIdx
44
+ let keptIdx = targetIdx;
45
+ const firstKeptId = targetCompaction.firstKeptEntryId;
46
+ if (firstKeptId && firstKeptId !== targetCompaction.id) {
47
+ const found = branch.findIndex((e) => e.id === firstKeptId);
48
+ if (found >= 0 && found <= targetIdx) {
49
+ keptIdx = found;
50
+ }
51
+ }
52
+ // 2. 确定前序压缩起始点 startIndex
53
+ let startIdx = 0;
54
+ for (let i = targetIdx - 1; i >= 0; i--) {
55
+ if (branch[i].type === "compaction") {
56
+ const prevComp = branch[i];
57
+ const prevFirstKeptId = prevComp.firstKeptEntryId;
58
+ if (prevFirstKeptId && prevFirstKeptId !== prevComp.id) {
59
+ const prevKeptIdx = branch.findIndex((e) => e.id === prevFirstKeptId);
60
+ if (prevKeptIdx >= 0 && prevKeptIdx < targetIdx) {
61
+ startIdx = prevKeptIdx;
62
+ break;
63
+ }
64
+ }
65
+ startIdx = i + 1;
66
+ break;
67
+ }
68
+ }
69
+ if (startIdx >= keptIdx)
70
+ return [];
71
+ return branch.slice(startIdx, keptIdx);
72
+ }
73
+ /**
74
+ * 将被折叠的原始 entries 转换为只读 UiMessage[] 列表。
75
+ * 纯函数。
76
+ */
77
+ export function serializeCompactedEntries(entries) {
78
+ const result = [];
79
+ let seq = 1;
80
+ for (const entry of entries) {
81
+ if (entry.type === "message" && entry.message) {
82
+ if (entry.message.role === "system")
83
+ continue;
84
+ const ui = serializeMessage(entry.message, seq++);
85
+ if (ui)
86
+ result.push(ui);
87
+ }
88
+ else if (entry.type === "custom_message" && entry.display !== false) {
89
+ const m = {
90
+ role: "custom",
91
+ content: entry.content,
92
+ timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : 0,
93
+ };
94
+ const ui = serializeMessage(m, seq++);
95
+ if (ui)
96
+ result.push(ui);
97
+ }
98
+ }
99
+ return result;
100
+ }
101
+ /**
102
+ * 根据前端 compactionMessageId 提取被折叠的历史消息(综合入口)。
103
+ */
104
+ export function getCompactedMessages(sessionManager, compactionMessageId, seqOf) {
105
+ try {
106
+ const contextEntries = sessionManager.buildContextEntries?.() ?? [];
107
+ const allEntries = sessionManager.getEntries?.() ?? contextEntries;
108
+ // 1. 定位目标 compaction 条目
109
+ const defaultSeqOf = () => 1;
110
+ let targetCompaction = findEntryByUiId(contextEntries, compactionMessageId, seqOf ?? defaultSeqOf);
111
+ if (!targetCompaction) {
112
+ targetCompaction = findEntryByUiId(allEntries, compactionMessageId, seqOf ?? defaultSeqOf);
113
+ }
114
+ if (!targetCompaction || targetCompaction.type !== "compaction") {
115
+ // 直接以 raw id 查
116
+ targetCompaction =
117
+ sessionManager.getEntry?.(compactionMessageId) ??
118
+ allEntries.find((e) => e.id === compactionMessageId && e.type === "compaction") ??
119
+ null;
120
+ }
121
+ if (!targetCompaction || targetCompaction.type !== "compaction") {
122
+ return { messages: [], error: "Compaction entry not found" };
123
+ }
124
+ // 2. 获取该节点的分支祖先链
125
+ let branch = [];
126
+ if (typeof sessionManager.getBranch === "function") {
127
+ try {
128
+ branch = sessionManager.getBranch(targetCompaction.id) ?? [];
129
+ }
130
+ catch {
131
+ branch = [];
132
+ }
133
+ }
134
+ if (branch.length === 0) {
135
+ branch = walkAncestors(targetCompaction.id, allEntries);
136
+ }
137
+ // 3. 提取折叠区间并序列化
138
+ const foldedEntries = sliceCompactedEntries(targetCompaction, branch);
139
+ const messages = serializeCompactedEntries(foldedEntries);
140
+ return { messages };
141
+ }
142
+ catch (err) {
143
+ const msg = err instanceof Error ? err.message : String(err);
144
+ return { messages: [], error: msg };
145
+ }
146
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * server/delegate-mode.ts
3
+ *
4
+ * 审查者模式(自动委派 / delegate mode):主对话**只审阅不施工**,活由服务端
5
+ * 派给一个**常驻落盘执行对话**(每会话一个,跨轮复用)。
6
+ *
7
+ * 形态:会话级开关(`set_delegate_mode`)→ 开启后:
8
+ * 1. 自动路由:用户在主对话发的每条 prompt,服务端直接转给执行对话执行
9
+ * (`ClientSession.dispatchToDelegate`),主对话这一轮**不跑模型**;
10
+ * 2. 纯委派闸门:主对话的写类工具 / 非常规 bash / 派发类工具一律拒
11
+ * —— 即便模型自己想动手也过不去(与计划模式同一套 `withXGate` 骨架)。
12
+ *
13
+ * 与计划模式的关系:**互斥优先级**。计划模式开着的时候不自动派活(计划模式已经
14
+ * 把 spawn/旁路工具全拒了,再自动派活就是死锁),由计划模式那一轮说了算。
15
+ *
16
+ * 判定全部是纯函数(无 IO),单测见 tests/unit/delegate-mode.test.ts。
17
+ */
18
+ import { bashCommandIsReadOnly } from "./plan-mode.js";
19
+ /** 审查者模式下确定禁止的工具:与计划模式同名单(写 / 改 / 增删 / git 写 / 形态变换)。 */
20
+ const BLOCKED_TOOLS = new Set([
21
+ "write",
22
+ "create",
23
+ "create_file",
24
+ "write_file",
25
+ "edit",
26
+ "edit_file",
27
+ "edit_soft",
28
+ "apply_patch",
29
+ "patch",
30
+ "patch_tool",
31
+ "str_replace",
32
+ "str_replace_editor",
33
+ "multi_edit",
34
+ "notebook_edit",
35
+ "mv",
36
+ "move",
37
+ "cp",
38
+ "copy",
39
+ "mkdir",
40
+ "rm",
41
+ "rmdir",
42
+ "delete",
43
+ "delete_file",
44
+ "touch",
45
+ "chmod",
46
+ "chown",
47
+ "truncate",
48
+ "rename",
49
+ "git",
50
+ "format",
51
+ "prettier",
52
+ "lint_fix",
53
+ "autofix",
54
+ ]);
55
+ /**
56
+ * 审查者模式下也禁掉的「派发类」工具:派活是**服务端**的活(自动路由),
57
+ * 模型再自己 spawn 一个只会绕开「一个常驻执行对话」这条语义线(多份上下文、
58
+ * 多份配额、左栏一堆对话),而且落盘对话占项目会话名额。
59
+ * 与计划模式禁它的理由不同:那边是「别绕开只读约束」,这边是「别绕开统一派活」。
60
+ */
61
+ const DISPATCH_TOOLS = new Set([
62
+ "spawn",
63
+ "subagent_spawn",
64
+ "subagent",
65
+ "delegate_task",
66
+ "spawn_subagent",
67
+ "schedule",
68
+ "set_goal",
69
+ "start_goal_wizard",
70
+ "set_plan_mode",
71
+ ]);
72
+ /** bash 家族(要逐条判命令是否只读)。 */
73
+ const BASH_TOOLS = new Set(["bash", "shell", "sh", "zsh", "terminal", "terminal_bash", "run_command", "exec"]);
74
+ /** 从工具参数里取命令文本(各工具字段名不同)。 */
75
+ function commandOf(params) {
76
+ if (!params || typeof params !== "object")
77
+ return "";
78
+ const p = params;
79
+ for (const key of ["command", "cmd", "script", "input"]) {
80
+ const v = p[key];
81
+ if (typeof v === "string" && v.trim() !== "")
82
+ return v;
83
+ }
84
+ return "";
85
+ }
86
+ /**
87
+ * 审查者模式闸门(纯函数):命中即拒绝,并把「该干什么」告诉模型 ——
88
+ * 提示词已经说明规则,这里再把可操作的下一步重复一遍(长任务里模型最容易
89
+ * 「顺手改一下」,拒绝理由就是它唯一的纠偏机会)。
90
+ */
91
+ export function delegationDenial(toolName, params) {
92
+ const name = (toolName ?? "").trim();
93
+ if (!name)
94
+ return undefined;
95
+ if (DISPATCH_TOOLS.has(name)) {
96
+ return {
97
+ kind: "dispatch-tool",
98
+ reason: `审查者模式:不能调用 ${name} —— 派活由服务端统一做(一个常驻执行对话)。请只描述需求、审阅结果或提问。`,
99
+ reasonEn: `Reviewer mode: ${name} is unavailable — dispatching is done by the server (one persistent executor conversation). Describe the work, review the result, or ask questions.`,
100
+ };
101
+ }
102
+ if (BASH_TOOLS.has(name)) {
103
+ const cmd = commandOf(params);
104
+ if (cmd.trim() === "") {
105
+ return {
106
+ kind: "bash",
107
+ reason: "审查者模式:终端不可用 —— 施工由执行对话做。请用 read/grep/只读 git 看结果。",
108
+ reasonEn: "Reviewer mode: the terminal is unavailable — the executor conversation does the work. Inspect with read/grep/read-only git.",
109
+ };
110
+ }
111
+ if (bashCommandIsReadOnly(cmd))
112
+ return undefined;
113
+ return {
114
+ kind: "bash",
115
+ reason: `审查者模式:只允许只读命令,命令被拒:\`${cmd.slice(0, 200)}\`。施工请交给执行对话;你要做的是看结果、判断是否达标。`,
116
+ reasonEn: `Reviewer mode: only read-only commands are allowed; rejected: \`${cmd.slice(0, 200)}\`. The executor conversation makes the changes — your job is to inspect the result and judge whether it meets the goal.`,
117
+ };
118
+ }
119
+ if (BLOCKED_TOOLS.has(name)) {
120
+ return {
121
+ kind: "write-tool",
122
+ reason: `审查者模式:本对话不施工,${name} 被拒。需求已由服务端转给执行对话;请改为审阅它交回来的结果(读 diff/测试输出)并提出下一轮要求。`,
123
+ reasonEn: `Reviewer mode: this conversation does not make changes — ${name} was rejected. The request was forwarded to the executor conversation; review its result (read the diff, test output) and state what the next round should do.`,
124
+ };
125
+ }
126
+ return undefined;
127
+ }
128
+ /** 审查者模式系统提示词段(英文:进入模型上下文,按工具提示词卫生口径只写英文)。 */
129
+ export const DELEGATION_SYSTEM_PROMPT = [
130
+ "# Reviewer mode (you review; a server-side executor does the work)",
131
+ "Reviewer mode is on for this conversation. The server forwards every user request to ONE persistent executor conversation, and that conversation is the only one allowed to change anything.",
132
+ "Hard rules:",
133
+ "- Never try to change files, run write commands, or dispatch other agents: those tools are blocked by the server and the rejection comes back as a tool error. Do not retry them.",
134
+ "- Your job per turn: state what you expect the executor to deliver (constraints, acceptance criteria, files/paths in scope), then when a result comes back, verify it — read the diff, run read-only commands, check the tests it claims to have run — and either accept it or state precise follow-up requirements for the next round.",
135
+ "- Keep your own messages short and decision-oriented. The executor keeps the working context; you are the reviewer, not the worker.",
136
+ "- If plan mode is also on, plan mode wins: no dispatch happens and you produce the plan yourself.",
137
+ ].join("\n");
138
+ /** 拒给用户的界面文案(notice,zh + en 成对)。 */
139
+ export function delegateNoticeText(enabled) {
140
+ return enabled
141
+ ? {
142
+ text: "🔎 审查者模式:本对话只审阅,你的每条请求已由服务端转给常驻执行对话执行",
143
+ textEn: "🔎 Reviewer mode on: this conversation only reviews; the server forwards your requests to a persistent executor conversation",
144
+ }
145
+ : {
146
+ text: "审查者模式已关闭:恢复正常对话(本对话可以直接施工)",
147
+ textEn: "Reviewer mode off: back to a normal conversation (this one can make changes again)",
148
+ };
149
+ }
@@ -51,7 +51,7 @@ export function normalizeDelegation(params) {
51
51
  export function validateDelegation(input, usableNames, lang = "en") {
52
52
  if (!input.agent || !usableNames.includes(input.agent)) {
53
53
  const shown = usableNames.slice(0, 12).join(" · ") + (usableNames.length > 12 ? " · …" : "");
54
- return pick(lang, `派单被驳回:模板 "${input.agent || "(空)"}" 不可用(不存在或已停用)。可用模板:${shown || "(无)"}。用 subagent_templates 查简介再选;不要编造模板名。`, `Delegation rejected: template "${input.agent || "(empty)"}" is unavailable (missing or disabled). Available templates: ${shown || "(none)"}. Use subagent_templates for descriptions; do not invent template names.`, "delegate.validate.agent", { agent: input.agent, available: shown });
54
+ return pick(lang, `派单被驳回:模板 "${input.agent || "(空)"}" 不可用(不存在或已停用)。可用模板:${shown || "(无)"}。用 subagent(action="templates") 查简介再选;不要编造模板名。`, `Delegation rejected: template "${input.agent || "(empty)"}" is unavailable (missing or disabled). Available templates: ${shown || "(none)"}. Use subagent(action="templates") for descriptions; do not invent template names.`, "delegate.validate.agent", { agent: input.agent, available: shown });
55
55
  }
56
56
  const checks = [
57
57
  { section: "TASK", text: input.task, min: MIN_TASK },
@@ -63,7 +63,7 @@ export function validateDelegation(input, usableNames, lang = "en") {
63
63
  ];
64
64
  for (const c of checks) {
65
65
  if (c.text.trim().length < c.min) {
66
- return pick(lang, `派单被驳回:${c.section} 段太短(至少 ${c.min} 字,当前 ${c.text.trim().length} 字)。六段缺一不可、含糊不得:补全后重试,不要改用 subagent_spawn 绕过校验。`, `Delegation rejected: section ${c.section} is too short (minimum ${c.min} chars, got ${c.text.trim().length}). All six sections are mandatory and vague prompts fail: complete it and retry; do not bypass validation via subagent_spawn.`, "delegate.validate.short", { section: c.section, min: c.min });
66
+ return pick(lang, `派单被驳回:${c.section} 段太短(至少 ${c.min} 字,当前 ${c.text.trim().length} 字)。六段缺一不可、含糊不得:补全后重试,不要改用 subagent(action="spawn") 绕过校验。`, `Delegation rejected: section ${c.section} is too short (minimum ${c.min} chars, got ${c.text.trim().length}). All six sections are mandatory and vague prompts fail: complete it and retry; do not bypass validation via subagent(action="spawn").`, "delegate.validate.short", { section: c.section, min: c.min });
67
67
  }
68
68
  }
69
69
  return null;
@@ -102,7 +102,7 @@ export function buildDelegationPrompt(input, lang = "en") {
102
102
  const delegateSchema = Type.Object({
103
103
  agent: Type.String({
104
104
  description: "Specialist template to delegate to (e.g. oracle, metis, momus, explore, librarian, sisyphus-junior, multimodal-looker, review). " +
105
- "Must be an enabled template — use subagent_templates to see descriptions and pick the domain match.",
105
+ "Must be an enabled template — use subagent (action='templates') to see descriptions and pick the domain match.",
106
106
  }),
107
107
  task: Type.String({
108
108
  description: "Atomic, specific goal: ONE action per delegation (minimum 20 chars). Vague tasks are rejected.",
@@ -144,12 +144,12 @@ export function makeDelegateTaskTool(host, lang) {
144
144
  label: "Delegate task",
145
145
  description: "Delegate ONE well-defined task to a specialist subagent template via a structured six-section brief (TASK / EXPECTED OUTCOME / REQUIRED TOOLS / MUST DO / MUST NOT DO / CONTEXT). " +
146
146
  "The brief is validated server-side — missing or vague sections are rejected, so fill every section concretely. " +
147
- "Prefer over subagent_spawn when the work fits a specialist template. Spawns a real subagent conversation; " +
148
- "collect with subagent_wait_all / subagent_get_result, redirect with subagent_steer, stop with subagent_stop. " +
147
+ "Prefer over subagent (action='spawn') when the work fits a specialist template. Spawns a real subagent conversation; " +
148
+ "collect with subagent (action='wait_all' / 'get_result'), redirect with subagent (action='steer'), stop with subagent (action='stop'). " +
149
149
  "For follow-ups continue the SAME subagent session instead of delegating again.",
150
150
  promptSnippet: "Delegate a well-defined task to a specialist template with a validated six-section brief",
151
151
  promptGuidelines: [
152
- "Prefer delegate_task over subagent_spawn when the work matches a specialist template's domain",
152
+ "Prefer delegate_task over subagent (action='spawn') when the work matches a specialist template's domain",
153
153
  "Before delegating, declare which template you chose and WHY its description matches the task",
154
154
  "After delegation ALWAYS verify the result: " +
155
155
  "does it work, does it follow codebase patterns, did it respect MUST DO / MUST NOT DO",
@@ -179,10 +179,10 @@ export function makeDelegateTaskTool(host, lang) {
179
179
  const modelLineZh = input.model ? `\n模型:${input.model}` : "";
180
180
  const modelLineEn = input.model ? `\nModel: ${input.model}` : "";
181
181
  return text(pick(getLang(), `已派单:${convId}\n模板:${input.agent} · 标题:${title}${modelLineZh}` +
182
- `\n子代理已在左栏运行列表中。用 subagent_wait_all 一次等全部完成(不用轮询)、subagent_get_result 取单个结果;` +
183
- `追问请在同一子代理会话里继续(subagent_steer),不要重复派单。拿到结果后必须验证再汇报。`, `Delegated: ${convId}\nTemplate: ${input.agent} · Title: ${title}${modelLineEn}` +
184
- `\nThe subagent is in the left running list. Use subagent_wait_all to wait for all at once (no polling), ` +
185
- `subagent_get_result for a single result; continue follow-ups in the SAME subagent session (subagent_steer), ` +
182
+ `\n子代理已在左栏运行列表中。用 subagent(action="wait_all") 一次等全部完成(不用轮询)、subagent(action="get_result") 取单个结果;` +
183
+ `追问请在同一子代理会话里继续(subagent(action="steer")),不要重复派单。拿到结果后必须验证再汇报。`, `Delegated: ${convId}\nTemplate: ${input.agent} · Title: ${title}${modelLineEn}` +
184
+ `\nThe subagent is in the left running list. Use subagent(action="wait_all") to wait for all at once (no polling), ` +
185
+ `subagent(action="get_result") for a single result; continue follow-ups in the SAME subagent session (subagent(action="steer")), ` +
186
186
  `do not delegate again. Verify the result before reporting.`, "delegate.started", { convId: convId, agent: input.agent, title: title, "input.model": input.model }), { convId, agent: input.agent, template: input.agent, model: input.model });
187
187
  },
188
188
  });
@@ -31,6 +31,7 @@ import { homedir } from "node:os";
31
31
  import { BgServerTracker } from "../bg-servers.js";
32
32
  import { ClientStateStore, DEFAULT_RETRY_MAX_ATTEMPTS, normalizePathKey, normalizeToolWatchdogTimeoutMs, } from "../client-state.js";
33
33
  import { normalizeUiLayout } from "../client-state.js";
34
+ import { PLAN_MODE_SYSTEM_PROMPT } from "../plan-mode.js";
34
35
  import { FilesService, workspacePath, desktopDirWire } from "../files-service.js";
35
36
  import { QuiesceRejectedError } from "../agent-service.js";
36
37
  import { NATIVE_COMMANDS, parseSlash } from "../slash-commands.js";
@@ -1506,6 +1507,18 @@ export class DshClientSession {
1506
1507
  // prompt / 附件
1507
1508
  // -----------------------------------------------------------------------
1508
1509
  async prompt(text, attachments, queue = false) {
1510
+ const trimmedText = (text ?? "").trim();
1511
+ const hasAttachments = Boolean(attachments && attachments.length > 0);
1512
+ if (!trimmedText && !hasAttachments) {
1513
+ this.emit({
1514
+ type: "notice",
1515
+ level: "warning",
1516
+ text: "发送已忽略:提示词为空且未附带文件或上下文引用。",
1517
+ textEn: "Prompt ignored: text is empty and no attachments were provided.",
1518
+ });
1519
+ this.flushSnapshot();
1520
+ return;
1521
+ }
1509
1522
  // 斜杠命令拦截(内置 NATIVE + 插件 registerCommand);带附件时不拦截。
1510
1523
  const parsed = parseSlash(text);
1511
1524
  if (parsed && !attachments?.length) {
@@ -2293,10 +2306,10 @@ export class DshClientSession {
2293
2306
  // 项目 / 文件 / SCM / 搜索
2294
2307
  // -----------------------------------------------------------------------
2295
2308
  async pushProjects() {
2296
- const saved = this.stateStore.get(this.clientId);
2309
+ const recent = this.stateStore.getRecentProjects(this.clientId);
2297
2310
  const removedKeys = new Set(this.stateStore.getRemovedProjects(this.clientId).map(normalizePathKey));
2298
2311
  const projects = new Map();
2299
- for (const p of saved.projects ?? []) {
2312
+ for (const p of recent) {
2300
2313
  if (!removedKeys.has(normalizePathKey(p.path))) {
2301
2314
  projects.set(p.path, p.lastUsed);
2302
2315
  }
@@ -2639,6 +2652,10 @@ export class DshClientSession {
2639
2652
  // DSH 无「AI 生成提交信息」(scm_commitmsg 分发处直接报错),保协议完整。
2640
2653
  scmCommitMsgPromptMode: "append",
2641
2654
  scmCommitMsgPrompt: "",
2655
+ // DSH 引擎不渲染计划按钮(无 customTools 注册面),保协议完整。
2656
+ planModePromptMode: "append",
2657
+ planModePrompt: "",
2658
+ planModeDefaultPrompt: PLAN_MODE_SYSTEM_PROMPT,
2642
2659
  reviewPrompt: this.settings.reviewPrompt,
2643
2660
  reviewDisabledSkills: [],
2644
2661
  disabledPlugins: this.settings.disabledPlugins,
@@ -3041,6 +3058,22 @@ export class DshClientSession {
3041
3058
  });
3042
3059
  }
3043
3060
  }
3061
+ /**
3062
+ * 审查者模式:DSH 不支持(无 customTools 注册面 → 工具硬闸门无处可挂,
3063
+ * 自动路由也没有可派发的会话通道)。明确回报而不是静默失败 ——
3064
+ * “点了没反应”比“用不了”更让人怀疑是不是页面坏了。
3065
+ */
3066
+ async setDelegateMode(enabled) {
3067
+ if (!enabled)
3068
+ return;
3069
+ this.emit({
3070
+ type: "notice",
3071
+ level: "warning",
3072
+ text: "审查者模式仅支持 pi 引擎(DSH 没有工具硬闸门的注册面),未开启。",
3073
+ textEn: "Reviewer mode needs the pi engine (DSH has no tool-gate registration surface); not enabled.",
3074
+ });
3075
+ this.flushSnapshot();
3076
+ }
3044
3077
  async savePreset(name) {
3045
3078
  const presets = this.stateStore.getPresets(this.clientId);
3046
3079
  const existing = presets.find((p) => p.name === name);
@@ -136,7 +136,9 @@ export class DshRuntime {
136
136
  throw new DshTransportError(bilingual(`launcher missing: ${this.launcher}`, `launcher 不存在: ${this.launcher}`));
137
137
  }
138
138
  if (!existsSync(this.jsonrpcEntry)) {
139
- throw new DshTransportError(bilingual(`dsh-sdk-jsonrpc-server is not installed (missing ${this.jsonrpcEntry}). Run npm i @deepseek-ai/dsh-sdk-jsonrpc-server@0.1.1-rc.2 first`, `dsh-sdk-jsonrpc-server 未安装(缺 ${this.jsonrpcEntry})。请先 npm i @deepseek-ai/dsh-sdk-jsonrpc-server@0.1.1-rc.2`));
139
+ // 不写死版本号:依赖版本会随 package.json 升(见 docs/dsh-engine.md §1
140
+ // 「版本族」),写死的提示迟早骗人。报当前实际解析到的路径即可。
141
+ throw new DshTransportError(bilingual(`dsh-sdk-jsonrpc-server is not installed (missing ${this.jsonrpcEntry}). Run npm i (see docs/dsh-engine.md for the pinned version family) first`, `dsh-sdk-jsonrpc-server 未安装(缺 ${this.jsonrpcEntry})。请先 npm i(版本族见 docs/dsh-engine.md)`));
140
142
  }
141
143
  const key = loadDeepSeekKey(this.agentDir);
142
144
  const env = {
@@ -503,7 +503,9 @@ export function makeEvalTool(opts) {
503
503
  code: Type.String({
504
504
  description: "The code snippet to evaluate. Top-level variables and functions are preserved across calls.",
505
505
  }),
506
- language: Type.Optional(Type.Union([Type.Literal("py"), Type.Literal("js"), Type.Literal("ts")], {
506
+ language: Type.Optional(Type.Unsafe({
507
+ type: "string",
508
+ enum: ["py", "js", "ts"],
507
509
  description: 'Target language: "py" for Python (default), "js" or "ts" for Node.js sandbox.',
508
510
  })),
509
511
  title: Type.Optional(Type.String({