@jwilger/pi-development-system 0.10.5 → 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 (36) 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/core/test-runner.ts +1 -1
  10. package/src/gates/red-first-guard.ts +4 -1
  11. package/src/subagents/VENDORED.md +38 -0
  12. package/src/subagents/index.ts +544 -0
  13. package/src/subagents/orch/inherited-tools.ts +85 -0
  14. package/src/subagents/orch/mailbox.ts +262 -0
  15. package/src/subagents/orch/manager.ts +765 -0
  16. package/src/subagents/orch/paths.ts +74 -0
  17. package/src/subagents/orch/prompt.ts +25 -0
  18. package/src/subagents/orch/runtime.ts +784 -0
  19. package/src/subagents/orch/tools.ts +166 -0
  20. package/src/subagents/orch/transcript.ts +187 -0
  21. package/src/subagents/prefs/agent-import.ts +246 -0
  22. package/src/subagents/prefs/config.ts +1029 -0
  23. package/src/subagents/prefs/import-discovery.ts +644 -0
  24. package/src/subagents/prefs/import-instructions.ts +157 -0
  25. package/src/subagents/prefs/models.ts +78 -0
  26. package/src/subagents/prefs/settings.ts +371 -0
  27. package/src/subagents/types.ts +200 -0
  28. package/src/subagents/ui/agent-navigation-editor.ts +210 -0
  29. package/src/subagents/ui/dialog.ts +651 -0
  30. package/src/subagents/ui/import-picker.ts +232 -0
  31. package/src/subagents/ui/live-agent-view.ts +572 -0
  32. package/src/subagents/ui/model-picker.ts +452 -0
  33. package/src/subagents/ui/settings-ui.ts +329 -0
  34. package/src/subagents/ui/status-ui.ts +545 -0
  35. package/src/subagents/ui/thread-tree.ts +135 -0
  36. package/src/subagents/ui/ui.ts +1507 -0
@@ -0,0 +1,545 @@
1
+ // @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
2
+ import type {
3
+ ExtensionCommandContext,
4
+ ExtensionContext,
5
+ ExtensionUIContext,
6
+ Theme,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ getKeybindings,
10
+ setKeybindings,
11
+ Key,
12
+ matchesKey,
13
+ type Keybinding,
14
+ } from "@earendil-works/pi-tui";
15
+ import {
16
+ canOpenDialog,
17
+ DIALOG_OPTIONS,
18
+ type DialogHost,
19
+ DialogSession,
20
+ scopeDialogContext,
21
+ dialogMenu,
22
+ dialogEditor,
23
+ dialogHeight,
24
+ dialogText,
25
+ frameDialog,
26
+ } from "./dialog.ts";
27
+ import type { ThreadService } from "../types.ts";
28
+ import { buildStatusTree, type StatusRow } from "./thread-tree.ts";
29
+ import {
30
+ AGENT_PROGRESS_INTERVAL, agentProgressIcon, agentTypeBadge, agentTypeLabel, showThreads, threadMetrics,
31
+ } from "./ui.ts";
32
+ import { LiveAgentView, fillViewport, type AgentViewportState } from "./live-agent-view.ts";
33
+
34
+ export { buildStatusTree, type StatusRow };
35
+
36
+ const ROOT = "/root";
37
+ const INACTIVE_STATES = new Set(["paused", "stopped", "failed", "completed"]);
38
+ const TITLE = "Agents status";
39
+ const TREE_TITLE = "Agents tree";
40
+ const FOOTER =
41
+ "Esc close · ↑↓ select · ←→ fold · PgUp/PgDn · Enter inspect · r refresh · Ctrl+C stop all";
42
+
43
+ function agentCount(threads: readonly { path?: string }[]): number {
44
+ const paths = new Set<string>();
45
+ for (const thread of threads) {
46
+ if (thread?.path && thread.path !== ROOT) paths.add(thread.path);
47
+ }
48
+ return paths.size;
49
+ }
50
+
51
+ function lexicalParent(path: string): string | null {
52
+ const index = path.lastIndexOf("/");
53
+ return index > 0 ? path.slice(0, index) : null;
54
+ }
55
+
56
+ function treeParent(rows: StatusRow[], index: number): StatusRow | undefined {
57
+ const depth = rows[index]?.prefix.length ?? 0;
58
+ for (let cursor = index - 1; cursor >= 0; cursor--) {
59
+ if (rows[cursor]!.prefix.length < depth) return rows[cursor];
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ function firstChild(rows: StatusRow[], index: number): StatusRow | undefined {
65
+ const next = rows[index + 1];
66
+ return next && next.prefix.length > (rows[index]?.prefix.length ?? 0) ? next : undefined;
67
+ }
68
+
69
+ function label(row: StatusRow): string {
70
+ if (!row.thread)
71
+ return row.path === ROOT
72
+ ? `${dialogText(row.path)} main running main`
73
+ : `${dialogText(row.path)} missing parent`;
74
+ return [row.path, row.thread.type, row.thread.state, row.thread.status]
75
+ .map((part) => dialogText(part))
76
+ .join(" ");
77
+ }
78
+
79
+ export interface AgentTreeState {
80
+ selectedPath: string;
81
+ collapsed: Set<string>;
82
+ }
83
+
84
+ export class StatusDialog {
85
+ private state: AgentTreeState;
86
+ private get selectedPath(): string {
87
+ return this.state.selectedPath;
88
+ }
89
+ private set selectedPath(value: string) {
90
+ this.state.selectedPath = value;
91
+ }
92
+ private get collapsed(): Set<string> {
93
+ return this.state.collapsed;
94
+ }
95
+ private viewport = 1;
96
+ private timer: ReturnType<typeof setInterval> | undefined;
97
+ private refreshDelay = 0;
98
+ private refreshing = false;
99
+
100
+ constructor(
101
+ private host: DialogHost,
102
+ private theme: Theme,
103
+ private service: ThreadService,
104
+ private done: (path: string | undefined) => void,
105
+ selected?: string,
106
+ private title = TITLE,
107
+ private navigation?: {
108
+ state: AgentTreeState;
109
+ actions(path: string): void;
110
+ fullscreen?: boolean;
111
+ },
112
+ private nerdFontIcons = false,
113
+ ) {
114
+ this.state = navigation?.state ?? { selectedPath: selected || ROOT, collapsed: new Set() };
115
+ }
116
+
117
+ invalidate(): void {}
118
+
119
+ /** Poll retained status, accelerating the existing timer only for animated live agents. */
120
+ startRefresh(): void {
121
+ this.stopRefresh();
122
+ this.refreshing = true;
123
+ this.syncRefresh();
124
+ }
125
+
126
+ /** Stop the refresh. Safe to call more than once, including from dispose(). */
127
+ dispose(): void {
128
+ this.refreshing = false;
129
+ this.stopRefresh();
130
+ }
131
+
132
+ private syncRefresh(): void {
133
+ if (!this.refreshing) return;
134
+ const delay = this.nerdFontIcons && this.service.list().some((thread) =>
135
+ thread.path !== ROOT && (thread.state === "starting" || thread.state === "running"))
136
+ ? AGENT_PROGRESS_INTERVAL : 1000;
137
+ if (this.timer !== undefined && this.refreshDelay === delay) return;
138
+ this.stopRefresh();
139
+ this.refreshDelay = delay;
140
+ this.timer = setInterval(() => {
141
+ this.syncRefresh();
142
+ this.host.requestRender();
143
+ }, delay);
144
+ if (typeof this.timer.unref === "function") this.timer.unref();
145
+ }
146
+
147
+ private stopRefresh(): void {
148
+ if (this.timer === undefined) return;
149
+ clearInterval(this.timer);
150
+ this.timer = undefined;
151
+ this.refreshDelay = 0;
152
+ }
153
+
154
+ /** Stop every live agent. Stopping a subtree covers its descendants, so only top-most live paths are sent. */
155
+ private stopAll(): void {
156
+ const live = this.service
157
+ .list()
158
+ .filter((thread) => thread.path !== ROOT && (thread.state === "starting" || thread.state === "running"))
159
+ .map((thread) => thread.path);
160
+ const tops = live.filter((path) => !live.some((other) => path.startsWith(`${other}/`)));
161
+ for (const path of tops) {
162
+ void this.service
163
+ .stop(path)
164
+ .catch(() => {})
165
+ .finally(() => this.host.requestRender());
166
+ }
167
+ this.host.requestRender();
168
+ }
169
+
170
+ private rows(): StatusRow[] {
171
+ return buildStatusTree(this.service.list(), this.collapsed);
172
+ }
173
+
174
+ private locate(rows: StatusRow[]): number {
175
+ const exact = rows.findIndex((row) => row.path === this.selectedPath);
176
+ if (exact >= 0) return exact;
177
+ let parent = lexicalParent(this.selectedPath);
178
+ while (parent) {
179
+ const index = rows.findIndex((row) => row.path === parent);
180
+ if (index >= 0) return index;
181
+ parent = lexicalParent(parent);
182
+ }
183
+ const root = rows.findIndex((row) => row.path === ROOT);
184
+ return root >= 0 ? root : 0;
185
+ }
186
+
187
+ handleInput(data: string): void {
188
+ const keys = getKeybindings();
189
+ if (matchesKey(data, Key.ctrl("c"))) {
190
+ this.stopAll();
191
+ return;
192
+ }
193
+ if (keys.matches(data, "tui.select.cancel")) {
194
+ this.done(undefined);
195
+ return;
196
+ }
197
+ const rows = this.rows();
198
+ const index = this.locate(rows);
199
+ const row = rows[index];
200
+ if (row) this.selectedPath = row.path;
201
+ if (keys.matches(data, "tui.select.confirm")) {
202
+ if (this.navigation && row?.path === ROOT) this.done(undefined);
203
+ else if (row?.thread && row.path !== ROOT) this.done(row.path);
204
+ return;
205
+ }
206
+ if (data === "i" && this.navigation) {
207
+ if (row?.thread && row.path !== ROOT) this.navigation.actions(row.path);
208
+ return;
209
+ }
210
+ if (matchesKey(data, Key.ctrl("q")) && this.navigation) {
211
+ this.done(undefined);
212
+ return;
213
+ }
214
+ if (data === "r") {
215
+ this.host.requestRender();
216
+ return;
217
+ }
218
+ if (matchesKey(data, Key.left)) {
219
+ if (row?.hasChildren && !this.collapsed.has(row.path)) this.collapsed.add(row.path);
220
+ else if (row) {
221
+ const parent = treeParent(rows, index);
222
+ if (parent) this.selectedPath = parent.path;
223
+ }
224
+ } else if (matchesKey(data, Key.right)) {
225
+ if (row?.hasChildren && this.collapsed.has(row.path)) this.collapsed.delete(row.path);
226
+ else {
227
+ const child = row?.hasChildren ? firstChild(rows, index) : undefined;
228
+ if (child) this.selectedPath = child.path;
229
+ }
230
+ } else {
231
+ let next = index;
232
+ if (keys.matches(data, "tui.select.up")) next -= 1;
233
+ else if (keys.matches(data, "tui.select.down")) next += 1;
234
+ else if (matchesKey(data, Key.pageUp)) next -= this.viewport;
235
+ else if (matchesKey(data, Key.pageDown)) next += this.viewport;
236
+ else if (matchesKey(data, Key.home)) next = 0;
237
+ else if (matchesKey(data, Key.end)) next = Math.max(0, rows.length - 1);
238
+ else return;
239
+ this.selectedPath =
240
+ rows[Math.max(0, Math.min(rows.length - 1, next))]?.path ?? this.selectedPath;
241
+ }
242
+ this.host.requestRender();
243
+ }
244
+
245
+ private line(row: StatusRow, selected: boolean): string {
246
+ const marker = row.hasChildren ? (this.collapsed.has(row.path) ? "▸ " : "▾ ") : "";
247
+ const head = ` ${row.prefix}${selected ? "›" : " "} ${marker}`;
248
+ const thread = row.thread;
249
+ if (!thread) {
250
+ const text = `${head}${label(row)}`;
251
+ return selected ? this.theme.fg("accent", text) : text;
252
+ }
253
+ const inactive = INACTIVE_STATES.has(thread.state);
254
+ if (inactive) {
255
+ // Completed fades furthest (dim); the state word keeps a theme hint for done/failed.
256
+ const base = thread.state === "completed" ? "dim" : "muted";
257
+ const hint =
258
+ thread.state === "completed" ? "success" : thread.state === "failed" ? "error" : base;
259
+ const state = dialogText(thread.state);
260
+ const path = dialogText(thread.path);
261
+ const status = dialogText(thread.status);
262
+ return [
263
+ this.theme.fg(selected ? "accent" : base, head),
264
+ this.theme.fg(base, `${agentTypeLabel(thread.type, thread.icon, this.nerdFontIcons)} ${path} `),
265
+ this.theme.fg(hint, state),
266
+ this.theme.fg(base, ` ${status}`),
267
+ ].join("");
268
+ }
269
+ const rest = [thread.path, thread.state, thread.status]
270
+ .map((part) => dialogText(part))
271
+ .join(" ");
272
+ const badge = agentTypeBadge(thread.type, thread.color, this.theme,
273
+ agentProgressIcon(thread, this.nerdFontIcons), this.nerdFontIcons);
274
+ if (selected)
275
+ return `${this.theme.fg("accent", head)}${badge} ${this.theme.fg("accent", rest)}`;
276
+ return `${head}${badge} ${rest}`;
277
+ }
278
+
279
+ private detail(row: StatusRow | undefined): string[] {
280
+ if (!row?.thread) {
281
+ return [
282
+ ` ${row?.path === ROOT ? (agentCount(this.service.list()) ? "Main Pi session" : "Main Pi session — No agents yet") : "Missing parent"}`,
283
+ "",
284
+ ];
285
+ }
286
+ return [` task ${dialogText(row.thread.task)}`, ` ${threadMetrics(row.thread)}`];
287
+ }
288
+
289
+ render(width: number): string[] {
290
+ this.syncRefresh();
291
+ const rows = this.rows();
292
+ const height = this.navigation?.fullscreen
293
+ ? Math.max(1, this.host.terminal?.rows ?? 24)
294
+ : dialogHeight(this.host);
295
+ const index = this.locate(rows);
296
+ if (rows[index]) this.selectedPath = rows[index].path;
297
+ const budget = Math.max(0, height - 4);
298
+ const detail = budget > 2 ? this.detail(rows[index]) : [];
299
+ const treeCount = Math.max(0, budget - detail.length);
300
+ this.viewport = Math.max(1, treeCount);
301
+ const start = treeCount
302
+ ? Math.max(0, Math.min(index - treeCount + 1, Math.max(0, rows.length - treeCount)))
303
+ : 0;
304
+ const body = [
305
+ ...rows
306
+ .slice(start, start + treeCount)
307
+ .map((row) => this.line(row, row.path === this.selectedPath)),
308
+ ...detail,
309
+ ];
310
+ return frameDialog(
311
+ this.theme,
312
+ width,
313
+ height,
314
+ this.heading(rows, start, treeCount),
315
+ body,
316
+ this.navigation
317
+ ? "Esc main · ↑↓ select · ←→ fold · i Actions · Ctrl+C stop all"
318
+ : FOOTER,
319
+ );
320
+ }
321
+
322
+ private heading(rows: StatusRow[], start: number, treeCount: number): string {
323
+ if (this.title !== TREE_TITLE) return this.title;
324
+ const agents = agentCount(this.service.list());
325
+ const noun = agents === 1 ? "agent" : "agents";
326
+ const total = rows.length;
327
+ const shown = Math.max(0, Math.min(treeCount, total - start));
328
+ if (shown <= 0) return `${TREE_TITLE} ${agents} ${noun}`;
329
+ return `${TREE_TITLE} ${agents} ${noun} ${start + 1}-${start + shown}/${total}`;
330
+ }
331
+ }
332
+
333
+ async function showAgentDialog(
334
+ ctx: ExtensionCommandContext,
335
+ service: ThreadService,
336
+ options: {
337
+ title?: string;
338
+ selected?: string;
339
+ inspect?: (path: string) => Promise<void>;
340
+ nerdFontIcons?: boolean;
341
+ } = {},
342
+ ): Promise<void> {
343
+ const open = options.inspect ?? ((path: string) => showThreads(ctx, service, path));
344
+ let selected = options.selected;
345
+ for (;;) {
346
+ // Cleared on close and from dispose(): DialogSession host disposal can
347
+ // leave this promise pending, so finally alone may not run immediately.
348
+ let dialog: StatusDialog | undefined;
349
+ try {
350
+ const chosen = await ctx.ui.custom<string | undefined>((host, theme, _keys, done) => {
351
+ dialog = new StatusDialog(host, theme, service, done, selected, options.title, undefined, options.nerdFontIcons);
352
+ dialog.startRefresh();
353
+ return dialog;
354
+ }, DIALOG_OPTIONS);
355
+ dialog?.dispose();
356
+ dialog = undefined;
357
+ if (!chosen) return;
358
+ selected = chosen;
359
+ await open(chosen);
360
+ } finally {
361
+ dialog?.dispose();
362
+ }
363
+ }
364
+ }
365
+
366
+ export async function showAgentStatus(
367
+ ctx: ExtensionCommandContext,
368
+ service: ThreadService,
369
+ inspect?: (path: string) => Promise<void>,
370
+ nerdFontIcons = false,
371
+ ): Promise<void> {
372
+ if (!canOpenDialog(ctx)) return;
373
+ await showAgentDialog(ctx, service, { inspect, nerdFontIcons });
374
+ }
375
+
376
+ /** Root-owned navigation state. Opening and closing never changes the executing session. */
377
+ export class AgentNavigationController {
378
+ private tree: AgentTreeState = { selectedPath: ROOT, collapsed: new Set() };
379
+ private viewports = new Map<string, AgentViewportState>();
380
+ private session: DialogSession | undefined;
381
+ private opening: Promise<void> | undefined;
382
+ private generation = 0;
383
+
384
+ get isOpen(): boolean {
385
+ return this.opening !== undefined;
386
+ }
387
+
388
+ close(): void {
389
+ ++this.generation;
390
+ this.session?.close();
391
+ this.session = undefined;
392
+ }
393
+
394
+ open(ctx: ExtensionContext, service: ThreadService, selectedPath?: string, nerdFontIcons = false): Promise<void> {
395
+ if (this.opening) return this.opening;
396
+ if (!canOpenDialog(ctx)) return Promise.resolve();
397
+ if (selectedPath) this.tree.selectedPath = selectedPath;
398
+ const generation = ++this.generation;
399
+ let failure: unknown;
400
+ let failed = false;
401
+ let ownedSession: DialogSession | undefined;
402
+ const task = ctx.ui.custom<void>(
403
+ (host, theme, keys, done) => {
404
+ const session = new DialogSession(host, theme, keys, done);
405
+ ownedSession = session;
406
+ if (generation !== this.generation) {
407
+ void Promise.resolve().then(() => session.close());
408
+ return session;
409
+ }
410
+ this.session = session;
411
+ // The alt-screen renderer's listener runs before extension listeners. Temporarily
412
+ // remove only its search action from the public keybinding lookup, before dispatch.
413
+ const previousKeybindings = getKeybindings();
414
+ const watchingKeybindings = new Proxy(previousKeybindings, {
415
+ get(target, property) {
416
+ if (property === "matches")
417
+ return (data: string, action: Keybinding) =>
418
+ action === "tui.altScreen.search" ? false : target.matches(data, action);
419
+ // Preserve all other public manager methods and their original receiver.
420
+ const value = Reflect.get(target, property, target);
421
+ return typeof value === "function" ? value.bind(target) : value;
422
+ },
423
+ });
424
+ setKeybindings(watchingKeybindings);
425
+ const dispose = session.dispose.bind(session);
426
+ session.dispose = () => {
427
+ // Do not overwrite a newer keybinding owner installed by another extension.
428
+ if (getKeybindings() === watchingKeybindings) setKeybindings(previousKeybindings);
429
+ dispose();
430
+ };
431
+ // One fullscreen surface also covers shorter Actions dialogs without exposing main.
432
+ const render = session.render.bind(session);
433
+ session.render = (width) => fillViewport(render(width), width, host.terminal.rows);
434
+ const scoped = scopeDialogContext(ctx, session);
435
+ // Built-in UI dialogs otherwise stack independently. Mount these actions in our child slot.
436
+ const ui = scoped.ui as ExtensionUIContext;
437
+ ui.select = (title, options) =>
438
+ dialogMenu(
439
+ scoped,
440
+ title,
441
+ options.map((label) => ({ id: label, label })),
442
+ );
443
+ ui.confirm = async (title, message) =>
444
+ (await dialogMenu(
445
+ scoped,
446
+ title,
447
+ [
448
+ { id: "Yes", label: "Yes", help: message },
449
+ { id: "No", label: "No", help: message },
450
+ ],
451
+ { selectedId: "No" },
452
+ )) === "Yes";
453
+ ui.editor = (title, prefill) => dialogEditor(scoped, title, prefill ?? "");
454
+ // Defer until Pi has installed/focused the overlay.
455
+ void Promise.resolve()
456
+ .then(async () => {
457
+ while (generation === this.generation) {
458
+ let treeDialog: StatusDialog | undefined;
459
+ const chosen = await session.mount<
460
+ { kind: "watch" | "actions"; path: string } | undefined
461
+ >((tui, activeTheme, _keys, finish) => {
462
+ treeDialog = new StatusDialog(
463
+ tui,
464
+ activeTheme,
465
+ service,
466
+ (path) => finish(path ? { kind: "watch", path } : undefined),
467
+ undefined,
468
+ TREE_TITLE,
469
+ {
470
+ state: this.tree,
471
+ fullscreen: true,
472
+ actions: (path) => finish({ kind: "actions", path }),
473
+ },
474
+ nerdFontIcons,
475
+ );
476
+ treeDialog.startRefresh();
477
+ return treeDialog;
478
+ });
479
+ treeDialog?.dispose();
480
+ if (!chosen || generation !== this.generation) break;
481
+ if (chosen.kind === "actions") {
482
+ await showThreads(scoped, service, chosen.path);
483
+ continue;
484
+ }
485
+ let viewport = this.viewports.get(chosen.path);
486
+ if (!viewport) {
487
+ viewport = { scrollTop: 0, follow: true };
488
+ this.viewports.set(chosen.path, viewport);
489
+ }
490
+ const result = await session.mount<"back" | "main">(
491
+ (tui, activeTheme, _keys, finish) =>
492
+ new LiveAgentView(tui, activeTheme, service, chosen.path, viewport!, finish, nerdFontIcons),
493
+ );
494
+ if (result !== "back") break;
495
+ }
496
+ })
497
+ .then(
498
+ () => session.close(),
499
+ (error: unknown) => {
500
+ failed = true;
501
+ failure = error;
502
+ ctx.ui.notify(
503
+ dialogText(error instanceof Error ? error.message : String(error)),
504
+ "error",
505
+ );
506
+ session.close();
507
+ },
508
+ );
509
+ return session;
510
+ },
511
+ {
512
+ overlay: true,
513
+ overlayOptions: { width: "100%", maxHeight: "100%", row: 0, col: 0, margin: 0 },
514
+ },
515
+ );
516
+ this.opening = task
517
+ .then(() => {
518
+ if (failed) throw failure;
519
+ })
520
+ .finally(() => {
521
+ // Also clean up if the host rejects mounting after our factory returned.
522
+ ownedSession?.dispose();
523
+ if (this.opening === result) this.opening = undefined;
524
+ if (generation === this.generation) this.session = undefined;
525
+ });
526
+ const result = this.opening;
527
+ return result;
528
+ }
529
+ }
530
+
531
+ const navigationByService = new WeakMap<ThreadService, AgentNavigationController>();
532
+
533
+ export async function showAgentTree(
534
+ ctx: ExtensionContext,
535
+ service: ThreadService,
536
+ selectedPath?: string,
537
+ nerdFontIcons = false,
538
+ ): Promise<void> {
539
+ let navigation = navigationByService.get(service);
540
+ if (!navigation) {
541
+ navigation = new AgentNavigationController();
542
+ navigationByService.set(service, navigation);
543
+ }
544
+ await navigation.open(ctx, service, selectedPath, nerdFontIcons);
545
+ }
@@ -0,0 +1,135 @@
1
+ // @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
2
+ import type { ThreadView } from "../types.ts";
3
+
4
+ const ROOT = "/root";
5
+
6
+ export interface StatusRow {
7
+ path: string;
8
+ prefix: string;
9
+ thread?: ThreadView;
10
+ hasChildren: boolean;
11
+ }
12
+
13
+ interface StatusNode {
14
+ path: string;
15
+ thread?: ThreadView;
16
+ children: StatusNode[];
17
+ }
18
+
19
+ function lexicalParent(path: string): string | null {
20
+ const index = path.lastIndexOf("/");
21
+ return index > 0 ? path.slice(0, index) : null;
22
+ }
23
+
24
+ function comparePath(a: string, b: string): number {
25
+ return a < b ? -1 : a > b ? 1 : 0;
26
+ }
27
+
28
+ function chainHas(
29
+ parentOf: Map<string, string | null>,
30
+ start: string,
31
+ target: string,
32
+ ): boolean {
33
+ const seen = new Set<string>();
34
+ let current: string | null = start;
35
+ while (current) {
36
+ if (current === target || seen.has(current)) return true;
37
+ seen.add(current);
38
+ current = parentOf.get(current) ?? null;
39
+ }
40
+ return false;
41
+ }
42
+
43
+ /** Flatten retained agents. Parent links win over path shape; /root is always present.
44
+ * `comparePaths` orders siblings and roots only. Cycle repair stays lexical.
45
+ */
46
+ export function buildStatusTree(
47
+ threads: ThreadView[],
48
+ collapsed: Set<string>,
49
+ comparePaths: (a: string, b: string) => number = comparePath,
50
+ ): StatusRow[] {
51
+ const threadsByPath = new Map<string, ThreadView>();
52
+ for (const thread of threads) {
53
+ if (thread?.path && !threadsByPath.has(thread.path))
54
+ threadsByPath.set(thread.path, thread);
55
+ }
56
+ const nodes = new Map<string, StatusNode>();
57
+ const parentOf = new Map<string, string | null>();
58
+ const nodeFor = (path: string): StatusNode => {
59
+ let node = nodes.get(path);
60
+ if (!node) {
61
+ node = { path, thread: threadsByPath.get(path), children: [] };
62
+ nodes.set(path, node);
63
+ }
64
+ return node;
65
+ };
66
+ const declaredParent = (path: string): string | null => {
67
+ if (path === ROOT) return null;
68
+ const thread = threadsByPath.get(path);
69
+ if (thread)
70
+ return thread.parent && thread.parent !== path ? thread.parent : null;
71
+ return lexicalParent(path);
72
+ };
73
+ const ensure = (path: string, seen = new Set<string>()): void => {
74
+ if (!path || seen.has(path)) return;
75
+ seen.add(path);
76
+ nodeFor(path);
77
+ if (parentOf.has(path)) return;
78
+ const parent = declaredParent(path);
79
+ parentOf.set(path, parent);
80
+ if (parent) ensure(parent, seen);
81
+ };
82
+ ensure(ROOT);
83
+ for (const path of threadsByPath.keys()) ensure(path);
84
+
85
+ const linked = new Set<string>();
86
+ for (const path of [...parentOf.keys()].sort(comparePath)) {
87
+ const parent = parentOf.get(path);
88
+ if (!parent || parent === path) continue;
89
+ if (chainHas(parentOf, parent, path)) {
90
+ parentOf.set(path, null);
91
+ continue;
92
+ }
93
+ const parentNode = nodeFor(parent);
94
+ const child = nodeFor(path);
95
+ if (!parentNode.children.includes(child)) parentNode.children.push(child);
96
+ linked.add(path);
97
+ }
98
+
99
+ const rows: StatusRow[] = [];
100
+ const visited = new Set<string>();
101
+ const conceal = (node: StatusNode) => {
102
+ for (const child of node.children) {
103
+ if (visited.has(child.path)) continue;
104
+ visited.add(child.path);
105
+ conceal(child);
106
+ }
107
+ };
108
+ const walk = (list: StatusNode[], ancestors: boolean[], top: boolean) => {
109
+ const ordered = [...list]
110
+ .filter((node) => !visited.has(node.path))
111
+ .sort((a, b) => comparePaths(a.path, b.path));
112
+ for (const [index, node] of ordered.entries()) {
113
+ visited.add(node.path);
114
+ const last = index === ordered.length - 1;
115
+ const stem = ancestors.map((isLast) => (isLast ? " " : "│ ")).join("");
116
+ rows.push({
117
+ path: node.path,
118
+ prefix: stem + (top ? "" : last ? "└─ " : "├─ "),
119
+ hasChildren: node.children.length > 0,
120
+ ...(node.thread ? { thread: node.thread } : {}),
121
+ });
122
+ if (collapsed.has(node.path)) conceal(node);
123
+ else if (node.children.length > 0)
124
+ walk(node.children, top ? [] : [...ancestors, last], false);
125
+ }
126
+ };
127
+ walk(
128
+ [...nodes.values()].filter((node) => !linked.has(node.path)),
129
+ [],
130
+ true,
131
+ );
132
+ const missed = [...nodes.values()].filter((node) => !visited.has(node.path));
133
+ if (missed.length) walk(missed, [], true);
134
+ return rows;
135
+ }