@clanker-code/pi-subagents 0.10.8 → 0.11.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/AGENTS.md +2 -0
- package/CHANGELOG.md +29 -0
- package/README.md +22 -2
- package/dist/agent-manager.d.ts +11 -0
- package/dist/agent-manager.js +55 -22
- package/dist/agent-runner.d.ts +14 -0
- package/dist/agent-runner.js +50 -4
- package/dist/agent-tool-description.d.ts +7 -1
- package/dist/agent-tool-description.js +3 -3
- package/dist/cross-extension-rpc.d.ts +4 -0
- package/dist/cross-extension-rpc.js +11 -1
- package/dist/dashboard-ui.d.ts +15 -0
- package/dist/dashboard-ui.js +231 -0
- package/dist/default-agents.js +0 -1
- package/dist/index.js +104 -13
- package/dist/peek.js +8 -2
- package/dist/schedule.d.ts +9 -1
- package/dist/schedule.js +7 -1
- package/dist/subagent-list-clear.d.ts +57 -0
- package/dist/subagent-list-clear.js +331 -0
- package/dist/ui/agent-tool-rendering.js +1 -1
- package/dist/ui/agent-widget-tree.js +19 -2
- package/dist/ui/agent-widget.d.ts +7 -1
- package/dist/ui/agent-widget.js +52 -10
- package/package.json +1 -1
- package/src/agent-manager.ts +48 -13
- package/src/agent-runner.ts +59 -3
- package/src/agent-tool-description.ts +10 -4
- package/src/cross-extension-rpc.ts +14 -1
- package/src/dashboard-ui.ts +291 -0
- package/src/default-agents.ts +0 -1
- package/src/index.ts +121 -17
- package/src/peek.ts +7 -2
- package/src/schedule.ts +20 -1
- package/src/subagent-list-clear.ts +405 -0
- package/src/ui/agent-tool-rendering.ts +1 -1
- package/src/ui/agent-widget-tree.ts +16 -2
- package/src/ui/agent-widget.ts +50 -10
package/src/agent-manager.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { isAbsolute } from "node:path";
|
|
|
12
12
|
import type { Model } from "@earendil-works/pi-ai";
|
|
13
13
|
import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
14
|
import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
|
|
15
|
+
import type { EventBus } from "./cross-extension-rpc.js";
|
|
15
16
|
import { type AgentInvocation, type AgentRecord, type IsolationMode, MAX_RECURSIVE_DEPTH, type SubagentType, type ThinkingLevel } from "./types.js";
|
|
16
17
|
import { addUsage } from "./usage.js";
|
|
17
18
|
import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
|
|
@@ -102,6 +103,8 @@ interface SpawnOptions {
|
|
|
102
103
|
onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
|
|
103
104
|
/** Called when the session successfully compacts. */
|
|
104
105
|
onCompaction?: (info: CompactionInfo) => void;
|
|
106
|
+
/** Parent's event bus — shared with child sessions so lifecycle events propagate to the parent widget. */
|
|
107
|
+
eventBus?: EventBus;
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
interface ResumeOptions {
|
|
@@ -126,6 +129,10 @@ export class AgentManager {
|
|
|
126
129
|
private queue: { id: string; args: SpawnArgs }[] = [];
|
|
127
130
|
/** Number of currently running background agents. */
|
|
128
131
|
private runningBackground = 0;
|
|
132
|
+
/** Background agents by id; foreground agents must not emit background completion callbacks. */
|
|
133
|
+
private backgroundAgentIds = new Set<string>();
|
|
134
|
+
/** Background terminal callbacks already emitted; prevents abort/settle double delivery. */
|
|
135
|
+
private completedBackgroundCallbacks = new Set<string>();
|
|
129
136
|
|
|
130
137
|
constructor(
|
|
131
138
|
onComplete?: OnAgentComplete,
|
|
@@ -194,6 +201,7 @@ export class AgentManager {
|
|
|
194
201
|
options.onOutputFileCreated?.(record.outputFile, id);
|
|
195
202
|
}
|
|
196
203
|
this.agents.set(id, record);
|
|
204
|
+
if (options.isBackground) this.backgroundAgentIds.add(id);
|
|
197
205
|
|
|
198
206
|
const args: SpawnArgs = { pi, ctx, type, prompt, options };
|
|
199
207
|
|
|
@@ -208,12 +216,22 @@ export class AgentManager {
|
|
|
208
216
|
try {
|
|
209
217
|
this.startAgent(id, record, args);
|
|
210
218
|
} catch (err) {
|
|
219
|
+
this.backgroundAgentIds.delete(id);
|
|
211
220
|
this.agents.delete(id);
|
|
212
221
|
throw err;
|
|
213
222
|
}
|
|
214
223
|
return id;
|
|
215
224
|
}
|
|
216
225
|
|
|
226
|
+
/** Emit background completion once, optionally releasing a running concurrency slot. */
|
|
227
|
+
private completeBackground(record: AgentRecord, releaseRunningSlot: boolean, drain = true): void {
|
|
228
|
+
if (this.completedBackgroundCallbacks.has(record.id)) return;
|
|
229
|
+
this.completedBackgroundCallbacks.add(record.id);
|
|
230
|
+
if (releaseRunningSlot) this.runningBackground = Math.max(0, this.runningBackground - 1);
|
|
231
|
+
try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
|
|
232
|
+
if (drain) this.drainQueue();
|
|
233
|
+
}
|
|
234
|
+
|
|
217
235
|
/** Actually start an agent (called immediately or from queue drain). */
|
|
218
236
|
private startAgent(id: string, record: AgentRecord, { pi, ctx, type, prompt, options }: SpawnArgs) {
|
|
219
237
|
// Re-validate a caller-supplied cwd: queued spawns can start minutes after
|
|
@@ -295,6 +313,7 @@ export class AgentManager {
|
|
|
295
313
|
},
|
|
296
314
|
depth: record.depth,
|
|
297
315
|
parentAgentId: record.parentAgentId,
|
|
316
|
+
eventBus: options.eventBus,
|
|
298
317
|
onSessionCreated: (session) => {
|
|
299
318
|
record.session = session;
|
|
300
319
|
// Flush any steers that arrived before the session was ready
|
|
@@ -338,9 +357,7 @@ export class AgentManager {
|
|
|
338
357
|
}
|
|
339
358
|
|
|
340
359
|
if (options.isBackground) {
|
|
341
|
-
this.
|
|
342
|
-
try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
|
|
343
|
-
this.drainQueue();
|
|
360
|
+
this.completeBackground(record, true);
|
|
344
361
|
}
|
|
345
362
|
return responseText;
|
|
346
363
|
})
|
|
@@ -369,9 +386,7 @@ export class AgentManager {
|
|
|
369
386
|
}
|
|
370
387
|
|
|
371
388
|
if (options.isBackground) {
|
|
372
|
-
this.
|
|
373
|
-
this.onComplete?.(record);
|
|
374
|
-
this.drainQueue();
|
|
389
|
+
this.completeBackground(record, true);
|
|
375
390
|
}
|
|
376
391
|
return "";
|
|
377
392
|
});
|
|
@@ -393,7 +408,7 @@ export class AgentManager {
|
|
|
393
408
|
record.status = "error";
|
|
394
409
|
record.error = err instanceof Error ? err.message : String(err);
|
|
395
410
|
record.completedAt = Date.now();
|
|
396
|
-
this.
|
|
411
|
+
this.completeBackground(record, false, false);
|
|
397
412
|
}
|
|
398
413
|
}
|
|
399
414
|
}
|
|
@@ -434,6 +449,8 @@ export class AgentManager {
|
|
|
434
449
|
record.error = undefined;
|
|
435
450
|
record.resultConsumed = false;
|
|
436
451
|
record.abortController = new AbortController();
|
|
452
|
+
this.backgroundAgentIds.add(id);
|
|
453
|
+
this.completedBackgroundCallbacks.delete(id);
|
|
437
454
|
this.runningBackground++;
|
|
438
455
|
this.onStart?.(record);
|
|
439
456
|
|
|
@@ -463,18 +480,14 @@ export class AgentManager {
|
|
|
463
480
|
record.result = responseText;
|
|
464
481
|
record.completedAt = Date.now();
|
|
465
482
|
detach();
|
|
466
|
-
this.
|
|
467
|
-
try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
|
|
468
|
-
this.drainQueue();
|
|
483
|
+
this.completeBackground(record, true);
|
|
469
484
|
return responseText;
|
|
470
485
|
}).catch((err) => {
|
|
471
486
|
if (record.status !== "stopped") record.status = "error";
|
|
472
487
|
record.error = err instanceof Error ? err.message : String(err);
|
|
473
488
|
record.completedAt = Date.now();
|
|
474
489
|
detach();
|
|
475
|
-
this.
|
|
476
|
-
try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
|
|
477
|
-
this.drainQueue();
|
|
490
|
+
this.completeBackground(record, true);
|
|
478
491
|
return "";
|
|
479
492
|
});
|
|
480
493
|
|
|
@@ -501,6 +514,7 @@ export class AgentManager {
|
|
|
501
514
|
this.queue = this.queue.filter(q => q.id !== id);
|
|
502
515
|
record.status = "stopped";
|
|
503
516
|
record.completedAt = Date.now();
|
|
517
|
+
this.completeBackground(record, false);
|
|
504
518
|
return true;
|
|
505
519
|
}
|
|
506
520
|
|
|
@@ -508,6 +522,7 @@ export class AgentManager {
|
|
|
508
522
|
record.abortController?.abort();
|
|
509
523
|
record.status = "stopped";
|
|
510
524
|
record.completedAt = Date.now();
|
|
525
|
+
if (this.backgroundAgentIds.has(id)) this.completeBackground(record, true);
|
|
511
526
|
return true;
|
|
512
527
|
}
|
|
513
528
|
|
|
@@ -515,9 +530,24 @@ export class AgentManager {
|
|
|
515
530
|
private removeRecord(id: string, record: AgentRecord): void {
|
|
516
531
|
record.session?.dispose?.();
|
|
517
532
|
record.session = undefined;
|
|
533
|
+
this.backgroundAgentIds.delete(id);
|
|
534
|
+
this.completedBackgroundCallbacks.delete(id);
|
|
518
535
|
this.agents.delete(id);
|
|
519
536
|
}
|
|
520
537
|
|
|
538
|
+
/** Remove selected terminal records. Running and queued records are never removed. */
|
|
539
|
+
clearRecords(ids: string[]): string[] {
|
|
540
|
+
const removed: string[] = [];
|
|
541
|
+
for (const id of ids) {
|
|
542
|
+
const record = this.agents.get(id);
|
|
543
|
+
if (!record) continue;
|
|
544
|
+
if (record.status === "running" || record.status === "queued") continue;
|
|
545
|
+
this.removeRecord(id, record);
|
|
546
|
+
removed.push(id);
|
|
547
|
+
}
|
|
548
|
+
return removed;
|
|
549
|
+
}
|
|
550
|
+
|
|
521
551
|
private cleanup() {
|
|
522
552
|
const cutoff = Date.now() - 10 * 60_000;
|
|
523
553
|
for (const [id, record] of this.agents) {
|
|
@@ -554,6 +584,7 @@ export class AgentManager {
|
|
|
554
584
|
if (record) {
|
|
555
585
|
record.status = "stopped";
|
|
556
586
|
record.completedAt = Date.now();
|
|
587
|
+
if (this.backgroundAgentIds.has(record.id)) this.completedBackgroundCallbacks.add(record.id);
|
|
557
588
|
count++;
|
|
558
589
|
}
|
|
559
590
|
}
|
|
@@ -564,9 +595,11 @@ export class AgentManager {
|
|
|
564
595
|
record.abortController?.abort();
|
|
565
596
|
record.status = "stopped";
|
|
566
597
|
record.completedAt = Date.now();
|
|
598
|
+
if (this.backgroundAgentIds.has(record.id)) this.completedBackgroundCallbacks.add(record.id);
|
|
567
599
|
count++;
|
|
568
600
|
}
|
|
569
601
|
}
|
|
602
|
+
this.runningBackground = 0;
|
|
570
603
|
return count;
|
|
571
604
|
}
|
|
572
605
|
|
|
@@ -593,6 +626,8 @@ export class AgentManager {
|
|
|
593
626
|
record.session?.dispose();
|
|
594
627
|
}
|
|
595
628
|
this.agents.clear();
|
|
629
|
+
this.backgroundAgentIds.clear();
|
|
630
|
+
this.completedBackgroundCallbacks.clear();
|
|
596
631
|
// Prune any orphaned git worktrees (crash recovery)
|
|
597
632
|
try { pruneWorktrees(process.cwd()); } catch { /* ignore */ }
|
|
598
633
|
// Also prune repos that caller-supplied cwds created worktrees in — a clean
|
package/src/agent-runner.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type AgentSession,
|
|
12
12
|
type AgentSessionEvent,
|
|
13
13
|
createAgentSession,
|
|
14
|
+
createEventBus,
|
|
14
15
|
DefaultResourceLoader,
|
|
15
16
|
type ExtensionAPI,
|
|
16
17
|
getAgentDir,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
} from "@earendil-works/pi-coding-agent";
|
|
20
21
|
import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
|
|
21
22
|
import { buildParentContext, extractText } from "./context.js";
|
|
23
|
+
import type { EventBus } from "./cross-extension-rpc.js";
|
|
22
24
|
import { DEFAULT_AGENTS } from "./default-agents.js";
|
|
23
25
|
import { detectEnv } from "./env.js";
|
|
24
26
|
import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
|
|
@@ -36,6 +38,8 @@ export const SUBAGENT_TOOL_NAMES = {
|
|
|
36
38
|
AGENT: "Agent",
|
|
37
39
|
GET_RESULT: "get_subagent_result",
|
|
38
40
|
STEER: "steer_subagent",
|
|
41
|
+
LIST_SUBAGENTS: "list_subagents",
|
|
42
|
+
CLEAR_SUBAGENTS: "clear_subagents",
|
|
39
43
|
LIST_MODELS: "list_models",
|
|
40
44
|
} as const;
|
|
41
45
|
|
|
@@ -53,6 +57,40 @@ const RECURSIVE_TOOL_NAMES: string[] = [
|
|
|
53
57
|
];
|
|
54
58
|
|
|
55
59
|
const EXTENSION_DEPTH_KEY = Symbol.for("pi-subagents:extension-depth");
|
|
60
|
+
|
|
61
|
+
/** Lifecycle event names that should propagate from child to parent sessions. */
|
|
62
|
+
const FORWARDABLE_EVENTS = new Set([
|
|
63
|
+
"subagents:created",
|
|
64
|
+
"subagents:started",
|
|
65
|
+
"subagents:completed",
|
|
66
|
+
"subagents:failed",
|
|
67
|
+
"subagents:compacted",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Create a forwarding event bus for a child session.
|
|
72
|
+
* The child gets its own local bus for emit/on, but lifecycle events
|
|
73
|
+
* (subagents:*) are also forwarded to the parent bus so the parent widget
|
|
74
|
+
* can display depth 2+ agents.
|
|
75
|
+
*/
|
|
76
|
+
export function createForwardingEventBus(parentBus: EventBus): EventBus {
|
|
77
|
+
// Use the parent's EventBus factory to create a properly isolated local bus
|
|
78
|
+
const localBus = createEventBus();
|
|
79
|
+
return {
|
|
80
|
+
on(event, handler) {
|
|
81
|
+
// Subscribe to local bus only — child doesn't see parent/sibling events
|
|
82
|
+
return localBus.on(event, handler);
|
|
83
|
+
},
|
|
84
|
+
emit(event, data) {
|
|
85
|
+
// Always emit on local bus for child's own listeners
|
|
86
|
+
localBus.emit(event, data);
|
|
87
|
+
// Forward lifecycle events to parent bus for parent widget visibility
|
|
88
|
+
if (FORWARDABLE_EVENTS.has(event)) {
|
|
89
|
+
parentBus.emit(event, data);
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
56
94
|
const AUTO_EXPOSE_EXTENSION_NAMES = new Set(["pi-c2c"]);
|
|
57
95
|
let extensionDepthLoadChain: Promise<void> = Promise.resolve();
|
|
58
96
|
const packageNameCache = new Map<string, string[]>();
|
|
@@ -69,7 +107,12 @@ function getLoadingExtensionAgentId(): string | undefined {
|
|
|
69
107
|
return value && typeof value.agentId === "string" ? value.agentId : undefined;
|
|
70
108
|
}
|
|
71
109
|
|
|
72
|
-
|
|
110
|
+
function getLoadingExtensionParentAgentId(): string | undefined {
|
|
111
|
+
const value = (globalThis as any)[EXTENSION_DEPTH_KEY];
|
|
112
|
+
return value && typeof value.parentAgentId === "string" ? value.parentAgentId : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function withLoadingExtensionDepth<T>(depth: number, agentId: string | undefined, parentAgentId: string | undefined, fn: () => Promise<T>): Promise<T> {
|
|
73
116
|
const previous = extensionDepthLoadChain;
|
|
74
117
|
let release!: () => void;
|
|
75
118
|
extensionDepthLoadChain = new Promise<void>((resolve) => {
|
|
@@ -80,7 +123,7 @@ async function withLoadingExtensionDepth<T>(depth: number, agentId: string | und
|
|
|
80
123
|
try {
|
|
81
124
|
const g = globalThis as any;
|
|
82
125
|
const prev = g[EXTENSION_DEPTH_KEY];
|
|
83
|
-
g[EXTENSION_DEPTH_KEY] = { depth, agentId };
|
|
126
|
+
g[EXTENSION_DEPTH_KEY] = { depth, agentId, parentAgentId };
|
|
84
127
|
try {
|
|
85
128
|
return await fn();
|
|
86
129
|
} finally {
|
|
@@ -100,6 +143,10 @@ export function getCurrentExtensionAgentId(): string | undefined {
|
|
|
100
143
|
return getLoadingExtensionAgentId();
|
|
101
144
|
}
|
|
102
145
|
|
|
146
|
+
export function getCurrentExtensionParentAgentId(): string | undefined {
|
|
147
|
+
return getLoadingExtensionParentAgentId();
|
|
148
|
+
}
|
|
149
|
+
|
|
103
150
|
/**
|
|
104
151
|
* Canonical name of an extension for `extensions: [...]` allowlist matching.
|
|
105
152
|
* Lowercased — extension names match case-insensitively so `extensions: [Mcp]`
|
|
@@ -354,6 +401,9 @@ export interface RunOptions {
|
|
|
354
401
|
depth?: number;
|
|
355
402
|
/** Parent subagent id when spawned recursively from another subagent. */
|
|
356
403
|
parentAgentId?: string;
|
|
404
|
+
/** Parent's event bus — shared with the child session so lifecycle events
|
|
405
|
+
* (subagents:created, subagents:started, etc.) propagate to the parent widget. */
|
|
406
|
+
eventBus?: EventBus;
|
|
357
407
|
}
|
|
358
408
|
|
|
359
409
|
export interface RunResult {
|
|
@@ -544,6 +594,11 @@ export async function runAgent(
|
|
|
544
594
|
};
|
|
545
595
|
};
|
|
546
596
|
|
|
597
|
+
// Create a forwarding event bus so the child session's lifecycle events
|
|
598
|
+
// (subagents:created, subagents:started, etc.) propagate to the parent's
|
|
599
|
+
// event bus — making depth 2+ agents visible in the parent widget.
|
|
600
|
+
const childEventBus = options.eventBus ? createForwardingEventBus(options.eventBus) : undefined;
|
|
601
|
+
|
|
547
602
|
const loader = new DefaultResourceLoader({
|
|
548
603
|
cwd: configCwd,
|
|
549
604
|
agentDir,
|
|
@@ -556,8 +611,9 @@ export async function runAgent(
|
|
|
556
611
|
noContextFiles: true,
|
|
557
612
|
systemPromptOverride: () => systemPrompt,
|
|
558
613
|
appendSystemPromptOverride: () => [],
|
|
614
|
+
eventBus: childEventBus,
|
|
559
615
|
});
|
|
560
|
-
await withLoadingExtensionDepth(depth, options.agentId, () => loader.reload());
|
|
616
|
+
await withLoadingExtensionDepth(depth, options.agentId, options.parentAgentId, () => loader.reload());
|
|
561
617
|
|
|
562
618
|
// Plain entries in `tools:` are expected to be built-in names (extension tools
|
|
563
619
|
// go through `ext:`), so an unknown name there is unambiguously a typo. Previously
|
|
@@ -44,7 +44,13 @@ const buildCompactTypeListText = () =>
|
|
|
44
44
|
|
|
45
45
|
export interface AgentToolDescriptionOptions {
|
|
46
46
|
mode: ToolDescriptionMode;
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Depth at which the NEXT spawned subagent will run.
|
|
49
|
+
* This is `extensionDepth + 1` — the agent's own depth plus one.
|
|
50
|
+
* Displayed as "Current recursive depth" in the tool description so the
|
|
51
|
+
* LLM sees the depth of the agent it is about to create, not its own depth.
|
|
52
|
+
*/
|
|
53
|
+
nextSubagentDepth: number;
|
|
48
54
|
schedulingEnabled: boolean;
|
|
49
55
|
}
|
|
50
56
|
|
|
@@ -56,7 +62,7 @@ export function buildScheduleGuideline(schedulingEnabled: boolean): string {
|
|
|
56
62
|
|
|
57
63
|
export function buildAgentToolDescription(options: AgentToolDescriptionOptions): string {
|
|
58
64
|
const scheduleGuideline = buildScheduleGuideline(options.schedulingEnabled);
|
|
59
|
-
const recursiveGuideline = `Recursive agents are allowed through depth ${MAX_RECURSIVE_DEPTH}. Current recursive depth: ${options.
|
|
65
|
+
const recursiveGuideline = `Recursive agents are allowed through depth ${MAX_RECURSIVE_DEPTH}. Current recursive depth: ${options.nextSubagentDepth}/${MAX_RECURSIVE_DEPTH}.`;
|
|
60
66
|
|
|
61
67
|
const compactAgentToolDescription = `Launch an autonomous agent for complex, multi-step tasks. Agent types:
|
|
62
68
|
${buildCompactTypeListText()}
|
|
@@ -67,7 +73,7 @@ Notes:
|
|
|
67
73
|
- description: 3-5 words (shown in UI). Prompts must be self-contained — the agent has not seen this conversation.
|
|
68
74
|
- Parallel work: one message, multiple Agent calls; they all run in the background. You are notified when agents finish — never poll or sleep.
|
|
69
75
|
- Background by default: when you have useful independent work, launch it and continue. Doing nothing while an agent runs is worse than letting background work proceed.
|
|
70
|
-
- Recursive agents: current depth ${options.
|
|
76
|
+
- Recursive agents: current depth ${options.nextSubagentDepth}/${MAX_RECURSIVE_DEPTH}; you may spawn subagents until depth ${MAX_RECURSIVE_DEPTH}.
|
|
71
77
|
- The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
|
|
72
78
|
- resume continues a previous agent by ID; steer_subagent messages a running one.
|
|
73
79
|
- list_models enumerates the model registry the \`model:\` param accepts — call it before picking a model explicitly.
|
|
@@ -125,7 +131,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
125
131
|
compactTypeList: buildCompactTypeListText,
|
|
126
132
|
agentDir: getAgentDir,
|
|
127
133
|
scheduleGuideline: () => scheduleGuideline,
|
|
128
|
-
currentDepth: () => String(options.
|
|
134
|
+
currentDepth: () => String(options.nextSubagentDepth),
|
|
129
135
|
maxDepth: () => String(MAX_RECURSIVE_DEPTH),
|
|
130
136
|
recursiveGuideline: () => recursiveGuideline,
|
|
131
137
|
};
|
|
@@ -36,6 +36,10 @@ export interface RpcDeps {
|
|
|
36
36
|
pi: unknown; // passed through to manager.spawn
|
|
37
37
|
getCtx: () => unknown | undefined; // returns current ExtensionContext
|
|
38
38
|
manager: SpawnCapable;
|
|
39
|
+
/** Default recursive depth for RPC-spawned subagents in this session. */
|
|
40
|
+
depth?: number;
|
|
41
|
+
/** Parent subagent id for RPC-spawned subagents in this session. */
|
|
42
|
+
parentAgentId?: string;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
export interface RpcHandle {
|
|
@@ -108,7 +112,16 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
|
108
112
|
normalizedOptions = { ...normalizedOptions, model: resolved };
|
|
109
113
|
}
|
|
110
114
|
|
|
111
|
-
|
|
115
|
+
const spawnOptions = {
|
|
116
|
+
...normalizedOptions,
|
|
117
|
+
eventBus: events,
|
|
118
|
+
depth: normalizedOptions.depth ?? deps.depth,
|
|
119
|
+
parentAgentId: normalizedOptions.parentAgentId ?? deps.parentAgentId,
|
|
120
|
+
};
|
|
121
|
+
if (spawnOptions.depth === undefined) delete spawnOptions.depth;
|
|
122
|
+
if (spawnOptions.parentAgentId === undefined) delete spawnOptions.parentAgentId;
|
|
123
|
+
|
|
124
|
+
return { id: manager.spawn(pi, ctx, type, prompt, spawnOptions) };
|
|
112
125
|
},
|
|
113
126
|
);
|
|
114
127
|
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dashboard-ui.ts — Register dashboard UI modules for subagent visibility.
|
|
3
|
+
*
|
|
4
|
+
* Provides three integration points with pi-agent-dashboard:
|
|
5
|
+
* 1. Footer-segment decorator showing running/completed agent counts
|
|
6
|
+
* 2. Management-modal module with a table view of all subagent history
|
|
7
|
+
* 3. Round-trip event handlers for data fetch, abort, and steer actions
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import type { AgentManager } from "./agent-manager.js";
|
|
12
|
+
import { formatMs, getDisplayName } from "./ui/agent-widget.js";
|
|
13
|
+
import { getLifetimeTotal } from "./usage.js";
|
|
14
|
+
|
|
15
|
+
// Dashboard shared types (inlined to avoid adding a dependency)
|
|
16
|
+
interface DecoratorDescriptor {
|
|
17
|
+
kind: "footer-segment" | "agent-metric" | "breadcrumb" | "gate" | "toast";
|
|
18
|
+
namespace: string;
|
|
19
|
+
id: string;
|
|
20
|
+
payload: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ExtensionUiModule {
|
|
24
|
+
kind: "management-modal";
|
|
25
|
+
id: string;
|
|
26
|
+
command: string;
|
|
27
|
+
title: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
icon?: string;
|
|
30
|
+
category?: string;
|
|
31
|
+
view: {
|
|
32
|
+
kind: "table" | "grid" | "form";
|
|
33
|
+
dataEvent?: string;
|
|
34
|
+
rowKey?: string;
|
|
35
|
+
fields?: Array<{
|
|
36
|
+
key: string;
|
|
37
|
+
label: string;
|
|
38
|
+
kind: string;
|
|
39
|
+
width?: string | number;
|
|
40
|
+
}>;
|
|
41
|
+
rowActions?: Array<{
|
|
42
|
+
id: string;
|
|
43
|
+
label: string;
|
|
44
|
+
icon?: string;
|
|
45
|
+
variant?: "primary" | "secondary" | "danger";
|
|
46
|
+
event: string;
|
|
47
|
+
confirm?: string;
|
|
48
|
+
}>;
|
|
49
|
+
emptyState?: string;
|
|
50
|
+
actions?: Array<{
|
|
51
|
+
id: string;
|
|
52
|
+
label: string;
|
|
53
|
+
icon?: string;
|
|
54
|
+
variant?: "primary" | "secondary" | "danger";
|
|
55
|
+
event: string;
|
|
56
|
+
}>;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type ModuleProbe = { modules: Array<ExtensionUiModule | DecoratorDescriptor> };
|
|
61
|
+
|
|
62
|
+
const NAMESPACE = "subagents";
|
|
63
|
+
const MODULE_ID = "subagents-overview";
|
|
64
|
+
const DATA_EVENT = "subagents:rows";
|
|
65
|
+
const INVALIDATE_DEBOUNCE_MS = 500;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build a row for the management-modal table from an AgentRecord.
|
|
69
|
+
*/
|
|
70
|
+
function buildAgentRow(record: any) {
|
|
71
|
+
const durationMs = record.completedAt
|
|
72
|
+
? record.completedAt - record.startedAt
|
|
73
|
+
: Date.now() - record.startedAt;
|
|
74
|
+
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
id: record.id,
|
|
78
|
+
type: getDisplayName(record.type),
|
|
79
|
+
description: record.description ?? "",
|
|
80
|
+
model: record.invocation?.modelName ?? "—",
|
|
81
|
+
status: record.status,
|
|
82
|
+
toolUses: record.toolUses ?? 0,
|
|
83
|
+
tokens: totalTokens > 0 ? formatTokenCount(totalTokens) : "—",
|
|
84
|
+
duration: formatMs(durationMs),
|
|
85
|
+
outputFile: record.outputFile ?? "",
|
|
86
|
+
startedAt: record.startedAt,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formatTokenCount(n: number): string {
|
|
91
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
92
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
93
|
+
return String(n);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Register all dashboard UI integration points.
|
|
98
|
+
* Call once during extension setup when pi.events is available.
|
|
99
|
+
*/
|
|
100
|
+
export function registerDashboardModules(pi: ExtensionAPI, manager: AgentManager): void {
|
|
101
|
+
if (!pi.events) return;
|
|
102
|
+
|
|
103
|
+
let invalidateTimer: ReturnType<typeof setTimeout> | undefined;
|
|
104
|
+
|
|
105
|
+
function scheduleInvalidate() {
|
|
106
|
+
if (invalidateTimer) return;
|
|
107
|
+
invalidateTimer = setTimeout(() => {
|
|
108
|
+
invalidateTimer = undefined;
|
|
109
|
+
pi.events.emit("ui:invalidate", {});
|
|
110
|
+
}, INVALIDATE_DEBOUNCE_MS);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── 1. Module Discovery (ui:list-modules) ──────────────────────────
|
|
114
|
+
// Guard against duplicate pushes: the bridge may call refreshUiModules
|
|
115
|
+
// multiple times per probe cycle when multiple sessions each register
|
|
116
|
+
// their own ui:invalidate listener. Check if our modules are already
|
|
117
|
+
// present before pushing.
|
|
118
|
+
pi.events.on("ui:list-modules", ((probe: ModuleProbe) => {
|
|
119
|
+
const alreadyContributed = probe.modules.some(
|
|
120
|
+
(m: any) => m.kind === "management-modal" && m.id === MODULE_ID,
|
|
121
|
+
);
|
|
122
|
+
if (alreadyContributed) return;
|
|
123
|
+
|
|
124
|
+
const agents = manager.listAgents();
|
|
125
|
+
const running = agents.filter(a => a.status === "running").length;
|
|
126
|
+
const completed = agents.filter(a => a.status === "completed").length;
|
|
127
|
+
const total = agents.length;
|
|
128
|
+
|
|
129
|
+
// Footer-segment: running/completed counts
|
|
130
|
+
const parts: string[] = [];
|
|
131
|
+
if (running > 0) parts.push(`● ${running} running`);
|
|
132
|
+
if (completed > 0) parts.push(`✓ ${completed} done`);
|
|
133
|
+
if (total === 0) parts.push("No agents");
|
|
134
|
+
|
|
135
|
+
probe.modules.push({
|
|
136
|
+
kind: "footer-segment",
|
|
137
|
+
namespace: NAMESPACE,
|
|
138
|
+
id: "agent-counts",
|
|
139
|
+
payload: {
|
|
140
|
+
text: parts.join(" · "),
|
|
141
|
+
tooltip: `${total} total agents (${running} running, ${completed} completed)`,
|
|
142
|
+
icon: "mdiRobot",
|
|
143
|
+
},
|
|
144
|
+
} as DecoratorDescriptor);
|
|
145
|
+
|
|
146
|
+
// Management-modal: subagent overview table
|
|
147
|
+
probe.modules.push({
|
|
148
|
+
kind: "management-modal",
|
|
149
|
+
id: MODULE_ID,
|
|
150
|
+
command: "/subagents",
|
|
151
|
+
title: "Subagents",
|
|
152
|
+
description: "View and manage background subagents",
|
|
153
|
+
icon: "mdiRobotOutline",
|
|
154
|
+
category: "subagents",
|
|
155
|
+
view: {
|
|
156
|
+
kind: "table",
|
|
157
|
+
dataEvent: DATA_EVENT,
|
|
158
|
+
rowKey: "id",
|
|
159
|
+
fields: [
|
|
160
|
+
{ key: "id", label: "ID", kind: "text", width: 120 },
|
|
161
|
+
{ key: "type", label: "Type", kind: "text", width: 100 },
|
|
162
|
+
{ key: "description", label: "Description", kind: "text" },
|
|
163
|
+
{ key: "model", label: "Model", kind: "text", width: 80 },
|
|
164
|
+
{ key: "status", label: "Status", kind: "text", width: 90 },
|
|
165
|
+
{ key: "toolUses", label: "Tools", kind: "number", width: 60 },
|
|
166
|
+
{ key: "tokens", label: "Tokens", kind: "text", width: 80 },
|
|
167
|
+
{ key: "duration", label: "Duration", kind: "text", width: 80 },
|
|
168
|
+
],
|
|
169
|
+
rowActions: [
|
|
170
|
+
{
|
|
171
|
+
id: "view-result",
|
|
172
|
+
label: "View Result",
|
|
173
|
+
icon: "mdiEye",
|
|
174
|
+
variant: "primary",
|
|
175
|
+
event: "subagents:ui:view-result",
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: "abort",
|
|
179
|
+
label: "Abort",
|
|
180
|
+
icon: "mdiStop",
|
|
181
|
+
variant: "danger",
|
|
182
|
+
event: "subagents:ui:abort",
|
|
183
|
+
confirm: "Abort this running agent?",
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: "steer",
|
|
187
|
+
label: "Steer",
|
|
188
|
+
icon: "mdiMessageArrowRight",
|
|
189
|
+
variant: "secondary",
|
|
190
|
+
event: "subagents:ui:steer",
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
emptyState: "No subagents have been spawned in this session.",
|
|
194
|
+
actions: [
|
|
195
|
+
{
|
|
196
|
+
id: "refresh",
|
|
197
|
+
label: "Refresh",
|
|
198
|
+
icon: "mdiRefresh",
|
|
199
|
+
variant: "secondary",
|
|
200
|
+
event: "subagents:ui:refresh",
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
} as ExtensionUiModule);
|
|
205
|
+
}) as any);
|
|
206
|
+
|
|
207
|
+
// ── 2. Data Fetch Handler ──────────────────────────────────────────
|
|
208
|
+
pi.events.on(DATA_EVENT, ((data: any) => {
|
|
209
|
+
const agents = manager.listAgents();
|
|
210
|
+
data.items = agents.map(buildAgentRow);
|
|
211
|
+
}) as any);
|
|
212
|
+
|
|
213
|
+
// ── 3. Action Handlers ─────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
// Refresh: just invalidate to re-probe + re-fetch
|
|
216
|
+
pi.events.on("subagents:ui:refresh", (() => {
|
|
217
|
+
scheduleInvalidate();
|
|
218
|
+
}) as any);
|
|
219
|
+
|
|
220
|
+
// View Result: return the agent's result as table rows so the modal
|
|
221
|
+
// displays it. The bridge's synchronous fast path calls `_reply(items)`
|
|
222
|
+
// when `data.items` is populated by the handler — do NOT call
|
|
223
|
+
// `scheduleInvalidate()` here as the subsequent re-probe would
|
|
224
|
+
// overwrite the returned rows with the original table data.
|
|
225
|
+
pi.events.on("subagents:ui:view-result", ((data: any) => {
|
|
226
|
+
// Bridge spreads msg.params into data; row identity is at data.row.id.
|
|
227
|
+
const agentId = data.row?.id ?? data.id;
|
|
228
|
+
if (!agentId) return;
|
|
229
|
+
const record = manager.getRecord(agentId);
|
|
230
|
+
if (!record) return;
|
|
231
|
+
|
|
232
|
+
const resultText = record.result?.trim() || "No output yet.";
|
|
233
|
+
const preview = resultText.length > 2000
|
|
234
|
+
? resultText.slice(0, 2000) + "\n…(truncated)"
|
|
235
|
+
: resultText;
|
|
236
|
+
|
|
237
|
+
// Populate data.items — the bridge's synchronous fast path forwards
|
|
238
|
+
// this as a `ui_data_list` message back to the dashboard.
|
|
239
|
+
data.items = [{
|
|
240
|
+
id: record.id,
|
|
241
|
+
type: getDisplayName(record.type),
|
|
242
|
+
description: record.description,
|
|
243
|
+
status: record.status,
|
|
244
|
+
result: preview,
|
|
245
|
+
outputFile: record.outputFile ?? "",
|
|
246
|
+
}];
|
|
247
|
+
}) as any);
|
|
248
|
+
|
|
249
|
+
// Abort: stop the running agent via the manager's abort() method
|
|
250
|
+
// which properly cancels the AbortController and cleans up state.
|
|
251
|
+
pi.events.on("subagents:ui:abort", ((data: any) => {
|
|
252
|
+
const agentId = data.row?.id ?? data.id;
|
|
253
|
+
if (!agentId) return;
|
|
254
|
+
manager.abort(agentId);
|
|
255
|
+
scheduleInvalidate();
|
|
256
|
+
}) as any);
|
|
257
|
+
|
|
258
|
+
// Steer: send a steering message to a running agent's session.
|
|
259
|
+
// The management-modal row action carries the row identity; we steer
|
|
260
|
+
// with a default "Continue" nudge. A future form view could accept
|
|
261
|
+
// custom text.
|
|
262
|
+
pi.events.on("subagents:ui:steer", ((data: any) => {
|
|
263
|
+
const agentId = data.row?.id ?? data.id;
|
|
264
|
+
if (!agentId) return;
|
|
265
|
+
const record = manager.getRecord(agentId);
|
|
266
|
+
if (!record) return;
|
|
267
|
+
|
|
268
|
+
if (record.status === "running" && record.session) {
|
|
269
|
+
// Session is live — steer immediately
|
|
270
|
+
record.session.steer("Continue").catch(() => {});
|
|
271
|
+
} else if (record.status === "queued") {
|
|
272
|
+
// Session not yet created — queue the steer for flush on start
|
|
273
|
+
if (!record.pendingSteers) record.pendingSteers = [];
|
|
274
|
+
record.pendingSteers.push("Continue");
|
|
275
|
+
}
|
|
276
|
+
scheduleInvalidate();
|
|
277
|
+
}) as any);
|
|
278
|
+
|
|
279
|
+
// ── 4. Invalidate on agent lifecycle events ────────────────────────
|
|
280
|
+
const lifecycleEvents = [
|
|
281
|
+
"subagents:created",
|
|
282
|
+
"subagents:started",
|
|
283
|
+
"subagents:completed",
|
|
284
|
+
"subagents:failed",
|
|
285
|
+
"subagents:compacted",
|
|
286
|
+
];
|
|
287
|
+
|
|
288
|
+
for (const event of lifecycleEvents) {
|
|
289
|
+
pi.events.on(event, (() => scheduleInvalidate()) as any);
|
|
290
|
+
}
|
|
291
|
+
}
|