pi-web-ui 0.90.1 → 0.92.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 (54) hide show
  1. package/CHANGELOG.md +81 -1
  2. package/bin/pi-web-ui.mjs +21 -2
  3. package/dist/server/agent-service.js +1206 -106
  4. package/dist/server/claim-files-tool.js +116 -0
  5. package/dist/server/claim-store.js +301 -0
  6. package/dist/server/client-state.js +119 -5
  7. package/dist/server/compaction-markers.js +287 -0
  8. package/dist/server/conversation-read-tool.js +369 -27
  9. package/dist/server/conversation-touches.js +357 -0
  10. package/dist/server/delegate-task.js +12 -1
  11. package/dist/server/dsh/dsh-agent-service.js +28 -1
  12. package/dist/server/file-transfer-routes.js +4 -1
  13. package/dist/server/icon-svg.js +35 -0
  14. package/dist/server/index.js +152 -34
  15. package/dist/server/model-admin.js +124 -2
  16. package/dist/server/model-enrich.js +478 -0
  17. package/dist/server/plugin-catalog.js +5 -0
  18. package/dist/server/plugin-facilities.js +42 -11
  19. package/dist/server/plugin-schedule.js +27 -2
  20. package/dist/server/plugins.js +112 -18
  21. package/dist/server/present-files-tool.js +303 -0
  22. package/dist/server/prompt-composer.js +1 -1
  23. package/dist/server/protocol-version.js +1 -1
  24. package/dist/server/read-tool.js +91 -0
  25. package/dist/server/schedule-agent-tool.js +23 -5
  26. package/dist/server/scheduler-tasks.js +60 -1
  27. package/dist/server/scm-commitmsg.js +140 -0
  28. package/dist/server/scm.js +42 -0
  29. package/dist/server/serialize.js +21 -1
  30. package/dist/server/settings-service.js +47 -0
  31. package/dist/server/skill-tool.js +120 -0
  32. package/dist/server/soft-cap.js +68 -0
  33. package/dist/server/subagent-templates.js +6 -0
  34. package/dist/server/subagents.js +157 -16
  35. package/dist/server/text-sniff.js +15 -0
  36. package/dist/server/themes.js +17 -2
  37. package/dist/server/tool-manager.js +13 -1
  38. package/package.json +2 -1
  39. package/plugin-sdk/README.md +1 -0
  40. package/plugins/catalog.json +20 -0
  41. package/themes/catppuccin-latte.css +136 -0
  42. package/themes/catppuccin.css +140 -0
  43. package/themes/nord.css +130 -0
  44. package/themes/one-dark.css +131 -0
  45. package/themes/paper.css +12 -12
  46. package/themes/solarized-light.css +131 -0
  47. package/themes/tokyo-night.css +140 -0
  48. package/web/dist/assets/{TerminalPanel-CkxM4Byl.js → TerminalPanel-RACpk6AX.js} +1 -1
  49. package/web/dist/assets/es-DIDZppIF.js +2 -0
  50. package/web/dist/assets/index-BgrnP9Jz.js +364 -0
  51. package/web/dist/assets/index-Cy8dSTZt.css +1 -0
  52. package/web/dist/index.html +2 -2
  53. package/web/dist/assets/index-DJIV4A-t.js +0 -355
  54. package/web/dist/assets/index-DLdcRBs_.css +0 -1
@@ -0,0 +1,120 @@
1
+ // ---------------------------------------------------------------------------
2
+ // skill-tool.ts — 让 AI 按名取技能全文(名录 + skill 工具)
3
+ // ---------------------------------------------------------------------------
4
+ // 背景:{{skills}} 默认只渲染名录(name/description/location),引导语原来让
5
+ // 模型“用 read 工具加载技能文件”——模型要自己拼 location 路径调 read,权限大
6
+ // 且容易猜错路径。本工具提供专用通道:skill({name}) 精确命中,读文件包成与
7
+ // SDK 一致的 `<skill name location>` 块(前端 parseSkillBlock 直接认,渲染折叠
8
+ // 卡片);不给 name 则返回名录 + 用法,找错名则给纠错列表。
9
+ //
10
+ // 约束(第 1 条定稿,不破坏现有体系):
11
+ // - 名录渲染不动;skillsFullText 名单注入不动;/skill:name 手势不动;
12
+ // - 正文每次重读(不缓存),单文件 8KB 封顶(与 fillSkillContents 同口径);
13
+ // - 禁用集由 host 在调用时过滤(与 skillsOverride 主会话语义一致),工具侧
14
+ // 查不到被禁用的技能;
15
+ // - 与 SDK 同名工具撞名时 customTools 按 name 覆盖(bash 本体是先例)。
16
+ //
17
+ // 双语约定(issue #91):definition 走 bilingual(en, zh) 内联双语;per-call
18
+ // 返回文本按 lang 取 pick(lang, zh, en, key),缺表回落英文内联。
19
+ // ---------------------------------------------------------------------------
20
+ import { statSync, readFileSync } from "node:fs";
21
+ import { defineTool } from "@earendil-works/pi-coding-agent";
22
+ import { Type } from "typebox";
23
+ import { bilingual, pick } from "./i18n.js";
24
+ import { decodeText } from "./text-sniff.js";
25
+ /** 单文件正文上限(与 agent-service fillSkillContents 同口径:8KB)。 */
26
+ export const SKILL_TOOL_FILE_CAP = 8192;
27
+ /** 精确名匹配(大小写敏感,与 skillsFullText 名单语义一致)。 */
28
+ export function findSkill(skills, name) {
29
+ const n = name.trim();
30
+ if (!n)
31
+ return undefined;
32
+ return skills.find((s) => s.name === n);
33
+ }
34
+ /** 大小写不敏感子串纠错(空 query 全匹配,调用方截行数)。 */
35
+ export function suggestSkills(skills, name) {
36
+ const q = name.trim().toLowerCase();
37
+ if (!q)
38
+ return skills;
39
+ return skills.filter((s) => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q));
40
+ }
41
+ /** 名录文本(一行一名 + 一句话描述,无技能时给空目录句)。 */
42
+ export function formatSkillCatalog(skills, lang) {
43
+ if (skills.length === 0) {
44
+ return pick(lang, "当前没有可用技能。", "No skills are currently available.", "skill.catalog.empty");
45
+ }
46
+ const head = pick(lang, `可用技能(${skills.length}):`, `Available skills (${skills.length}):`, "skill.catalog.title", { count: skills.length });
47
+ const lines = skills.map((s) => `- ${s.name}${s.description.trim() ? ` — ${s.description.trim()}` : ""}`);
48
+ return `${head}\n${lines.join("\n")}`;
49
+ }
50
+ /** 技能全文块(与 SDK 的 /skill:name 展开格式一致,前端按 skill 卡片渲染)。 */
51
+ export function renderSkillContent(name, location, body) {
52
+ return `<skill name="${name}" location="${location}">\n${body.trim()}\n</skill>`;
53
+ }
54
+ export function makeSkillTool(host, lang) {
55
+ const getLang = lang ?? (() => "en");
56
+ const text = (t, details = {}) => ({
57
+ content: [{ type: "text", text: t }],
58
+ details,
59
+ });
60
+ return defineTool({
61
+ name: "skill",
62
+ label: "Load a skill",
63
+ description: bilingual("Load a skill's full text by its exact name (see the <available_skills> catalog in the system prompt). " +
64
+ "Prefer this over reading the skill file with the read tool — no path guessing needed. " +
65
+ "Call without a name to list the current catalog.", "按技能精确名加载其全文(名录见系统提示词里的 <available_skills>)。" +
66
+ "优先用它,不要自己拼路径调 read 工具读技能文件。" +
67
+ "不给名则返回当前名录。"),
68
+ promptSnippet: bilingual("load a skill's full text by name (skill tool, preferred over read)", "按名加载技能全文(skill 工具,优先于 read)"),
69
+ parameters: Type.Object({
70
+ name: Type.Optional(Type.String({
71
+ description: "Exact skill name from the catalog. Omit to list the current catalog.",
72
+ })),
73
+ }),
74
+ execute: async (_id, p) => {
75
+ let skills = [];
76
+ try {
77
+ skills = host.listSkills();
78
+ }
79
+ catch {
80
+ skills = [];
81
+ }
82
+ const L = getLang();
83
+ const name = typeof p.name === "string" ? p.name.trim() : "";
84
+ // 无名 → 名录 + 用法。
85
+ if (!name) {
86
+ const hint = pick(L, "用 skill({name}) 按名取全文。", "Use skill({name}) to load the full text by name.", "skill.catalog.hint");
87
+ return text(`${formatSkillCatalog(skills, L)}\n${hint}`, { count: skills.length });
88
+ }
89
+ const found = findSkill(skills, name);
90
+ // 找错名 → 纠错列表(不抛错,报名录)。
91
+ if (!found) {
92
+ const sug = suggestSkills(skills, name).slice(0, 10);
93
+ const head = pick(L, `没有名为 ${name} 的技能。`, `No skill named ${name}.`, "skill.not.found", { name });
94
+ const sugText = sug.length > 0 ? `\n${formatSkillCatalog(sug, L)}` : `\n${formatSkillCatalog(skills, L)}`;
95
+ return text(`${head}${sugText}`, { name, count: skills.length });
96
+ }
97
+ // 命中 → 最好努力读正文(每次重读,不缓存;失败/超限给可读错)。
98
+ if (!found.filePath) {
99
+ return text(pick(L, `技能 ${name} 没有关联文件路径,取不到全文。`, `Skill ${name} has no file path; full text unavailable.`, "skill.no.filepath", { name }));
100
+ }
101
+ try {
102
+ const st = statSync(found.filePath);
103
+ if (!st.isFile() || st.size <= 0 || st.size > SKILL_TOOL_FILE_CAP) {
104
+ return text(pick(L, `技能 ${name} 的文件不可读(不存在/超 ${SKILL_TOOL_FILE_CAP / 1024}KB 上限)。`, `Skill ${name} file unreadable (missing or over the ${SKILL_TOOL_FILE_CAP / 1024}KB cap).`, "skill.file.unreadable", { name }));
105
+ }
106
+ const body = decodeText(readFileSync(found.filePath).subarray(0, st.size)).trim();
107
+ if (!body) {
108
+ return text(pick(L, `技能 ${name} 的文件是空的。`, `Skill ${name} file is empty.`, "skill.file.empty", { name }));
109
+ }
110
+ return text(renderSkillContent(found.name, found.filePath, body), {
111
+ name: found.name,
112
+ location: found.filePath,
113
+ });
114
+ }
115
+ catch {
116
+ return text(pick(L, `技能 ${name} 的文件读取失败(可能已被删除或移走)。`, `Failed to read skill ${name} file (may have been deleted or moved).`, "skill.file.failed", { name }));
117
+ }
118
+ },
119
+ });
120
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * 自定义上下文压缩阈值(Soft Cap,issue #229)。
3
+ *
4
+ * 背景:SDK 的自动压缩只看物理上限(shouldCompact:tokens > window - reserveTokens,
5
+ * reserveTokens 默认 16384)。对 1M 上下文的模型会堆到 ~900K 才压;对 Grok 这类
6
+ * 200K 起阶梯翻倍计费的模型,会在高价区跑很久,还伴随长上下文降智。
7
+ *
8
+ * 实现:软上限 C 转成 reserveTokens = W - C,经
9
+ * session.settingsManager.applyOverrides({ compaction: { reserveTokens } })
10
+ * 注入(与重试次数覆盖同一 live 机制,reload/建会话/换模型后重放)。
11
+ * 关掉软上限时回填 SDK 默认(DEFAULT_COMPACTION_SETTINGS.reserveTokens),
12
+ * 不让旧覆盖泄漏。
13
+ */
14
+ import { DEFAULT_COMPACTION_SETTINGS } from "@earendil-works/pi-coding-agent";
15
+ /** SDK 默认保留 token(软上限关闭时的回填值)。 */
16
+ export const DEFAULT_COMPACTION_RESERVE_TOKENS = DEFAULT_COMPACTION_SETTINGS.reserveTokens;
17
+ /** 软上限下限(tokens):低于此值每轮都压,无意义,直接视为关闭。 */
18
+ export const SOFT_CAP_MIN_TOKENS = 2000;
19
+ /** 压缩后至少要剩这么多工作空间(摘要本身也要占 reserve*0.8 的预算)。 */
20
+ export const SOFT_CAP_MIN_HEADROOM = 2048;
21
+ /** 单客户端最多存多少条按模型覆盖(防手滑粘贴刷爆 client-state)。 */
22
+ export const SOFT_CAP_MAX_OVERRIDES = 64;
23
+ /** 归一化全局软上限:非数值/<=0 = 关闭;钳制到 [0, 10_000_000] 整数。 */
24
+ export function normalizeSoftCapTokens(v) {
25
+ const n = Math.floor(Number(v));
26
+ if (!Number.isFinite(n) || n <= 0)
27
+ return 0;
28
+ return Math.min(10_000_000, n);
29
+ }
30
+ /** 归一化按模型覆盖:key = "provider/id" 非空(≤200 字),value 走
31
+ * normalizeSoftCapTokens;0 值条目丢弃(= 用全局);上限 64 条。纯函数。 */
32
+ export function normalizeSoftCapByModel(v) {
33
+ if (!v || typeof v !== "object" || Array.isArray(v))
34
+ return {};
35
+ const out = {};
36
+ for (const [k, val] of Object.entries(v)) {
37
+ if (Object.keys(out).length >= SOFT_CAP_MAX_OVERRIDES)
38
+ break;
39
+ const key = String(k).trim();
40
+ if (!key || key.length > 200)
41
+ continue;
42
+ const n = normalizeSoftCapTokens(val);
43
+ if (n > 0)
44
+ out[key] = n;
45
+ }
46
+ return out;
47
+ }
48
+ /** 当前模型的生效软上限(0 = 关闭):按模型覆盖优先于全局。 */
49
+ export function effectiveSoftCap(globalCap, byModel, modelId) {
50
+ if (modelId) {
51
+ const per = byModel?.[modelId.trim()];
52
+ if (per !== undefined && per > 0)
53
+ return per;
54
+ }
55
+ return globalCap > 0 ? globalCap : 0;
56
+ }
57
+ /** 软上限 → SDK reserveTokens(null = 不合法/关闭,走 SDK 默认)。
58
+ * 合法条件:cap ≥ SOFT_CAP_MIN_TOKENS 且压完至少剩 SOFT_CAP_MIN_HEADROOM。 */
59
+ export function softCapToReserve(contextWindow, cap) {
60
+ if (!Number.isFinite(contextWindow) || contextWindow <= 0)
61
+ return null;
62
+ if (!Number.isFinite(cap) || cap < SOFT_CAP_MIN_TOKENS)
63
+ return null;
64
+ const reserve = Math.floor(contextWindow - cap);
65
+ if (reserve < SOFT_CAP_MIN_HEADROOM)
66
+ return null;
67
+ return reserve;
68
+ }
@@ -623,6 +623,12 @@ export class SubagentTemplatesStore {
623
623
  const t = normalize(input);
624
624
  if (!t)
625
625
  return `模板名称不合法(去空白后 1-${NAME_MAX} 字符)`;
626
+ // replace + 空提示词 = 子代理静默用默认 persona 运行(模板等于没生效),
627
+ // 在保存期直接拦下:只想限定白名单请用 append 模式。读盘的老数据不拦
628
+ // (向后兼容,只拦新保存)。
629
+ if (t.promptMode === "replace" && !t.systemPrompt.trim() && !(t.systemPromptEn ?? "").trim()) {
630
+ return `模板 "${t.name}" 用了 replace 模式但系统提示词为空:replace 会整体替换子代理 persona,空提示词等于模板没生效。请填写提示词(中文或英文至少一个),只想限定技能/扩展白名单请用 append 模式`;
631
+ }
626
632
  const list = this.load();
627
633
  const i = list.findIndex((x) => x.name === t.name);
628
634
  if (i >= 0)
@@ -130,7 +130,18 @@ export function makeSubagentTools(host, lang, selfConvId) {
130
130
  if (p.template && !host.isTemplateUsable(p.template)) {
131
131
  return text(pick(getLang(), `子代理模板不可用:${p.template}(不存在或已停用)。用 subagent_templates 查看当前可用模板清单;不传 template 则按默认配置运行。`, `Subagent template unavailable: ${p.template} (missing or disabled). Use subagent_templates to list available templates; omit template to run with defaults.`, "subagents.spawn.template.unavailable", { "p.template": p.template }));
132
132
  }
133
- const convId = await host.spawnSubagent(p.prompt, p.type ?? "general", p.cwd ?? ctx.cwd, p.template, p.model);
133
+ // spawn 通道是唯一的失败面(数量上限 / runtime 创建失败 / 坏 cwd 都经由
134
+ // host 抛错):转成返回文本而不是直接抛,让 AI 能读到原因并调整重试。
135
+ let convId;
136
+ try {
137
+ convId = await host.spawnSubagent(p.prompt, p.type ?? "general", p.cwd ?? ctx.cwd, p.template, p.model);
138
+ }
139
+ catch (err) {
140
+ const msg = err instanceof Error ? err.message : String(err);
141
+ return text(pick(getLang(), `子代理启动失败:${msg}`, `Failed to start subagent: ${msg}`, "subagents.spawn.failed", {
142
+ error: msg,
143
+ }));
144
+ }
134
145
  const subagentType = p.type ?? "general";
135
146
  const subagentTitleText = subagentTitle(p.prompt);
136
147
  // Optional segments are pre-rendered per language (translators pick
@@ -197,6 +208,10 @@ export function makeSubagentTools(host, lang, selfConvId) {
197
208
  }),
198
209
  }),
199
210
  execute: async (_id, p) => {
211
+ if (!host.getSubagent(p.runId)) {
212
+ const missingSteerId = shortId(p.runId);
213
+ return text(pick(getLang(), `未找到子代理 ${missingSteerId}(可能已移出),消息未注入。请用 subagent_list 确认仍在运行的子代理。`, `Subagent ${missingSteerId} not found (may have been dismissed); message not injected. Use subagent_list to confirm running subagents.`, "subagents.steer.not.found", { missingSteerId: missingSteerId }));
214
+ }
200
215
  await host.steerSubagent(p.runId, p.message);
201
216
  const steerId = shortId(p.runId);
202
217
  return text(pick(getLang(), `已向子代理 ${steerId} 注入消息。`, `Message injected into subagent ${steerId}.`, "subagents.steer.injected", { steerId: steerId }));
@@ -229,6 +244,10 @@ export function makeSubagentTools(host, lang, selfConvId) {
229
244
  }),
230
245
  }),
231
246
  execute: async (_id, p) => {
247
+ if (!host.getSubagent(p.runId)) {
248
+ const missingStopId = shortId(p.runId);
249
+ return text(pick(getLang(), `未找到子代理 ${missingStopId}(可能已移出或已结束),无需停止。请用 subagent_list 确认仍在运行的子代理。`, `Subagent ${missingStopId} not found (may have been dismissed or already finished); nothing to stop. Use subagent_list to confirm running subagents.`, "subagents.stop.not.found", { missingStopId: missingStopId }));
250
+ }
232
251
  await host.stopSubagent(p.runId);
233
252
  const stopId = shortId(p.runId);
234
253
  return text(pick(getLang(), `已请求停止子代理 ${stopId}。`, `Stop requested for subagent ${stopId}.`, "subagents.stop.requested", { stopId: stopId }));
@@ -239,12 +258,15 @@ export function makeSubagentTools(host, lang, selfConvId) {
239
258
  label: "Wait for subagents",
240
259
  description: bilingual("Wait for multiple subagents to finish at once (blocks this round until all reach a terminal state or time out), " +
241
260
  "then summarize each result/error — no need to poll subagent_get_result. Pass runIds for specific subagents " +
242
- "(convIds returned by subagent_spawn); omit = wait for all currently running ones. The calling session itself " +
243
- "is never waited on (a subagent calling this without runIds won't deadlock on itself). " +
261
+ "(convIds returned by subagent_spawn); omit = wait for own descendant subagents when called from a subagent, " +
262
+ "else all currently running ones. The calling session itself and its ancestors are never waited on " +
263
+ "(a subagent calling this without runIds won't deadlock on itself or its parent). " +
264
+ "Descendants spawned during the wait are picked up automatically. " +
244
265
  "On timeout or abort of this round, returns the remaining unfinished list; call again to continue waiting. " +
245
266
  "Good for: collecting parallel subagents.", "一次性等待多个子代理全部完成(阻塞本回合直到它们都到达终态或超时),然后汇总返回每个的结果/错误——" +
246
267
  "不用反复调 subagent_get_result 轮询。传 runIds 指定要等的子代理(subagent_spawn 返回的 convId);" +
247
- "不传 = 等当前全部运行中的子代理。调用者自身永不计入等待(子代理不传 runIds 时不会等自己)。" +
268
+ "不传 = 子代理调用时只等自己的后代,主对话调用时等当前全部运行中的子代理。调用者自身与祖先永不计入等待" +
269
+ "(子代理不传 runIds 时不会等自己或父级,避免父子互等到超时)。等待期间新派生的后代会自动纳入。" +
248
270
  "超时或本轮被中止时返回剩余未完成名单,可再次调用继续等。" +
249
271
  "适合:并行派发多个子代理后收口。"),
250
272
  promptSnippet: "wait for multiple subagents to finish (no polling) and get all results",
@@ -259,25 +281,129 @@ export function makeSubagentTools(host, lang, selfConvId) {
259
281
  })),
260
282
  }),
261
283
  execute: async (_id, p, signal) => {
262
- const wanted = new Set(p.runIds && p.runIds.length > 0 ? p.runIds : host.listSubagents().map((r) => r.convId));
263
- // 调用者自身永不等待:子代理调本工具时它自己正在 streaming,不排除
264
- // 就是自己等自己、永远到超时(self-wait deadlock)。主会话的普通
265
- // 对话 id 不在子代理列表里,delete 是 no-op。
266
- if (selfConvId)
267
- wanted.delete(selfConvId);
284
+ // 祖先链:调用者往上经 parentId 能走到的子代理集合。父级正在执行中的
285
+ // wait_all 卡着(streaming=true),等它 = 父子互等、必到超时才返回。
286
+ // parentId 可能指向普通主对话(不在子代理列表里),走到空即停;环按 visited 截断。
287
+ const ancestorIdsOf = (selfId) => {
288
+ const out = new Set();
289
+ const seen = new Set([selfId]);
290
+ let cur = host.getSubagent(selfId)?.parentId;
291
+ while (cur && !seen.has(cur)) {
292
+ seen.add(cur);
293
+ // 只有子代理才可能被圈进等待集;普通主对话记下来也无妨(wanted 里没有它)。
294
+ out.add(cur);
295
+ cur = host.getSubagent(cur)?.parentId;
296
+ }
297
+ return out;
298
+ };
299
+ // 后代展开:roots 里任一 id 经 parentId 链能向上走到的子代理。等待集
300
+ // 传了显式 runIds 时也要顺带等它们的后代(fire-and-forget 的孙子辈否则会漏)。
301
+ const expandDescendants = (roots) => {
302
+ if (roots instanceof Set ? roots.size === 0 : roots.length === 0)
303
+ return [];
304
+ const rootSet = roots instanceof Set ? roots : new Set(roots);
305
+ const all = host.listSubagents();
306
+ const byId = new Map(all.map((s) => [s.convId, s]));
307
+ const out = [];
308
+ for (const s of all) {
309
+ if (rootSet.has(s.convId))
310
+ continue;
311
+ let curParent = s.parentId;
312
+ const seen = new Set();
313
+ while (curParent) {
314
+ if (rootSet.has(curParent)) {
315
+ out.push(s.convId);
316
+ break;
317
+ }
318
+ if (seen.has(curParent))
319
+ break;
320
+ seen.add(curParent);
321
+ curParent = byId.get(curParent)?.parentId;
322
+ // parentId 指向普通主对话(不在 byId 里):链到此为止,
323
+ // 但 root 本身可能就是那个主对话 id(主调 wait_all 的 selfConvId)。
324
+ if (!curParent)
325
+ break;
326
+ }
327
+ }
328
+ return out;
329
+ };
330
+ const explicit = p.runIds && p.runIds.length > 0;
331
+ const wanted = new Set();
332
+ const roots = new Set();
333
+ let implicitGlobal = false;
334
+ // 调用者自身 + 祖先永不等待:子代理调本工具时它自己正在 streaming,
335
+ // 父级同样 streaming(卡在它自己的 wait 里),等谁都是互等死锁到超时。
336
+ // 先算好排除集,显式 runIds 里的祖先直接剔除(不再展开它的子树,
337
+ // 否则等父会顺带把整棵子树含兄弟分支都圈进来)。
338
+ const excluded = new Set();
339
+ if (selfConvId) {
340
+ excluded.add(selfConvId);
341
+ for (const a of ancestorIdsOf(selfConvId))
342
+ excluded.add(a);
343
+ }
344
+ if (explicit) {
345
+ for (const id of p.runIds) {
346
+ if (!excluded.has(id))
347
+ roots.add(id);
348
+ }
349
+ for (const id of roots)
350
+ wanted.add(id);
351
+ for (const id of expandDescendants(roots)) {
352
+ if (!excluded.has(id))
353
+ wanted.add(id);
354
+ }
355
+ }
356
+ else if (selfConvId && host.getSubagent(selfConvId)) {
357
+ // 子代理无参:只等自己的后代(不含自己)。全局等会把父级/无关兄弟
358
+ // 也圈进来:父级卡在 wait 里 streaming=true,子等父 = 父子互等到超时。
359
+ roots.add(selfConvId);
360
+ for (const id of expandDescendants(roots)) {
361
+ if (!excluded.has(id))
362
+ wanted.add(id);
363
+ }
364
+ }
365
+ else {
366
+ // 主对话无参:等当前全部(保持老语义),但同样动态追后代。
367
+ implicitGlobal = true;
368
+ for (const r of host.listSubagents()) {
369
+ if (!excluded.has(r.convId))
370
+ wanted.add(r.convId);
371
+ }
372
+ }
268
373
  if (wanted.size === 0) {
269
374
  const emptyLang = getLang();
270
- return text(pick(emptyLang, "没有需要等待的子代理(调用者自身不计入等待)。", "No subagents to wait for (the calling session itself is never waited on).", "subagents.wait.empty"));
375
+ return text(pick(emptyLang, "没有需要等待的子代理(调用者自身与祖先不计入等待;子代理无参时只等自己的后代)。", "No subagents to wait for (the calling session itself and its ancestors are never waited on; a subagent without runIds only waits for its own descendants).", "subagents.wait.empty"));
271
376
  }
272
377
  const timeoutMs = Math.min(Math.max(p.timeoutSeconds ?? 600, 1), Math.floor(WAIT_CAP_MS / 1000)) * 1000;
273
378
  const waitStart = Date.now();
274
379
  const deadline = waitStart + timeoutMs;
275
380
  // 已到终态的、(或已被移出找不到的)直接归位;剩下的阻塞轮询到
276
381
  // 全部完成/超时/中止(移出 = 无法再等,立即按收口处理)。
277
- const pending = () => [...wanted].filter((id) => {
278
- const r = host.getSubagent(id);
279
- return r !== undefined && !isSubagentTerminal(r);
280
- });
382
+ // 每轮动态追踪:等待期间新派生的后代(roots 的子孙、或主调全局新增)自动纳入,
383
+ // fire-and-forget 的孙子辈不会漏;自身与祖先永远排除(父子互等死锁)。
384
+ const trackNewArrivals = () => {
385
+ if (implicitGlobal) {
386
+ for (const r of host.listSubagents()) {
387
+ if (excluded.has(r.convId))
388
+ continue;
389
+ wanted.add(r.convId);
390
+ }
391
+ }
392
+ else {
393
+ for (const id of expandDescendants(roots)) {
394
+ if (excluded.has(id))
395
+ continue;
396
+ wanted.add(id);
397
+ }
398
+ }
399
+ };
400
+ const pending = () => {
401
+ trackNewArrivals();
402
+ return [...wanted].filter((id) => {
403
+ const r = host.getSubagent(id);
404
+ return r !== undefined && !isSubagentTerminal(r);
405
+ });
406
+ };
281
407
  while (pending().length > 0 && Date.now() < deadline && !(signal?.aborted ?? false)) {
282
408
  await new Promise((resolve) => setTimeout(resolve, 300));
283
409
  }
@@ -295,7 +421,7 @@ export function makeSubagentTools(host, lang, selfConvId) {
295
421
  ? `- ${shortId(r.convId)}(${r.type})· ${r.title} · ${subagentVerdict(r, tLang)}`
296
422
  : `- ${shortId(r.convId)} (${r.type}) · ${r.title} · ${subagentVerdict(r, tLang)}`) +
297
423
  (body ? `\n ${body}` : "") +
298
- (r.output ? `\n ${r.output.split("\n").slice(0, 30).join("\n ")}` : ""));
424
+ (r.output ? `\n ${clipSubagentOutput(r.output, tLang).split("\n").join("\n ")}` : ""));
299
425
  })
300
426
  .join("\n");
301
427
  const timeoutSecs = Math.round(timeoutMs / 1000);
@@ -354,6 +480,21 @@ export function makeSubagentTools(host, lang, selfConvId) {
354
480
  function shortId(id) {
355
481
  return id.slice(0, 8);
356
482
  }
483
+ /**
484
+ * wait_all 收口时的长输出截断:留头(任务复述)+ 留尾(最终结论),中间折叠。
485
+ * 旧实现只取前 30 行,长输出的子代理结论(一般在尾部)会被丢掉,模型收回一个
486
+ * 没结论的摘要。短输出原样返回。
487
+ */
488
+ function clipSubagentOutput(output, lang) {
489
+ const HEAD = 10;
490
+ const TAIL = 30;
491
+ const lines = output.split("\n");
492
+ if (lines.length <= HEAD + TAIL + 5)
493
+ return output;
494
+ const omitted = lines.length - HEAD - TAIL;
495
+ const marker = lang === "zh" ? `… [中间省略 ${omitted} 行,头尾保留] …` : `… [${omitted} lines omitted, head and tail kept] …`;
496
+ return [...lines.slice(0, HEAD), marker, ...lines.slice(-TAIL)].join("\n");
497
+ }
357
498
  /**
358
499
  * 收集 root 的传递子代理后代 id(parentId 链向上能走到 root 的;含嵌套的嵌套)。
359
500
  * root 自身不含;非子代理对话不含(普通对话不参与子代理清理口径)。
@@ -20,6 +20,10 @@ const PREVIEW_IMAGE_EXTS = new Set([
20
20
  "tiff",
21
21
  ]);
22
22
  const PREVIEW_VIDEO_EXTS = new Set(["mp4", "webm", "mov", "mkv", "avi", "m4v", "ogv", "mpg", "mpeg", "wmv", "flv"]);
23
+ /** 浏览器 <audio> 能直接播放的音频容器(present_files 的卡片与 /api/file 白名单
24
+ * 共用这一份):不在表里的(wma/aiff/midi/…)一律按二进制处理,让用户下载或
25
+ * 本地打开,而不是给一个永远播不出来的播放器。 */
26
+ const PREVIEW_AUDIO_EXTS = new Set(["mp3", "wav", "wave", "ogg", "oga", "opus", "m4a", "aac", "flac", "weba"]);
23
27
  const PREVIEW_TEXT_EXTS = new Set([
24
28
  // code
25
29
  "ts",
@@ -151,6 +155,17 @@ export function previewKind(name) {
151
155
  return "text";
152
156
  return "none";
153
157
  }
158
+ /**
159
+ * 扩展名是否为浏览器可直接播放的音频。与 previewKind 分开:PreviewKind 只有
160
+ * image/video/text/none 四档(右栏图标、附件、预览弹窗都按它分支),把音频塞进去
161
+ * 要动整条链路;而 /api/file 的媒体白名单与 present_files 的卡片只需要一个
162
+ * 「能不能 inline 播」的布尔判断。
163
+ */
164
+ export function isAudioFile(name) {
165
+ const dot = name.lastIndexOf(".");
166
+ const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
167
+ return PREVIEW_AUDIO_EXTS.has(ext);
168
+ }
154
169
  /**
155
170
  * Content sniff for the preview: any data that has no NUL bytes and no
156
171
  * meaningful control-char ratio is treated as text — so files with unknown
@@ -22,9 +22,10 @@ const ID_RE = /^[A-Za-z0-9_-]+$/;
22
22
  * filename stays ASCII (id must match ID_RE). */
23
23
  const THEME_NAME_RE = /\/\*\s*theme-name:\s*(.+?)\s*\*\//;
24
24
  const THEME_NAME_EN_RE = /\/\*\s*theme-name-en:\s*(.+?)\s*\*\//;
25
+ const THEME_GROUP_RE = /\/\*\s*theme-group:\s*(.+?)\s*\*\//;
25
26
  function readDisplayName(path, fallback) {
26
27
  try {
27
- const head = readFileSync(path, "utf8").slice(0, 300);
28
+ const head = readFileSync(path, "utf8").slice(0, 400);
28
29
  return head.match(THEME_NAME_RE)?.[1]?.trim() || fallback;
29
30
  }
30
31
  catch {
@@ -33,13 +34,25 @@ function readDisplayName(path, fallback) {
33
34
  }
34
35
  function readDisplayNameEn(path) {
35
36
  try {
36
- const head = readFileSync(path, "utf8").slice(0, 300);
37
+ const head = readFileSync(path, "utf8").slice(0, 400);
37
38
  return head.match(THEME_NAME_EN_RE)?.[1]?.trim() || undefined;
38
39
  }
39
40
  catch {
40
41
  return undefined;
41
42
  }
42
43
  }
44
+ function readGroup(path) {
45
+ try {
46
+ const head = readFileSync(path, "utf8").slice(0, 400);
47
+ const g = head.match(THEME_GROUP_RE)?.[1]?.trim();
48
+ if (g === "classic")
49
+ return "classic";
50
+ }
51
+ catch {
52
+ // ignore
53
+ }
54
+ return "builtin";
55
+ }
43
56
  export function listThemes(builtinDir, userDir) {
44
57
  const scan = (dir, builtin) => {
45
58
  if (!existsSync(dir))
@@ -53,10 +66,12 @@ export function listThemes(builtinDir, userDir) {
53
66
  const id = f.slice(0, -4);
54
67
  const name = readDisplayName(path, id);
55
68
  const nameEn = readDisplayNameEn(path);
69
+ const group = readGroup(path);
56
70
  return {
57
71
  id,
58
72
  name,
59
73
  builtin,
74
+ group,
60
75
  ...(nameEn ? { nameEn } : {}),
61
76
  };
62
77
  });
@@ -50,14 +50,20 @@ export const BROWSER_PAGE_TOOL_NAME = "browser_page";
50
50
  /** 别的对话读取工具(定义见 conversation-read-tool.ts):运行中对话(含子代理)+
51
51
  * 历史会话转录,只读。 */
52
52
  export const CONVERSATION_READ_TOOL_NAME = "conversation_read";
53
+ /** 技能全文按名加载工具(定义见 skill-tool.ts):名录见 {{skills}} 段,
54
+ * 全文走本工具按需取,不再让模型拼路径调 read。 */
55
+ export const SKILL_TOOL_NAME = "skill";
53
56
  /** 定时任务工具(定义见 schedule-agent-tool.ts):创建/查看/取消内置调度任务,
54
57
  * 到期自动唤醒发起对话执行 prompt 并汇报。 */
55
58
  export const SCHEDULE_TASK_TOOL_NAME = "schedule_task";
56
59
  export const SCHEDULE_LIST_TOOL_NAME = "schedule_list";
57
60
  export const SCHEDULE_CANCEL_TOOL_NAME = "schedule_cancel";
61
+ /** 展示文件工具(定义见 present-files-tool.ts):把图片/视频/文本作为预览卡片
62
+ * 推到对话里,卡片带预览/本地打开/在文件夹中显示/下载/复制路径。 */
63
+ export const PRESENT_FILES_TOOL_NAME = "present_files";
58
64
  /** 旧工具名(持久化迁移用;新代码一律用 MARKERS_LIST_TOOL_NAME)。 */
59
65
  export const LEGACY_MARKERS_LIST_TOOL_NAME = "markers_list";
60
- /** 可开关的 Agent 工具总目录(共 23 个;bash 本体与 SDK 内置 edit/read
66
+ /** 可开关的 Agent 工具总目录(共 25 个;bash 本体与 SDK 内置 edit/read
61
67
  * 不进目录——关了 agent 就残了,不给关)。 */
62
68
  export const AGENT_TOOL_CATALOG = [
63
69
  ...TERMINAL_TOOL_NAMES.map((name) => ({
@@ -81,11 +87,17 @@ export const AGENT_TOOL_CATALOG = [
81
87
  { name: BROWSER_PAGE_TOOL_NAME, group: "other", defaultOn: false, dshVisible: false },
82
88
  // 只读别的对话(含子代理实时消息与历史转录),默认开;DSH 引擎没有该 customTool。
83
89
  { name: CONVERSATION_READ_TOOL_NAME, group: "other", defaultOn: true, dshVisible: false },
90
+ // 技能全文按名加载(名录仍在 {{skills}} 段),默认开;DSH 引擎没有该 customTool
91
+ // (走 goal-rpc,无 customTool 注册面)。
92
+ { name: SKILL_TOOL_NAME, group: "other", defaultOn: true, dshVisible: false },
84
93
  // 定时/延时唤醒:默认开(不打开 AI 根本不知道能定时;60s 间隔底线+面板可随时取消),
85
94
  // DSH 引擎没有该 customTool(走 goal-rpc,无 customTool 注册面)。
86
95
  { name: SCHEDULE_TASK_TOOL_NAME, group: "other", defaultOn: true, dshVisible: false },
87
96
  { name: SCHEDULE_LIST_TOOL_NAME, group: "other", defaultOn: true, dshVisible: false },
88
97
  { name: SCHEDULE_CANCEL_TOOL_NAME, group: "other", defaultOn: true, dshVisible: false },
98
+ // 展示文件给用户(图片/视频内联、文本开预览弹窗、本地打开按钮):默认开,
99
+ // 不打开模型根本不知道能“给用户看”;DSH 引擎没有该 customTool(走 shipped preset)。
100
+ { name: PRESENT_FILES_TOOL_NAME, group: "other", defaultOn: true, dshVisible: false },
89
101
  ];
90
102
  const KNOWN_NAMES = new Set(AGENT_TOOL_CATALOG.map((t) => t.name));
91
103
  /** 是否为本表登记的可开关工具(未知名一律 false,不抛错)。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.90.1",
3
+ "version": "0.92.0",
4
4
  "description": "Just open your browser to get all your work done — browser cockpit for AI coding agents (chat, code, files, terminal, Git in one tab)",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -91,6 +91,7 @@
91
91
  "electron-updater": "^6.8.9",
92
92
  "express": "^5.2.1",
93
93
  "highlight.js": "^11.12.0",
94
+ "html-to-image": "^1.11.13",
94
95
  "katex": "^0.18.7",
95
96
  "node-pty": "^1.1.0",
96
97
  "react": "^19.3.0",
@@ -120,6 +120,7 @@ describe("my-plugin", () => {
120
120
  | `kind: "select"` + `options` + `value`,`host.ui.update(id, { value })` 刷新 | ✅ | 顶栏/输入框/消息工具条已渲染 |
121
121
  | `when` 肯定形条件(`file.isDir` / `file.isFile` / `session.isRunning` / `message.hasSelection`) | — | 右键菜单现场求值,置灰 |
122
122
  | `settings` 新增 `secret` 类型(加密存,浏览器只见有无) | ✅ | 设置表单掩码 + 留空不改 |
123
+ | `settings` 的 `select` 支持 `optionsFrom`(宿主现算候选值:`models` / `thinkingLevels`) | ✅ | 下拉自动带「跟随全局默认」 |
123
124
  | `host.llm.complete`(孤立无工具补全,不建对话) | ✅(要 `llm` 能力族) | — |
124
125
  | `schedule` 持久版(`{id, persistent, catchUp, label}`,落盘+补跑+进后台面板) | ✅ | — |
125
126
  | `host.requestPermission`(动态授权:net 补主机 / llm 限模型,用户逐条确认可记住) | ✅(基础族须已声明) | 确认框 + 设置面板「已授权能力」 |