@maplezzk/pi-interactive-subagents 3.13.1 → 3.15.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.
package/README.md CHANGED
@@ -173,6 +173,8 @@ The persisted Herdr mode, child-spawning policy, and explicit child-extension li
173
173
 
174
174
  `herdrMode` accepts `split` (default, backward-compatible pane layout) or `tab` (one background tab per subagent). The `/config:subagent herdr split|tab` command updates this field. `allowSubagentSpawning` is a global switch for whether child subagents may create or manage other subagents; it defaults to `false`. Set it to `true` to enable the lifecycle tools in child sessions when the corresponding extension is selected. `subagentExtensions` is an optional list of extension paths to load explicitly in child sessions; automatic project/global extension discovery is disabled. Paths may be absolute, start with `~/`, or be relative to `PI_CODING_AGENT_DIR`. `subagent-done.ts` is always loaded. Explicit `deny-tools` restrictions still apply.
175
175
 
176
+ You can configure `subagentExtensions` from the input slash-command menu instead of editing JSON: `/config:subagent extensions` discovers project and global Pi extension entrypoints and opens a toggle-style chooser. The built-in `subagent-done.ts` entry is always shown as required and cannot be disabled. Uninstalled extensions are shown as missing and are skipped; saving the chooser removes their stale paths. Choose `Enter extension paths manually` for custom paths. Direct forms remain available: `/config:subagent extensions path-a.ts,path-b.ts` saves paths, and `/config:subagent extensions clear` removes all optional entries. The dedicated `/config:subagent-extensions` command (also `/subagent-extensions`) supports the same behavior. Changes apply to the next child session.
177
+
176
178
  `config.json` is gitignored so local overrides don't get committed.
177
179
 
178
180
  ---
package/README.zh-CN.md CHANGED
@@ -62,7 +62,9 @@ zellij --session pi # 然后运行 pi
62
62
  }
63
63
  ```
64
64
 
65
- `herdrMode` 支持 `split`(默认,兼容原有 pane 布局)和 `tab`(每个 subagent 独立后台 Tab)。`allowSubagentSpawning` 是全局开关,控制子 agent 是否可以创建或管理其他 subagent,默认值为 `false`;设置为 `true` 后,子 agent 才会获得这些生命周期工具。`subagentExtensions` 是可选扩展路径列表;子 agent 不再自动发现项目级和全局扩展,只加载列表中的扩展,`subagent-done.ts` 始终加载。路径可以是绝对路径、`~/` 路径,或相对于 `PI_CODING_AGENT_DIR` 的路径。`/config:subagent herdr split|tab` 会更新 Herdr 字段。
65
+ `herdrMode` 支持 `split`(默认,兼容原有分屏布局)和 `tab`(每个 subagent 独立后台 Tab)。`allowSubagentSpawning` 是全局开关,控制子 agent 是否可以创建或管理其他 subagent,默认值为 `false`;设置为 `true` 后,子 agent 才会获得这些生命周期工具。`subagentExtensions` 是可选扩展路径列表;子 agent 不再自动发现项目级和全局扩展,只加载列表中的扩展,`subagent-done.ts` 始终加载。路径可以是绝对路径、`~/` 路径,或相对于 `PI_CODING_AGENT_DIR` 的路径。`/config:subagent herdr split|tab` 会更新 Herdr 字段。
66
+
67
+ 现在也可以直接在输入框使用斜杠命令配置扩展:`/config:subagent extensions` 会自动发现项目级和全局 Pi 扩展,并打开可切换的候选列表。内置的 `subagent-done.ts` 会显示为必选项,始终加载且不能取消。已卸载的插件会显示为缺失并被跳过;保存候选列表时会清理这些残留路径。也可以选择“手动输入扩展路径”配置自定义入口。直接配置仍然支持 `/config:subagent extensions path-a.ts,path-b.ts`,清空可使用 `/config:subagent extensions clear`。另提供 `/config:subagent-extensions`(别名 `/subagent-extensions`),用法相同。修改会在下一个子会话启动时生效。
66
68
 
67
69
  ## 致谢
68
70
 
package/SKILL.md CHANGED
@@ -11,6 +11,8 @@ description: "配置与排查 interactive subagents 的终端复用器、Herdr s
11
11
 
12
12
  环境变量优先级:`PI_TERMINAL_MUX` > `PI_SUBAGENT_MUX` > 持久化 `mux` > `auto`;`PI_SUBAGENT_HERDR_MODE` > 持久化 `herdrMode` > `split`。
13
13
 
14
+ `/config:subagent extensions` 或 `/config:subagent-extensions` 可直接配置 `subagentExtensions`:命令会发现项目级和全局扩展并提供候选列表,`subagent-done.ts` 始终加载且不能移除;已卸载的扩展会被检测并跳过。也可以手动输入路径,命令参数用逗号分隔,输入 `clear` 清空可选扩展。
15
+
14
16
  ## 修改
15
17
 
16
18
  优先运行:
@@ -46,5 +46,69 @@
46
46
  "muxInteractiveOnly": {
47
47
  "zh-CN": "请在支持 UI 的 Pi 会话中运行 /config:subagent。",
48
48
  "en-US": "Run /config:subagent in a Pi session with UI support."
49
+ },
50
+ "extensionCommandDescription": {
51
+ "zh-CN": "配置子 agent 显式加载的扩展",
52
+ "en-US": "Configure extensions explicitly loaded by child subagents"
53
+ },
54
+ "extensionConfigTitle": {
55
+ "zh-CN": "配置子 agent 扩展(每行一个路径;命令参数用逗号分隔)",
56
+ "en-US": "Configure subagent extensions (one path per line; comma-separated in commands)"
57
+ },
58
+ "extensionSaved": {
59
+ "zh-CN": "子 agent 扩展已设置为:{value}",
60
+ "en-US": "Subagent extensions set to: {value}"
61
+ },
62
+ "extensionInvalid": {
63
+ "zh-CN": "扩展配置无效:{value}。编辑器中每行输入一个路径;命令参数请使用逗号分隔,或输入 clear 清空。",
64
+ "en-US": "Invalid extension configuration: {value}. Use one path per line in the editor, comma-separated paths in commands, or clear to remove them."
65
+ },
66
+ "extensionInteractiveOnly": {
67
+ "zh-CN": "请在支持 UI 的 Pi 会话中配置子 agent 扩展。",
68
+ "en-US": "Configure subagent extensions in a Pi session with UI support."
69
+ },
70
+ "extensionNone": {
71
+ "zh-CN": "未配置",
72
+ "en-US": "none"
73
+ },
74
+ "extensionClearDescription": {
75
+ "zh-CN": "清空子 agent 扩展列表",
76
+ "en-US": "Clear the child-session extension list"
77
+ },
78
+ "extensionConfiguredDescription": {
79
+ "zh-CN": "当前配置的扩展路径",
80
+ "en-US": "Currently configured extension path"
81
+ },
82
+ "extensionChooserTitle": {
83
+ "zh-CN": "选择要加载到 subagent 的扩展",
84
+ "en-US": "Choose extensions to load in subagents"
85
+ },
86
+ "extensionChooserDone": {
87
+ "zh-CN": "完成并保存",
88
+ "en-US": "Done and save"
89
+ },
90
+ "extensionChooserManual": {
91
+ "zh-CN": "手动输入扩展路径",
92
+ "en-US": "Enter extension paths manually"
93
+ },
94
+ "extensionCandidateProject": {
95
+ "zh-CN": "项目扩展",
96
+ "en-US": "project extension"
97
+ },
98
+ "extensionCandidateGlobal": {
99
+ "zh-CN": "全局扩展",
100
+ "en-US": "global extension"
101
+ },
102
+ "extensionCandidateConfigured": {
103
+ "zh-CN": "已配置扩展",
104
+ "en-US": "configured extension"
105
+ },
106
+ "extensionCandidateRequired": {
107
+ "zh-CN": "始终加载,不可移除",
108
+ "en-US": "always loaded and cannot be removed"
109
+ },
110
+ "extensionCandidateMissing": {
111
+ "zh-CN": "插件未安装,已跳过(完成保存后会移除)",
112
+ "en-US": "plugin is not installed and will be skipped (removed when saved)"
49
113
  }
50
114
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maplezzk/pi-interactive-subagents",
3
- "version": "3.13.1",
3
+ "version": "3.15.0",
4
4
  "description": "Interactive async subagents for pi — spawn, orchestrate, and manage sub-agent sessions in multiplexer panes. Fork of HazAT/pi-interactive-subagents.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -62,17 +62,17 @@
62
62
  ],
63
63
  "dependencies": {
64
64
  "ajv": "^8.20.0",
65
- "pi-extensions-i18n": "^0.4.0",
66
- "pi-terminal-mux": "^0.5.1"
65
+ "pi-extensions-i18n": "^0.5.0",
66
+ "pi-terminal-mux": "^0.6.2"
67
67
  },
68
68
  "peerDependencies": {
69
- "@earendil-works/pi-coding-agent": ">=0.80.0 <0.81.0",
70
- "@earendil-works/pi-tui": ">=0.80.0 <0.81.0",
69
+ "@earendil-works/pi-coding-agent": ">=0.80.0",
70
+ "@earendil-works/pi-tui": ">=0.80.0",
71
71
  "@sinclair/typebox": "*"
72
72
  },
73
73
  "devDependencies": {
74
- "@earendil-works/pi-coding-agent": "0.80.10",
75
- "@earendil-works/pi-tui": "0.80.10",
74
+ "@earendil-works/pi-coding-agent": "0.85.1",
75
+ "@earendil-works/pi-tui": "0.85.1",
76
76
  "@sinclair/typebox": "^0.34.49",
77
77
  "@types/node": "24.12.4",
78
78
  "tsx": "4.23.1",
@@ -2,12 +2,13 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
2
2
  import { keyHint } from "@earendil-works/pi-coding-agent";
3
3
  import { Type, type Static } from "@sinclair/typebox";
4
4
  import { Box, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
- import { dirname, isAbsolute, join } from "node:path";
5
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
- import { createTranslator, loadCatalog } from "pi-extensions-i18n";
7
+ import { createTranslator, loadCatalog, notifyWithSource, type NoticeColor, type NoticeSource } from "pi-extensions-i18n";
8
8
  import {
9
9
  readdirSync,
10
10
  readFileSync,
11
+ type Dirent,
11
12
  writeFileSync,
12
13
  existsSync,
13
14
  mkdirSync,
@@ -51,6 +52,7 @@ import {
51
52
  loadSubagentSpawningConfig,
52
53
  saveHerdrMode,
53
54
  saveMuxPreference,
55
+ saveSubagentExtensions,
54
56
  SUBAGENT_MUX_BACKENDS,
55
57
  type HerdrSurfaceMode,
56
58
  type SubagentMuxPreference,
@@ -85,6 +87,21 @@ import {
85
87
 
86
88
  const i18n = createTranslator(loadCatalog(new URL("../../locales/index.json", import.meta.url)));
87
89
 
90
+ /** 本扩展的提示标签;短且唯一,便于在会话里定位来源。 */
91
+ const NOTICE_TAG = "subagents";
92
+ /** 提示标签颜色;与其它扩展错开,避免看起来像同一条消息。 */
93
+ const NOTICE_COLOR: NoticeColor = "toolTitle";
94
+ /** 本扩展的提示来源。 */
95
+ const NOTICE_SOURCE: NoticeSource = { tag: NOTICE_TAG, color: NOTICE_COLOR };
96
+
97
+ /**
98
+ * 统一的用户可见提示出口:加来源标签后交给 Pi 的 notify。
99
+ * 包内所有 notify 都走这里,避免遗漏标签或颜色。
100
+ */
101
+ function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error"): void {
102
+ notifyWithSource({ ctx, source: NOTICE_SOURCE, level, message });
103
+ }
104
+
88
105
  /** Absolute path to `pi-extension/subagents`. https://github.com/nodejs/node/issues/37845 */
89
106
  const SUBAGENTS_DIR = dirname(fileURLToPath(import.meta.url));
90
107
 
@@ -275,17 +292,167 @@ function getBundledAgentsDir(): string {
275
292
  }
276
293
 
277
294
  const HOME_PREFIX = "~/";
295
+ const PROJECT_EXTENSIONS_DIR = [".pi", "extensions"] as const;
296
+ const GLOBAL_EXTENSIONS_DIR = "extensions";
297
+ const EXTENSION_ENTRY_SUFFIXES = [".ts", ".js", ".mjs", ".cjs"] as const;
298
+ const EXTENSION_DECLARATION_SUFFIX = ".d.ts";
299
+ const EXTENSION_INDEX_FILE_NAMES = ["index.ts", "index.js", "index.mjs", "index.cjs"] as const;
300
+
301
+ const EXTENSION_SOURCE_PROJECT = "project" as const;
302
+ const EXTENSION_SOURCE_GLOBAL = "global" as const;
303
+ const EXTENSION_SOURCE_CONFIGURED = "configured" as const;
304
+
305
+ type SubagentExtensionCandidateSource =
306
+ | typeof EXTENSION_SOURCE_PROJECT
307
+ | typeof EXTENSION_SOURCE_GLOBAL
308
+ | typeof EXTENSION_SOURCE_CONFIGURED;
309
+
310
+ export interface SubagentExtensionCandidate {
311
+ path: string;
312
+ label: string;
313
+ source: SubagentExtensionCandidateSource;
314
+ missing?: boolean;
315
+ required?: boolean;
316
+ }
317
+
318
+ /** Narrow unknown manifest values to object records before reading fields. */
319
+ function isRecord(value: unknown): value is Record<string, unknown> {
320
+ return value !== null && typeof value === "object" && !Array.isArray(value);
321
+ }
322
+
323
+ /** Check whether a file can be loaded as an extension entrypoint, excluding declarations. */
324
+ function isExtensionEntry(fileName: string): boolean {
325
+ return EXTENSION_ENTRY_SUFFIXES.some((suffix) => fileName.endsWith(suffix)) && !fileName.endsWith(EXTENSION_DECLARATION_SUFFIX);
326
+ }
278
327
 
279
328
  /** Resolve an extension path from the user config against the Pi agent directory. */
280
- function resolveSubagentExtensionPath(extensionPath: string): string {
329
+ function resolveSubagentExtensionPath(extensionPath: string, agentDir = getAgentConfigDir()): string {
281
330
  if (extensionPath === "~") return homedir();
282
331
  if (extensionPath.startsWith(HOME_PREFIX)) return join(homedir(), extensionPath.slice(HOME_PREFIX.length));
283
- return isAbsolute(extensionPath) ? extensionPath : join(getAgentConfigDir(), extensionPath);
332
+ return isAbsolute(extensionPath) ? extensionPath : join(agentDir, extensionPath);
284
333
  }
285
334
 
286
- /** Load and resolve explicitly configured child-session extension paths. */
335
+ /** Read the Pi extension manifest fields needed to discover an installed entrypoint. */
336
+ function readExtensionManifest(extensionDir: string): { name?: string; extensions: string[] } | null {
337
+ try {
338
+ const parsed = JSON.parse(readFileSync(join(extensionDir, "package.json"), "utf8")) as unknown;
339
+ if (!isRecord(parsed)) return null;
340
+
341
+ const packageName = typeof parsed.name === "string" ? parsed.name : undefined;
342
+ const pi = isRecord(parsed.pi) ? parsed.pi : undefined;
343
+ const configured = pi?.extensions;
344
+ const extensions = Array.isArray(configured)
345
+ ? configured.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== "")
346
+ : typeof configured === "string" && configured.trim() !== ""
347
+ ? [configured]
348
+ : [];
349
+ return { name: packageName, extensions };
350
+ } catch {
351
+ return null;
352
+ }
353
+ }
354
+
355
+ /** Discover extension entrypoints in one Pi extension root without throwing on stale installs. */
356
+ function discoverExtensionRoot(
357
+ root: string,
358
+ source: Exclude<SubagentExtensionCandidateSource, "configured">,
359
+ ): SubagentExtensionCandidate[] {
360
+ if (!existsSync(root)) return [];
361
+
362
+ let entries: Dirent[];
363
+ try {
364
+ entries = readdirSync(root, { withFileTypes: true });
365
+ } catch {
366
+ return [];
367
+ }
368
+
369
+ const candidates: SubagentExtensionCandidate[] = [];
370
+ const seen = new Set<string>();
371
+
372
+ /** Add an existing extension candidate once, using its normalized path as the key. */
373
+ const add = (path: string, label: string) => {
374
+ const normalizedPath = resolve(path);
375
+ if (!existsSync(path) || seen.has(normalizedPath)) return;
376
+ seen.add(normalizedPath);
377
+ candidates.push({ path: normalizedPath, label, source });
378
+ };
379
+
380
+ for (const entry of entries) {
381
+ const entryPath = join(root, entry.name);
382
+ if (entry.isFile()) {
383
+ if (isExtensionEntry(entry.name)) add(entryPath, entry.name);
384
+ continue;
385
+ }
386
+ if (!entry.isDirectory()) continue;
387
+
388
+ const manifest = readExtensionManifest(entryPath);
389
+ const manifestEntries = manifest?.extensions ?? [];
390
+ if (manifestEntries.length > 0) {
391
+ for (const extension of manifestEntries) {
392
+ const extensionPath = isAbsolute(extension) ? extension : resolve(entryPath, extension);
393
+ add(extensionPath, manifest?.name ?? entry.name);
394
+ }
395
+ continue;
396
+ }
397
+
398
+ const indexPath = EXTENSION_INDEX_FILE_NAMES
399
+ .map((fileName) => join(entryPath, fileName))
400
+ .find((path) => existsSync(path));
401
+ if (indexPath) {
402
+ add(indexPath, manifest?.name ?? entry.name);
403
+ continue;
404
+ }
405
+
406
+ try {
407
+ for (const child of readdirSync(entryPath, { withFileTypes: true })) {
408
+ if (child.isFile() && isExtensionEntry(child.name)) {
409
+ add(join(entryPath, child.name), `${manifest?.name ?? entry.name}/${child.name}`);
410
+ }
411
+ }
412
+ } catch {
413
+ // An extension can disappear while the chooser is open; skip that entry.
414
+ }
415
+ }
416
+
417
+ return candidates;
418
+ }
419
+
420
+ /** Discover project/global extension candidates and retain stale configured paths for cleanup. */
421
+ export function discoverSubagentExtensionCandidates(params: {
422
+ cwd?: string;
423
+ agentDir?: string;
424
+ configured?: readonly string[];
425
+ } = {}): SubagentExtensionCandidate[] {
426
+ const cwd = params.cwd ?? process.cwd();
427
+ const agentDir = params.agentDir ?? getAgentConfigDir();
428
+ const candidates = [
429
+ ...discoverExtensionRoot(join(cwd, ...PROJECT_EXTENSIONS_DIR), EXTENSION_SOURCE_PROJECT),
430
+ ...discoverExtensionRoot(join(agentDir, GLOBAL_EXTENSIONS_DIR), EXTENSION_SOURCE_GLOBAL),
431
+ ];
432
+ const known = new Set(candidates.map((candidate) => resolve(candidate.path)));
433
+ const configured = params.configured ?? loadSubagentExtensionsConfig().extensions;
434
+
435
+ for (const configuredPath of configured) {
436
+ const resolvedPath = resolveSubagentExtensionPath(configuredPath, agentDir);
437
+ const key = resolve(resolvedPath);
438
+ if (known.has(key)) continue;
439
+ candidates.push({
440
+ path: key,
441
+ label: basename(key),
442
+ source: EXTENSION_SOURCE_CONFIGURED,
443
+ missing: !existsSync(key),
444
+ });
445
+ known.add(key);
446
+ }
447
+
448
+ return candidates;
449
+ }
450
+
451
+ /** Load and resolve explicitly configured child-session extension paths that still exist. */
287
452
  function getConfiguredSubagentExtensions(): string[] {
288
- return loadSubagentExtensionsConfig().extensions.map(resolveSubagentExtensionPath);
453
+ return loadSubagentExtensionsConfig().extensions
454
+ .map((extensionPath) => resolveSubagentExtensionPath(extensionPath))
455
+ .filter((extensionPath) => existsSync(extensionPath));
289
456
  }
290
457
 
291
458
  /** Build the no-discovery flag and explicit extensions for a child Pi command. */
@@ -1037,7 +1204,227 @@ function parseMuxConfigRequest(requested: string): MuxConfigSelection | null {
1037
1204
  return { preference, herdrMode: requestedMode as HerdrSurfaceMode };
1038
1205
  }
1039
1206
 
1040
- /** 保存 mux 选择,并在 Herdr 选项携带模式时一并持久化。 */
1207
+ const SUBAGENT_EXTENSIONS_COMMAND = "extensions";
1208
+ const SUBAGENT_EXTENSIONS_CLEAR = "clear";
1209
+ const SUBAGENT_EXTENSIONS_COMMAND_NAMES = [
1210
+ "config:subagent-extensions",
1211
+ "subagent-extensions",
1212
+ ] as const;
1213
+
1214
+ /** Parse comma-separated extension paths from a slash-command argument. */
1215
+ function parseSubagentExtensionPaths(value: string): string[] | null {
1216
+ const parts = value.split(",").map((part) => part.trim());
1217
+ if (parts.length === 0 || parts.some((part) => !part)) return null;
1218
+ return [...new Set(parts)];
1219
+ }
1220
+
1221
+ /** Format the configured child-session extension paths for user-facing messages. */
1222
+ function formatSubagentExtensions(extensions: readonly string[]): string {
1223
+ return extensions.length > 0 ? extensions.join(", ") : i18n.t("extensionNone");
1224
+ }
1225
+
1226
+ /** Complete the shared subagent configuration command's first argument. */
1227
+ function getSubagentConfigArgumentCompletions(argumentPrefix: string) {
1228
+ const trimmedPrefix = argumentPrefix.trimStart();
1229
+ const [firstToken] = trimmedPrefix.split(/\s+/, 1);
1230
+ if (firstToken?.toLowerCase() === SUBAGENT_EXTENSIONS_COMMAND && trimmedPrefix !== firstToken) {
1231
+ return getSubagentExtensionsArgumentCompletions(trimmedPrefix.slice(firstToken.length).trimStart());
1232
+ }
1233
+
1234
+ const prefix = argumentPrefix.trim().toLowerCase();
1235
+ const options = [
1236
+ {
1237
+ value: SUBAGENT_EXTENSIONS_COMMAND,
1238
+ label: SUBAGENT_EXTENSIONS_COMMAND,
1239
+ description: i18n.t("extensionCommandDescription"),
1240
+ },
1241
+ { value: AUTO_MUX_PREFERENCE, label: AUTO_MUX_PREFERENCE, description: i18n.t("muxAuto") },
1242
+ ...SUBAGENT_MUX_BACKENDS.map((backend) => ({
1243
+ value: backend,
1244
+ label: backend,
1245
+ description: i18n.t("muxCommandDescription"),
1246
+ })),
1247
+ ];
1248
+ return options.filter((option) => !prefix || option.value.startsWith(prefix));
1249
+ }
1250
+
1251
+ /** Complete the dedicated extension command with clear and current paths. */
1252
+ function getSubagentExtensionsArgumentCompletions(argumentPrefix: string) {
1253
+ const prefix = argumentPrefix.trim().toLowerCase();
1254
+ const configured = loadSubagentExtensionsConfig().extensions;
1255
+ const options = [
1256
+ {
1257
+ value: SUBAGENT_EXTENSIONS_CLEAR,
1258
+ label: SUBAGENT_EXTENSIONS_CLEAR,
1259
+ description: i18n.t("extensionClearDescription"),
1260
+ },
1261
+ ...configured.map((extension) => ({
1262
+ value: extension,
1263
+ label: extension,
1264
+ description: i18n.t("extensionConfiguredDescription"),
1265
+ })),
1266
+ ];
1267
+ return options.filter((option) => !prefix || option.value.toLowerCase().startsWith(prefix));
1268
+ }
1269
+
1270
+ /** Return a localized source label for an extension candidate. */
1271
+ function extensionCandidateSourceLabel(source: SubagentExtensionCandidateSource): string {
1272
+ if (source === EXTENSION_SOURCE_PROJECT) return i18n.t("extensionCandidateProject");
1273
+ if (source === EXTENSION_SOURCE_GLOBAL) return i18n.t("extensionCandidateGlobal");
1274
+ return i18n.t("extensionCandidateConfigured");
1275
+ }
1276
+
1277
+ /** Format one candidate for the toggle-style slash-command chooser. */
1278
+ function formatExtensionCandidateOption(
1279
+ candidate: SubagentExtensionCandidate,
1280
+ selected: ReadonlySet<string>,
1281
+ ): string {
1282
+ if (candidate.required) {
1283
+ return `● ${candidate.label} — ${i18n.t("extensionCandidateRequired")}`;
1284
+ }
1285
+ if (candidate.missing) {
1286
+ return `⚠ ${candidate.label} — ${i18n.t("extensionCandidateMissing")}`;
1287
+ }
1288
+ const marker = selected.has(resolve(candidate.path)) ? "●" : "○";
1289
+ return `${marker} ${candidate.label} — ${candidate.path} (${extensionCandidateSourceLabel(candidate.source)})`;
1290
+ }
1291
+
1292
+ /** Edit explicit paths manually and return the entered values without persisting them. */
1293
+ async function editSubagentExtensionsManually(
1294
+ ctx: ExtensionContext,
1295
+ initial: readonly string[],
1296
+ ): Promise<string[] | undefined> {
1297
+ if (!ctx.hasUI) {
1298
+ notify(ctx, i18n.t("extensionInteractiveOnly"), "warning");
1299
+ return undefined;
1300
+ }
1301
+
1302
+ const value = await ctx.ui.editor(i18n.t("extensionConfigTitle"), initial.join("\n"));
1303
+ if (value === null || value === undefined) return undefined;
1304
+ return value
1305
+ .split(/\r?\n/)
1306
+ .map((extension) => extension.trim())
1307
+ .filter(Boolean);
1308
+ }
1309
+
1310
+ /** Save a selected extension list and report the effective configuration. */
1311
+ function saveSelectedSubagentExtensions(ctx: ExtensionContext, extensions: readonly string[]): void {
1312
+ const saved = saveSubagentExtensions(extensions);
1313
+ notify(ctx,
1314
+ i18n.t("extensionSaved", { value: formatSubagentExtensions(saved.extensions) }),
1315
+ "info",
1316
+ );
1317
+ }
1318
+
1319
+ /** Let the user toggle discovered extensions while keeping the built-in done hook fixed. */
1320
+ async function chooseSubagentExtensions(ctx: ExtensionContext): Promise<void> {
1321
+ if (!ctx.hasUI) {
1322
+ notify(ctx, i18n.t("extensionInteractiveOnly"), "warning");
1323
+ return;
1324
+ }
1325
+
1326
+ const configured = loadSubagentExtensionsConfig().extensions;
1327
+ const configuredResolved = configured.map((extension) => resolveSubagentExtensionPath(extension));
1328
+ const requiredPath = resolve(join(SUBAGENTS_DIR, SUBAGENT_DONE_EXTENSION_FILE));
1329
+ const candidateMap = new Map<string, SubagentExtensionCandidate>();
1330
+ for (const candidate of [
1331
+ {
1332
+ path: requiredPath,
1333
+ label: SUBAGENT_DONE_EXTENSION_FILE,
1334
+ source: EXTENSION_SOURCE_CONFIGURED,
1335
+ required: true,
1336
+ },
1337
+ ...discoverSubagentExtensionCandidates({ configured }),
1338
+ ]) {
1339
+ const key = resolve(candidate.path);
1340
+ if (!candidateMap.has(key)) candidateMap.set(key, candidate);
1341
+ }
1342
+ const candidates = [...candidateMap.values()];
1343
+ const selected = new Set(
1344
+ configuredResolved
1345
+ .filter((extensionPath) => existsSync(extensionPath))
1346
+ .map((extensionPath) => resolve(extensionPath)),
1347
+ );
1348
+
1349
+ const candidateOptions = new Map<string, SubagentExtensionCandidate>();
1350
+ while (true) {
1351
+ candidateOptions.clear();
1352
+ const options = candidates.map((candidate) => {
1353
+ const option = formatExtensionCandidateOption(candidate, selected);
1354
+ candidateOptions.set(option, candidate);
1355
+ return option;
1356
+ });
1357
+ const doneOption = i18n.t("extensionChooserDone");
1358
+ const manualOption = i18n.t("extensionChooserManual");
1359
+ options.push(manualOption, doneOption);
1360
+
1361
+ const choice = await ctx.ui.select(i18n.t("extensionChooserTitle"), options);
1362
+ if (choice === undefined) return;
1363
+ if (choice === doneOption) {
1364
+ saveSelectedSubagentExtensions(ctx, [...selected]);
1365
+ return;
1366
+ }
1367
+ if (choice === manualOption) {
1368
+ const manual = await editSubagentExtensionsManually(ctx, [...selected]);
1369
+ if (manual !== undefined) {
1370
+ selected.clear();
1371
+ for (const extension of manual) selected.add(resolveSubagentExtensionPath(extension));
1372
+ }
1373
+ continue;
1374
+ }
1375
+
1376
+ const candidate = candidateOptions.get(choice);
1377
+ if (!candidate || candidate.required || candidate.missing) continue;
1378
+ const normalizedPath = resolve(candidate.path);
1379
+ if (selected.has(normalizedPath)) selected.delete(normalizedPath);
1380
+ else selected.add(normalizedPath);
1381
+ }
1382
+ }
1383
+
1384
+ /** Handle `/config:subagent extensions ...` or the dedicated extension command. */
1385
+ async function handleSubagentExtensionsCommand(
1386
+ args: string,
1387
+ ctx: ExtensionContext,
1388
+ hasKeyword: boolean,
1389
+ ): Promise<boolean> {
1390
+ const requested = args.trim();
1391
+ let extensionArgs = requested;
1392
+
1393
+ if (hasKeyword) {
1394
+ const [keyword] = requested.split(/\s+/, 1);
1395
+ if (keyword?.toLowerCase() !== SUBAGENT_EXTENSIONS_COMMAND) return false;
1396
+ extensionArgs = requested.slice(keyword.length).trim();
1397
+ }
1398
+
1399
+ if (!extensionArgs) {
1400
+ await chooseSubagentExtensions(ctx);
1401
+ return true;
1402
+ }
1403
+
1404
+ if (extensionArgs.toLowerCase() === SUBAGENT_EXTENSIONS_CLEAR) {
1405
+ const saved = saveSubagentExtensions([]);
1406
+ notify(ctx,
1407
+ i18n.t("extensionSaved", { value: formatSubagentExtensions(saved.extensions) }),
1408
+ "info",
1409
+ );
1410
+ return true;
1411
+ }
1412
+
1413
+ const extensions = parseSubagentExtensionPaths(extensionArgs);
1414
+ if (!extensions) {
1415
+ notify(ctx, i18n.t("extensionInvalid", { value: requested }), "warning");
1416
+ return true;
1417
+ }
1418
+
1419
+ const saved = saveSubagentExtensions(extensions);
1420
+ notify(ctx,
1421
+ i18n.t("extensionSaved", { value: formatSubagentExtensions(saved.extensions) }),
1422
+ "info",
1423
+ );
1424
+ return true;
1425
+ }
1426
+
1427
+ /** Save mux selection, and optionally its Herdr surface mode. */
1041
1428
  function saveMuxConfigSelection(selection: MuxConfigSelection): MuxConfigSelection {
1042
1429
  const savedMux = saveMuxPreference(selection.preference);
1043
1430
  const savedMode = selection.herdrMode ? saveHerdrMode(selection.herdrMode).herdrMode : undefined;
@@ -1048,12 +1435,14 @@ function saveMuxConfigSelection(selection: MuxConfigSelection): MuxConfigSelecti
1048
1435
  function registerMuxConfigCommand(pi: ExtensionAPI): void {
1049
1436
  const command = {
1050
1437
  description: i18n.t("muxCommandDescription"),
1438
+ getArgumentCompletions: getSubagentConfigArgumentCompletions,
1051
1439
  handler: async (args, ctx) => {
1052
1440
  const requested = args.trim();
1441
+ if (requested && await handleSubagentExtensionsCommand(requested, ctx, true)) return;
1053
1442
  if (requested) {
1054
1443
  const selection = parseMuxConfigRequest(requested);
1055
1444
  if (!selection) {
1056
- ctx.ui.notify(i18n.t("muxInvalid", { value: requested }), "warning");
1445
+ notify(ctx, i18n.t("muxInvalid", { value: requested }), "warning");
1057
1446
  return;
1058
1447
  }
1059
1448
  try {
@@ -1061,18 +1450,18 @@ function registerMuxConfigCommand(pi: ExtensionAPI): void {
1061
1450
  const herdrMode = saved.preference === HERDR_MUX_BACKEND
1062
1451
  ? saved.herdrMode ?? loadHerdrModeConfig().herdrMode
1063
1452
  : undefined;
1064
- ctx.ui.notify(
1453
+ notify(ctx,
1065
1454
  i18n.t("muxSaved", { value: muxPreferenceLabel(saved.preference, herdrMode) }),
1066
1455
  "info",
1067
1456
  );
1068
1457
  } catch (error) {
1069
- ctx.ui.notify(String(error), "error");
1458
+ notify(ctx, String(error), "error");
1070
1459
  }
1071
1460
  return;
1072
1461
  }
1073
1462
 
1074
1463
  if (!ctx.hasUI) {
1075
- ctx.ui.notify(i18n.t("muxInteractiveOnly"), "warning");
1464
+ notify(ctx, i18n.t("muxInteractiveOnly"), "warning");
1076
1465
  return;
1077
1466
  }
1078
1467
 
@@ -1117,20 +1506,31 @@ function registerMuxConfigCommand(pi: ExtensionAPI): void {
1117
1506
  if (!selected) return;
1118
1507
  try {
1119
1508
  const saved = saveMuxConfigSelection(selected);
1120
- ctx.ui.notify(
1509
+ notify(ctx,
1121
1510
  i18n.t("muxSaved", {
1122
1511
  value: muxPreferenceLabel(saved.preference, saved.herdrMode),
1123
1512
  }),
1124
1513
  "info",
1125
1514
  );
1126
1515
  } catch (error) {
1127
- ctx.ui.notify(String(error), "error");
1516
+ notify(ctx, String(error), "error");
1128
1517
  }
1129
1518
  },
1130
1519
  };
1131
1520
  for (const name of ["config:subagent", "subagent-config", "pi-subagent-config"] as const) {
1132
1521
  pi.registerCommand(name, command);
1133
1522
  }
1523
+
1524
+ const extensionsCommand = {
1525
+ description: i18n.t("extensionCommandDescription"),
1526
+ getArgumentCompletions: getSubagentExtensionsArgumentCompletions,
1527
+ handler: async (args: string, ctx: ExtensionContext) => {
1528
+ await handleSubagentExtensionsCommand(args, ctx, false);
1529
+ },
1530
+ };
1531
+ for (const name of SUBAGENT_EXTENSIONS_COMMAND_NAMES) {
1532
+ pi.registerCommand(name, extensionsCommand);
1533
+ }
1134
1534
  }
1135
1535
 
1136
1536
  /** 每次启动或恢复都用新 surface 重建归属,不能继承父进程的改名范围。 */
@@ -1140,13 +1540,20 @@ function buildTerminalRenameEnvironment(surface: string, backend = getMuxBackend
1140
1540
  }
1141
1541
 
1142
1542
  export const __test__ = {
1543
+ notify,
1544
+ NOTICE_SOURCE,
1143
1545
  buildTerminalRenameEnvironment,
1144
1546
  borderLine,
1145
1547
  parseMuxConfigRequest,
1548
+ parseSubagentExtensionPaths,
1549
+ getSubagentConfigArgumentCompletions,
1550
+ getSubagentExtensionsArgumentCompletions,
1146
1551
  getShellReadyDelayMs,
1147
1552
  renderSubagentWidgetLines,
1148
1553
  loadAgentDefaults,
1149
1554
  discoverAgentDefinitions,
1555
+ discoverSubagentExtensionCandidates,
1556
+ getConfiguredSubagentExtensions,
1150
1557
  resolveEffectiveSessionMode,
1151
1558
  resolveLaunchBehavior,
1152
1559
  resolveEffectiveInteractive,
@@ -2224,7 +2631,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
2224
2631
  handler: async (args, ctx) => {
2225
2632
  const trimmed = args.trim();
2226
2633
  if (!trimmed) {
2227
- ctx.ui.notify("Usage: /subagent <agent> [task]", "warning");
2634
+ notify(ctx, "Usage: /subagent <agent> [task]", "warning");
2228
2635
  return;
2229
2636
  }
2230
2637
 
@@ -2234,7 +2641,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
2234
2641
 
2235
2642
  const defs = loadAgentDefaults(agentName);
2236
2643
  if (!defs) {
2237
- ctx.ui.notify(
2644
+ notify(ctx,
2238
2645
  `Agent "${agentName}" not found in ~/.pi/agent/agents/ or .pi/agents/`,
2239
2646
  "error",
2240
2647
  );
@@ -2392,7 +2799,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
2392
2799
  handler: async (args, ctx) => {
2393
2800
  const task = args.trim();
2394
2801
  if (!task) {
2395
- ctx.ui.notify("Usage: /plan <what to build>", "warning");
2802
+ notify(ctx, "Usage: /plan <what to build>", "warning");
2396
2803
  return;
2397
2804
  }
2398
2805
 
@@ -146,6 +146,16 @@ export function loadSubagentExtensionsConfig(path = muxConfigPath()): SubagentEx
146
146
  return { extensions: [...DEFAULT_SUBAGENT_EXTENSIONS], source: "default" };
147
147
  }
148
148
 
149
+ /** Persist the explicit extension list for child sessions. */
150
+ export function saveSubagentExtensions(
151
+ extensions: readonly string[],
152
+ path = muxConfigPath(),
153
+ ): SubagentExtensionsConfig {
154
+ const normalized = [...new Set(extensions.map((extension) => extension.trim()).filter(Boolean))];
155
+ writeConfigObject(path, { ...readConfigObject(path), subagentExtensions: normalized });
156
+ return { extensions: normalized, source: "file" };
157
+ }
158
+
149
159
  /** Apply persisted mux and Herdr mode settings when no explicit environment override exists. */
150
160
  export function applyPersistedMuxPreference(path = muxConfigPath()): void {
151
161
  if (!environmentPreference()) {