pi-web-ui 0.76.0 → 0.77.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.
@@ -134,6 +134,9 @@ export class DshClientSession {
134
134
  convs = new Map();
135
135
  activeId = "";
136
136
  convSeq = 0;
137
+ /** 待答问卷快照(见 attachRuntimeEvents 的 question.pending 与
138
+ * UiState.pendingQuestion):重连/刷新后由快照恢复对话框。 */
139
+ pendingQuestion = null;
137
140
  /** 客户端级目标/审查偏好(跨会话共享的默认值,per-conversation goal 用它初始化)。 */
138
141
  goalPrefs = { reviewModel: null, maxRounds: 2, locked: false };
139
142
  sinks = new Set();
@@ -454,30 +457,39 @@ export class DshClientSession {
454
457
  void this.answerQuestion(params0.id, [], true);
455
458
  return;
456
459
  }
460
+ const mapped = (params0.questions ?? []).map((q) => ({
461
+ id: String(q.id ?? ""),
462
+ question: String(q.question ?? ""),
463
+ ...(typeof q.detail === "string"
464
+ ? { detail: q.detail }
465
+ : {}),
466
+ ...(typeof q.header === "string"
467
+ ? { header: q.header }
468
+ : {}),
469
+ ...(Array.isArray(q.options)
470
+ ? {
471
+ options: q.options.map((o) => ({
472
+ label: String(o.label ?? ""),
473
+ ...(typeof o.description === "string" ? { description: o.description } : {}),
474
+ ...(typeof o.preview === "string" ? { preview: o.preview } : {}),
475
+ })),
476
+ }
477
+ : {}),
478
+ ...(q.multiSelect ? { multiSelect: true } : {}),
479
+ }));
480
+ // 记下待答问卷:`question_pending` 只推给「当时在线」的连接,刷新页面
481
+ // /WS 重连后靠快照(UiState.pendingQuestion)把对话框恢复出来。
482
+ // DSH 的提问桥是 runtime 级的(无 conversationId),故不分对话。
483
+ this.pendingQuestion = {
484
+ id: params0.id,
485
+ ...(typeof params0.deadline === "number" ? { deadline: params0.deadline } : {}),
486
+ questions: mapped,
487
+ };
457
488
  this.emit({
458
489
  type: "question_pending",
459
490
  id: params0.id,
460
491
  ...(typeof params0.deadline === "number" ? { deadline: params0.deadline } : {}),
461
- questions: (params0.questions ?? []).map((q) => ({
462
- id: String(q.id ?? ""),
463
- question: String(q.question ?? ""),
464
- ...(typeof q.detail === "string"
465
- ? { detail: q.detail }
466
- : {}),
467
- ...(typeof q.header === "string"
468
- ? { header: q.header }
469
- : {}),
470
- ...(Array.isArray(q.options)
471
- ? {
472
- options: q.options.map((o) => ({
473
- label: String(o.label ?? ""),
474
- ...(typeof o.description === "string" ? { description: o.description } : {}),
475
- ...(typeof o.preview === "string" ? { preview: o.preview } : {}),
476
- })),
477
- }
478
- : {}),
479
- ...(q.multiSelect ? { multiSelect: true } : {}),
480
- })),
492
+ questions: mapped,
481
493
  });
482
494
  }
483
495
  else if (method === "tools.call.request") {
@@ -490,8 +502,24 @@ export class DshClientSession {
490
502
  }
491
503
  });
492
504
  }
505
+ /** 快照侧的待答问卷(UiState.pendingQuestion):重连/刷新后靠它恢复对话框。
506
+ * DSH 的提问自带超时(goal-rpc 到点 reject),deadline 已过的不再下发——
507
+ * 否则已经没人等的问卷会被重连的客户端当成活的弹出来。标准引擎不限时,
508
+ * 没有 deadline,生命周期由回答/取消/dispose 精确终止。 */
509
+ pendingQuestionForSnapshot() {
510
+ const p = this.pendingQuestion;
511
+ if (!p)
512
+ return null;
513
+ if (p.deadline !== undefined && p.deadline <= Date.now())
514
+ return null;
515
+ return p;
516
+ }
493
517
  /** 前端回答模型提问(question/answer → runtime 恢复工具结果)。 */
494
518
  async answerQuestion(id, answers, cancelled) {
519
+ // 无论成功失败都清掉待答快照:同 id 不会再有下一次,留着会让重连的客户端
520
+ // 恢复到一张已经没人在等的问卷。
521
+ if (this.pendingQuestion?.id === id)
522
+ this.pendingQuestion = null;
495
523
  try {
496
524
  await this.runtime.answerQuestion(id, answers, cancelled);
497
525
  }
@@ -1028,6 +1056,7 @@ export class DshClientSession {
1028
1056
  thinkingLevel: this.thinkingLevel,
1029
1057
  availableThinkingLevels: ["high"],
1030
1058
  queue: { steering: conv.queue.steering, followUp: conv.queue.followUp },
1059
+ pendingQuestion: this.pendingQuestionForSnapshot(),
1031
1060
  tools: [],
1032
1061
  version: ++this.version,
1033
1062
  piConfigured: !!loadDeepSeekKey(),
@@ -2191,6 +2220,8 @@ export class DshClientSession {
2191
2220
  customSystemPrompt: this.settings.customSystemPrompt,
2192
2221
  disabledSkills: this.settings.disabledSkills,
2193
2222
  disabledExtensions: this.settings.disabledExtensions,
2223
+ // DSH engine: no unified tool gating (no subagent/edit_soft); empty keeps protocol complete.
2224
+ disabledAgentTools: [],
2194
2225
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
2195
2226
  terminalBash: this.settings.terminalBash,
2196
2227
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
@@ -2201,6 +2232,8 @@ export class DshClientSession {
2201
2232
  goalModeEnabled: this.settings.goalModeEnabled,
2202
2233
  thinkingWrap: this.settings.thinkingWrap,
2203
2234
  toolsWrap: this.settings.toolsWrap,
2235
+ // DSH 无 skill 全文注入概念,给空保协议完整。
2236
+ skillsFullText: [],
2204
2237
  visionBridgeEnabled: false,
2205
2238
  visionBridgeModel: null,
2206
2239
  visionBridgePromptMode: "append",
@@ -2342,11 +2375,15 @@ export class DshClientSession {
2342
2375
  promptOverrides: {},
2343
2376
  disabledSkills: this.settings.disabledSkills,
2344
2377
  disabledExtensions: this.settings.disabledExtensions,
2378
+ // DSH engine: no unified tool gating (no subagent/edit_soft); empty keeps protocol complete.
2379
+ disabledAgentTools: [],
2345
2380
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
2346
2381
  terminalBash: this.settings.terminalBash,
2347
2382
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2348
2383
  editSoftEnabled: this.settings.editSoftEnabled,
2349
2384
  // DSH 无独立重试配置,预设沿用默认值。
2385
+ // DSH 无 skill 全文注入概念,给空保预设类型完整。
2386
+ skillsFullText: [],
2350
2387
  retryMaxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
2351
2388
  visionBridgePromptMode: "append",
2352
2389
  visionBridgePrompt: "",
@@ -23,7 +23,9 @@ import { isAbsolute, join, resolve as nodeResolve } from "node:path";
23
23
  import { Type } from "typebox";
24
24
  import { defineTool, generateDiffString, generateUnifiedPatch, withFileMutationQueue, } from "@earendil-works/pi-coding-agent";
25
25
  import { bilingual, pick } from "./i18n.js";
26
- export const SOFT_EDIT_TOOL_NAME = "edit_soft";
26
+ import { EDIT_SOFT_TOOL_NAME } from "./tool-manager.js";
27
+ /** 独立宽松编辑工具名(唯一登记见 tool-manager.ts;此处别名保兼容)。 */
28
+ export const SOFT_EDIT_TOOL_NAME = EDIT_SOFT_TOOL_NAME;
27
29
  const replaceEditSchema = Type.Object({
28
30
  oldText: Type.String({
29
31
  description: bilingual("Text to replace. Loose matching: the content of each non-empty line (trimmed of leading/trailing whitespace) must match the corresponding file lines; leading-indentation (spaces/tabs) differences are ignored. Prefer whole lines/blocks.", "要替换的文本。宽松匹配:每个非空行的内容(去掉首尾空白)需与文件中对应行一致;行首缩进(空格/制表符)的差异会被忽略。建议按整行/整块提供。"),
@@ -402,6 +402,21 @@ if (existsSync(webDist)) {
402
402
  }
403
403
  },
404
404
  }));
405
+ // 缺失的静态文件必须 404(不能落进下面的 SPA catch-all):缺少的 hash 产物若
406
+ // 回 index.html(200),浏览器会把 HTML 当 JS/CSS 执行失败黑屏,SW 还会把
407
+ // 它按 200 缓进 STATIC_CACHE,之后即使文件恢复也要清缓存才能好。
408
+ app.use((req, res, next) => {
409
+ const p = req.path;
410
+ if (p.startsWith("/assets/") ||
411
+ p.startsWith("/icons/") ||
412
+ p === "/favicon.svg" ||
413
+ p === "/icon.ico" ||
414
+ p === "/manifest.webmanifest") {
415
+ res.status(404).end();
416
+ return;
417
+ }
418
+ next();
419
+ });
405
420
  app.get(/^\/(?!api\/|ws).*/, (_req, res) => {
406
421
  // Callback form: a failed stat here (npm i -g is mid-replacement of the
407
422
  // package dir) responds 503 instead of crashing the request pipeline
@@ -936,6 +951,7 @@ wss.on("connection", (ws) => {
936
951
  promptOverrides: msg.promptOverrides,
937
952
  disabledSkills: msg.disabledSkills,
938
953
  disabledExtensions: msg.disabledExtensions,
954
+ disabledAgentTools: msg.disabledAgentTools,
939
955
  disabledPlugins: msg.disabledPlugins,
940
956
  terminalToolsEnabled: msg.terminalToolsEnabled,
941
957
  terminalBash: msg.terminalBash,
@@ -945,6 +961,7 @@ wss.on("connection", (ws) => {
945
961
  goalModeEnabled: msg.goalModeEnabled,
946
962
  thinkingWrap: msg.thinkingWrap,
947
963
  toolsWrap: msg.toolsWrap,
964
+ skillsFullText: msg.skillsFullText,
948
965
  visionBridgeEnabled: msg.visionBridgeEnabled,
949
966
  visionBridgeModel: msg.visionBridgeModel,
950
967
  visionBridgePromptMode: msg.visionBridgePromptMode,
@@ -43,14 +43,14 @@ const TODO_GUIDANCE_ZH = [
43
43
  "# 内联标记工具(状态类操作请写在回答正文,不要调用工具)",
44
44
  "- 标记语法:[[todo:new:<主题>]] 新建;[[todo:set:<id>,completed|in_progress|pending]] 状态;[[todo:remove:<id>]] 删除;[[todo:dep:<id>,blocks=<依赖id,逗号分隔>]] 设依赖。",
45
45
  "- 状态变化全部用上面的 [[todo:...]] 内联标记表达,不会中断回答,无需等待返回。",
46
- "- 想查看/list 当前任务列表时,才用 `markers_list` 工具(读操作走工具)。",
46
+ "- 想查看/list 当前任务列表时,才用 `todo_list` 工具(读操作走工具)。",
47
47
  "- 不要编造不存在的任务 id;id 由 [[todo:new:...]] 分配,首次分配是自增整数。",
48
48
  ];
49
49
  const TODO_GUIDANCE_EN = [
50
50
  "# Inline marker tools (express state changes inline in your reply text — never call a tool for them)",
51
51
  "- Marker syntax: [[todo:new:<subject>]] to create; [[todo:set:<id>,completed|in_progress|pending]] for status; [[todo:remove:<id>]] to delete; [[todo:dep:<id>,blocks=<dep ids, comma-separated>]] to set dependencies.",
52
52
  "- Express all status changes with the [[todo:...]] inline markers above; they never interrupt your reply and need no waiting for a result.",
53
- "- Only use the `markers_list` tool (the read path goes through the tool) when you want to list the current tasks.",
53
+ "- Only use the `todo_list` tool (the read path goes through the tool) when you want to list the current tasks.",
54
54
  "- Never invent task ids; ids are assigned by [[todo:new:...]], starting from incrementing integers.",
55
55
  ];
56
56
  /** 语言感知的 todo guidance(issue #91):en 用英译、zh 用中文,默认英文。 */
@@ -0,0 +1,109 @@
1
+ const EN = [
2
+ "<orchestrator>",
3
+ "You are an orchestrator, not just an implementer. Your value is decomposition,",
4
+ "delegation, and quality control. Default bias: DELEGATE. Work directly only when",
5
+ "the task is trivially small (single file, known location, direct answer).",
6
+ "",
7
+ "## Intent gate (every message)",
8
+ "- Check skills FIRST: if the request matches a skill trigger, read that skill file",
9
+ " before classifying or acting. Skills handle their tasks better than ad-hoc work.",
10
+ "- Verbalize routing BEFORE acting: state the detected intent (research /",
11
+ " implementation / investigation / evaluation / fix / open-ended) and your approach",
12
+ " (e.g. explore recon first, then answer; plan then delegate).",
13
+ "- Implement ONLY when the current message explicitly asks for implementation",
14
+ " (implement/add/create/fix/change/write), the scope is concrete, and no pending",
15
+ " specialist result blocks you. Otherwise research/clarify and wait.",
16
+ "- Reclassify intent from the CURRENT message only; never auto-carry",
17
+ " implementation mode from prior turns.",
18
+ "",
19
+ "## Delegation protocol",
20
+ "Available specialist templates (subagent_spawn template=):",
21
+ "- oracle: architecture decisions, hard debugging, multi-system tradeoffs",
22
+ "- metis: pre-planning analysis when scope is unclear",
23
+ "- momus: review a work plan for gaps before implementing",
24
+ "- explore: codebase recon (where is X, which file has Y)",
25
+ "- librarian: external docs / library usage / open-source examples",
26
+ "- sisyphus-junior: well-defined single-scope implementation (already researched)",
27
+ "- multimodal-looker: PDFs, images, diagrams needing interpretation",
28
+ "- review / implement / research / scout / audit: built-in execution and review roles",
29
+ "Match the task domain to the template. Visual work goes to frontend-capable",
30
+ "execution; hard logic and architecture go to oracle; unclear scope goes to metis first.",
31
+ "BEFORE each delegation, declare: which template, WHY its description matches the",
32
+ "task domain, and the expected outcome. Then call subagent_spawn.",
33
+ "Delegation prompts MUST include all six sections:",
34
+ "1. TASK (atomic, one action) 2. EXPECTED OUTCOME (concrete deliverables +",
35
+ "done criteria) 3. REQUIRED TOOLS (explicit whitelist) 4. MUST DO (leave nothing",
36
+ "implicit) 5. MUST NOT DO (forbidden actions) 6. CONTEXT (file paths, patterns,",
37
+ "constraints). Vague prompts fail; if your prompt is shorter than 5 lines it is too vague.",
38
+ "For follow-ups continue the SAME subagent session instead of starting fresh.",
39
+ "After delegation ALWAYS verify: does it work, does it follow codebase patterns,",
40
+ "did it respect MUST DO / MUST NOT DO. Never start implementing work that a",
41
+ "pending oracle/momus result was asked to decide.",
42
+ "Anti-duplication: once explore/librarian are tasked, do NOT redo their search yourself.",
43
+ "",
44
+ "## Task management",
45
+ "Multi-step task (2+ steps) -> create a todo list IMMEDIATELY, in detail.",
46
+ "Mark exactly ONE item in_progress before starting it; mark completed IMMEDIATELY",
47
+ "after (never batch). If scope changes, update todos before proceeding.",
48
+ "No todos on non-trivial work = incomplete work.",
49
+ "",
50
+ "## Constraints",
51
+ "NEVER: suppress type errors (as any, @ts-ignore); commit without an explicit",
52
+ "request; speculate about unread code; leave code broken after failures;",
53
+ "use empty catch blocks; delete failing tests to pass.",
54
+ "Prefer existing libraries, small focused changes, and minimal bugfixes",
55
+ "(never refactor while fixing). Run diagnostics/build/tests on changed files",
56
+ "before reporting completion.",
57
+ "</orchestrator>",
58
+ ].join("\n");
59
+ const ZH = [
60
+ "<orchestrator>",
61
+ "你是编排者(orchestrator),而不只是一个执行者。你的价值在于任务分解、",
62
+ "委派和质量把关。默认倾向:能委派就委派。只有任务极小(单文件、位置明确、",
63
+ "直接可答)时才亲自动手。",
64
+ "",
65
+ "## 意图门(每条消息先过这一关)",
66
+ "- 先查技能:请求命中某技能触发条件时,先用 read 读该技能文件,再分类或动手。",
67
+ " 技能覆盖的任务,照技能流程做比临场发挥更可靠。",
68
+ "- 先说路由再动手:明确本轮意图(调研 / 实现 / 排查 / 评估 / 修 bug / 开放式),",
69
+ " 并说出你的路线(例如先 explore 摸底再回答、先计划再委派)。",
70
+ "- 只有同时满足才实现:本轮消息有明确的实现动词(实现/加/创建/修/改/写)、",
71
+ " 范围足够具体、不依赖尚未返回的 specialist 结果。否则只做调研/澄清然后等待。",
72
+ "- 每轮只按当前消息重判意图,不把上一轮的“实现模式”自动带过来。",
73
+ "",
74
+ "## 委派协议",
75
+ "可用 specialist 模板(subagent_spawn 的 template 参数):",
76
+ "- oracle:架构决策、难调的 bug、多系统权衡",
77
+ "- metis:范围不清时先做预分析",
78
+ "- momus:实现前先评审计划查缺补漏",
79
+ "- explore:代码库侦察(X 在哪、Y 在哪个文件)",
80
+ "- librarian:外部文档 / 第三方库用法 / 开源实现参考",
81
+ "- sisyphus-junior:已调研清楚的单点实现任务",
82
+ "- multimodal-looker:需要解读的 PDF / 图片 / 图表",
83
+ "- review / implement / research / scout / audit:内置的执行与审查角色",
84
+ "按任务领域选模板:界面视觉走前端执行、硬逻辑与架构走 oracle、范围不清先走 metis。",
85
+ "每次委派前必须声明:选哪个模板、它的简介与任务哪里匹配、期望产出,然后再调 subagent_spawn。",
86
+ "派单词必须包含六段:1. TASK(原子目标) 2. EXPECTED OUTCOME(交付物+完成标准)",
87
+ "3. REQUIRED TOOLS(可用工具白名单) 4. MUST DO(要求写尽) 5. MUST NOT DO(禁区)",
88
+ "6. CONTEXT(文件路径、既有模式、约束)。派单词短于 5 行就是太含糊,一定失败。",
89
+ "追问要在同一个子代理会话里继续,不要另起炉灶。",
90
+ "委派后必须验证:能跑吗、符合代码库既有模式吗、遵守 MUST DO / MUST NOT DO 了吗。",
91
+ "oracle/momus 还没回来之前,不准先把它们要定的实现写了。",
92
+ "禁重复劳动:explore/librarian 已经在查的东西,自己不要再查一遍。",
93
+ "",
94
+ "## 任务管理",
95
+ "多步任务(2 步以上)→ 立刻建 todo 列表,越细越好。",
96
+ "一次只把一项标 in_progress,做完立刻标 completed(不批量)。范围变了先更新 todo 再动手。",
97
+ "复杂任务没有 todo = 没做完。",
98
+ "",
99
+ "## 禁区",
100
+ "绝不:压类型错误(as any、@ts-ignore);未经明确要求就 commit;臆测没读过的代码;",
101
+ "失败后留下一堆 broken 代码;空 catch;删掉失败的测试来“通过”。",
102
+ "优先用现成库、小而聚焦的改动、最小化修 bug(修 bug 时不顺手重构)。",
103
+ "汇报完成前,先对改动过的文件跑一遍诊断/构建/测试。",
104
+ "</orchestrator>",
105
+ ].join("\n");
106
+ /** 编排指导块(按语言选;Oh-my-pi Behavior/Delegation/Task/Constraints 的原生改写版)。 */
107
+ export function buildOrchestratorText(lang = "en") {
108
+ return lang === "zh" ? ZH : EN;
109
+ }
@@ -140,8 +140,10 @@ function escapeXml(s) {
140
140
  .replace(/"/g, "&quot;")
141
141
  .replace(/'/g, "&apos;");
142
142
  }
143
- /** 技能段文本(不含前导空行)。与 SDK formatSkillsForPrompt 一致。 */
144
- export function buildSkillsText(skills, lang = "en") {
143
+ /** 技能段文本(不含前导空行)。与 SDK formatSkillsForPrompt 一致。
144
+ * fullText = true 全员全文注入(oh-my-pi 式:标题 + 引用描述 + body 全文);
145
+ * 传技能名数组 = 只注入名单里的;content 缺失的条目回落列表行,不中断渲染。 */
146
+ export function buildSkillsText(skills, lang = "en", fullText = false) {
145
147
  const visible = skills.filter((s) => !s.disableModelInvocation);
146
148
  if (visible.length === 0)
147
149
  return "";
@@ -153,6 +155,15 @@ export function buildSkillsText(skills, lang = "en") {
153
155
  "<available_skills>",
154
156
  ];
155
157
  for (const skill of visible) {
158
+ // 全文模式且有内容:oh-my-pi 注入格式(### Skill: 名 / > 描述 / 全文)。
159
+ const inject = fullText === true || (Array.isArray(fullText) && fullText.includes(skill.name));
160
+ if (inject && skill.content?.trim()) {
161
+ lines.push(`### Skill: ${skill.name}`);
162
+ if (skill.description.trim())
163
+ lines.push(`> ${skill.description.trim()}`);
164
+ lines.push("", skill.content.trim(), "");
165
+ continue;
166
+ }
156
167
  lines.push(" <skill>");
157
168
  lines.push(` <name>${escapeXml(skill.name)}</name>`);
158
169
  lines.push(` <description>${escapeXml(skill.description)}</description>`);
@@ -220,7 +231,7 @@ export function resolveSectionTexts(inputs) {
220
231
  terminal: inputs.terminalGuidance,
221
232
  markers: inputs.markersGuidance,
222
233
  context: buildContextText(inputs.contextFiles, lang),
223
- skills: buildSkillsText(inputs.skills, lang),
234
+ skills: buildSkillsText(inputs.skills, lang, inputs.skillsFullText ?? false),
224
235
  cwd: `Current working directory: ${cwd}`,
225
236
  };
226
237
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * 排队消息(插队 steer / 排队 followUp)纯函数 —— 独立成模块是为了可单测
3
+ * (`AgentService` 依赖 SDK 与运行时,不适合在单测里 import)。
4
+ *
5
+ * 背景:pi SDK 没有「按条操作队列」的 API,服务端移除一条排队消息的做法是
6
+ * `clearQueue()` 之后把幸存者按原顺序重新入队(见 `AgentService.removeQueued`)。
7
+ * 重建时若用值过滤(`list.filter((t) => t !== text)`),会把**所有**同文本项一起删掉;
8
+ * 而气泡上的 ✕ 只对应一条消息、本地显示镜像也只移除一条 → 同一条文本被排队两次时
9
+ * 「点一次 ✕ 删两条」,且显示与真实队列在下次 `queue_update` 之前不一致。
10
+ * 因此统一按「只移除第一处匹配」处理。
11
+ */
12
+ /** 移除列表中第一处等于 `text` 的项;找不到时返回入参的拷贝(不修改入参)。 */
13
+ export function removeFirstOccurrence(list, text) {
14
+ const index = list.indexOf(text);
15
+ if (index < 0)
16
+ return [...list];
17
+ return [...list.slice(0, index), ...list.slice(index + 1)];
18
+ }
@@ -8,9 +8,10 @@
8
8
  */
9
9
  import { existsSync, readdirSync } from "node:fs";
10
10
  import { basename, dirname, join } from "node:path";
11
- import { extensionKey, normalizeRetryMaxAttempts, } from "./client-state.js";
11
+ import { extensionKey, normalizeRetryMaxAttempts, normalizeSkillList, } from "./client-state.js";
12
12
  import { findVisionModels, SYSTEM_PROMPT } from "./vision-bridge.js";
13
13
  import { DEFAULT_TEMPLATES } from "./subagent-templates.js";
14
+ import { deriveLegacy, foldLegacyIntoDisabled, normalizeDisabledAgentTools } from "./tool-manager.js";
14
15
  export class SettingsService {
15
16
  host;
16
17
  templates;
@@ -141,13 +142,21 @@ export class SettingsService {
141
142
  // 推送都会把已删除的 skill 以灰条形式永恒地补回面板(“关闭过的
142
143
  // skill 被一直记录”)。session 未就绪时保守跳过。
143
144
  if (loadedSkillNames !== null) {
144
- const stale = [...new Set([...this.settings.disabledSkills, ...this.settings.reviewDisabledSkills])].filter((name) => !loadedSkillNames.has(name) && !this.skillStillOnDisk(name));
145
+ const stale = [
146
+ ...new Set([
147
+ ...this.settings.disabledSkills,
148
+ ...this.settings.reviewDisabledSkills,
149
+ ...normalizeSkillList(this.settings.skillsFullText),
150
+ ]),
151
+ ].filter((name) => !loadedSkillNames.has(name) && !this.skillStillOnDisk(name));
145
152
  if (stale.length > 0) {
146
153
  this.settings.disabledSkills = this.settings.disabledSkills.filter((n) => !stale.includes(n));
147
154
  this.settings.reviewDisabledSkills = this.settings.reviewDisabledSkills.filter((n) => !stale.includes(n));
155
+ this.settings.skillsFullText = normalizeSkillList(this.settings.skillsFullText).filter((n) => !stale.includes(n));
148
156
  this.host.stateStore.saveSettings(this.host.clientId, {
149
157
  disabledSkills: this.settings.disabledSkills,
150
158
  reviewDisabledSkills: this.settings.reviewDisabledSkills,
159
+ skillsFullText: this.settings.skillsFullText,
151
160
  });
152
161
  }
153
162
  }
@@ -180,6 +189,8 @@ export class SettingsService {
180
189
  const extensions = [...this.knownExtensions.values()]
181
190
  .map((e) => ({ ...e, enabled: !disabledExts.has(e.id) }))
182
191
  .sort((a, b) => a.name.localeCompare(b.name));
192
+ // 统一工具开关是单源(disabledAgentTools),遗留三开关推送时推导,保证面板一致。
193
+ const legacyTools = deriveLegacy(this.settings.disabledAgentTools ?? []);
183
194
  // 当前会话提示词快照:完整生效文本 + 各来源默认(自动)内容(只读预览)。
184
195
  const promptSnap = this.host.promptSnapshot();
185
196
  this.host.emit({
@@ -189,11 +200,12 @@ export class SettingsService {
189
200
  customSystemPrompt: this.settings.customSystemPrompt,
190
201
  promptTemplate: this.settings.promptTemplate ?? "",
191
202
  promptOverrides: { ...this.settings.promptOverrides },
192
- terminalToolsEnabled: this.settings.terminalToolsEnabled,
203
+ disabledAgentTools: [...normalizeDisabledAgentTools(this.settings.disabledAgentTools)],
204
+ terminalToolsEnabled: legacyTools.terminalToolsEnabled,
193
205
  terminalBash: this.settings.terminalBash,
194
206
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
195
- editSoftEnabled: this.settings.editSoftEnabled,
196
- questionnaireEnabled: this.settings.questionnaireEnabled,
207
+ editSoftEnabled: legacyTools.editSoftEnabled,
208
+ questionnaireEnabled: legacyTools.questionnaireEnabled,
197
209
  goalModeEnabled: this.settings.goalModeEnabled,
198
210
  thinkingWrap: this.settings.thinkingWrap,
199
211
  toolsWrap: this.settings.toolsWrap,
@@ -204,6 +216,7 @@ export class SettingsService {
204
216
  reviewPrompt: this.settings.reviewPrompt,
205
217
  reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
206
218
  disabledPlugins: [...(this.settings.disabledPlugins ?? [])],
219
+ skillsFullText: [...normalizeSkillList(this.settings.skillsFullText)],
207
220
  // The composed system prompt actually in effect (read-only view).
208
221
  effectiveSystemPrompt: promptSnap.full,
209
222
  // 每个来源未覆盖时的默认(自动)内容(「各来源」行预览用)。
@@ -282,9 +295,12 @@ export class SettingsService {
282
295
  partial.promptTemplate !== undefined ||
283
296
  partial.promptOverrides !== undefined ||
284
297
  partial.disabledSkills !== undefined ||
285
- partial.disabledExtensions !== undefined ||
298
+ partial.disabledExtensions !== undefined;
299
+ // 统一工具开关 live 生效(ActiveSet 加减,无需 reload),见末尾 applyToolGating。
300
+ const toolGatingChanged = partial.disabledAgentTools !== undefined ||
286
301
  partial.terminalToolsEnabled !== undefined ||
287
- partial.editSoftEnabled !== undefined;
302
+ partial.editSoftEnabled !== undefined ||
303
+ partial.questionnaireEnabled !== undefined;
288
304
  if (partial.promptMode !== undefined)
289
305
  this.settings.promptMode = partial.promptMode;
290
306
  if (partial.customSystemPrompt !== undefined) {
@@ -314,8 +330,25 @@ export class SettingsService {
314
330
  if (partial.disabledPlugins !== undefined) {
315
331
  this.settings.disabledPlugins = partial.disabledPlugins;
316
332
  }
317
- if (partial.terminalToolsEnabled !== undefined) {
318
- this.settings.terminalToolsEnabled = partial.terminalToolsEnabled;
333
+ // 统一工具开关:新字段优先;只给遗留单开关时折回新字段。两边写完再由
334
+ // deriveLegacy 回填遗留别名,保证内存/推送/落盘三处一致。
335
+ if (partial.disabledAgentTools !== undefined) {
336
+ this.settings.disabledAgentTools = normalizeDisabledAgentTools(partial.disabledAgentTools);
337
+ }
338
+ if (partial.terminalToolsEnabled !== undefined ||
339
+ partial.editSoftEnabled !== undefined ||
340
+ partial.questionnaireEnabled !== undefined) {
341
+ this.settings.disabledAgentTools = foldLegacyIntoDisabled(this.settings.disabledAgentTools ?? [], {
342
+ terminalToolsEnabled: partial.terminalToolsEnabled,
343
+ editSoftEnabled: partial.editSoftEnabled,
344
+ questionnaireEnabled: partial.questionnaireEnabled,
345
+ });
346
+ }
347
+ {
348
+ const legacy = deriveLegacy(this.settings.disabledAgentTools ?? []);
349
+ this.settings.terminalToolsEnabled = legacy.terminalToolsEnabled;
350
+ this.settings.editSoftEnabled = legacy.editSoftEnabled;
351
+ this.settings.questionnaireEnabled = legacy.questionnaireEnabled;
319
352
  }
320
353
  if (partial.terminalBash !== undefined) {
321
354
  this.settings.terminalBash = partial.terminalBash;
@@ -323,13 +356,6 @@ export class SettingsService {
323
356
  if (partial.terminalBashIdleMs !== undefined) {
324
357
  this.settings.terminalBashIdleMs = Math.max(0, Math.floor(partial.terminalBashIdleMs) || 0);
325
358
  }
326
- if (partial.editSoftEnabled !== undefined) {
327
- this.settings.editSoftEnabled = partial.editSoftEnabled;
328
- }
329
- // 问卷开关:运行时无需重载(bridge 处实时读取)。
330
- if (partial.questionnaireEnabled !== undefined) {
331
- this.settings.questionnaireEnabled = partial.questionnaireEnabled;
332
- }
333
359
  // 目标模式总开关:运行时无需重载(goal bar / 服务端入口实时读取)。
334
360
  if (partial.goalModeEnabled !== undefined) {
335
361
  this.settings.goalModeEnabled = partial.goalModeEnabled;
@@ -340,6 +366,11 @@ export class SettingsService {
340
366
  if (partial.toolsWrap !== undefined) {
341
367
  this.settings.toolsWrap = partial.toolsWrap;
342
368
  }
369
+ // 编排模式 / skill 全文注入:before_agent_start 逐 run 实时读取(agent-service
370
+ // composeInputs + 指导块追加),开关下一轮即生效,无需 reload runtime。
371
+ if (partial.skillsFullText !== undefined) {
372
+ this.settings.skillsFullText = normalizeSkillList(partial.skillsFullText);
373
+ }
343
374
  if (partial.visionBridgeEnabled !== undefined) {
344
375
  this.settings.visionBridgeEnabled = partial.visionBridgeEnabled;
345
376
  }
@@ -382,6 +413,9 @@ export class SettingsService {
382
413
  }
383
414
  this.host.stateStore.saveSettings(this.host.clientId, this.settings);
384
415
  this.push();
416
+ // 统一工具开关 live 生效(ActiveSet 加减;失败静默,下次创建/reload 重放)。
417
+ if (toolGatingChanged)
418
+ this.host.applyToolGating();
385
419
  if (needsReload)
386
420
  await this.applyRuntime();
387
421
  }
@@ -405,6 +439,7 @@ export class SettingsService {
405
439
  promptOverrides: { ...this.settings.promptOverrides },
406
440
  disabledSkills: [...this.settings.disabledSkills],
407
441
  disabledExtensions: [...this.settings.disabledExtensions],
442
+ disabledAgentTools: [...normalizeDisabledAgentTools(this.settings.disabledAgentTools)],
408
443
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
409
444
  terminalBash: this.settings.terminalBash,
410
445
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
@@ -412,6 +447,7 @@ export class SettingsService {
412
447
  retryMaxAttempts: this.settings.retryMaxAttempts,
413
448
  reviewPrompt: this.settings.reviewPrompt,
414
449
  reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
450
+ skillsFullText: [...normalizeSkillList(this.settings.skillsFullText)],
415
451
  };
416
452
  const existing = this.presets.findIndex((p) => p.name === n);
417
453
  if (existing >= 0)
@@ -433,6 +469,14 @@ export class SettingsService {
433
469
  });
434
470
  return;
435
471
  }
472
+ // 统一工具开关随预设走;旧预设缺新字段时按遗留两开关折算(问卷不进预设,
473
+ // 从当前禁用名单继承,即保留当前问卷状态)。
474
+ const presetDisabled = normalizeDisabledAgentTools(p.disabledAgentTools ??
475
+ foldLegacyIntoDisabled(this.settings.disabledAgentTools ?? [], {
476
+ terminalToolsEnabled: p.terminalToolsEnabled,
477
+ editSoftEnabled: p.editSoftEnabled,
478
+ }));
479
+ const presetLegacy = deriveLegacy(presetDisabled);
436
480
  this.settings = {
437
481
  promptMode: p.promptMode,
438
482
  customSystemPrompt: p.customSystemPrompt,
@@ -440,12 +484,12 @@ export class SettingsService {
440
484
  promptOverrides: { ...(p.promptOverrides ?? this.settings.promptOverrides) },
441
485
  disabledSkills: [...p.disabledSkills],
442
486
  disabledExtensions: [...p.disabledExtensions],
443
- // 旧版持久化的预设可能没有该字段——保留当前值。
444
- terminalToolsEnabled: p.terminalToolsEnabled ?? this.settings.terminalToolsEnabled,
487
+ disabledAgentTools: presetDisabled,
488
+ terminalToolsEnabled: presetLegacy.terminalToolsEnabled,
445
489
  // 终端接管偏好随预设走;旧预设缺字段时保留当前值。
446
490
  terminalBash: p.terminalBash ?? this.settings.terminalBash,
447
491
  terminalBashIdleMs: p.terminalBashIdleMs ?? this.settings.terminalBashIdleMs,
448
- editSoftEnabled: p.editSoftEnabled ?? this.settings.editSoftEnabled,
492
+ editSoftEnabled: presetLegacy.editSoftEnabled,
449
493
  // 重试次数随预设走;旧预设缺字段时保留当前值,应用后即时注入各会话。
450
494
  retryMaxAttempts: p.retryMaxAttempts ?? this.settings.retryMaxAttempts,
451
495
  // 问卷开关不进预设——保留当前值。
@@ -454,6 +498,8 @@ export class SettingsService {
454
498
  goalModeEnabled: this.settings.goalModeEnabled,
455
499
  reviewPrompt: p.reviewPrompt ?? this.settings.reviewPrompt,
456
500
  reviewDisabledSkills: [...(p.reviewDisabledSkills ?? this.settings.reviewDisabledSkills)],
501
+ // 全文注入名单随预设走;旧预设缺字段时保留当前值。
502
+ skillsFullText: normalizeSkillList(p.skillsFullText ?? this.settings.skillsFullText),
457
503
  // 纯 UI 偏好不进预设——保留当前值。
458
504
  thinkingWrap: this.settings.thinkingWrap,
459
505
  toolsWrap: this.settings.toolsWrap,
@@ -472,6 +518,8 @@ export class SettingsService {
472
518
  // 预设可能改了重试次数:即时注入(流式中延迟的 reload 之后还会由调用方重放)。
473
519
  this.host.applyRetryOverrides();
474
520
  this.push();
521
+ // 预设带了工具开关:live 应用(reload 路径会重放,流式中延迟到 agent_end)。
522
+ this.host.applyToolGating();
475
523
  await this.applyRuntime();
476
524
  }
477
525
  /** Remove a named preset. */