pi-web-ui 0.91.0 → 0.93.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 (64) hide show
  1. package/CHANGELOG.md +90 -1
  2. package/README.md +69 -14
  3. package/README.zh-CN.md +36 -10
  4. package/bin/pi-web-ui.mjs +39 -8
  5. package/dist/server/agent-service.js +938 -163
  6. package/dist/server/auth-cookie.js +26 -0
  7. package/dist/server/claim-files-tool.js +115 -0
  8. package/dist/server/claim-store.js +301 -0
  9. package/dist/server/client-state.js +114 -6
  10. package/dist/server/conversation-read-tool.js +371 -28
  11. package/dist/server/conversation-touches.js +357 -0
  12. package/dist/server/delegate-task.js +10 -3
  13. package/dist/server/dsh/dsh-agent-service.js +50 -1
  14. package/dist/server/files-service.js +78 -26
  15. package/dist/server/http-proxy.js +72 -0
  16. package/dist/server/index.js +94 -11
  17. package/dist/server/model-admin.js +124 -2
  18. package/dist/server/model-enrich.js +478 -0
  19. package/dist/server/patch-node-pty.js +11 -0
  20. package/dist/server/plugin-schedule.js +27 -2
  21. package/dist/server/plugins.js +66 -10
  22. package/dist/server/present-files-tool.js +303 -0
  23. package/dist/server/protocol-version.js +1 -1
  24. package/dist/server/read-tool.js +125 -0
  25. package/dist/server/resolve-global-sdk.js +72 -0
  26. package/dist/server/sdk-origin.js +64 -0
  27. package/dist/server/serialize.js +80 -22
  28. package/dist/server/settings-service.js +19 -1
  29. package/dist/server/skill-tool.js +2 -1
  30. package/dist/server/subagents.js +30 -13
  31. package/dist/server/terminals.js +38 -18
  32. package/dist/server/text-sniff.js +15 -0
  33. package/dist/server/themes.js +32 -2
  34. package/dist/server/tool-info.js +89 -0
  35. package/dist/server/tool-manager.js +110 -11
  36. package/package.json +2 -2
  37. package/plugin-sdk/README.md +1 -0
  38. package/plugins/catalog.json +20 -0
  39. package/themes/ayu-light.css +136 -0
  40. package/themes/catppuccin-latte.css +140 -0
  41. package/themes/catppuccin.css +144 -0
  42. package/themes/codex.css +136 -0
  43. package/themes/cyberpunk.css +4 -0
  44. package/themes/dazzle.css +4 -0
  45. package/themes/everforest-light.css +136 -0
  46. package/themes/geist.css +136 -0
  47. package/themes/gruvbox-light.css +136 -0
  48. package/themes/kanagawa-lotus.css +136 -0
  49. package/themes/md-preview.css +4 -0
  50. package/themes/mist.css +4 -0
  51. package/themes/nord.css +134 -0
  52. package/themes/one-dark.css +135 -0
  53. package/themes/paper.css +16 -12
  54. package/themes/rose-pine-dawn.css +136 -0
  55. package/themes/sakura.css +4 -0
  56. package/themes/solarized-light.css +135 -0
  57. package/themes/tokyo-night.css +144 -0
  58. package/themes/white.css +4 -0
  59. package/web/dist/assets/{TerminalPanel-GtDQ1h85.js → TerminalPanel-C1A23aI-.js} +1 -1
  60. package/web/dist/assets/index-B48QWf0I.js +364 -0
  61. package/web/dist/assets/index-DGL5OInG.css +1 -0
  62. package/web/dist/index.html +2 -2
  63. package/web/dist/assets/index-CdsKltOS.css +0 -1
  64. package/web/dist/assets/index-OoULNF70.js +0 -363
@@ -0,0 +1,72 @@
1
+ /**
2
+ * resolve-global-sdk — 可选的「优先用全局/祖先那份 pi SDK」解析钩子(issue #260)。
3
+ *
4
+ * 背景:pi-web-ui 依赖 `@earendil-works/pi-coding-agent`,而 npm 全局安装会把依赖
5
+ * **嵌在** `<npm root -g>/pi-web-ui/node_modules/`(不 hoist,实测),Node 又「嵌套优先于
6
+ * 祖先」—— 于是用户 `npm i -g @earendil-works/pi-coding-agent@latest` 改的是全局那份,
7
+ * 服务加载的仍是自带那份,表现为「升了 0.86.1,横幅和 /api/health 还显示 0.85.1」。
8
+ *
9
+ * 本模块把解析顺序反过来,但**只在显式开启时**:
10
+ *
11
+ * PI_WEB_SDK=global → 祖先链上存在一份版本**更新**的 SDK 时,把裸标识符重定向到它;
12
+ * 否则原样不动(同版本也不折腾 —— 没意义)。
13
+ * (缺省 / 其它值) → 什么都不做,用自带副本。
14
+ *
15
+ * 为什么默认关:服务运行的 SDK 版本会变成「用户机器上装了什么」,同一个 pi-web-ui 版本在
16
+ * 不同机器上跑不同 SDK,报 bug 时无法复现;自带副本可复现、且是 CI 覆盖的那一份。
17
+ *
18
+ * 为什么只做「必须更新」这一道门:仓库没有 semver 依赖,手写完整范围解析容易出错,
19
+ * 「祖先那份比自带那份新」是保守且够用的判据(旧的 / 同版本的都不采用)。
20
+ *
21
+ * 安全约定:整个注册流程包在 try/catch 里,任何异常都退回默认解析 —— 这个模块**绝不允许**
22
+ * 让服务起不来。它必须在任何 SDK 静态 import **之前**被加载(`--import <本文件>`,
23
+ * 或前台启动时在 import 服务入口之前先 import 它)。
24
+ */
25
+ import { registerHooks } from "node:module";
26
+ import { pathToFileURL } from "node:url";
27
+ import { compareVersions, sdkCopies } from "./sdk-origin.js";
28
+ const PKG = "@earendil-works/pi-coding-agent";
29
+ /**
30
+ * 该用哪一份对外解析(纯函数,便于单测)。
31
+ * `copies` 按 Node 的解析顺序([0] = 自带的、实际会被用到的),返回 null = 保持默认。
32
+ */
33
+ export function pickGlobalSdk(copies, mode) {
34
+ if ((mode ?? "bundled").trim().toLowerCase() !== "global")
35
+ return null;
36
+ const bundled = copies[0];
37
+ // 只考虑祖先链上的副本,且必须是**更新**的(旧的或同版本都不折腾)。
38
+ return copies.slice(1).find((c) => !bundled || compareVersions(c.version, bundled.version) > 0) ?? null;
39
+ }
40
+ /** 注册钩子;返回实际选中的副本(null = 没启用 / 没找到可用的)。 */
41
+ export function registerGlobalSdkPreference(mode = process.env.PI_WEB_SDK, fromFile = import.meta.url) {
42
+ try {
43
+ const copies = sdkCopies(fromFile);
44
+ const chosen = pickGlobalSdk(copies, mode);
45
+ if (!chosen)
46
+ return null;
47
+ registerHooks({
48
+ resolve(specifier, context, nextResolve) {
49
+ if (specifier !== PKG)
50
+ return nextResolve(specifier, context);
51
+ // 以目标副本的 package.json 为父重新解析:Node 从那里往上找,
52
+ // 第一个命中的就是它自己 —— 等价于「从那个包内部 import 自己」。
53
+ try {
54
+ return nextResolve(specifier, { ...context, parentURL: pathToFileURL(chosen.path).href });
55
+ }
56
+ catch {
57
+ return nextResolve(specifier, context);
58
+ }
59
+ },
60
+ });
61
+ return chosen;
62
+ }
63
+ catch {
64
+ // 钩子注册失败(Node 太老 / 环境怪异)→ 静默退回默认解析,绝不影响启动。
65
+ return null;
66
+ }
67
+ }
68
+ // ---- 副作用:被 `--import` 加载时立刻生效 --------------------------------
69
+ const used = registerGlobalSdkPreference();
70
+ if (used) {
71
+ console.log(`[pi-web-ui] PI_WEB_SDK=global → using the pi SDK at ${used.path} (v${used.version})`);
72
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * sdk-origin — 本进程实际加载的是哪一份 pi SDK,以及被它遮蔽的副本(issue #260)。
3
+ *
4
+ * 背景:npm 全局安装会把依赖**嵌在** `<npm root -g>/pi-web-ui/node_modules/`(不 hoist,
5
+ * 实测),而 Node 的解析顺序是「嵌套优先于祖先」。于是用户按更新面板/README 的提示
6
+ * `npm i -g @earendil-works/pi-coding-agent@latest` 升级全局那份时,服务实际加载的
7
+ * 仍是自带副本 —— 表现为「升了 0.86.1,横幅和 /api/health 还显示 0.85.1」。
8
+ *
9
+ * 这里把「所有能被解析到的副本」按 Node 的顺序算出来([0] = 生效的那份),
10
+ * 让启动横幅与健康检查说实话,而不是让用户去猜。
11
+ *
12
+ * 纯函数 + 零副作用,便于单测(见 tests/unit/sdk-origin.test.ts)。
13
+ */
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ const PKG = "@earendil-works/pi-coding-agent";
18
+ /** 从某个文件出发,逐级向上查找 `<dir>/node_modules/<pkg>/package.json`。
19
+ * `fromFile` 收 URL(`import.meta.url`)或普通文件路径(便于单测)。 */
20
+ export function sdkCopies(fromFile = import.meta.url) {
21
+ const out = [];
22
+ let dir = dirname(fromFile.startsWith("file:") ? fileURLToPath(fromFile) : fromFile);
23
+ for (;;) {
24
+ const pj = join(dir, "node_modules", PKG, "package.json");
25
+ if (existsSync(pj)) {
26
+ try {
27
+ const version = JSON.parse(readFileSync(pj, "utf8")).version;
28
+ if (typeof version === "string" && version)
29
+ out.push({ path: pj, version });
30
+ }
31
+ catch {
32
+ // 坏 JSON:跳过这一份,不影响其它候选(best-effort,同 process-utils 的口径)
33
+ }
34
+ }
35
+ const parent = dirname(dir);
36
+ if (parent === dir)
37
+ break;
38
+ dir = parent;
39
+ }
40
+ return out;
41
+ }
42
+ /** 点分版本号比较(够用即可:只比数字段,缺位当 0)。 */
43
+ export function compareVersions(a, b) {
44
+ const pa = a.split(".");
45
+ const pb = b.split(".");
46
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
47
+ const na = Number.parseInt(pa[i] ?? "0", 10) || 0;
48
+ const nb = Number.parseInt(pb[i] ?? "0", 10) || 0;
49
+ if (na !== nb)
50
+ return na < nb ? -1 : 1;
51
+ }
52
+ return 0;
53
+ }
54
+ /**
55
+ * 启动横幅要打印的一行(不含前缀),以及是否需要提示「你升的不是服务在用的那份」。
56
+ * `effective` 为空时(没找到副本)返回空串,调用方照旧只打印版本号。
57
+ */
58
+ export function sdkOriginNote(copies, effectiveVersion) {
59
+ const shadowed = copies.slice(1);
60
+ const newer = shadowed.filter((c) => compareVersions(c.version, effectiveVersion) > 0);
61
+ if (newer.length === 0)
62
+ return null;
63
+ return `a newer pi SDK is installed elsewhere (${newer.map((c) => `v${c.version}`).join(", ")}) but pi-web-ui runs its own bundled copy (v${effectiveVersion}).`;
64
+ }
@@ -1,35 +1,59 @@
1
1
  const TEXT_CAP = 200_000;
2
2
  const TOOL_OUTPUT_CAP = 100_000;
3
3
  const ARGS_CAP = 20_000;
4
+ /**
5
+ * toolResult 里单张图片进快照的上限(dataUrl 字符数 ≈ 1.5MB 二进制)。
6
+ * 视口/元素截图随便过;超大整页截图回落成占位文本(模型侧不受影响 —— 图它
7
+ * 已经看过,只是浏览器这边的缩略图不带)。超限截断 base64 会得到一张坏图,
8
+ * 所以是整张丢、不是截一半。
9
+ */
10
+ const TOOL_RESULT_IMAGE_CAP = 2_000_000;
11
+ /** 单条 toolResult 最多带几张图进快照(防图片刷屏把快照撑爆)。 */
12
+ const TOOL_RESULT_IMAGE_MAX = 8;
13
+ /**
14
+ * toolResult.details 的体积上限。details 是给 UI 用的结构化元数据(如
15
+ * present_files 的卡片数据、ask_user_question 的答案),快照每 60ms 推一次,
16
+ * 不能无节制。超过上限时**整个丢掉**(而不是截断 —— 截断后的 JSON 不可解析,
17
+ * 前端还得写容错);工具作者应自己封顶(见 present-files-tool.ts 的摘录预算)。
18
+ */
19
+ const TOOL_DETAILS_CAP = 64_000;
4
20
  function truncate(s, cap) {
5
21
  if (s.length <= cap)
6
22
  return { text: s, truncated: false };
7
23
  return { text: `${s.slice(0, cap)}\n\n… [truncated]`, truncated: true };
8
24
  }
25
+ /**
26
+ * SDK/工具的图片块 → 前端可直接 <img> 的 UiImageBlock。
27
+ * Canonical ImageContent shape is { type, data, mimeType }; tolerate the
28
+ * legacy { source } wrapper too.
29
+ * cap: dataUrl 超过该字符数回 undefined(调用方按占位文本处理);默认不限
30
+ * (用户粘贴图走 image-paste 的缩放管线,尺寸本来就有界)。
31
+ */
32
+ function imageBlockToUi(b, cap = Number.POSITIVE_INFINITY) {
33
+ const img = b;
34
+ if (typeof img.data === "string" && img.data.length > 0) {
35
+ const dataUrl = `data:${img.mimeType ?? "image/png"};base64,${img.data}`;
36
+ if (dataUrl.length > cap)
37
+ return undefined;
38
+ return { type: "image", dataUrl, mimeType: img.mimeType };
39
+ }
40
+ const src = img.source;
41
+ if (src?.type === "base64" && src.data) {
42
+ const dataUrl = `data:${src.mediaType ?? "image/png"};base64,${src.data}`;
43
+ if (dataUrl.length > cap)
44
+ return undefined;
45
+ return { type: "image", dataUrl, mimeType: src.mediaType };
46
+ }
47
+ if (typeof src?.url === "string" && src.url)
48
+ return { type: "image", dataUrl: src.url };
49
+ return undefined;
50
+ }
9
51
  function serializeUserContent(content) {
10
52
  if (typeof content === "string")
11
53
  return [{ type: "text", text: content }];
12
54
  return content.map((b) => {
13
55
  if (b.type === "image") {
14
- const img = b;
15
- // Canonical ImageContent shape is { type, data, mimeType }; tolerate the
16
- // legacy { source } wrapper too.
17
- if (typeof img.data === "string" && img.data.length > 0) {
18
- return {
19
- type: "image",
20
- dataUrl: `data:${img.mimeType ?? "image/png"};base64,${img.data}`,
21
- mimeType: img.mimeType,
22
- };
23
- }
24
- const src = img.source;
25
- if (src?.type === "base64" && src.data) {
26
- return {
27
- type: "image",
28
- dataUrl: `data:${src.mediaType ?? "image/png"};base64,${src.data}`,
29
- mimeType: src.mediaType,
30
- };
31
- }
32
- return { type: "image", dataUrl: src?.url };
56
+ return imageBlockToUi(b) ?? { type: "image", dataUrl: undefined };
33
57
  }
34
58
  return { type: "text", text: String(b.text ?? "") };
35
59
  });
@@ -100,21 +124,55 @@ export function serializeMessage(m, seq) {
100
124
  timestamp: m.timestamp,
101
125
  model: m.model,
102
126
  provider: m.provider,
127
+ usageCost: typeof m.usage?.cost?.total === "number" ? m.usage.cost.total : undefined,
103
128
  stopReason: m.stopReason,
104
129
  errorMessage: m.errorMessage,
105
130
  };
106
131
  case "toolResult": {
107
- const raw = m.content.map((c) => (c.type === "text" ? c.text : "[image result]")).join("\n");
132
+ // 工具结果里的图片(web_shot 截图、read 读到的图……)要下发浏览器:
133
+ // 卡片里直接显示缩略图、点开放大(见 ToolCallBlock)。以前这里统一丢成
134
+ // "[image result]",用户只能看到占位文本。超限/超数的图仍回落占位文本。
135
+ const textParts = [];
136
+ const images = [];
137
+ for (const c of m.content) {
138
+ if (c.type === "text") {
139
+ textParts.push(c.text);
140
+ continue;
141
+ }
142
+ if (c.type === "image" && images.length < TOOL_RESULT_IMAGE_MAX) {
143
+ const ui = imageBlockToUi(c, TOOL_RESULT_IMAGE_CAP);
144
+ if (ui) {
145
+ images.push(ui);
146
+ continue;
147
+ }
148
+ }
149
+ textParts.push("[image result]");
150
+ }
151
+ const raw = textParts.join("\n");
108
152
  const { text, truncated } = truncate(raw, TOOL_OUTPUT_CAP);
109
- return {
153
+ const content = raw || images.length === 0 ? [{ type: "text", text, truncated }, ...images] : [...images];
154
+ const msg = {
110
155
  id: `t-${m.toolCallId}`,
111
156
  role: "toolResult",
112
- content: [{ type: "text", text, truncated }],
157
+ content,
113
158
  toolCallId: m.toolCallId,
114
159
  toolName: m.toolName,
115
160
  isError: m.isError,
116
161
  timestamp: m.timestamp,
117
162
  };
163
+ // 结构化元数据(tool result details)也要下发:present_files 的预览卡片
164
+ // 就靠它拿 kind/size/excerpt(前端不能靠再打一次 HTTP 才知类型),
165
+ // ask_user_question/todo_list 同理。体积封顶,超限整丢(见 TOOL_DETAILS_CAP)。
166
+ if (m.details !== undefined) {
167
+ try {
168
+ if (JSON.stringify(m.details).length <= TOOL_DETAILS_CAP)
169
+ msg.details = m.details;
170
+ }
171
+ catch {
172
+ // 循环引用等序列化不了的值:details 是附加信息,丢掉不影响消息本体。
173
+ }
174
+ }
175
+ return msg;
118
176
  }
119
177
  case "bashExecution": {
120
178
  const { text, truncated } = truncate(m.output, TOOL_OUTPUT_CAP);
@@ -9,7 +9,7 @@
9
9
  import { existsSync, readdirSync, readFileSync } from "node:fs";
10
10
  import { basename, dirname, join } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
- import { extensionKey, normalizeDisabledPluginTools, normalizeRetryMaxAttempts, normalizeSkillList, normalizeUiLayout, } from "./client-state.js";
12
+ import { extensionKey, normalizeDisabledPluginTools, normalizeRetryMaxAttempts, normalizeSkillList, normalizeToolWatchdogTimeoutMs, normalizeUiLayout, } from "./client-state.js";
13
13
  import { normalizeSoftCapByModel, normalizeSoftCapTokens } from "./soft-cap.js";
14
14
  import { findVisionModels, SYSTEM_PROMPT } from "./vision-bridge.js";
15
15
  import { COMMITMSG_SYSTEM_PROMPT } from "./scm-commitmsg.js";
@@ -289,6 +289,8 @@ export class SettingsService {
289
289
  terminalToolsEnabled: legacyTools.terminalToolsEnabled,
290
290
  terminalBash: this.settings.terminalBash,
291
291
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
292
+ toolWatchdogTimeoutMs: this.settings.toolWatchdogTimeoutMs,
293
+ readDirEnabled: this.settings.readDirEnabled !== false,
292
294
  editSoftEnabled: legacyTools.editSoftEnabled,
293
295
  questionnaireEnabled: legacyTools.questionnaireEnabled,
294
296
  parallelReminderEnabled: this.settings.parallelReminderEnabled ?? true,
@@ -297,6 +299,7 @@ export class SettingsService {
297
299
  autoReload: this.settings.autoReload ?? this.defaultDevNoCache(),
298
300
  thinkingWrap: this.settings.thinkingWrap,
299
301
  toolsWrap: this.settings.toolsWrap,
302
+ toolImagesEnabled: this.settings.toolImagesEnabled ?? true,
300
303
  visionBridgeEnabled: this.settings.visionBridgeEnabled,
301
304
  visionBridgeModel: this.settings.visionBridgeModel,
302
305
  visionBridgePromptMode: this.settings.visionBridgePromptMode,
@@ -458,6 +461,13 @@ export class SettingsService {
458
461
  if (partial.terminalBashIdleMs !== undefined) {
459
462
  this.settings.terminalBashIdleMs = Math.max(0, Math.floor(partial.terminalBashIdleMs) || 0);
460
463
  }
464
+ if (partial.toolWatchdogTimeoutMs !== undefined) {
465
+ this.settings.toolWatchdogTimeoutMs = normalizeToolWatchdogTimeoutMs(partial.toolWatchdogTimeoutMs);
466
+ }
467
+ // read 读目录开关:覆盖定义每次调用实时读取,改动即时生效,无需 reload。
468
+ if (partial.readDirEnabled !== undefined) {
469
+ this.settings.readDirEnabled = partial.readDirEnabled;
470
+ }
461
471
  // 目标模式总开关:运行时无需重载(goal bar / 服务端入口实时读取)。
462
472
  if (partial.goalModeEnabled !== undefined) {
463
473
  this.settings.goalModeEnabled = partial.goalModeEnabled;
@@ -478,6 +488,9 @@ export class SettingsService {
478
488
  if (partial.toolsWrap !== undefined) {
479
489
  this.settings.toolsWrap = partial.toolsWrap;
480
490
  }
491
+ if (partial.toolImagesEnabled !== undefined) {
492
+ this.settings.toolImagesEnabled = partial.toolImagesEnabled;
493
+ }
481
494
  // 编排模式 / skill 全文注入:before_agent_start 逐 run 实时读取(agent-service
482
495
  // composeInputs + 指导块追加),开关下一轮即生效,无需 reload runtime。
483
496
  if (partial.skillsFullText !== undefined) {
@@ -623,6 +636,10 @@ export class SettingsService {
623
636
  // 终端接管偏好随预设走;旧预设缺字段时保留当前值。
624
637
  terminalBash: p.terminalBash ?? this.settings.terminalBash,
625
638
  terminalBashIdleMs: p.terminalBashIdleMs ?? this.settings.terminalBashIdleMs,
639
+ // read 读目录是纯运行行为开关,不进预设——保留当前值。
640
+ readDirEnabled: this.settings.readDirEnabled !== false,
641
+ // toolWatchdogTimeoutMs 是纯运行行为参数,不进预设——保留当前值。
642
+ toolWatchdogTimeoutMs: this.settings.toolWatchdogTimeoutMs,
626
643
  editSoftEnabled: presetLegacy.editSoftEnabled,
627
644
  // 重试次数随预设走;旧预设缺字段时保留当前值,应用后即时注入各会话。
628
645
  retryMaxAttempts: p.retryMaxAttempts ?? this.settings.retryMaxAttempts,
@@ -644,6 +661,7 @@ export class SettingsService {
644
661
  autoReload: this.settings.autoReload,
645
662
  thinkingWrap: this.settings.thinkingWrap,
646
663
  toolsWrap: this.settings.toolsWrap,
664
+ toolImagesEnabled: this.settings.toolImagesEnabled ?? true,
647
665
  // UI 布局偏好也不进预设——保留当前值。
648
666
  uiLayout: normalizeUiLayout(this.settings.uiLayout),
649
667
  // Presets don't capture vision-bridge prefs — keep the current ones.
@@ -21,6 +21,7 @@ import { statSync, readFileSync } from "node:fs";
21
21
  import { defineTool } from "@earendil-works/pi-coding-agent";
22
22
  import { Type } from "typebox";
23
23
  import { bilingual, pick } from "./i18n.js";
24
+ import { SKILL_TOOL_NAME } from "./tool-manager.js";
24
25
  import { decodeText } from "./text-sniff.js";
25
26
  /** 单文件正文上限(与 agent-service fillSkillContents 同口径:8KB)。 */
26
27
  export const SKILL_TOOL_FILE_CAP = 8192;
@@ -58,7 +59,7 @@ export function makeSkillTool(host, lang) {
58
59
  details,
59
60
  });
60
61
  return defineTool({
61
- name: "skill",
62
+ name: SKILL_TOOL_NAME,
62
63
  label: "Load a skill",
63
64
  description: bilingual("Load a skill's full text by its exact name (see the <available_skills> catalog in the system prompt). " +
64
65
  "Prefer this over reading the skill file with the read tool — no path guessing needed. " +
@@ -61,7 +61,7 @@ export function subagentTitle(prompt) {
61
61
  export function withSubagentOwner(host, ownerId) {
62
62
  return {
63
63
  ...host,
64
- spawnSubagent: (prompt, type, cwd, templateName, model) => host.spawnSubagent(prompt, type, cwd, templateName, model, ownerId),
64
+ spawnSubagent: (prompt, type, cwd, templateName, model, _parentId, persist) => host.spawnSubagent(prompt, type, cwd, templateName, model, ownerId, persist),
65
65
  };
66
66
  }
67
67
  /**
@@ -125,6 +125,11 @@ export function makeSubagentTools(host, lang, selfConvId) {
125
125
  cwd: Type.Optional(Type.String({
126
126
  description: bilingual("Subagent working directory (relative/absolute). Defaults to the main session's cwd.", "子代理工作目录(相对/绝对)。默认继承主会话工作目录。"),
127
127
  })),
128
+ persist: Type.Optional(Type.Boolean({
129
+ description: bilingual("Optional: persist this conversation to disk as a regular session (saved in history, resumable). " +
130
+ "Default false (lightweight in-memory subagent). Use true for tasks that need long-term retention or human follow-up.", "可选:是否将该对话持久化落盘为普通对话(保存在历史会话中,可随时回顾与继续)。" +
131
+ "默认 false(轻量内存子代理)。需要长期留存或后续人工跟进的任务建议设为 true。"),
132
+ })),
128
133
  }),
129
134
  execute: async (_id, p, _signal, _onUpdate, ctx) => {
130
135
  if (p.template && !host.isTemplateUsable(p.template)) {
@@ -134,7 +139,7 @@ export function makeSubagentTools(host, lang, selfConvId) {
134
139
  // host 抛错):转成返回文本而不是直接抛,让 AI 能读到原因并调整重试。
135
140
  let convId;
136
141
  try {
137
- convId = await host.spawnSubagent(p.prompt, p.type ?? "general", p.cwd ?? ctx.cwd, p.template, p.model);
142
+ convId = await host.spawnSubagent(p.prompt, p.type ?? "general", p.cwd ?? ctx.cwd, p.template, p.model, undefined, p.persist);
138
143
  }
139
144
  catch (err) {
140
145
  const msg = err instanceof Error ? err.message : String(err);
@@ -151,8 +156,10 @@ export function makeSubagentTools(host, lang, selfConvId) {
151
156
  const templateLineEn = p.template ? `\nTemplate: ${p.template}` : "";
152
157
  const modelLineZh = p.model ? `\n模型:${p.model}` : "";
153
158
  const modelLineEn = p.model ? `\nModel: ${p.model}` : "";
154
- return text(pick(getLang(), `子代理已启动(运行列表可见):${convId}\n类型:${subagentType} · 标题:${subagentTitleText}${templateLineZh}${modelLineZh}` +
155
- `\n用 subagent_wait_all 一次等全部完成(不用轮询),subagent_get_result 取单个结果,subagent_list 看运行态,subagent_steer 改向,subagent_stop 停止。`, `Subagent started (visible in the running list): ${convId}\nType: ${subagentType} · Title: ${subagentTitleText}${templateLineEn}${modelLineEn}` +
159
+ const kindLabelZh = p.persist ? "普通持久化对话" : "子代理";
160
+ const kindLabelEn = p.persist ? "Persistent conversation" : "Subagent";
161
+ return text(pick(getLang(), `${kindLabelZh}已启动(运行列表可见):${convId}\n类型:${subagentType} · 标题:${subagentTitleText}${templateLineZh}${modelLineZh}` +
162
+ `\n用 subagent_wait_all 一次等全部完成(不用轮询),subagent_get_result 取单个结果,subagent_list 看运行态,subagent_steer 改向,subagent_stop 停止。`, `${kindLabelEn} started (visible in the running list): ${convId}\nType: ${subagentType} · Title: ${subagentTitleText}${templateLineEn}${modelLineEn}` +
156
163
  `\nUse subagent_wait_all to wait for all at once (no polling), subagent_get_result for a single result, subagent_list for live status, subagent_steer to redirect, subagent_stop to stop.`, "subagents.spawn.started", {
157
164
  convId: convId,
158
165
  subagentType: subagentType,
@@ -163,7 +170,7 @@ export function makeSubagentTools(host, lang, selfConvId) {
163
170
  templateLineEn: templateLineEn,
164
171
  modelLineZh: modelLineZh,
165
172
  modelLineEn: modelLineEn,
166
- }), { convId, template: p.template, model: p.model });
173
+ }), { convId, template: p.template, model: p.model, persisted: !!p.persist });
167
174
  },
168
175
  }),
169
176
  defineTool({
@@ -220,16 +227,24 @@ export function makeSubagentTools(host, lang, selfConvId) {
220
227
  defineTool({
221
228
  name: "subagent_list",
222
229
  label: "List subagents",
223
- description: bilingual("List all subagents and their live status: convId, type, state, title, message count (errors/aborts are marked in the state).", "列出全部子代理的运行态:convId、类型、状态、标题、消息数(报错/中止的会在状态里标出)。"),
230
+ description: bilingual("List all subagents and managed conversations with their live status: convId, type, state, title, message count (errors/aborts are marked in the state).", "列出全部子代理及受控对话的运行态:convId、类型、状态、标题、消息数(报错/中止的会在状态里标出)。"),
224
231
  promptSnippet: "list all subagents and their live status",
225
- parameters: Type.Object({}),
226
- execute: async () => {
227
- const list = host.listSubagents();
232
+ parameters: Type.Object({
233
+ kind: Type.Optional(Type.String({
234
+ enum: ["all", "subagent", "persistent"],
235
+ description: bilingual("Filter: all (default) = all managed tasks; subagent = only ephemeral in-memory subagents; persistent = only persistent conversations.", "过滤类型:all(默认)= 全部受控对话;subagent = 仅临时内存子代理;persistent = 仅持久化普通对话。"),
236
+ })),
237
+ }),
238
+ execute: async (_id, p) => {
239
+ const list = host.listSubagents(p.kind);
228
240
  if (list.length === 0)
229
- return text(pick(getLang(), "当前没有子代理。", "No subagents running.", "subagents.list.empty"));
241
+ return text(pick(getLang(), "当前没有运行中的对话/子代理。", "No managed conversations or subagents running.", "subagents.list.empty"));
230
242
  const tLang = getLang();
231
- const lines = list.map((r) => `- ${r.convId} · ${r.type} · ${subagentVerdict(r, tLang)} · ${r.title}` +
232
- (tLang === "zh" ? `(msg: ${r.messageCount})` : ` (msg: ${r.messageCount})`));
243
+ const lines = list.map((r) => {
244
+ const tag = r.persisted ? (tLang === "zh" ? "持久化" : "persistent") : tLang === "zh" ? "子代理" : "subagent";
245
+ return (`- ${r.convId} · ${r.type} · ${subagentVerdict(r, tLang)} · ${r.title}` +
246
+ (tLang === "zh" ? `(${tag} · msg: ${r.messageCount})` : ` (${tag} · msg: ${r.messageCount})`));
247
+ });
233
248
  return text(lines.join("\n"));
234
249
  },
235
250
  }),
@@ -374,7 +389,9 @@ export function makeSubagentTools(host, lang, selfConvId) {
374
389
  const emptyLang = getLang();
375
390
  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"));
376
391
  }
377
- const timeoutMs = Math.min(Math.max(p.timeoutSeconds ?? 600, 1), Math.floor(WAIT_CAP_MS / 1000)) * 1000;
392
+ const currentWatchdogMs = host.getWatchdogTimeoutMs?.() ?? WAIT_CAP_MS;
393
+ const currentWaitCapMs = currentWatchdogMs > 0 ? Math.max(60_000, Math.floor(currentWatchdogMs * 0.8)) : 3600_000;
394
+ const timeoutMs = Math.min(Math.max(p.timeoutSeconds ?? 600, 1), Math.floor(currentWaitCapMs / 1000)) * 1000;
378
395
  const waitStart = Date.now();
379
396
  const deadline = waitStart + timeoutMs;
380
397
  // 已到终态的、(或已被移出找不到的)直接归位;剩下的阻塞轮询到
@@ -1295,10 +1295,16 @@ export class TerminalManager {
1295
1295
  w.cb(null);
1296
1296
  entry.exitCode = exitCode;
1297
1297
  this.terms.delete(id);
1298
+ // 进程已退出,立即释放底层的 PTY 句柄(Windows ConPTY / HPCON / sockets 等),
1299
+ // 避免已退出的终端长期占用 ConPTY 与 MSYS2 控制台资源(issue #269)。
1300
+ this.killNative(entry);
1298
1301
  while (this.history.size >= MAX_TERMINAL_HISTORY) {
1299
1302
  const oldest = this.history.keys().next().value;
1300
1303
  if (typeof oldest !== "string")
1301
1304
  break;
1305
+ const oldEntry = this.history.get(oldest);
1306
+ if (oldEntry)
1307
+ this.killNative(oldEntry);
1302
1308
  this.history.delete(oldest);
1303
1309
  }
1304
1310
  this.history.set(id, entry);
@@ -1339,8 +1345,13 @@ export class TerminalManager {
1339
1345
  this.emitList();
1340
1346
  return;
1341
1347
  }
1342
- if (this.history.delete(id))
1348
+ const histEntry = this.history.get(id);
1349
+ if (histEntry) {
1350
+ this.killNative(histEntry);
1351
+ this.history.delete(id);
1343
1352
  this.emitList();
1353
+ return;
1354
+ }
1344
1355
  }
1345
1356
  /** Rename a terminal tab (live or retained history). Empty names ignored. */
1346
1357
  rename(id, title) {
@@ -1354,27 +1365,33 @@ export class TerminalManager {
1354
1365
  this.emitList();
1355
1366
  }
1356
1367
  /**
1357
- * Kill the native PTY (issue #215:Windows ConPTY 关机死锁)。
1368
+ * Kill the native PTY (issue #215:Windows ConPTY 关机死锁;issue #269:MSYS2 控制台耗尽死锁)。
1358
1369
  *
1359
- * Windows 下 `pty.kill()` 底层走 `ClosePseudoConsole`,管道有未排空数据时会
1360
- * 内核级同步死锁,直接冻住单线程事件循环(进程内 setTimeout 看门狗与二次
1361
- * Ctrl+C 全灭)。所以 Windows 先走 Node 原生的 `process.kill(pid)`
1362
- *(TerminateProcess,从不阻塞)把 shell 干掉:
1363
- * - 关机(shutdown=true):到此为止,不再调 `pty.kill()`。进程马上就
1364
- * `process.exit`,HPC 句柄由 OS 回收;再调只会自找死锁。
1365
- * - 日常(tab 关闭/移出对话):子进程已死、管道见 EOF 后再调 `pty.kill()`
1366
- * 释放 HPC 句柄,此时不再阻塞(长驻服务必须释放句柄,不能像关机那样一走了之)。
1367
- * 非 Windows 原样直调 `pty.kill()`。
1370
+ * Windows 下:
1371
+ * 1. 若进程已经退出(entry.exited):绝不可再调用 Node 的 process.kill(pid),
1372
+ * 直接调 pty.kill() 释放 HPCON 句柄(子进程已死、管道见 EOF,绝不会死锁)。
1373
+ * 2. 若进程尚未退出(!entry.exited):
1374
+ * 先尝试优雅关闭(向 PTY 写入 \x03exit\r),让 bash 正常触发清理钩子;
1375
+ * 若仍未退出才以 process.kill(pid)(TerminateProcess)强制兜底。
1376
+ * 3. 关机(shutdown=true):跳过 pty.kill(),由 OS 回收。
1377
+ * 非 Windows 原样直调 pty.kill()。
1368
1378
  */
1369
1379
  killNative(entry, shutdown = false) {
1370
1380
  if (process.platform === "win32") {
1371
- const pid = entry.pty.pid;
1372
- if (typeof pid === "number") {
1373
- try {
1374
- process.kill(pid);
1375
- }
1376
- catch {
1377
- // already dead
1381
+ if (!entry.exited) {
1382
+ const pid = entry.pty.pid;
1383
+ if (typeof pid === "number") {
1384
+ try {
1385
+ // 优雅关闭:先发 Ctrl+C,再发 exit\r
1386
+ entry.pty.write("\x03exit\r");
1387
+ }
1388
+ catch { }
1389
+ try {
1390
+ process.kill(pid);
1391
+ }
1392
+ catch {
1393
+ // already dead
1394
+ }
1378
1395
  }
1379
1396
  }
1380
1397
  if (shutdown)
@@ -1410,6 +1427,9 @@ export class TerminalManager {
1410
1427
  w.cb(null);
1411
1428
  entry.watches = [];
1412
1429
  }
1430
+ for (const entry of this.history.values()) {
1431
+ this.killNative(entry, shutdown);
1432
+ }
1413
1433
  this.terms.clear();
1414
1434
  this.history.clear();
1415
1435
  this.emitList();
@@ -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,24 @@ 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*\*\//;
26
+ /** 从主题 CSS 头部读取 color-scheme(浅/暗),无法识别时返回 undefined。 */
27
+ const THEME_SCHEME_RE = /color-scheme:\s*(dark|light)/i;
28
+ function readScheme(path) {
29
+ try {
30
+ const head = readFileSync(path, "utf8").slice(0, 800);
31
+ const m = head.match(THEME_SCHEME_RE);
32
+ if (m && (m[1] === "dark" || m[1] === "light"))
33
+ return m[1];
34
+ }
35
+ catch {
36
+ // ignore
37
+ }
38
+ return undefined;
39
+ }
25
40
  function readDisplayName(path, fallback) {
26
41
  try {
27
- const head = readFileSync(path, "utf8").slice(0, 300);
42
+ const head = readFileSync(path, "utf8").slice(0, 400);
28
43
  return head.match(THEME_NAME_RE)?.[1]?.trim() || fallback;
29
44
  }
30
45
  catch {
@@ -33,13 +48,25 @@ function readDisplayName(path, fallback) {
33
48
  }
34
49
  function readDisplayNameEn(path) {
35
50
  try {
36
- const head = readFileSync(path, "utf8").slice(0, 300);
51
+ const head = readFileSync(path, "utf8").slice(0, 400);
37
52
  return head.match(THEME_NAME_EN_RE)?.[1]?.trim() || undefined;
38
53
  }
39
54
  catch {
40
55
  return undefined;
41
56
  }
42
57
  }
58
+ function readGroup(path) {
59
+ try {
60
+ const head = readFileSync(path, "utf8").slice(0, 400);
61
+ const g = head.match(THEME_GROUP_RE)?.[1]?.trim();
62
+ if (g === "classic")
63
+ return "classic";
64
+ }
65
+ catch {
66
+ // ignore
67
+ }
68
+ return "builtin";
69
+ }
43
70
  export function listThemes(builtinDir, userDir) {
44
71
  const scan = (dir, builtin) => {
45
72
  if (!existsSync(dir))
@@ -53,10 +80,13 @@ export function listThemes(builtinDir, userDir) {
53
80
  const id = f.slice(0, -4);
54
81
  const name = readDisplayName(path, id);
55
82
  const nameEn = readDisplayNameEn(path);
83
+ const group = readGroup(path);
56
84
  return {
57
85
  id,
58
86
  name,
59
87
  builtin,
88
+ group,
89
+ scheme: readScheme(path),
60
90
  ...(nameEn ? { nameEn } : {}),
61
91
  };
62
92
  });