@maplezzk/pi-interactive-subagents 3.13.0 → 3.14.1

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.0",
3
+ "version": "3.14.1",
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.0"
65
+ "pi-extensions-i18n": "^0.4.1",
66
+ "pi-terminal-mux": "^0.6.1"
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
7
  import { createTranslator, loadCatalog } 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,
@@ -275,17 +277,167 @@ function getBundledAgentsDir(): string {
275
277
  }
276
278
 
277
279
  const HOME_PREFIX = "~/";
280
+ const PROJECT_EXTENSIONS_DIR = [".pi", "extensions"] as const;
281
+ const GLOBAL_EXTENSIONS_DIR = "extensions";
282
+ const EXTENSION_ENTRY_SUFFIXES = [".ts", ".js", ".mjs", ".cjs"] as const;
283
+ const EXTENSION_DECLARATION_SUFFIX = ".d.ts";
284
+ const EXTENSION_INDEX_FILE_NAMES = ["index.ts", "index.js", "index.mjs", "index.cjs"] as const;
285
+
286
+ const EXTENSION_SOURCE_PROJECT = "project" as const;
287
+ const EXTENSION_SOURCE_GLOBAL = "global" as const;
288
+ const EXTENSION_SOURCE_CONFIGURED = "configured" as const;
289
+
290
+ type SubagentExtensionCandidateSource =
291
+ | typeof EXTENSION_SOURCE_PROJECT
292
+ | typeof EXTENSION_SOURCE_GLOBAL
293
+ | typeof EXTENSION_SOURCE_CONFIGURED;
294
+
295
+ export interface SubagentExtensionCandidate {
296
+ path: string;
297
+ label: string;
298
+ source: SubagentExtensionCandidateSource;
299
+ missing?: boolean;
300
+ required?: boolean;
301
+ }
302
+
303
+ /** Narrow unknown manifest values to object records before reading fields. */
304
+ function isRecord(value: unknown): value is Record<string, unknown> {
305
+ return value !== null && typeof value === "object" && !Array.isArray(value);
306
+ }
307
+
308
+ /** Check whether a file can be loaded as an extension entrypoint, excluding declarations. */
309
+ function isExtensionEntry(fileName: string): boolean {
310
+ return EXTENSION_ENTRY_SUFFIXES.some((suffix) => fileName.endsWith(suffix)) && !fileName.endsWith(EXTENSION_DECLARATION_SUFFIX);
311
+ }
278
312
 
279
313
  /** Resolve an extension path from the user config against the Pi agent directory. */
280
- function resolveSubagentExtensionPath(extensionPath: string): string {
314
+ function resolveSubagentExtensionPath(extensionPath: string, agentDir = getAgentConfigDir()): string {
281
315
  if (extensionPath === "~") return homedir();
282
316
  if (extensionPath.startsWith(HOME_PREFIX)) return join(homedir(), extensionPath.slice(HOME_PREFIX.length));
283
- return isAbsolute(extensionPath) ? extensionPath : join(getAgentConfigDir(), extensionPath);
317
+ return isAbsolute(extensionPath) ? extensionPath : join(agentDir, extensionPath);
284
318
  }
285
319
 
286
- /** Load and resolve explicitly configured child-session extension paths. */
320
+ /** Read the Pi extension manifest fields needed to discover an installed entrypoint. */
321
+ function readExtensionManifest(extensionDir: string): { name?: string; extensions: string[] } | null {
322
+ try {
323
+ const parsed = JSON.parse(readFileSync(join(extensionDir, "package.json"), "utf8")) as unknown;
324
+ if (!isRecord(parsed)) return null;
325
+
326
+ const packageName = typeof parsed.name === "string" ? parsed.name : undefined;
327
+ const pi = isRecord(parsed.pi) ? parsed.pi : undefined;
328
+ const configured = pi?.extensions;
329
+ const extensions = Array.isArray(configured)
330
+ ? configured.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== "")
331
+ : typeof configured === "string" && configured.trim() !== ""
332
+ ? [configured]
333
+ : [];
334
+ return { name: packageName, extensions };
335
+ } catch {
336
+ return null;
337
+ }
338
+ }
339
+
340
+ /** Discover extension entrypoints in one Pi extension root without throwing on stale installs. */
341
+ function discoverExtensionRoot(
342
+ root: string,
343
+ source: Exclude<SubagentExtensionCandidateSource, "configured">,
344
+ ): SubagentExtensionCandidate[] {
345
+ if (!existsSync(root)) return [];
346
+
347
+ let entries: Dirent[];
348
+ try {
349
+ entries = readdirSync(root, { withFileTypes: true });
350
+ } catch {
351
+ return [];
352
+ }
353
+
354
+ const candidates: SubagentExtensionCandidate[] = [];
355
+ const seen = new Set<string>();
356
+
357
+ /** Add an existing extension candidate once, using its normalized path as the key. */
358
+ const add = (path: string, label: string) => {
359
+ const normalizedPath = resolve(path);
360
+ if (!existsSync(path) || seen.has(normalizedPath)) return;
361
+ seen.add(normalizedPath);
362
+ candidates.push({ path: normalizedPath, label, source });
363
+ };
364
+
365
+ for (const entry of entries) {
366
+ const entryPath = join(root, entry.name);
367
+ if (entry.isFile()) {
368
+ if (isExtensionEntry(entry.name)) add(entryPath, entry.name);
369
+ continue;
370
+ }
371
+ if (!entry.isDirectory()) continue;
372
+
373
+ const manifest = readExtensionManifest(entryPath);
374
+ const manifestEntries = manifest?.extensions ?? [];
375
+ if (manifestEntries.length > 0) {
376
+ for (const extension of manifestEntries) {
377
+ const extensionPath = isAbsolute(extension) ? extension : resolve(entryPath, extension);
378
+ add(extensionPath, manifest?.name ?? entry.name);
379
+ }
380
+ continue;
381
+ }
382
+
383
+ const indexPath = EXTENSION_INDEX_FILE_NAMES
384
+ .map((fileName) => join(entryPath, fileName))
385
+ .find((path) => existsSync(path));
386
+ if (indexPath) {
387
+ add(indexPath, manifest?.name ?? entry.name);
388
+ continue;
389
+ }
390
+
391
+ try {
392
+ for (const child of readdirSync(entryPath, { withFileTypes: true })) {
393
+ if (child.isFile() && isExtensionEntry(child.name)) {
394
+ add(join(entryPath, child.name), `${manifest?.name ?? entry.name}/${child.name}`);
395
+ }
396
+ }
397
+ } catch {
398
+ // An extension can disappear while the chooser is open; skip that entry.
399
+ }
400
+ }
401
+
402
+ return candidates;
403
+ }
404
+
405
+ /** Discover project/global extension candidates and retain stale configured paths for cleanup. */
406
+ export function discoverSubagentExtensionCandidates(params: {
407
+ cwd?: string;
408
+ agentDir?: string;
409
+ configured?: readonly string[];
410
+ } = {}): SubagentExtensionCandidate[] {
411
+ const cwd = params.cwd ?? process.cwd();
412
+ const agentDir = params.agentDir ?? getAgentConfigDir();
413
+ const candidates = [
414
+ ...discoverExtensionRoot(join(cwd, ...PROJECT_EXTENSIONS_DIR), EXTENSION_SOURCE_PROJECT),
415
+ ...discoverExtensionRoot(join(agentDir, GLOBAL_EXTENSIONS_DIR), EXTENSION_SOURCE_GLOBAL),
416
+ ];
417
+ const known = new Set(candidates.map((candidate) => resolve(candidate.path)));
418
+ const configured = params.configured ?? loadSubagentExtensionsConfig().extensions;
419
+
420
+ for (const configuredPath of configured) {
421
+ const resolvedPath = resolveSubagentExtensionPath(configuredPath, agentDir);
422
+ const key = resolve(resolvedPath);
423
+ if (known.has(key)) continue;
424
+ candidates.push({
425
+ path: key,
426
+ label: basename(key),
427
+ source: EXTENSION_SOURCE_CONFIGURED,
428
+ missing: !existsSync(key),
429
+ });
430
+ known.add(key);
431
+ }
432
+
433
+ return candidates;
434
+ }
435
+
436
+ /** Load and resolve explicitly configured child-session extension paths that still exist. */
287
437
  function getConfiguredSubagentExtensions(): string[] {
288
- return loadSubagentExtensionsConfig().extensions.map(resolveSubagentExtensionPath);
438
+ return loadSubagentExtensionsConfig().extensions
439
+ .map((extensionPath) => resolveSubagentExtensionPath(extensionPath))
440
+ .filter((extensionPath) => existsSync(extensionPath));
289
441
  }
290
442
 
291
443
  /** Build the no-discovery flag and explicit extensions for a child Pi command. */
@@ -1037,7 +1189,227 @@ function parseMuxConfigRequest(requested: string): MuxConfigSelection | null {
1037
1189
  return { preference, herdrMode: requestedMode as HerdrSurfaceMode };
1038
1190
  }
1039
1191
 
1040
- /** 保存 mux 选择,并在 Herdr 选项携带模式时一并持久化。 */
1192
+ const SUBAGENT_EXTENSIONS_COMMAND = "extensions";
1193
+ const SUBAGENT_EXTENSIONS_CLEAR = "clear";
1194
+ const SUBAGENT_EXTENSIONS_COMMAND_NAMES = [
1195
+ "config:subagent-extensions",
1196
+ "subagent-extensions",
1197
+ ] as const;
1198
+
1199
+ /** Parse comma-separated extension paths from a slash-command argument. */
1200
+ function parseSubagentExtensionPaths(value: string): string[] | null {
1201
+ const parts = value.split(",").map((part) => part.trim());
1202
+ if (parts.length === 0 || parts.some((part) => !part)) return null;
1203
+ return [...new Set(parts)];
1204
+ }
1205
+
1206
+ /** Format the configured child-session extension paths for user-facing messages. */
1207
+ function formatSubagentExtensions(extensions: readonly string[]): string {
1208
+ return extensions.length > 0 ? extensions.join(", ") : i18n.t("extensionNone");
1209
+ }
1210
+
1211
+ /** Complete the shared subagent configuration command's first argument. */
1212
+ function getSubagentConfigArgumentCompletions(argumentPrefix: string) {
1213
+ const trimmedPrefix = argumentPrefix.trimStart();
1214
+ const [firstToken] = trimmedPrefix.split(/\s+/, 1);
1215
+ if (firstToken?.toLowerCase() === SUBAGENT_EXTENSIONS_COMMAND && trimmedPrefix !== firstToken) {
1216
+ return getSubagentExtensionsArgumentCompletions(trimmedPrefix.slice(firstToken.length).trimStart());
1217
+ }
1218
+
1219
+ const prefix = argumentPrefix.trim().toLowerCase();
1220
+ const options = [
1221
+ {
1222
+ value: SUBAGENT_EXTENSIONS_COMMAND,
1223
+ label: SUBAGENT_EXTENSIONS_COMMAND,
1224
+ description: i18n.t("extensionCommandDescription"),
1225
+ },
1226
+ { value: AUTO_MUX_PREFERENCE, label: AUTO_MUX_PREFERENCE, description: i18n.t("muxAuto") },
1227
+ ...SUBAGENT_MUX_BACKENDS.map((backend) => ({
1228
+ value: backend,
1229
+ label: backend,
1230
+ description: i18n.t("muxCommandDescription"),
1231
+ })),
1232
+ ];
1233
+ return options.filter((option) => !prefix || option.value.startsWith(prefix));
1234
+ }
1235
+
1236
+ /** Complete the dedicated extension command with clear and current paths. */
1237
+ function getSubagentExtensionsArgumentCompletions(argumentPrefix: string) {
1238
+ const prefix = argumentPrefix.trim().toLowerCase();
1239
+ const configured = loadSubagentExtensionsConfig().extensions;
1240
+ const options = [
1241
+ {
1242
+ value: SUBAGENT_EXTENSIONS_CLEAR,
1243
+ label: SUBAGENT_EXTENSIONS_CLEAR,
1244
+ description: i18n.t("extensionClearDescription"),
1245
+ },
1246
+ ...configured.map((extension) => ({
1247
+ value: extension,
1248
+ label: extension,
1249
+ description: i18n.t("extensionConfiguredDescription"),
1250
+ })),
1251
+ ];
1252
+ return options.filter((option) => !prefix || option.value.toLowerCase().startsWith(prefix));
1253
+ }
1254
+
1255
+ /** Return a localized source label for an extension candidate. */
1256
+ function extensionCandidateSourceLabel(source: SubagentExtensionCandidateSource): string {
1257
+ if (source === EXTENSION_SOURCE_PROJECT) return i18n.t("extensionCandidateProject");
1258
+ if (source === EXTENSION_SOURCE_GLOBAL) return i18n.t("extensionCandidateGlobal");
1259
+ return i18n.t("extensionCandidateConfigured");
1260
+ }
1261
+
1262
+ /** Format one candidate for the toggle-style slash-command chooser. */
1263
+ function formatExtensionCandidateOption(
1264
+ candidate: SubagentExtensionCandidate,
1265
+ selected: ReadonlySet<string>,
1266
+ ): string {
1267
+ if (candidate.required) {
1268
+ return `● ${candidate.label} — ${i18n.t("extensionCandidateRequired")}`;
1269
+ }
1270
+ if (candidate.missing) {
1271
+ return `⚠ ${candidate.label} — ${i18n.t("extensionCandidateMissing")}`;
1272
+ }
1273
+ const marker = selected.has(resolve(candidate.path)) ? "●" : "○";
1274
+ return `${marker} ${candidate.label} — ${candidate.path} (${extensionCandidateSourceLabel(candidate.source)})`;
1275
+ }
1276
+
1277
+ /** Edit explicit paths manually and return the entered values without persisting them. */
1278
+ async function editSubagentExtensionsManually(
1279
+ ctx: ExtensionContext,
1280
+ initial: readonly string[],
1281
+ ): Promise<string[] | undefined> {
1282
+ if (!ctx.hasUI) {
1283
+ ctx.ui.notify(i18n.t("extensionInteractiveOnly"), "warning");
1284
+ return undefined;
1285
+ }
1286
+
1287
+ const value = await ctx.ui.editor(i18n.t("extensionConfigTitle"), initial.join("\n"));
1288
+ if (value === null || value === undefined) return undefined;
1289
+ return value
1290
+ .split(/\r?\n/)
1291
+ .map((extension) => extension.trim())
1292
+ .filter(Boolean);
1293
+ }
1294
+
1295
+ /** Save a selected extension list and report the effective configuration. */
1296
+ function saveSelectedSubagentExtensions(ctx: ExtensionContext, extensions: readonly string[]): void {
1297
+ const saved = saveSubagentExtensions(extensions);
1298
+ ctx.ui.notify(
1299
+ i18n.t("extensionSaved", { value: formatSubagentExtensions(saved.extensions) }),
1300
+ "info",
1301
+ );
1302
+ }
1303
+
1304
+ /** Let the user toggle discovered extensions while keeping the built-in done hook fixed. */
1305
+ async function chooseSubagentExtensions(ctx: ExtensionContext): Promise<void> {
1306
+ if (!ctx.hasUI) {
1307
+ ctx.ui.notify(i18n.t("extensionInteractiveOnly"), "warning");
1308
+ return;
1309
+ }
1310
+
1311
+ const configured = loadSubagentExtensionsConfig().extensions;
1312
+ const configuredResolved = configured.map((extension) => resolveSubagentExtensionPath(extension));
1313
+ const requiredPath = resolve(join(SUBAGENTS_DIR, SUBAGENT_DONE_EXTENSION_FILE));
1314
+ const candidateMap = new Map<string, SubagentExtensionCandidate>();
1315
+ for (const candidate of [
1316
+ {
1317
+ path: requiredPath,
1318
+ label: SUBAGENT_DONE_EXTENSION_FILE,
1319
+ source: EXTENSION_SOURCE_CONFIGURED,
1320
+ required: true,
1321
+ },
1322
+ ...discoverSubagentExtensionCandidates({ configured }),
1323
+ ]) {
1324
+ const key = resolve(candidate.path);
1325
+ if (!candidateMap.has(key)) candidateMap.set(key, candidate);
1326
+ }
1327
+ const candidates = [...candidateMap.values()];
1328
+ const selected = new Set(
1329
+ configuredResolved
1330
+ .filter((extensionPath) => existsSync(extensionPath))
1331
+ .map((extensionPath) => resolve(extensionPath)),
1332
+ );
1333
+
1334
+ const candidateOptions = new Map<string, SubagentExtensionCandidate>();
1335
+ while (true) {
1336
+ candidateOptions.clear();
1337
+ const options = candidates.map((candidate) => {
1338
+ const option = formatExtensionCandidateOption(candidate, selected);
1339
+ candidateOptions.set(option, candidate);
1340
+ return option;
1341
+ });
1342
+ const doneOption = i18n.t("extensionChooserDone");
1343
+ const manualOption = i18n.t("extensionChooserManual");
1344
+ options.push(manualOption, doneOption);
1345
+
1346
+ const choice = await ctx.ui.select(i18n.t("extensionChooserTitle"), options);
1347
+ if (choice === undefined) return;
1348
+ if (choice === doneOption) {
1349
+ saveSelectedSubagentExtensions(ctx, [...selected]);
1350
+ return;
1351
+ }
1352
+ if (choice === manualOption) {
1353
+ const manual = await editSubagentExtensionsManually(ctx, [...selected]);
1354
+ if (manual !== undefined) {
1355
+ selected.clear();
1356
+ for (const extension of manual) selected.add(resolveSubagentExtensionPath(extension));
1357
+ }
1358
+ continue;
1359
+ }
1360
+
1361
+ const candidate = candidateOptions.get(choice);
1362
+ if (!candidate || candidate.required || candidate.missing) continue;
1363
+ const normalizedPath = resolve(candidate.path);
1364
+ if (selected.has(normalizedPath)) selected.delete(normalizedPath);
1365
+ else selected.add(normalizedPath);
1366
+ }
1367
+ }
1368
+
1369
+ /** Handle `/config:subagent extensions ...` or the dedicated extension command. */
1370
+ async function handleSubagentExtensionsCommand(
1371
+ args: string,
1372
+ ctx: ExtensionContext,
1373
+ hasKeyword: boolean,
1374
+ ): Promise<boolean> {
1375
+ const requested = args.trim();
1376
+ let extensionArgs = requested;
1377
+
1378
+ if (hasKeyword) {
1379
+ const [keyword] = requested.split(/\s+/, 1);
1380
+ if (keyword?.toLowerCase() !== SUBAGENT_EXTENSIONS_COMMAND) return false;
1381
+ extensionArgs = requested.slice(keyword.length).trim();
1382
+ }
1383
+
1384
+ if (!extensionArgs) {
1385
+ await chooseSubagentExtensions(ctx);
1386
+ return true;
1387
+ }
1388
+
1389
+ if (extensionArgs.toLowerCase() === SUBAGENT_EXTENSIONS_CLEAR) {
1390
+ const saved = saveSubagentExtensions([]);
1391
+ ctx.ui.notify(
1392
+ i18n.t("extensionSaved", { value: formatSubagentExtensions(saved.extensions) }),
1393
+ "info",
1394
+ );
1395
+ return true;
1396
+ }
1397
+
1398
+ const extensions = parseSubagentExtensionPaths(extensionArgs);
1399
+ if (!extensions) {
1400
+ ctx.ui.notify(i18n.t("extensionInvalid", { value: requested }), "warning");
1401
+ return true;
1402
+ }
1403
+
1404
+ const saved = saveSubagentExtensions(extensions);
1405
+ ctx.ui.notify(
1406
+ i18n.t("extensionSaved", { value: formatSubagentExtensions(saved.extensions) }),
1407
+ "info",
1408
+ );
1409
+ return true;
1410
+ }
1411
+
1412
+ /** Save mux selection, and optionally its Herdr surface mode. */
1041
1413
  function saveMuxConfigSelection(selection: MuxConfigSelection): MuxConfigSelection {
1042
1414
  const savedMux = saveMuxPreference(selection.preference);
1043
1415
  const savedMode = selection.herdrMode ? saveHerdrMode(selection.herdrMode).herdrMode : undefined;
@@ -1048,8 +1420,10 @@ function saveMuxConfigSelection(selection: MuxConfigSelection): MuxConfigSelecti
1048
1420
  function registerMuxConfigCommand(pi: ExtensionAPI): void {
1049
1421
  const command = {
1050
1422
  description: i18n.t("muxCommandDescription"),
1423
+ getArgumentCompletions: getSubagentConfigArgumentCompletions,
1051
1424
  handler: async (args, ctx) => {
1052
1425
  const requested = args.trim();
1426
+ if (requested && await handleSubagentExtensionsCommand(requested, ctx, true)) return;
1053
1427
  if (requested) {
1054
1428
  const selection = parseMuxConfigRequest(requested);
1055
1429
  if (!selection) {
@@ -1131,6 +1505,17 @@ function registerMuxConfigCommand(pi: ExtensionAPI): void {
1131
1505
  for (const name of ["config:subagent", "subagent-config", "pi-subagent-config"] as const) {
1132
1506
  pi.registerCommand(name, command);
1133
1507
  }
1508
+
1509
+ const extensionsCommand = {
1510
+ description: i18n.t("extensionCommandDescription"),
1511
+ getArgumentCompletions: getSubagentExtensionsArgumentCompletions,
1512
+ handler: async (args: string, ctx: ExtensionContext) => {
1513
+ await handleSubagentExtensionsCommand(args, ctx, false);
1514
+ },
1515
+ };
1516
+ for (const name of SUBAGENT_EXTENSIONS_COMMAND_NAMES) {
1517
+ pi.registerCommand(name, extensionsCommand);
1518
+ }
1134
1519
  }
1135
1520
 
1136
1521
  /** 每次启动或恢复都用新 surface 重建归属,不能继承父进程的改名范围。 */
@@ -1143,10 +1528,15 @@ export const __test__ = {
1143
1528
  buildTerminalRenameEnvironment,
1144
1529
  borderLine,
1145
1530
  parseMuxConfigRequest,
1531
+ parseSubagentExtensionPaths,
1532
+ getSubagentConfigArgumentCompletions,
1533
+ getSubagentExtensionsArgumentCompletions,
1146
1534
  getShellReadyDelayMs,
1147
1535
  renderSubagentWidgetLines,
1148
1536
  loadAgentDefaults,
1149
1537
  discoverAgentDefinitions,
1538
+ discoverSubagentExtensionCandidates,
1539
+ getConfiguredSubagentExtensions,
1150
1540
  resolveEffectiveSessionMode,
1151
1541
  resolveLaunchBehavior,
1152
1542
  resolveEffectiveInteractive,
@@ -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()) {