pi-web-ui 0.62.0 → 0.63.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 (56) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +504 -497
  3. package/README.zh-CN.md +427 -422
  4. package/bin/pi-web-ui.mjs +1809 -1881
  5. package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
  6. package/deploy/nginx-subpath.conf +88 -88
  7. package/deploy/pi-web-ui-task.xml +54 -54
  8. package/deploy/pi-web-ui.service +31 -31
  9. package/dist/server/agent-service.js +318 -88
  10. package/dist/server/attachments.js +37 -28
  11. package/dist/server/bg-servers.js +6 -1
  12. package/dist/server/client-state.js +15 -7
  13. package/dist/server/control-socket.js +1 -5
  14. package/dist/server/dsh/dsh-agent-service.js +454 -118
  15. package/dist/server/dsh/dsh-client.js +1 -3
  16. package/dist/server/dsh/dsh-sessions.js +4 -14
  17. package/dist/server/dsh/runtime/cordis.yml +1 -1
  18. package/dist/server/dsh/runtime/goal-rpc.mjs +645 -662
  19. package/dist/server/dsh/runtime/launcher.mjs +8 -18
  20. package/dist/server/dsh/runtime/override.patch.yml +9 -9
  21. package/dist/server/dsh/runtime/runtime-root.mjs +7 -11
  22. package/dist/server/ensure-bash.js +2 -5
  23. package/dist/server/files-service.js +42 -36
  24. package/dist/server/goal-service.js +59 -30
  25. package/dist/server/index.js +17 -10
  26. package/dist/server/mcp-bridge.js +11 -5
  27. package/dist/server/model-admin.js +78 -72
  28. package/dist/server/plugin-facilities.js +2 -4
  29. package/dist/server/plugin-updater.js +1 -1
  30. package/dist/server/plugins.js +26 -8
  31. package/dist/server/process-utils.js +4 -4
  32. package/dist/server/protocol-version.js +1 -1
  33. package/dist/server/scm.js +27 -21
  34. package/dist/server/serialize.js +1 -3
  35. package/dist/server/settings-service.js +63 -21
  36. package/dist/server/slash-commands.js +29 -3
  37. package/dist/server/subagent-templates.js +249 -0
  38. package/dist/server/subagents.js +150 -0
  39. package/dist/server/terminals.js +101 -40
  40. package/dist/server/text-sniff.js +4 -24
  41. package/dist/server/update-check.js +2 -4
  42. package/dist/server/uploads.js +1 -3
  43. package/dist/server/vision-bridge.js +2 -7
  44. package/dist/server/wait-subscription-scan.js +102 -21
  45. package/dist/server/webui-context.js +1 -1
  46. package/extensions/webui.ts +190 -192
  47. package/package.json +109 -103
  48. package/web/dist/assets/TerminalPanel-B9vcI7-_.js +2 -0
  49. package/web/dist/assets/index-B8Wv29p5.css +10 -0
  50. package/web/dist/assets/index-CWUWD0rq.js +323 -0
  51. package/web/dist/favicon.svg +8 -8
  52. package/web/dist/index.html +15 -15
  53. package/web/public/favicon.svg +8 -8
  54. package/web/dist/assets/TerminalPanel-BkCWDsIG.js +0 -2
  55. package/web/dist/assets/index-BRVKed7W.js +0 -321
  56. package/web/dist/assets/index-ORTpR6ZQ.css +0 -10
@@ -63,16 +63,27 @@ function unquotePath(s) {
63
63
  const inner = s.endsWith('"') ? s.slice(1, -1) : s.slice(1);
64
64
  return inner.replace(/\\(.)/g, (_m, c) => {
65
65
  switch (c) {
66
- case "n": return "\n";
67
- case "t": return "\t";
68
- case "r": return "\r";
69
- case "b": return "\b";
70
- case "a": return "\a";
71
- case "f": return "\f";
72
- case "v": return "\v";
73
- case "\\": return "\\";
74
- case '"': return '"';
75
- default: return c;
66
+ case "n":
67
+ return "\n";
68
+ case "t":
69
+ return "\t";
70
+ case "r":
71
+ return "\r";
72
+ case "b":
73
+ return "\b";
74
+ case "a":
75
+ // eslint-disable-next-line no-useless-escape -- bell escape for git C-style quoting
76
+ return "\a";
77
+ case "f":
78
+ return "\f";
79
+ case "v":
80
+ return "\v";
81
+ case "\\":
82
+ return "\\";
83
+ case '"':
84
+ return '"';
85
+ default:
86
+ return c;
76
87
  }
77
88
  });
78
89
  }
@@ -214,10 +225,7 @@ function parseNumStat(text) {
214
225
  del = 0;
215
226
  const path = unquotePath(line.slice(tab2 + 1).trim());
216
227
  const prev = stats[path];
217
- stats[path] = [
218
- (prev?.[0] ?? 0) + add,
219
- (prev?.[1] ?? 0) + del,
220
- ];
228
+ stats[path] = [(prev?.[0] ?? 0) + add, (prev?.[1] ?? 0) + del];
221
229
  }
222
230
  return stats;
223
231
  }
@@ -243,16 +251,14 @@ export async function scmHistory(cwd) {
243
251
  export async function scmStatus(cwd) {
244
252
  const [statusText, branchText, statText, cachedStatText] = await Promise.all([
245
253
  git(cwd, ["status", "--porcelain=v1", "-b", "--find-renames"]),
246
- git(cwd, [
247
- "for-each-ref",
248
- "refs/heads",
249
- "refs/remotes",
250
- "--format=%(refname)%09%(HEAD)",
251
- ]),
254
+ git(cwd, ["for-each-ref", "refs/heads", "refs/remotes", "--format=%(refname)%09%(HEAD)"]),
252
255
  git(cwd, ["diff", "--numstat"]),
253
256
  git(cwd, ["diff", "--cached", "--numstat"]),
254
257
  ]);
255
- const header = parseStatusHeader(statusText.split("\n").find((l) => l.startsWith("## "))?.slice(3) ?? "");
258
+ const header = parseStatusHeader(statusText
259
+ .split("\n")
260
+ .find((l) => l.startsWith("## "))
261
+ ?.slice(3) ?? "");
256
262
  // Worktree + staged line counts, merged per path.
257
263
  const merged = {};
258
264
  for (const [path, pair] of Object.entries(parseNumStat(statText))) {
@@ -80,9 +80,7 @@ export function serializeMessage(m, seq) {
80
80
  errorMessage: m.errorMessage,
81
81
  };
82
82
  case "toolResult": {
83
- const raw = m.content
84
- .map((c) => (c.type === "text" ? c.text : "[image result]"))
85
- .join("\n");
83
+ const raw = m.content.map((c) => (c.type === "text" ? c.text : "[image result]")).join("\n");
86
84
  const { text, truncated } = truncate(raw, TOOL_OUTPUT_CAP);
87
85
  return {
88
86
  id: `t-${m.toolCallId}`,
@@ -10,16 +10,21 @@ import { existsSync, readdirSync } from "node:fs";
10
10
  import { basename, dirname, join } from "node:path";
11
11
  import { extensionKey } from "./client-state.js";
12
12
  import { findVisionModels, SYSTEM_PROMPT } from "./vision-bridge.js";
13
+ import { DEFAULT_TEMPLATES } from "./subagent-templates.js";
13
14
  export class SettingsService {
14
15
  host;
16
+ templates;
15
17
  settings;
16
18
  presets;
17
19
  knownSkills = new Map();
18
20
  knownExtensions = new Map();
19
21
  /** 流式中改了需要 reload 的设置 → agent_end 后延迟应用(防拆毁运行中 run)。 */
20
22
  pendingReload = false;
21
- constructor(host) {
23
+ constructor(host,
24
+ /** 全局子代理模板库(所有客户端共享;模板改动无需 reload runtime)。 */
25
+ templates) {
22
26
  this.host = host;
27
+ this.templates = templates;
23
28
  this.settings = host.stateStore.getSettings(host.clientId);
24
29
  this.presets = host.stateStore.getPresets(host.clientId);
25
30
  }
@@ -59,10 +64,7 @@ export class SettingsService {
59
64
  return true;
60
65
  }
61
66
  // ④ npm 包内 skills(agent 级 + 项目级,含 @scope 两级子包)
62
- for (const npmRoot of [
63
- join(agentDir, "npm", "node_modules"),
64
- join(cwd, ".pi", "npm", "node_modules"),
65
- ]) {
67
+ for (const npmRoot of [join(agentDir, "npm", "node_modules"), join(cwd, ".pi", "npm", "node_modules")]) {
66
68
  try {
67
69
  for (const entry of readdirSync(npmRoot, { withFileTypes: true })) {
68
70
  if (!entry.isDirectory())
@@ -98,18 +100,17 @@ export class SettingsService {
98
100
  // Prune entries that no longer exist on disk AND aren't disabled
99
101
  // (e.g. a skill/extension file was deleted). Disabled entries are
100
102
  // kept so they can be re-enabled even when filtered out of the loader.
101
- const keepSkills = new Set([
102
- ...loadedSkills.map((s) => s.name),
103
- ...this.settings.disabledSkills,
104
- ]);
103
+ const keepSkills = new Set([...loadedSkills.map((s) => s.name), ...this.settings.disabledSkills]);
105
104
  const keepExts = new Set([
106
105
  ...loadedExts.map((e) => extensionKey(e)),
107
106
  ...this.settings.disabledExtensions,
108
107
  ]);
108
+ // eslint-disable-next-line unicorn/no-useless-spread -- snapshot: handlers may unsubscribe mid-emit
109
109
  for (const name of [...this.knownSkills.keys()]) {
110
110
  if (!keepSkills.has(name))
111
111
  this.knownSkills.delete(name);
112
112
  }
113
+ // eslint-disable-next-line unicorn/no-useless-spread -- snapshot: handlers may unsubscribe mid-emit
113
114
  for (const id of [...this.knownExtensions.keys()]) {
114
115
  if (!keepExts.has(id))
115
116
  this.knownExtensions.delete(id);
@@ -126,9 +127,7 @@ export class SettingsService {
126
127
  const p = e.sourceInfo?.path ?? e.path;
127
128
  this.knownExtensions.set(id, {
128
129
  id,
129
- name: e.sourceInfo?.origin === "package" && e.sourceInfo.source
130
- ? e.sourceInfo.source
131
- : basename(p),
130
+ name: e.sourceInfo?.origin === "package" && e.sourceInfo.source ? e.sourceInfo.source : basename(p),
132
131
  path: p,
133
132
  enabled: true,
134
133
  });
@@ -142,9 +141,7 @@ export class SettingsService {
142
141
  // 推送都会把已删除的 skill 以灰条形式永恒地补回面板(“关闭过的
143
142
  // skill 被一直记录”)。session 未就绪时保守跳过。
144
143
  if (loadedSkillNames !== null) {
145
- const stale = [
146
- ...new Set([...this.settings.disabledSkills, ...this.settings.reviewDisabledSkills]),
147
- ].filter((name) => !loadedSkillNames.has(name) && !this.skillStillOnDisk(name));
144
+ const stale = [...new Set([...this.settings.disabledSkills, ...this.settings.reviewDisabledSkills])].filter((name) => !loadedSkillNames.has(name) && !this.skillStillOnDisk(name));
148
145
  if (stale.length > 0) {
149
146
  this.settings.disabledSkills = this.settings.disabledSkills.filter((n) => !stale.includes(n));
150
147
  this.settings.reviewDisabledSkills = this.settings.reviewDisabledSkills.filter((n) => !stale.includes(n));
@@ -213,6 +210,8 @@ export class SettingsService {
213
210
  reviewSkills,
214
211
  extensions,
215
212
  presets: this.presets.map((p) => ({ ...p })),
213
+ subagentTemplates: this.templates.list(),
214
+ subagentDefaultTemplates: DEFAULT_TEMPLATES.map((t) => t.name),
216
215
  },
217
216
  });
218
217
  }
@@ -294,7 +293,12 @@ export class SettingsService {
294
293
  async savePreset(name) {
295
294
  const n = name.trim();
296
295
  if (!n) {
297
- this.host.emit({ type: "notice", level: "error", text: "预设名称不能为空" });
296
+ this.host.emit({
297
+ type: "notice",
298
+ level: "error",
299
+ text: "预设名称不能为空",
300
+ textEn: "Preset name cannot be empty",
301
+ });
298
302
  return;
299
303
  }
300
304
  const preset = {
@@ -321,7 +325,12 @@ export class SettingsService {
321
325
  async applyPreset(name) {
322
326
  const p = this.presets.find((x) => x.name === name);
323
327
  if (!p) {
324
- this.host.emit({ type: "notice", level: "error", text: `预设不存在:${name}` });
328
+ this.host.emit({
329
+ type: "notice",
330
+ level: "error",
331
+ text: `预设不存在:${name}`,
332
+ textEn: `Preset does not exist: ${name}`,
333
+ });
325
334
  return;
326
335
  }
327
336
  this.settings = {
@@ -335,9 +344,7 @@ export class SettingsService {
335
344
  terminalBash: p.terminalBash ?? this.settings.terminalBash,
336
345
  terminalBashIdleMs: p.terminalBashIdleMs ?? this.settings.terminalBashIdleMs,
337
346
  reviewPrompt: p.reviewPrompt ?? this.settings.reviewPrompt,
338
- reviewDisabledSkills: [
339
- ...(p.reviewDisabledSkills ?? this.settings.reviewDisabledSkills),
340
- ],
347
+ reviewDisabledSkills: [...(p.reviewDisabledSkills ?? this.settings.reviewDisabledSkills)],
341
348
  // 纯 UI 偏好不进预设——保留当前值。
342
349
  thinkingWrap: this.settings.thinkingWrap,
343
350
  toolsWrap: this.settings.toolsWrap,
@@ -357,6 +364,39 @@ export class SettingsService {
357
364
  this.host.stateStore.savePresets(this.host.clientId, this.presets);
358
365
  this.push();
359
366
  }
367
+ /** Upsert 一个子代理模板(全局共享)。模板只影响未来派生的子代理,
368
+ * 不需要 reload runtime —— 直接推送新设置状态即可。 */
369
+ async saveTemplate(template) {
370
+ const err = this.templates.upsert(template);
371
+ if (err) {
372
+ this.host.emit({
373
+ type: "notice",
374
+ level: "error",
375
+ text: `子代理模板保存失败:${err}`,
376
+ textEn: `Failed to save subagent template: ${err}`,
377
+ });
378
+ return;
379
+ }
380
+ this.push();
381
+ const n = template?.name?.trim() ?? "";
382
+ this.host.emit({
383
+ type: "notice",
384
+ level: "info",
385
+ text: `子代理模板已保存:${n}`,
386
+ textEn: `Subagent template saved: ${n}`,
387
+ });
388
+ }
389
+ /** 删除一个子代理模板。 */
390
+ async deleteTemplate(name) {
391
+ this.templates.remove(name);
392
+ this.push();
393
+ this.host.emit({
394
+ type: "notice",
395
+ level: "info",
396
+ text: `子代理模板已删除:${name}`,
397
+ textEn: `Subagent template deleted: ${name}`,
398
+ });
399
+ }
360
400
  /**
361
401
  * Make settings changes effective in the running runtime. The resource-loader
362
402
  * overrides read this.settings at call time, so a reload re-applies them.
@@ -371,6 +411,7 @@ export class SettingsService {
371
411
  type: "notice",
372
412
  level: "info",
373
413
  text: "当前回复进行中,设置将在回复结束后自动应用",
414
+ textEn: "A reply is in progress; settings will apply when it finishes",
374
415
  });
375
416
  return;
376
417
  }
@@ -382,13 +423,14 @@ export class SettingsService {
382
423
  await this.host.reloadSession();
383
424
  this.push();
384
425
  this.host.flushSnapshot();
385
- this.host.emit({ type: "notice", level: "info", text: "设置已应用" });
426
+ this.host.emit({ type: "notice", level: "info", text: "设置已应用", textEn: "Settings applied" });
386
427
  }
387
428
  catch (err) {
388
429
  this.host.emit({
389
430
  type: "notice",
390
431
  level: "error",
391
432
  text: `设置应用失败:${err.message}`,
433
+ textEn: `Failed to apply settings: ${err.message}`,
392
434
  });
393
435
  }
394
436
  }
@@ -4,9 +4,27 @@
4
4
  * sync with exec(). */
5
5
  export const NATIVE_COMMANDS = [
6
6
  { name: "new", description: "新建对话", descriptionEn: "New chat" },
7
- { name: "model", description: "切换模型", descriptionEn: "Switch model", argumentHint: "[名称]", argumentHintEn: "[name]" },
8
- { name: "compact", description: "压缩上下文", descriptionEn: "Compact context", argumentHint: "[说明]", argumentHintEn: "[instructions]" },
9
- { name: "cwd", description: "切换工作目录", descriptionEn: "Switch workspace", argumentHint: "<路径>", argumentHintEn: "<path>" },
7
+ {
8
+ name: "model",
9
+ description: "切换模型",
10
+ descriptionEn: "Switch model",
11
+ argumentHint: "[名称]",
12
+ argumentHintEn: "[name]",
13
+ },
14
+ {
15
+ name: "compact",
16
+ description: "压缩上下文",
17
+ descriptionEn: "Compact context",
18
+ argumentHint: "[说明]",
19
+ argumentHintEn: "[instructions]",
20
+ },
21
+ {
22
+ name: "cwd",
23
+ description: "切换工作目录",
24
+ descriptionEn: "Switch workspace",
25
+ argumentHint: "<路径>",
26
+ argumentHintEn: "<path>",
27
+ },
10
28
  {
11
29
  name: "thinking",
12
30
  description: "设置思考强度",
@@ -139,6 +157,7 @@ export class SlashCommandsService {
139
157
  type: "notice",
140
158
  level: "error",
141
159
  text: `没有匹配到模型:${args}(可用模型见顶栏模型列表)`,
160
+ textEn: `No matching model: ${args} (see the model list in the top bar)`,
142
161
  });
143
162
  return true;
144
163
  }
@@ -148,6 +167,7 @@ export class SlashCommandsService {
148
167
  type: "notice",
149
168
  level: "warning",
150
169
  text: `找到 ${matches.length} 个匹配模型,已选用:${pick.name}(精确匹配请用 provider/id)`,
170
+ textEn: `Found ${matches.length} matching models, using: ${pick.name} (use provider/id for an exact match)`,
151
171
  });
152
172
  }
153
173
  await this.host.setModel(`${pick.provider}/${pick.id}`);
@@ -169,6 +189,7 @@ export class SlashCommandsService {
169
189
  type: "notice",
170
190
  level: "info",
171
191
  text: `当前工作目录:${this.host.cwd()}。用法:/cwd <路径>`,
192
+ textEn: `Current directory: ${this.host.cwd()}. Usage: /cwd <path>`,
172
193
  });
173
194
  }
174
195
  else {
@@ -198,6 +219,7 @@ export class SlashCommandsService {
198
219
  type: "notice",
199
220
  level: "error",
200
221
  text: `无效的思考强度:${args || "(空)"}。可用:off / minimal / low / medium / high / xhigh / max`,
222
+ textEn: `Invalid thinking level: ${args || "(empty)"}. Available: off / minimal / low / medium / high / xhigh / max`,
201
223
  });
202
224
  return true;
203
225
  }
@@ -210,6 +232,7 @@ export class SlashCommandsService {
210
232
  type: "notice",
211
233
  level: "info",
212
234
  text: "会话列表已刷新,请在左侧「历史对话」中选择",
235
+ textEn: "Session list refreshed — pick one under History on the left",
213
236
  });
214
237
  return true;
215
238
  case "reload":
@@ -224,6 +247,7 @@ export class SlashCommandsService {
224
247
  type: "notice",
225
248
  level: "info",
226
249
  text: "已重新加载扩展、技能与提示模板",
250
+ textEn: "Reloaded extensions, skills and prompt templates",
227
251
  });
228
252
  }
229
253
  catch (err) {
@@ -231,6 +255,7 @@ export class SlashCommandsService {
231
255
  type: "notice",
232
256
  level: "error",
233
257
  text: `重新加载失败:${err.message}`,
258
+ textEn: `Reload failed: ${err.message}`,
234
259
  });
235
260
  }
236
261
  return true;
@@ -239,6 +264,7 @@ export class SlashCommandsService {
239
264
  type: "notice",
240
265
  level: "info",
241
266
  text: "正在退出 pi-web-ui… supervisor 将自动重启服务",
267
+ textEn: "Quitting pi-web-ui… the supervisor will restart the service",
242
268
  });
243
269
  setTimeout(() => {
244
270
  const didSchedule = this.host.onQuit?.() ?? false;
@@ -0,0 +1,249 @@
1
+ /**
2
+ * subagent-templates.ts — 子代理模板库(全局共享,<dataDir>/subagent-templates.json)。
3
+ *
4
+ * 模板 = 派生子代理时套用的预设:角色系统提示词(replace/append 同主设置语义)+
5
+ * 技能白名单 + 扩展白名单。白名单空数组 = 该维度不限定,子代理跟随主会话设置。
6
+ * 带 `enabled: false` 的模板停用:设置面板仍可见、可重新启用,但 AI 工具
7
+ * (subagent_templates / subagent_spawn)查询不到它、也不能选择它——「关闭 =
8
+ * 对 AI 不可见」。
9
+ *
10
+ * 与 per-client 的 client-state.json 不同:模板是配置品而非个人偏好,所有浏览器
11
+ * 客户端共用同一份。文件 I/O 一律 best-effort:持久化故障绝不能弄崩 server。
12
+ */
13
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
14
+ import { dirname } from "node:path";
15
+ /** 名字去空白折叠后非空且 ≤ 60 字符(工具参数可读,允许中文)。 */
16
+ const NAME_MAX = 60;
17
+ /**
18
+ * 内置默认模板(第一次运行时种子进列表;用户改动后以 <dataDir> 文件为准)。
19
+ * 文案改编自 pi-subagents 社区项目(tintinweb / nicobailon)的角色提示词,
20
+ * 剔除了本项目没有的专有工具引用(contact_supervisor / web_search / workflow…)。
21
+ * 白名单留空 = 技能/扩展跟随主会话设置,开箱即用。
22
+ */
23
+ export const DEFAULT_TEMPLATES = [
24
+ {
25
+ name: "review",
26
+ description: "代码 / 计划 / 方案 / PR 审查:有证据的 P0-P2 发现与合并结论",
27
+ promptMode: "replace",
28
+ systemPrompt: "你是一个严格的审查子代理。你的工作是检查、评估并给出有证据的结论。不要猜测;必须从代码、测试、文档或需求中核实。\n\n" +
29
+ "## 审查类型\n" +
30
+ "1. 代码 diff(改动文件):核对实现是否符合意图和需求;代码是否正确一致、覆盖边界情况;测试是否覆盖改动且仍然通过;有无意外副作用或回归;改动是否最小且可读。\n" +
31
+ "2. 计划:验证可行性、完整性、缺失步骤、隐藏风险、与现有架构和约束是否一致、范围是否适当。\n" +
32
+ "3. 方案:评估正确性与权衡、与代码库现有模式是否一致、是否存在更简单的替代、是否漏掉边界情况。\n" +
33
+ "4. 代码库整体状态:检查关键文件、测试与结构,寻找架构漂移、技术债、不一致的模式与命名、缺乏测试与文档的区域、明显 bug 或脆弱代码、简化和整合的机会。\n" +
34
+ "5. 特定 PR / issue:先理解上下文,再验证修复是否针对根因、改动是否最小聚焦、有无回归、测试与文档是否同步更新。\n\n" +
35
+ "## 工作规则\n" +
36
+ "- 先用精确的源文件、符号、类型、方法、路径定位,再阅读相关文件;仅在需要穷尽验证(调用点、import、已删除名字、模式不存在)时才用宽泛搜索。\n" +
37
+ "- 不要凭空捏造问题——只报告你能用证据证明的问题,按证据而非严重度过滤发现。\n" +
38
+ "- 引用代码时给出准确的文件路径和行号。\n" +
39
+ "- 如果一切正常,直说。不要为了找问题而找问题。\n\n" +
40
+ "## 输出格式\n" +
41
+ "## Review\n" +
42
+ "- 正确:已经做得好且符合预期的部分(附证据)\n" +
43
+ "- 修复:问题、位置和解决方法(若你做了修改)\n" +
44
+ "- 发现:P0/P1/P2 + 问题、位置、证据、最小修复方案\n" +
45
+ "- 合并结论:BLOCK / OK / OK with notes\n" +
46
+ "\n只报告由目标改动引起或可达的具体当前问题,并为每条提供源码证明、测试/复现或契约冲突。P0 = 阻止合并;P1 = 发布前应修;P2 = 仅报告。没有符合条件的问题时,明确写「No issues found.」。",
47
+ enabledSkills: [],
48
+ enabledExtensions: [],
49
+ enabled: true,
50
+ },
51
+ {
52
+ name: "implement",
53
+ description: "实现子代理:窄而正确的代码改动 + 验证与清晰的汇报",
54
+ promptMode: "replace",
55
+ systemPrompt: "你是 implement 子代理:唯一的写入线程。你的任务是执行分派的任务或已批准的方向,做窄而连贯的修改。主 agent 和用户保留决策权。\n\n" +
56
+ "默认职责:\n" +
57
+ "- 对照实际代码验证任务或已批准方向\n" +
58
+ "- 实现最小正确改动,遵循代码库现有模式\n" +
59
+ "- 尽量用适当检查(测试、类型检查、lint)验证结果\n" +
60
+ "- 清楚地汇报:改动、验证、风险、下一步\n\n" +
61
+ "工作规则:\n" +
62
+ "- 偏爱窄而正确的改动,不做大范围重写。\n" +
63
+ "- 保持源码可发现性:用具体名称、清晰类型、每个概念一种拼写、以源码命名的测试。\n" +
64
+ "- 不加投机性脚手架或未来前瞻,除非明确要求。\n" +
65
+ "- 不留占位代码、TODO 或静默范围变更。\n" +
66
+ "- 用终端命令做检查、验证和相关测试。\n" +
67
+ "- 若有提供的上下文或计划,先读它。\n" +
68
+ "- 若实现揭示了未批准的产品或架构选择,停下并报告,不要自作主张。\n" +
69
+ "- 若分派的任务期望代码/文件改动而你实际没做改动,不要返回成功摘要——做改动、说明受阻,或明确报告未做改动。\n\n" +
70
+ "最终回复格式:\n" +
71
+ "实现内容:X。\n改动文件:Y。\n验证:Z。\n未决风险/问题:R。\n建议下一步:N。",
72
+ enabledSkills: [],
73
+ enabledExtensions: [],
74
+ enabled: true,
75
+ },
76
+ {
77
+ name: "research",
78
+ description: "网络调研:多角度检索、来源充分的研究简报(Summary/Findings/Sources/Gaps)",
79
+ promptMode: "replace",
80
+ systemPrompt: "你是一个研究子代理。\n" +
81
+ "给定问题或主题,展开聚焦的研究并产出一份简洁、来源充分的简报。\n\n" +
82
+ "工作规则:\n" +
83
+ "- 把问题拆成 2-4 个不同的研究角度。\n" +
84
+ "- 使用可用的搜索/抓取工具;没有专用工具时可用终端命令访问公开资料。\n" +
85
+ "- 先读搜索结果摘要,再只对最有希望的来源抓取全文。\n" +
86
+ "- 优先一手来源、官方文档、规范、基准和直接证据,而非二手评论。\n" +
87
+ "- 丢弃过时、冗余或 SEO 堆砌的来源。\n" +
88
+ "- 第一轮搜索若留下重要缺口,用更聚焦的追问再搜一轮。\n\n" +
89
+ "输出格式:\n" +
90
+ "# Research: [主题]\n" +
91
+ "## Summary\n2-3 句直接答案。\n" +
92
+ "## Findings\n带内联来源引用的编号发现。\n" +
93
+ "## Sources\n保留(标题 + URL + 为什么重要)与丢弃(及原因)。\n" +
94
+ "## Gaps\n未能自信回答的部分与建议下一步。",
95
+ enabledSkills: [],
96
+ enabledExtensions: [],
97
+ enabled: true,
98
+ },
99
+ {
100
+ name: "scout",
101
+ description: "代码库侦察:快速勘察并返回另一个 agent 行动所需的最小上下文",
102
+ promptMode: "replace",
103
+ systemPrompt: "你是一个代码库侦察子代理。\n" +
104
+ "快速勘察代码库,返回另一个 agent 行动所需的最小上下文:\n" +
105
+ "- 相关入口点\n" +
106
+ "- 关键类型、接口、函数\n" +
107
+ "- 数据流和依赖\n" +
108
+ "- 很可能需要改动的文件\n" +
109
+ "- 约束、风险和未决问题\n\n" +
110
+ "工作规则:\n" +
111
+ "- 先读任务给出的路径与具体符号/类型/方法/文件名;用文件系统命令做路径发现;偏爱定向搜索和选择性阅读,除非任务明确需要宽泛搜索。\n" +
112
+ "- 引述代码时给准确的路径和行区间,输出保持精简。\n" +
113
+ "- 单独执行时,写完输出后在最终回复里简短总结你发现了什么。\n\n" +
114
+ "输出格式:\n" +
115
+ "# Code Context\n" +
116
+ "## Files Retrieved\n准确文件与行区间、为什么重要。\n" +
117
+ "## Key Code\n关键类型/接口/函数与小段代码。\n" +
118
+ "## Architecture\n各部分如何连接。\n" +
119
+ "## Start Here\n另一个 agent 应最先打开的文件及原因。",
120
+ enabledSkills: [],
121
+ enabledExtensions: [],
122
+ enabled: true,
123
+ },
124
+ {
125
+ name: "audit",
126
+ description: "轻量安全审计:密钥泄漏 / 注入 / 权限 / 敏感数据,带路径与修复建议",
127
+ promptMode: "replace",
128
+ systemPrompt: "你是一个轻量级安全审计子代理。被要求审查代码时,扫描:\n" +
129
+ "- 硬编码的密钥或凭据\n" +
130
+ "- 注入缺陷(SQL、命令、XSS 等)\n" +
131
+ "- 过宽的权限或危险的读写操作\n" +
132
+ "- 不安全的依赖使用或过时协议(明文传输、弱哈希、缺校验)\n" +
133
+ "- 敏感数据泄漏(日志、错误响应、客户端代码)\n\n" +
134
+ "对每条发现给出:文件路径 + 问题说明 + 最短修复建议,按严重度排序(P0 阻止合入 / P1 发布前修 / P2 建议)。\n" +
135
+ "只报告能由证据支持的发现,不要臆测。没有发现问题时明确说「未发现安全问题」。保持简洁。",
136
+ enabledSkills: [],
137
+ enabledExtensions: [],
138
+ enabled: true,
139
+ },
140
+ {
141
+ name: "delegate",
142
+ description: "通用委派:轻量指令执行,只做范围内的事并如实汇报(append 模式)",
143
+ promptMode: "append",
144
+ systemPrompt: "你是主 agent 委派的执行子代理,按照交给你的明确指令完成任务。规则:\n" +
145
+ "- 只处理指令范围内的工作,不要自作主张扩大范围。\n" +
146
+ "- 有疑问先尝试从工作区文件本身找答案;仍不确定则如实报告,不要猜测或编造。\n" +
147
+ "- 汇报时给出:做了什么、结果/证据、遇到的问题、建议的下一步。\n" +
148
+ "- 简洁、具体,引用证据(路径、命令输出、行号)。",
149
+ enabledSkills: [],
150
+ enabledExtensions: [],
151
+ enabled: true,
152
+ },
153
+ ];
154
+ /** 容忍脏数据/旧版本:非法条目整体丢弃。 */
155
+ function normalize(raw) {
156
+ if (!raw || typeof raw !== "object")
157
+ return null;
158
+ const o = raw;
159
+ const name = typeof o.name === "string" ? o.name.replace(/\s+/g, " ").trim() : "";
160
+ if (!name || name.length > NAME_MAX)
161
+ return null;
162
+ return {
163
+ name,
164
+ description: typeof o.description === "string" ? o.description : "",
165
+ promptMode: o.promptMode === "replace" ? "replace" : "append",
166
+ systemPrompt: typeof o.systemPrompt === "string" ? o.systemPrompt : "",
167
+ enabledSkills: Array.isArray(o.enabledSkills)
168
+ ? o.enabledSkills.filter((x) => typeof x === "string")
169
+ : [],
170
+ enabledExtensions: Array.isArray(o.enabledExtensions)
171
+ ? o.enabledExtensions.filter((x) => typeof x === "string")
172
+ : [],
173
+ enabled: o.enabled !== false,
174
+ };
175
+ }
176
+ /** 全局子代理模板库(原子写;失败静默)。 */
177
+ export class SubagentTemplatesStore {
178
+ filePath;
179
+ templates = null;
180
+ constructor(filePath) {
181
+ this.filePath = filePath;
182
+ }
183
+ load() {
184
+ if (this.templates)
185
+ return this.templates;
186
+ try {
187
+ const parsed = JSON.parse(readFileSync(this.filePath, "utf8"));
188
+ this.templates = Array.isArray(parsed)
189
+ ? parsed.map(normalize).filter((t) => t !== null)
190
+ : [];
191
+ }
192
+ catch {
193
+ // 文件不存在/损坏:以内置默认模板为种子(不落盘——用户一旦增删改,
194
+ // persist() 会把当前完整列表写盘,此后以文件为准,默认模板可删可改)。
195
+ this.templates = DEFAULT_TEMPLATES.map((t) => ({
196
+ ...t,
197
+ enabledSkills: [...t.enabledSkills],
198
+ enabledExtensions: [...t.enabledExtensions],
199
+ }));
200
+ }
201
+ return this.templates;
202
+ }
203
+ persist() {
204
+ try {
205
+ mkdirSync(dirname(this.filePath), { recursive: true });
206
+ const tmp = `${this.filePath}.${process.pid}.tmp`;
207
+ writeFileSync(tmp, JSON.stringify(this.templates, null, 2) + "\n");
208
+ renameSync(tmp, this.filePath);
209
+ }
210
+ catch {
211
+ // best effort
212
+ }
213
+ }
214
+ /** 全部模板(含停用的)。设置面板展示用。 */
215
+ list() {
216
+ return this.load().map((t) => ({
217
+ ...t,
218
+ enabledSkills: [...t.enabledSkills],
219
+ enabledExtensions: [...t.enabledExtensions],
220
+ }));
221
+ }
222
+ /** 按名取模板(含停用的;spawn 时由宿主做启用校验)。 */
223
+ get(name) {
224
+ return this.load().find((t) => t.name === name);
225
+ }
226
+ /** Upsert 一个模板(同名覆盖)。返回错误文本;成功返回 null。 */
227
+ upsert(input) {
228
+ const t = normalize(input);
229
+ if (!t)
230
+ return `模板名称不合法(去空白后 1-${NAME_MAX} 字符)`;
231
+ const list = this.load();
232
+ const i = list.findIndex((x) => x.name === t.name);
233
+ if (i >= 0)
234
+ list[i] = t;
235
+ else
236
+ list.push(t);
237
+ this.persist();
238
+ return null;
239
+ }
240
+ /** 删除一个模板(不存在时静默)。 */
241
+ remove(name) {
242
+ const list = this.load();
243
+ const next = list.filter((t) => t.name !== name);
244
+ if (next.length === list.length)
245
+ return;
246
+ this.templates = next;
247
+ this.persist();
248
+ }
249
+ }