@jwilger/pi-development-system 0.10.6 → 0.11.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 (34) hide show
  1. package/agents/architect.md +66 -0
  2. package/agents/coder.md +57 -0
  3. package/agents/researcher.md +60 -0
  4. package/agents/reviewer.md +53 -0
  5. package/agents/tasker.md +58 -0
  6. package/agents/writer.md +39 -0
  7. package/extensions/development-system.ts +2 -0
  8. package/package.json +5 -3
  9. package/src/subagents/VENDORED.md +38 -0
  10. package/src/subagents/index.ts +544 -0
  11. package/src/subagents/orch/inherited-tools.ts +85 -0
  12. package/src/subagents/orch/mailbox.ts +262 -0
  13. package/src/subagents/orch/manager.ts +765 -0
  14. package/src/subagents/orch/paths.ts +74 -0
  15. package/src/subagents/orch/prompt.ts +25 -0
  16. package/src/subagents/orch/runtime.ts +784 -0
  17. package/src/subagents/orch/tools.ts +166 -0
  18. package/src/subagents/orch/transcript.ts +187 -0
  19. package/src/subagents/prefs/agent-import.ts +246 -0
  20. package/src/subagents/prefs/config.ts +1029 -0
  21. package/src/subagents/prefs/import-discovery.ts +644 -0
  22. package/src/subagents/prefs/import-instructions.ts +157 -0
  23. package/src/subagents/prefs/models.ts +78 -0
  24. package/src/subagents/prefs/settings.ts +371 -0
  25. package/src/subagents/types.ts +200 -0
  26. package/src/subagents/ui/agent-navigation-editor.ts +210 -0
  27. package/src/subagents/ui/dialog.ts +651 -0
  28. package/src/subagents/ui/import-picker.ts +232 -0
  29. package/src/subagents/ui/live-agent-view.ts +572 -0
  30. package/src/subagents/ui/model-picker.ts +452 -0
  31. package/src/subagents/ui/settings-ui.ts +329 -0
  32. package/src/subagents/ui/status-ui.ts +545 -0
  33. package/src/subagents/ui/thread-tree.ts +135 -0
  34. package/src/subagents/ui/ui.ts +1507 -0
@@ -0,0 +1,544 @@
1
+ // @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
2
+ import { randomUUID } from "node:crypto";
3
+ import { buildSessionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import { fuzzyFilter } from "@earendil-works/pi-tui";
6
+ import { ConfigStore } from "./prefs/config.ts";
7
+ import { IMPORT_REQUEST_PREFIX, markImportOffered, offerAgentImport } from "./prefs/agent-import.ts";
8
+ import { ThreadManager } from "./orch/manager.ts";
9
+ import { loadManagerSettings } from "./prefs/settings.ts";
10
+ import { configureAgents } from "./ui/settings-ui.ts";
11
+ import { AgentNavigationController, showAgentStatus } from "./ui/status-ui.ts";
12
+ import { AgentNavigationEditor } from "./ui/agent-navigation-editor.ts";
13
+ import { createDriverFactory } from "./orch/runtime.ts";
14
+ import { createInheritedToolSource } from "./orch/inherited-tools.ts";
15
+ import { subagentPrompt } from "./orch/prompt.ts";
16
+ import { agentTools } from "./orch/tools.ts";
17
+ import { editAgentTypes, updateWidget } from "./ui/ui.ts";
18
+ import type { SavedThread, ThreadEvent } from "./types.ts";
19
+
20
+ const REGISTRY_ENTRY = "pi-subagent:registry:v1";
21
+ const ROOT_MAILBOX_ENTRY = "pi-subagent:root-mailbox:v1";
22
+ const ROOT_SUMMARY_MESSAGE = "pi-subagent:final-recap";
23
+ type RootNotification = {
24
+ rootSessionId: string;
25
+ content: string;
26
+ finalRecap?: boolean;
27
+ details: { mailboxId: string; path: string; state: string };
28
+ };
29
+
30
+ export default function piSubagent(pi: ExtensionAPI): void {
31
+ let context: ExtensionContext | undefined;
32
+ const inheritedTools = createInheritedToolSource(pi);
33
+ let store = new ConfigStore({
34
+ cwd: process.cwd(),
35
+ agentDir: getAgentDir(),
36
+ includeProject: false,
37
+ });
38
+ let manager: ThreadManager | undefined;
39
+ let limits = loadManagerSettings({
40
+ cwd: process.cwd(),
41
+ agentDir: getAgentDir(),
42
+ includeProject: false,
43
+ }).settings;
44
+ const loadLimits = (ctx: ExtensionContext) => {
45
+ const loaded = loadManagerSettings({
46
+ cwd: ctx.cwd,
47
+ agentDir: getAgentDir(),
48
+ includeProject: ctx.isProjectTrusted(),
49
+ });
50
+ limits = loaded.settings;
51
+ return loaded.diagnostics;
52
+ };
53
+ let generation = 0;
54
+ let rootTurnEnded = false;
55
+ const hiddenWidgetThreads = new Set<string>();
56
+ let widgetCollapsed = false;
57
+ let summaryRunning = false;
58
+ let suspendingSummaries = false;
59
+ let summaryTimer: ReturnType<typeof setTimeout> | undefined;
60
+ const cancelSummary = () => {
61
+ if (summaryTimer !== undefined) clearTimeout(summaryTimer);
62
+ summaryTimer = undefined;
63
+ summaryRunning = false;
64
+ };
65
+ let navigation = new AgentNavigationController();
66
+ let navigationEditor: AgentNavigationEditor | undefined;
67
+ let navigationEditorFactory: Parameters<ExtensionContext["ui"]["setEditorComponent"]>[0];
68
+ let navigationGeneration = 0;
69
+ const closeNavigation = (disposeEditor = false) => {
70
+ navigationGeneration++;
71
+ navigation.close();
72
+ if (disposeEditor) {
73
+ navigationEditor?.dispose();
74
+ navigationEditor = undefined;
75
+ }
76
+ };
77
+ const installNavigationEditor = (ctx: ExtensionContext, seedHistory: boolean) => {
78
+ // Pi has one custom-editor slot: do not replace another integration.
79
+ if (!ctx.hasUI || ctx.mode !== "tui" || !ctx.ui.setEditorComponent) return;
80
+ const existingFactory = ctx.ui.getEditorComponent?.();
81
+ if (existingFactory && existingFactory !== navigationEditorFactory) return;
82
+ // The host only transfers raw text, not cursor/undo/expanded-paste state.
83
+ if (ctx.ui.getEditorText?.()) return;
84
+ const history = buildSessionContext(ctx.sessionManager.getBranch()).messages.flatMap((message) => {
85
+ if (message.role !== "user") return [];
86
+ const content = message.content;
87
+ return [typeof content === "string" ? content : content
88
+ .filter((block) => block.type === "text").map((block) => block.text).join("")];
89
+ });
90
+ navigationEditorFactory = (tui, theme, keys) => {
91
+ const editor = new AgentNavigationEditor(tui, theme, keys, {
92
+ // Initial startup hydrates after session_start; replacement editors do not.
93
+ initialHistory: seedHistory ? history : undefined,
94
+ generation: () => navigationGeneration,
95
+ canOpen: () => manager !== undefined && context?.mode === "tui" &&
96
+ limits.subagentMode !== "off" && !navigation.isOpen &&
97
+ manager.list().some((thread) => thread.path !== "/root" &&
98
+ (thread.state === "starting" || thread.state === "running")),
99
+ openTree: () => manager && context
100
+ ? navigation.open(context, manager.scope("/root"), undefined, limits.nerdFontIcons) : undefined,
101
+ canCollapse: () => manager !== undefined && context?.mode === "tui" &&
102
+ limits.subagentMode !== "off" && !navigation.isOpen && !widgetCollapsed &&
103
+ limits.widgetMode === "full" && widgetThreads().some((thread) => thread.path !== "/root"),
104
+ collapseWidget: () => {
105
+ widgetCollapsed = true;
106
+ if (context) refreshWidget(context);
107
+ },
108
+ onError: (error) => context?.ui.notify(
109
+ error instanceof Error ? error.message : String(error), "error"),
110
+ });
111
+ navigationEditor = editor;
112
+ return editor;
113
+ };
114
+ ctx.ui.setEditorComponent(navigationEditorFactory);
115
+ };
116
+ let persistenceSignature = "";
117
+ let migrationRequested = false;
118
+ let importInProgress = false;
119
+ const importAgents = async (ctx: ExtensionContext, firstRun: boolean) => {
120
+ // Migration requires the root's file tools and must not delegate source prompts.
121
+ if (limits.subagentMode === "orchestration") {
122
+ if (!firstRun)
123
+ ctx.ui.notify("Agent import requires Opportunistic mode. Change Subagent Mode in /agents settings.", "info");
124
+ return;
125
+ }
126
+ if (importInProgress) return;
127
+ importInProgress = true;
128
+ try {
129
+ migrationRequested =
130
+ (await offerAgentImport(pi, ctx, {
131
+ agentDir: getAgentDir(),
132
+ store,
133
+ firstRun,
134
+ })) || migrationRequested;
135
+ } finally {
136
+ importInProgress = false;
137
+ }
138
+ };
139
+ const requireManager = () => {
140
+ if (!manager) throw new Error("Subagent threads are not initialized; start a Pi session first");
141
+ return manager;
142
+ };
143
+ const widgetThreads = () => requireManager().list().filter((thread) => {
144
+ // Resuming a retained session makes it visible for this task, even after it settles again.
145
+ if (thread.state === "starting" || thread.state === "running")
146
+ hiddenWidgetThreads.delete(thread.path);
147
+ return !hiddenWidgetThreads.has(thread.path);
148
+ });
149
+ const requireContext = () => {
150
+ if (!context) throw new Error("No active Pi session");
151
+ return context;
152
+ };
153
+ const persist = () => {
154
+ if (!manager) return;
155
+ const threads = manager.saved();
156
+ const signature = JSON.stringify(threads);
157
+ if (signature !== persistenceSignature) {
158
+ pi.appendEntry(REGISTRY_ENTRY, {
159
+ version: 1,
160
+ rootSessionId: requireContext().sessionManager.getSessionId(),
161
+ threads,
162
+ });
163
+ persistenceSignature = signature;
164
+ }
165
+ };
166
+ const warnDelivery = (error: unknown) => {
167
+ if (limits.subagentMode === "off") return;
168
+ const message = error instanceof Error ? error.message : String(error);
169
+ context?.ui.notify(`Subagent delivery failed: ${message}`, "warning");
170
+ };
171
+ const sendRootNotification = (notification: RootNotification) => {
172
+ if (limits.subagentMode === "off" || context?.isIdle?.() === false) return;
173
+ // Never submit to Pi's streaming queue: off must also suppress pending delivery.
174
+ pi.sendMessage(
175
+ {
176
+ customType: "pi-subagent:update",
177
+ content: notification.content,
178
+ // Keep asynchronous child updates in model context, not the main chat.
179
+ // Idle/settlement delivery can follow the root's final answer; rendering it
180
+ // would leave a child's output as the last visible message. The widget and
181
+ // agent browser provide status/output without displacing the root response.
182
+ display: false,
183
+ details: notification.details,
184
+ },
185
+ { triggerTurn: false },
186
+ );
187
+ };
188
+ const pendingSummaries = (ctx: ExtensionContext) => {
189
+ const entries = ctx.sessionManager.getBranch();
190
+ const summarized = new Set(entries.flatMap((entry) => {
191
+ if (entry.type !== "custom_message" || entry.customType !== ROOT_SUMMARY_MESSAGE) return [];
192
+ const ids = (entry.details as { mailboxIds?: unknown } | undefined)?.mailboxIds;
193
+ return Array.isArray(ids) ? ids.filter((id): id is string => typeof id === "string") : [];
194
+ }));
195
+ return entries.flatMap((entry) => {
196
+ if (entry.type !== "custom" || entry.customType !== ROOT_MAILBOX_ENTRY) return [];
197
+ const notification = entry.data as RootNotification | undefined;
198
+ return notification?.rootSessionId === ctx.sessionManager.getSessionId() &&
199
+ notification.finalRecap === true && typeof notification.content === "string" &&
200
+ typeof notification.details?.mailboxId === "string" &&
201
+ !summarized.has(notification.details.mailboxId) ? [notification] : [];
202
+ });
203
+ };
204
+ const scheduleSummary = (ctx: ExtensionContext) => {
205
+ if (!limits.finalRecap || limits.subagentMode === "off" || summaryRunning || suspendingSummaries ||
206
+ summaryTimer !== undefined || ctx.isIdle() === false || !pendingSummaries(ctx).length) return;
207
+ const currentGeneration = generation;
208
+ // Coalesce siblings finishing together and leave the settlement boundary before
209
+ // starting a new turn. Recheck settings/session/idle state after the deferral.
210
+ summaryTimer = setTimeout(() => {
211
+ summaryTimer = undefined;
212
+ if (currentGeneration !== generation || context?.sessionManager !== ctx.sessionManager ||
213
+ !limits.finalRecap || limits.subagentMode === "off" || suspendingSummaries || ctx.isIdle() === false) return;
214
+ const notifications = pendingSummaries(ctx);
215
+ if (!notifications.length) return;
216
+ summaryRunning = true;
217
+ try {
218
+ pi.sendMessage({
219
+ customType: ROOT_SUMMARY_MESSAGE,
220
+ content: "Summarize the newly finished asynchronous subagent results for the user. " +
221
+ "Their results are in the preceding subagent notifications; use agent_output if more detail is needed. " +
222
+ "Give a concise main-thread answer covering results and any failures. " +
223
+ "Do not spawn or resume agents solely to summarize.\nAgents: " +
224
+ [...new Set(notifications.map((notification) => notification.details.path))].join(", "),
225
+ display: false,
226
+ details: { mailboxIds: notifications.map((notification) => notification.details.mailboxId) },
227
+ }, { triggerTurn: true });
228
+ } catch (error) {
229
+ summaryRunning = false;
230
+ warnDelivery(error);
231
+ }
232
+ }, 0);
233
+ };
234
+ const restoreRootMailbox = (ctx: ExtensionContext) => {
235
+ if (limits.subagentMode === "off" || ctx.isIdle?.() === false) return;
236
+ const entries = ctx.sessionManager.getBranch();
237
+ const delivered = new Set(
238
+ entries.flatMap((entry) => {
239
+ if (entry.type !== "custom_message" || entry.customType !== "pi-subagent:update") return [];
240
+ const id = (entry.details as { mailboxId?: unknown } | undefined)?.mailboxId;
241
+ return typeof id === "string" ? [id] : [];
242
+ }),
243
+ );
244
+ for (const entry of entries) {
245
+ if (entry.type !== "custom" || entry.customType !== ROOT_MAILBOX_ENTRY) continue;
246
+ const notification = entry.data as RootNotification | undefined;
247
+ if (
248
+ notification?.rootSessionId === ctx.sessionManager.getSessionId() &&
249
+ typeof notification.content === "string" &&
250
+ typeof notification.details?.mailboxId === "string" &&
251
+ !delivered.has(notification.details.mailboxId)
252
+ ) {
253
+ sendRootNotification(notification);
254
+ delivered.add(notification.details.mailboxId);
255
+ }
256
+ }
257
+ scheduleSummary(ctx);
258
+ };
259
+ pi.on("agent_settled", async (_event, ctx) => {
260
+ if (context?.sessionManager === ctx.sessionManager) {
261
+ summaryRunning = false;
262
+ restoreRootMailbox(ctx);
263
+ }
264
+ });
265
+ const delivery = (event: ThreadEvent) => {
266
+ if (event.kind === "change" || event.kind === "metrics") return;
267
+ const thread = event.thread;
268
+ const message =
269
+ event.kind === "update"
270
+ ? `Progress from ${thread.path}: ${event.message}`
271
+ : thread.state === "completed"
272
+ ? `Agent ${thread.path} completed. Final answer:\n${thread.output?.slice(0, 16000) ?? "(no text)"}${(thread.output?.length ?? 0) > 16000 ? "\n[Output truncated; use agent_output for more.]" : ""}`
273
+ : `Agent ${thread.path} is ${thread.state}: ${thread.status}. ${thread.state === "paused" ? "No answer handback; send input to resume the same session." : "Session retained for further input."}`;
274
+ try {
275
+ if (event.recipient === "/root") {
276
+ const notification: RootNotification = {
277
+ rootSessionId: requireContext().sessionManager.getSessionId(),
278
+ content: message,
279
+ // Foreground results already handled by a busy main thread do not need
280
+ // another turn. Completions during a summary do need a follow-up summary.
281
+ finalRecap: limits.finalRecap && !suspendingSummaries && event.kind === "settled" &&
282
+ (thread.state === "completed" || thread.state === "failed") &&
283
+ (requireContext().isIdle() || summaryRunning),
284
+ details: {
285
+ mailboxId: randomUUID(),
286
+ path: thread.path,
287
+ state: thread.state,
288
+ },
289
+ };
290
+ // Persist first; defer delivery until the root is idle and the plugin is enabled.
291
+ // The eventual transcript message carries its ID for branch-local recovery.
292
+ pi.appendEntry(ROOT_MAILBOX_ENTRY, notification);
293
+ restoreRootMailbox(requireContext());
294
+ } else void requireManager().deliver(event.recipient, message).catch(warnDelivery);
295
+ } catch (error) {
296
+ warnDelivery(error);
297
+ }
298
+ };
299
+
300
+ const makeDriverFactory = () =>
301
+ createDriverFactory(
302
+ requireContext,
303
+ () => limits.modelSelection,
304
+ () => limits.toolFiltering,
305
+ () => inheritedTools.snapshot(),
306
+ );
307
+ let driverFactory = makeDriverFactory();
308
+ const toolsFor = (path: string) =>
309
+ agentTools(
310
+ requireManager,
311
+ path,
312
+ () => store.list(),
313
+ (type) => driverFactory.resolveAgentSettings(type, path),
314
+ );
315
+ const rootTools = toolsFor("/root");
316
+ let toolsEnabled: boolean | undefined;
317
+ const syncTools = () => {
318
+ const enabled = limits.subagentMode !== "off";
319
+ if (toolsEnabled === enabled) return;
320
+ toolsEnabled = enabled;
321
+ for (const tool of rootTools) {
322
+ pi.registerTool({
323
+ ...tool,
324
+ // Hidden tools are neither model-visible nor discoverable/callable via codemode.
325
+ exposure: enabled ? "direct" : "hidden",
326
+ prepareLoadout: inheritedTools.captureLoadout,
327
+ execute: (...args) => {
328
+ if (limits.subagentMode === "off") throw new Error("Subagent Mode is off");
329
+ inheritedTools.captureContext(args[4]);
330
+ return tool.execute(...args);
331
+ },
332
+ });
333
+ }
334
+ };
335
+ const refreshWidget = (ctx: ExtensionContext) => {
336
+ if (limits.subagentMode === "off") {
337
+ if (ctx.hasUI) ctx.ui.setWidget("pi-subagent", undefined);
338
+ } else updateWidget(ctx, widgetThreads(), widgetCollapsed ? "minimal" : limits.widgetMode,
339
+ limits.nerdFontIcons, rootTurnEnded);
340
+ };
341
+ pi.on("agent_start", async (_event, ctx) => {
342
+ // A new task must not resurrect the previous task's settled agents. Automatic
343
+ // final-recap turns still belong to that task and keep its results visible.
344
+ if (manager && !summaryRunning) {
345
+ for (const thread of manager.list()) {
346
+ if (thread.state !== "starting" && thread.state !== "running")
347
+ hiddenWidgetThreads.add(thread.path);
348
+ }
349
+ }
350
+ rootTurnEnded = false;
351
+ if (manager) refreshWidget(ctx);
352
+ });
353
+ pi.on("agent_end", async (_event, ctx) => {
354
+ rootTurnEnded = true;
355
+ if (manager) refreshWidget(ctx);
356
+ // A migration can span clarification turns. Reload after each root turn, without converting files.
357
+ if (limits.subagentMode === "off" || !migrationRequested) return;
358
+ store.reload();
359
+ if (store.diagnostics.length) context?.ui.notify(store.diagnostics.join("\n"), "warning");
360
+ });
361
+ syncTools();
362
+ pi.on("before_agent_start", async (event, ctx) => {
363
+ const prompt = subagentPrompt(limits);
364
+ if (!prompt) return;
365
+ if (limits.subagentMode === "opportunistic" && event.prompt.startsWith(IMPORT_REQUEST_PREFIX)) {
366
+ try {
367
+ markImportOffered(getAgentDir());
368
+ } catch (error) {
369
+ ctx.ui.notify(`Could not save import onboarding state: ${String(error)}`, "warning");
370
+ }
371
+ }
372
+ return {
373
+ systemPrompt: `${event.systemPrompt}\n\n${prompt}`,
374
+ };
375
+ });
376
+
377
+ const attachSession = async (ctx: ExtensionContext, installEditor = false, seedHistory = true) => {
378
+ cancelSummary();
379
+ const token = ++generation;
380
+ closeNavigation(installEditor);
381
+ navigation = new AgentNavigationController();
382
+ // session_start already belongs to the replacement session: never append the old registry here.
383
+ if (manager) await manager.shutdown();
384
+ inheritedTools.reset();
385
+ context = ctx;
386
+ rootTurnEnded = false;
387
+ if (installEditor) {
388
+ widgetCollapsed = false;
389
+ hiddenWidgetThreads.clear();
390
+ }
391
+ store = new ConfigStore({
392
+ cwd: ctx.cwd,
393
+ agentDir: getAgentDir(),
394
+ includeProject: ctx.isProjectTrusted(),
395
+ });
396
+ persistenceSignature = "";
397
+ const settingsDiagnostics = loadLimits(ctx);
398
+ syncTools();
399
+ driverFactory = makeDriverFactory();
400
+ const instance = new ThreadManager({
401
+ ...limits,
402
+ createDriver: driverFactory,
403
+ rootSnapshot: () => buildSessionContext(requireContext().sessionManager.getBranch()).messages,
404
+ getType: (name) => store.get(name),
405
+ toolsFor,
406
+ onEvent: (event) => {
407
+ if (token !== generation) return;
408
+ refreshWidget(requireContext());
409
+ if (event.kind !== "metrics") persist();
410
+ delivery(event);
411
+ },
412
+ });
413
+ manager = instance;
414
+ if (installEditor) installNavigationEditor(ctx, seedHistory);
415
+ const entries = ctx.sessionManager.getBranch();
416
+ const entry = [...entries]
417
+ .reverse()
418
+ .find((item) => item.type === "custom" && item.customType === REGISTRY_ENTRY);
419
+ if (entry?.type === "custom") {
420
+ try {
421
+ const data = entry.data as {
422
+ version: number;
423
+ rootSessionId: string;
424
+ threads: SavedThread[];
425
+ };
426
+ if (data.version !== 1 || !Array.isArray(data.threads))
427
+ throw new Error("Invalid registry format");
428
+ // Forked parents own a new registry; they must not share writable child transcripts.
429
+ if (data.rootSessionId === ctx.sessionManager.getSessionId())
430
+ instance.restore(data.threads);
431
+ else persist();
432
+ } catch (error) {
433
+ if (limits.subagentMode !== "off")
434
+ ctx.ui.notify(`Could not restore subagent registry: ${String(error)}`, "error");
435
+ }
436
+ }
437
+ restoreRootMailbox(ctx);
438
+ refreshWidget(ctx);
439
+ const diagnostics = [...store.diagnostics, ...settingsDiagnostics];
440
+ if (limits.subagentMode !== "off" && diagnostics.length)
441
+ ctx.ui.notify(diagnostics.join("\n"), "warning");
442
+ };
443
+ pi.on("session_start", async (event, ctx) => {
444
+ await attachSession(ctx, true, event.reason !== "startup");
445
+ if (limits.subagentMode !== "off") await importAgents(ctx, true);
446
+ });
447
+ pi.on("session_tree", async (_event, ctx) => attachSession(ctx));
448
+ const stopWorkingThreads = async () => {
449
+ cancelSummary();
450
+ closeNavigation();
451
+ suspendingSummaries = true;
452
+ try {
453
+ if (!manager) return;
454
+ for (const thread of manager.list()) {
455
+ const state = manager.get(thread.path).state;
456
+ if (state === "starting" || state === "running") await manager.stop("/root", thread.path);
457
+ }
458
+ } finally {
459
+ cancelSummary();
460
+ suspendingSummaries = false;
461
+ }
462
+ };
463
+ // Finish old-thread cancellation while appendEntry still points to the old branch/session.
464
+ pi.on("session_before_tree", stopWorkingThreads);
465
+ pi.on("session_before_switch", stopWorkingThreads);
466
+ pi.on("session_before_fork", stopWorkingThreads);
467
+ pi.on("session_shutdown", async () => {
468
+ cancelSummary();
469
+ generation++;
470
+ closeNavigation(true);
471
+ await manager?.shutdown();
472
+ persist();
473
+ if (context?.hasUI) context.ui.setWidget("pi-subagent", undefined);
474
+ manager = undefined;
475
+ context = undefined;
476
+ });
477
+ const agentSubcommands = ["tree", "status", "settings", "types", "import", "reload"] as const;
478
+ pi.registerCommand("agents", {
479
+ description: "Configure agent settings, import/edit definitions, or show the live tree",
480
+ getArgumentCompletions: (argumentText) => {
481
+ const text = argumentText.trimStart();
482
+ const completed = text.match(/^(\S+)(\s+)([\s\S]*)$/);
483
+ if (completed) {
484
+ const command = completed[1]!.toLowerCase();
485
+ if (command === "tree") {
486
+ const paths = (manager?.list() ?? [])
487
+ .map((thread) => thread.path)
488
+ .filter((path) => path !== "/root");
489
+ const matched = fuzzyFilter(paths, completed[3]!.trimStart(), (path) => path);
490
+ if (matched.length === 0) return null;
491
+ return matched.map((path) => ({ value: `tree ${path}`, label: path }));
492
+ }
493
+ if ((agentSubcommands as readonly string[]).includes(command)) return null;
494
+ }
495
+ const matched = fuzzyFilter([...agentSubcommands], text, (name) => name);
496
+ if (matched.length === 0) return null;
497
+ return matched.map((value) => ({ value, label: value }));
498
+ },
499
+ handler: async (args, ctx) => {
500
+ const [command, ...rest] = args.trim().split(/\s+/);
501
+ if (command === "import") {
502
+ await importAgents(ctx, false);
503
+ } else if (command === "types") {
504
+ await editAgentTypes(ctx, store, limits.nerdFontIcons);
505
+ store.reload();
506
+ } else if (command === "reload") {
507
+ store.reload();
508
+ const diagnostics = [...store.diagnostics, ...loadLimits(ctx)];
509
+ requireManager().setLimits(limits);
510
+ syncTools();
511
+ refreshWidget(ctx);
512
+ restoreRootMailbox(ctx);
513
+ ctx.ui.notify(
514
+ diagnostics.length
515
+ ? diagnostics.join("\n")
516
+ : `Loaded ${store.list().length} agent types; maximum ${limits.maxLevels} levels`,
517
+ diagnostics.length ? "warning" : "info",
518
+ );
519
+ } else if (!command || command === "settings") {
520
+ await configureAgents(ctx, {
521
+ store,
522
+ settings: limits,
523
+ agentDir: getAgentDir(),
524
+ apply: () => {
525
+ const diagnostics = loadLimits(ctx);
526
+ requireManager().setLimits(limits);
527
+ syncTools();
528
+ refreshWidget(ctx);
529
+ restoreRootMailbox(ctx);
530
+ if (diagnostics.length) ctx.ui.notify(diagnostics.join("\n"), "warning");
531
+ },
532
+ });
533
+ } else if (command === "status") {
534
+ await showAgentStatus(ctx, requireManager().scope("/root"), undefined, limits.nerdFontIcons);
535
+ } else if (command === "tree") {
536
+ await navigation.open(ctx, requireManager().scope("/root"), rest.join(" ") || undefined, limits.nerdFontIcons);
537
+ } else
538
+ ctx.ui.notify(
539
+ "Usage: /agents [tree [path] | status | settings | types | import | reload]",
540
+ "warning",
541
+ );
542
+ },
543
+ });
544
+ }
@@ -0,0 +1,85 @@
1
+ // @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
2
+ import type {
3
+ ExtensionAPI,
4
+ ExtensionToolContext,
5
+ ToolDefinition,
6
+ ToolLoadout,
7
+ } from "@earendil-works/pi-coding-agent";
8
+
9
+ /** A snapshot of the root's reachable tools, without loading its extensions again. */
10
+ export interface InheritedToolSource {
11
+ tools: ToolDefinition[];
12
+ activeNames: string[];
13
+ }
14
+
15
+ /**
16
+ * Capture the public loadout API and a root execution bridge. Keep the bridge alive for
17
+ * detached children, but always pass their signal rather than the spawning call's signal.
18
+ * Built-ins and manager controls are replaced with child-local implementations by runtime.
19
+ */
20
+ export function createInheritedToolSource(pi: ExtensionAPI) {
21
+ let loadout: ToolLoadout | undefined;
22
+ let context: ExtensionToolContext | undefined;
23
+ return {
24
+ captureLoadout(next: ToolLoadout) {
25
+ loadout = next;
26
+ return undefined;
27
+ },
28
+ captureContext(next: ExtensionToolContext) {
29
+ context = next;
30
+ },
31
+ reset() {
32
+ // session_start is emitted after the replacement loadout is prepared; do not
33
+ // discard that fresh snapshot (or an unchanged loadout on /tree).
34
+ context = undefined;
35
+ },
36
+ snapshot(): InheritedToolSource {
37
+ if (!loadout) return { tools: [], activeNames: [] };
38
+ const rootContext = context;
39
+ const callable = new Set(loadout.callable.map((tool) => tool.name));
40
+ const active = new Set(loadout.declared.map((tool) => tool.name));
41
+ const info = new Map(pi.getAllTools().map((tool) => [tool.name, tool]));
42
+ const tools: ToolDefinition[] = [];
43
+ for (const tool of loadout.registered) {
44
+ const exposure = loadout.getExposure(tool.name);
45
+ // Inactive direct tools and hidden tools are not available in the root either.
46
+ if (!callable.has(tool.name) && !active.has(tool.name)) continue;
47
+ if (exposure === "hidden") continue;
48
+ const metadata = info.get(tool.name);
49
+ tools.push({
50
+ ...tool,
51
+ // The root execution pipeline applies argument preparation. Running it in
52
+ // the child too would corrupt non-idempotent preparation shims.
53
+ prepareArguments: undefined,
54
+ label: tool.label,
55
+ exposure,
56
+ namespace: loadout.getNamespace(tool.name),
57
+ annotations: metadata?.annotations,
58
+ promptGuidelines: metadata?.promptGuidelines,
59
+ defaultActive: active.has(tool.name),
60
+ async execute(_id, args, signal, onUpdate) {
61
+ if (!callable.has(tool.name)) {
62
+ // The SDK rejects nested model-only execution. Calling its captured
63
+ // wrapper directly would bypass root permission/result hooks. Known
64
+ // SDK orchestrators are instead recreated locally by runtime.
65
+ throw new Error(
66
+ `Cannot safely inherit model-only tool ${tool.name}: the SDK does not ` +
67
+ "provide a hook-preserving execution bridge. Run it in the main session.",
68
+ );
69
+ }
70
+ const bridge = rootContext ?? context;
71
+ if (!bridge) {
72
+ throw new Error(
73
+ "Inherited tool execution needs a main-session agent_* tool call first. " +
74
+ "Use agent_steer to resume this child instead of the thread dialog.",
75
+ );
76
+ }
77
+ const outcome = await bridge.executeTool(tool.name, args, { signal, onUpdate });
78
+ return { ...outcome.result, isError: outcome.isError };
79
+ },
80
+ });
81
+ }
82
+ return { tools, activeNames: [...active] };
83
+ },
84
+ };
85
+ }