@bacnh85/pi-subagent 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -64
- package/agent-format.md +8 -3
- package/agents/reviewer.md +28 -22
- package/agents/scout.md +3 -3
- package/agents/worker.md +2 -2
- package/{agents.ts → extensions/agents.ts} +18 -9
- package/{index.ts → extensions/index.ts} +411 -117
- package/extensions/model.ts +86 -0
- package/extensions/package.json +3 -0
- package/{render.ts → extensions/render.ts} +33 -15
- package/{runner.ts → extensions/runner.ts} +54 -10
- package/extensions/service.ts +79 -0
- package/extensions/thread-viewer.ts +393 -0
- package/extensions/threads.ts +116 -0
- package/package.json +36 -29
|
@@ -15,17 +15,18 @@
|
|
|
15
15
|
|
|
16
16
|
import * as path from "node:path";
|
|
17
17
|
import type { Model } from "@earendil-works/pi-ai";
|
|
18
|
-
import { getModel } from "@earendil-works/pi-ai/compat";
|
|
19
18
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
20
19
|
import {
|
|
21
20
|
AuthStorage,
|
|
22
21
|
CONFIG_DIR_NAME,
|
|
22
|
+
DynamicBorder,
|
|
23
23
|
type ExtensionAPI,
|
|
24
|
+
type ExtensionContext,
|
|
24
25
|
getAgentDir,
|
|
25
26
|
getMarkdownTheme,
|
|
26
27
|
ModelRegistry,
|
|
27
28
|
} from "@earendil-works/pi-coding-agent";
|
|
28
|
-
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
29
|
+
import { Container, Markdown, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
|
|
29
30
|
import { Type } from "typebox";
|
|
30
31
|
|
|
31
32
|
import { type AgentConfig, type AgentScope, discoverAgents, formatAgentList, invalidateAgentCache } from "./agents.ts";
|
|
@@ -42,6 +43,9 @@ import {
|
|
|
42
43
|
formatUsageStats,
|
|
43
44
|
renderSingleResult,
|
|
44
45
|
} from "./render.ts";
|
|
46
|
+
import { type SubagentThread, threadStore } from "./threads.ts";
|
|
47
|
+
import { SUBAGENT_REQUEST_EVENT, runNamedAgent, type SubagentRunRequest } from "./service.ts";
|
|
48
|
+
import { ThreadViewer, type ThreadViewerCallbacks } from "./thread-viewer.ts";
|
|
45
49
|
|
|
46
50
|
// ---------------------------------------------------------------------------
|
|
47
51
|
// Constants
|
|
@@ -51,6 +55,8 @@ const MAX_PARALLEL_TASKS = 8;
|
|
|
51
55
|
const MAX_CONCURRENCY = 4;
|
|
52
56
|
const PER_TASK_OUTPUT_CAP = 50 * 1024; // 50 KB per parallel task
|
|
53
57
|
|
|
58
|
+
import { resolveModel } from "./model.ts";
|
|
59
|
+
|
|
54
60
|
// ---------------------------------------------------------------------------
|
|
55
61
|
// Helpers
|
|
56
62
|
// ---------------------------------------------------------------------------
|
|
@@ -66,35 +72,6 @@ function truncateParallelOutput(output: string): string {
|
|
|
66
72
|
return `${truncated}\n\n[Output truncated: ${byteLength - Buffer.byteLength(truncated, "utf8")} bytes omitted.]`;
|
|
67
73
|
}
|
|
68
74
|
|
|
69
|
-
interface ResolvedModel {
|
|
70
|
-
model: Model | null;
|
|
71
|
-
attempted: string[];
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function resolveModel(
|
|
75
|
-
modelName: string | undefined,
|
|
76
|
-
parentModel: Model | undefined,
|
|
77
|
-
): ResolvedModel {
|
|
78
|
-
const attempted: string[] = [];
|
|
79
|
-
if (modelName) {
|
|
80
|
-
// Try as provider/id first, then fall back to anthropic/id
|
|
81
|
-
const parts = modelName.split("/");
|
|
82
|
-
if (parts.length === 2) {
|
|
83
|
-
attempted.push(modelName);
|
|
84
|
-
const found = getModel(parts[0], parts[1]) ?? null;
|
|
85
|
-
if (found) return { model: found, attempted };
|
|
86
|
-
} else {
|
|
87
|
-
// Assume Anthropic shorthand
|
|
88
|
-
attempted.push(`anthropic/${modelName}`);
|
|
89
|
-
const found = getModel("anthropic", modelName) ?? null;
|
|
90
|
-
if (found) return { model: found, attempted };
|
|
91
|
-
}
|
|
92
|
-
} else if (parentModel) {
|
|
93
|
-
attempted.push(`${parentModel.provider}/${parentModel.id}`);
|
|
94
|
-
return { model: parentModel, attempted };
|
|
95
|
-
}
|
|
96
|
-
return { model: null, attempted };
|
|
97
|
-
}
|
|
98
75
|
|
|
99
76
|
// ---------------------------------------------------------------------------
|
|
100
77
|
// Tool parameter schema
|
|
@@ -140,6 +117,7 @@ const SubagentParams = Type.Object({
|
|
|
140
117
|
),
|
|
141
118
|
cwd: Type.Optional(Type.String({ description: "Working directory (single mode)" })),
|
|
142
119
|
timeout: Type.Optional(Type.Number({ description: "Global timeout in milliseconds for all sub-agents (overridden by per-task/step timeouts)" })),
|
|
120
|
+
instructions: Type.Optional(Type.String({ description: "Bounded repository/task instructions passed to each child (max 16 KB)" })),
|
|
143
121
|
abortOnFailure: Type.Optional(Type.Boolean({ description: "In parallel mode, cancel remaining tasks when one fails. Default: false.", default: false })),
|
|
144
122
|
});
|
|
145
123
|
|
|
@@ -159,9 +137,13 @@ interface SubagentDetails {
|
|
|
159
137
|
// ---------------------------------------------------------------------------
|
|
160
138
|
|
|
161
139
|
export default function (pi: ExtensionAPI) {
|
|
162
|
-
|
|
163
|
-
|
|
140
|
+
let currentCtx: ExtensionContext | undefined;
|
|
141
|
+
|
|
142
|
+
// Invalidate agent cache + clear thread store on session replacement.
|
|
143
|
+
pi.on("session_start", (event, ctx) => {
|
|
144
|
+
currentCtx = ctx;
|
|
164
145
|
if (event.reason === "reload") invalidateAgentCache();
|
|
146
|
+
threadStore.clear();
|
|
165
147
|
});
|
|
166
148
|
|
|
167
149
|
// Proactively steer agents toward sub-agent delegation when users mention it
|
|
@@ -176,22 +158,43 @@ export default function (pi: ExtensionAPI) {
|
|
|
176
158
|
}
|
|
177
159
|
});
|
|
178
160
|
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
161
|
+
// Resolve bundled agents directory relative to this extension file
|
|
162
|
+
const bundledAgentsDir = path.resolve(__dirname, "../agents");
|
|
163
|
+
|
|
164
|
+
// Public one-request/one-response service used by pi-review.
|
|
165
|
+
pi.events.on(SUBAGENT_REQUEST_EVENT, (raw) => {
|
|
166
|
+
const request = raw as SubagentRunRequest;
|
|
167
|
+
const ctx = currentCtx;
|
|
168
|
+
if (!ctx || !request?.id || typeof request.respond !== "function") return;
|
|
169
|
+
if (request.accept && !request.accept()) return;
|
|
170
|
+
const agent = discoverAgents(ctx.cwd, "user", bundledAgentsDir).agents.find((item) => item.name === request.agent);
|
|
171
|
+
if (!agent) {
|
|
172
|
+
request.respond({ id: request.id, ok: false, error: `Unknown agent: ${request.agent}` });
|
|
173
|
+
return;
|
|
189
174
|
}
|
|
175
|
+
const thread = threadStore.createThread({ agentName: agent.name, task: request.task, mode: "single" });
|
|
176
|
+
void runNamedAgent({
|
|
177
|
+
agent: request.readOnly ? { ...agent, tools: ["read", "grep", "find", "ls"] } : agent,
|
|
178
|
+
task: request.task,
|
|
179
|
+
cwd: request.cwd ?? ctx.cwd,
|
|
180
|
+
ctx,
|
|
181
|
+
timeout: request.timeout,
|
|
182
|
+
instructions: request.instructions,
|
|
183
|
+
signal: request.signal,
|
|
184
|
+
onMessage: (result) => threadStore.updateThread(thread.id, { result }),
|
|
185
|
+
}).then((result) => {
|
|
186
|
+
threadStore.updateThread(thread.id, {
|
|
187
|
+
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
188
|
+
result,
|
|
189
|
+
});
|
|
190
|
+
if (isFailedResult(result)) request.respond({ id: request.id, ok: false, error: getResultOutput(result) });
|
|
191
|
+
else request.respond({ id: request.id, ok: true, result });
|
|
192
|
+
}, (error) => {
|
|
193
|
+
threadStore.updateThread(thread.id, { status: "failed" });
|
|
194
|
+
request.respond({ id: request.id, ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
195
|
+
});
|
|
190
196
|
});
|
|
191
197
|
|
|
192
|
-
// Resolve bundled agents directory relative to this extension file
|
|
193
|
-
const bundledAgentsDir = path.resolve(__dirname, "agents");
|
|
194
|
-
|
|
195
198
|
// /subagent command — list available agents
|
|
196
199
|
pi.registerCommand("subagent", {
|
|
197
200
|
description: "List available sub-agents, reload agent definitions, or show agent details",
|
|
@@ -242,6 +245,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
242
245
|
`Agent: ${agent.name} (${agent.source})`,
|
|
243
246
|
`Description: ${agent.description}`,
|
|
244
247
|
`Model: ${agent.model || "inherits from parent"}`,
|
|
248
|
+
`Thinking: ${agent.thinking || "off"}`,
|
|
245
249
|
`Tools: ${agent.tools?.join(", ") || "all default"}`,
|
|
246
250
|
`Source file: ${agent.filePath}`,
|
|
247
251
|
"",
|
|
@@ -322,12 +326,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
322
326
|
};
|
|
323
327
|
}
|
|
324
328
|
|
|
325
|
-
//
|
|
326
|
-
if (
|
|
327
|
-
(agentScope === "project" || agentScope === "both") &&
|
|
328
|
-
confirmProjectAgents &&
|
|
329
|
-
ctx.hasUI
|
|
330
|
-
) {
|
|
329
|
+
// Handle project-local agent confirmation
|
|
330
|
+
if (agentScope === "project" || agentScope === "both") {
|
|
331
331
|
const requestedAgentNames = new Set<string>();
|
|
332
332
|
if (params.chain) for (const s of params.chain) requestedAgentNames.add(s.agent);
|
|
333
333
|
if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent);
|
|
@@ -337,16 +337,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
337
337
|
.map((name) => agents.find((a) => a.name === name))
|
|
338
338
|
.filter((a): a is AgentConfig => a?.source === "project");
|
|
339
339
|
|
|
340
|
-
if (projectAgentsRequested.length > 0) {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
340
|
+
if (projectAgentsRequested.length > 0 && confirmProjectAgents) {
|
|
341
|
+
if (ctx.hasUI) {
|
|
342
|
+
const names = projectAgentsRequested.map((a) => a.name).join(", ");
|
|
343
|
+
const dir = discovery.projectAgentsDir ?? "(unknown)";
|
|
344
|
+
const ok = await ctx.ui.confirm(
|
|
345
|
+
"Run project-local agents?",
|
|
346
|
+
`Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
|
|
347
|
+
);
|
|
348
|
+
if (!ok) {
|
|
349
|
+
return {
|
|
350
|
+
content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
|
|
351
|
+
details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
// ponytail: fail closed in headless sessions — project agent
|
|
356
|
+
// prompts and tools run without user oversight.
|
|
348
357
|
return {
|
|
349
|
-
content: [{
|
|
358
|
+
content: [{
|
|
359
|
+
type: "text",
|
|
360
|
+
text: "Cannot run project-local agents without UI confirmation. "
|
|
361
|
+
+ "Set confirmProjectAgents: false to allow in headless sessions, "
|
|
362
|
+
+ "or use agentScope: 'user' to skip project agents.",
|
|
363
|
+
}],
|
|
350
364
|
details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
|
|
351
365
|
};
|
|
352
366
|
}
|
|
@@ -354,14 +368,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
354
368
|
}
|
|
355
369
|
|
|
356
370
|
// Shared auth/model setup for SDK sessions
|
|
357
|
-
|
|
358
|
-
|
|
371
|
+
// ponytail: reuse parent modelRegistry instead of a fresh copy — avoids
|
|
372
|
+
// internal API casts (storeModelHeaders) and preserves env/headers/OAuth.
|
|
373
|
+
const authStorage = AuthStorage.inMemory();
|
|
374
|
+
const modelRegistry = ctx.modelRegistry;
|
|
359
375
|
|
|
360
376
|
// Helper: inject parent's API key into child auth storage
|
|
361
|
-
async function injectApiKey(model: Model): Promise<void> {
|
|
377
|
+
async function injectApiKey(model: Model<any>): Promise<void> {
|
|
362
378
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
363
|
-
if (auth.ok
|
|
364
|
-
authStorage.setRuntimeApiKey(model.provider, auth.apiKey);
|
|
379
|
+
if (auth.ok) {
|
|
380
|
+
if (auth.apiKey) authStorage.setRuntimeApiKey(model.provider, auth.apiKey);
|
|
381
|
+
// ponytail: headers/env stay on the parent registry — no copy needed.
|
|
365
382
|
}
|
|
366
383
|
}
|
|
367
384
|
|
|
@@ -372,6 +389,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
372
389
|
cwd: string | undefined,
|
|
373
390
|
parentSignal?: AbortSignal,
|
|
374
391
|
timeoutMs?: number,
|
|
392
|
+
onProgress?: (partial: SubAgentResult) => void,
|
|
375
393
|
): Promise<SubAgentResult> {
|
|
376
394
|
const agent = agents.find((a) => a.name === agentName);
|
|
377
395
|
|
|
@@ -388,7 +406,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
388
406
|
};
|
|
389
407
|
}
|
|
390
408
|
|
|
391
|
-
const resolved = resolveModel(agent.model, ctx.model);
|
|
409
|
+
const resolved = resolveModel(agent.model, ctx.model, ctx.modelRegistry);
|
|
392
410
|
if (!resolved.model) {
|
|
393
411
|
const tried = resolved.attempted.join(", ") || "none";
|
|
394
412
|
const parentInfo = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none";
|
|
@@ -412,52 +430,40 @@ export default function (pi: ExtensionAPI) {
|
|
|
412
430
|
let tools = agent.tools ?? defaultTools;
|
|
413
431
|
tools = tools.filter((t) => t !== "subagent");
|
|
414
432
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
433
|
+
const timeoutController = timeoutMs && timeoutMs > 0 ? new AbortController() : undefined;
|
|
434
|
+
const timeoutId = timeoutController ? setTimeout(() => timeoutController.abort(), timeoutMs) : undefined;
|
|
435
|
+
const signals = [parentSignal, timeoutController?.signal].filter((value): value is AbortSignal => Boolean(value));
|
|
436
|
+
const combinedSignal = signals.length > 1
|
|
437
|
+
? typeof (AbortSignal as any).any === "function"
|
|
438
|
+
? (AbortSignal as any).any(signals)
|
|
439
|
+
: signals[0]
|
|
440
|
+
: signals[0];
|
|
441
|
+
|
|
442
|
+
try {
|
|
443
|
+
const result = await runSubAgent({
|
|
444
|
+
cwd: cwd ?? ctx.cwd,
|
|
445
|
+
systemPrompt: params.instructions
|
|
446
|
+
? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, 16 * 1024)}`
|
|
447
|
+
: agent.systemPrompt,
|
|
448
|
+
task,
|
|
449
|
+
tools,
|
|
450
|
+
model: resolved.model,
|
|
451
|
+
authStorage,
|
|
452
|
+
modelRegistry,
|
|
453
|
+
signal: combinedSignal,
|
|
454
|
+
agentName,
|
|
455
|
+
thinkingLevel: agent.thinking,
|
|
456
|
+
onMessage: onProgress,
|
|
457
|
+
});
|
|
458
|
+
if (timeoutController?.signal.aborted && !parentSignal?.aborted) {
|
|
459
|
+
result.exitCode = 1;
|
|
460
|
+
result.stopReason = "timeout";
|
|
461
|
+
result.errorMessage ||= `Timeout after ${timeoutMs}ms`;
|
|
434
462
|
}
|
|
463
|
+
return result;
|
|
464
|
+
} finally {
|
|
465
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
435
466
|
}
|
|
436
|
-
|
|
437
|
-
const result = await runSubAgent({
|
|
438
|
-
cwd: cwd ?? ctx.cwd,
|
|
439
|
-
systemPrompt: agent.systemPrompt,
|
|
440
|
-
task,
|
|
441
|
-
tools,
|
|
442
|
-
model: resolved.model,
|
|
443
|
-
authStorage,
|
|
444
|
-
modelRegistry,
|
|
445
|
-
signal: combinedSignal,
|
|
446
|
-
agentName,
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
// Clean up timeout
|
|
450
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
451
|
-
|
|
452
|
-
// Detect timeout: our timeout controller fired, not the parent
|
|
453
|
-
const timedOut = timeoutController?.signal.aborted && !parentSignal?.aborted;
|
|
454
|
-
if (timedOut) {
|
|
455
|
-
result.exitCode = 1;
|
|
456
|
-
result.stopReason = "timeout";
|
|
457
|
-
if (!result.errorMessage) result.errorMessage = `Timeout after ${timeoutMs}ms`;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
return result;
|
|
461
467
|
}
|
|
462
468
|
|
|
463
469
|
// --- Chain mode ---
|
|
@@ -469,10 +475,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
469
475
|
const step = params.chain[i];
|
|
470
476
|
const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
|
|
471
477
|
|
|
478
|
+
const thread = threadStore.createThread({
|
|
479
|
+
agentName: step.agent,
|
|
480
|
+
task: taskWithContext,
|
|
481
|
+
mode: "chain-step",
|
|
482
|
+
toolCallId: _toolCallId,
|
|
483
|
+
});
|
|
472
484
|
const result = await runOne(
|
|
473
485
|
step.agent, taskWithContext, step.cwd,
|
|
474
486
|
signal, step.timeout ?? params.timeout,
|
|
487
|
+
(partial) => threadStore.updateThread(thread.id, { result: partial }),
|
|
475
488
|
);
|
|
489
|
+
threadStore.updateThread(thread.id, {
|
|
490
|
+
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
491
|
+
result,
|
|
492
|
+
});
|
|
476
493
|
results.push(result);
|
|
477
494
|
|
|
478
495
|
const isError = isFailedResult(result);
|
|
@@ -539,13 +556,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
539
556
|
|
|
540
557
|
const abortOnFailure = params.abortOnFailure ?? false;
|
|
541
558
|
const parallelController = new AbortController();
|
|
559
|
+
let abortCause: "parent" | "sibling" | undefined;
|
|
542
560
|
|
|
543
561
|
// Combine parent signal with parallel abort controller
|
|
544
562
|
let parallelSignal: AbortSignal = parallelController.signal;
|
|
545
563
|
if (signal) {
|
|
546
564
|
// Always link parent abort into parallelController so queued tasks see aborted state
|
|
547
|
-
if (signal.aborted)
|
|
548
|
-
|
|
565
|
+
if (signal.aborted) {
|
|
566
|
+
abortCause = "parent";
|
|
567
|
+
parallelController.abort();
|
|
568
|
+
} else {
|
|
569
|
+
signal.addEventListener("abort", () => {
|
|
570
|
+
if (!abortCause) abortCause = "parent";
|
|
571
|
+
parallelController.abort();
|
|
572
|
+
}, { once: true });
|
|
573
|
+
}
|
|
549
574
|
if (typeof (AbortSignal as any).any === "function") {
|
|
550
575
|
parallelSignal = (AbortSignal as any).any([signal, parallelController.signal]);
|
|
551
576
|
} else {
|
|
@@ -553,6 +578,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
553
578
|
}
|
|
554
579
|
}
|
|
555
580
|
|
|
581
|
+
// Pre-create threads for all parallel tasks
|
|
582
|
+
const parallelThreads = params.tasks.map((t) =>
|
|
583
|
+
threadStore.createThread({
|
|
584
|
+
agentName: t.agent,
|
|
585
|
+
task: t.task,
|
|
586
|
+
mode: "parallel-task",
|
|
587
|
+
toolCallId: _toolCallId,
|
|
588
|
+
}),
|
|
589
|
+
);
|
|
590
|
+
|
|
556
591
|
const allResults: SubAgentResult[] = new Array(params.tasks.length);
|
|
557
592
|
// Initialize placeholder results for streaming
|
|
558
593
|
for (let i = 0; i < params.tasks.length; i++) {
|
|
@@ -596,21 +631,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
596
631
|
stderr: "",
|
|
597
632
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
598
633
|
stopReason: "aborted",
|
|
599
|
-
errorMessage:
|
|
600
|
-
|
|
601
|
-
|
|
634
|
+
errorMessage:
|
|
635
|
+
abortCause === "sibling"
|
|
636
|
+
? "Cancelled: sibling task failed"
|
|
637
|
+
: "Cancelled: parent operation aborted",
|
|
602
638
|
};
|
|
603
639
|
allResults[index] = skippedResult;
|
|
640
|
+
threadStore.updateThread(parallelThreads[index].id, {
|
|
641
|
+
status: "aborted",
|
|
642
|
+
result: skippedResult,
|
|
643
|
+
});
|
|
604
644
|
emitParallelUpdate();
|
|
605
645
|
return skippedResult;
|
|
606
646
|
}
|
|
607
647
|
const result = await runOne(
|
|
608
648
|
t.agent, t.task, t.cwd,
|
|
609
649
|
parallelSignal, t.timeout ?? params.timeout,
|
|
650
|
+
(partial) => threadStore.updateThread(parallelThreads[index].id, { result: partial }),
|
|
610
651
|
);
|
|
611
652
|
allResults[index] = result;
|
|
653
|
+
threadStore.updateThread(parallelThreads[index].id, {
|
|
654
|
+
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
655
|
+
result,
|
|
656
|
+
});
|
|
612
657
|
// Early-abort: if this task failed and abortOnFailure is set
|
|
613
658
|
if (abortOnFailure && isFailedResult(result)) {
|
|
659
|
+
abortCause = "sibling";
|
|
614
660
|
parallelController.abort();
|
|
615
661
|
}
|
|
616
662
|
emitParallelUpdate();
|
|
@@ -643,10 +689,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
643
689
|
|
|
644
690
|
// --- Single mode ---
|
|
645
691
|
if (params.agent && params.task) {
|
|
692
|
+
const thread = threadStore.createThread({
|
|
693
|
+
agentName: params.agent,
|
|
694
|
+
task: params.task,
|
|
695
|
+
mode: "single",
|
|
696
|
+
toolCallId: _toolCallId,
|
|
697
|
+
});
|
|
646
698
|
const result = await runOne(
|
|
647
699
|
params.agent, params.task, params.cwd,
|
|
648
700
|
signal, params.timeout,
|
|
701
|
+
(partial) => threadStore.updateThread(thread.id, { result: partial }),
|
|
649
702
|
);
|
|
703
|
+
threadStore.updateThread(thread.id, {
|
|
704
|
+
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
705
|
+
result,
|
|
706
|
+
});
|
|
650
707
|
const isError = isFailedResult(result);
|
|
651
708
|
|
|
652
709
|
if (onUpdate) {
|
|
@@ -921,4 +978,241 @@ export default function (pi: ExtensionAPI) {
|
|
|
921
978
|
return new Text(fallback?.type === "text" ? fallback.text : "(no output)", 0, 0);
|
|
922
979
|
},
|
|
923
980
|
});
|
|
924
|
-
|
|
981
|
+
// /agent command — switch between subagent threads.
|
|
982
|
+
// When a thread is selected, the viewer replaces the main TUI (not overlay).
|
|
983
|
+
pi.registerCommand("agent", {
|
|
984
|
+
description: "Switch to a subagent thread to view its work in isolation",
|
|
985
|
+
handler: async (_args, ctx) => {
|
|
986
|
+
// Show picker overlay
|
|
987
|
+
const selectedId = await showAgentPicker(ctx, buildPickerItems(threadStore.getAllThreads()));
|
|
988
|
+
if (!selectedId) return; // Cancelled — stay in current view
|
|
989
|
+
|
|
990
|
+
// Main selected — close viewer if active, return to conversation
|
|
991
|
+
if (selectedId === "__main__") {
|
|
992
|
+
if (activeViewerDone) {
|
|
993
|
+
activeViewerDone();
|
|
994
|
+
activeViewerDone = null;
|
|
995
|
+
}
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Close existing viewer (if any) before opening new one
|
|
1000
|
+
if (activeViewerDone) {
|
|
1001
|
+
activeViewerDone();
|
|
1002
|
+
activeViewerDone = null;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// Show thread viewer (re-resolve against current store)
|
|
1006
|
+
const freshThreads = threadStore.getAllThreads();
|
|
1007
|
+
const idx = freshThreads.findIndex((t) => t.id === selectedId);
|
|
1008
|
+
if (idx === -1) {
|
|
1009
|
+
ctx.ui.notify("Selected subagent thread no longer exists.", "warning");
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
await showThreadViewer(ctx, freshThreads, idx);
|
|
1014
|
+
},
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
// ---------------------------------------------------------------------------
|
|
1018
|
+
// Module-level viewer state (so /agent can close an active viewer)
|
|
1019
|
+
// ---------------------------------------------------------------------------
|
|
1020
|
+
let activeViewerDone: (() => void) | null = null;
|
|
1021
|
+
|
|
1022
|
+
// ---------------------------------------------------------------------------
|
|
1023
|
+
// Picker helpers (shared between /agent handler and Ctrl+P in viewer)
|
|
1024
|
+
// ---------------------------------------------------------------------------
|
|
1025
|
+
|
|
1026
|
+
interface PickerItem { value: string; label: string; description: string }
|
|
1027
|
+
|
|
1028
|
+
function buildPickerItems(threads: SubagentThread[]): PickerItem[] {
|
|
1029
|
+
const items: PickerItem[] = [
|
|
1030
|
+
{ value: "__main__", label: "Main [default]", description: "(current)" },
|
|
1031
|
+
];
|
|
1032
|
+
for (const t of threads) {
|
|
1033
|
+
let statusIcon: string;
|
|
1034
|
+
switch (t.status) {
|
|
1035
|
+
case "running": statusIcon = "⏳"; break;
|
|
1036
|
+
case "completed": statusIcon = "✓"; break;
|
|
1037
|
+
case "failed": statusIcon = "✗"; break;
|
|
1038
|
+
case "aborted": statusIcon = "✗"; break;
|
|
1039
|
+
}
|
|
1040
|
+
let modeTag = "";
|
|
1041
|
+
if (t.mode === "parallel-task") modeTag = " [parallel]";
|
|
1042
|
+
else if (t.mode === "chain-step") modeTag = " [chain]";
|
|
1043
|
+
const label = `${statusIcon} ${t.agentName}${modeTag}`;
|
|
1044
|
+
const desc = t.task.length > 60 ? `${t.task.slice(0, 57)}...` : t.task;
|
|
1045
|
+
items.push({ value: t.id, label, description: desc });
|
|
1046
|
+
}
|
|
1047
|
+
return items;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
async function showAgentPicker(
|
|
1051
|
+
ctx: { ui: { custom: <T>(factory: any, opts?: any) => Promise<T> } },
|
|
1052
|
+
items: PickerItem[],
|
|
1053
|
+
): Promise<string | null> {
|
|
1054
|
+
return ctx.ui.custom<string | null>((tui: any, theme: any, _kb: any, done: (value: string | null) => void) => {
|
|
1055
|
+
const container = new Container();
|
|
1056
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
1057
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Subagents")), 1, 0));
|
|
1058
|
+
container.addChild(new Text(theme.fg("dim", "⌥ + ← previous, ⌥ + → next."), 1, 0));
|
|
1059
|
+
|
|
1060
|
+
const selectList = new SelectList(
|
|
1061
|
+
items.map((it) => ({ value: it.value, label: it.label, description: it.description })),
|
|
1062
|
+
Math.min(items.length + 2, 15),
|
|
1063
|
+
{
|
|
1064
|
+
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
1065
|
+
selectedText: (t: string) => theme.fg("accent", t),
|
|
1066
|
+
description: (t: string) => theme.fg("muted", t),
|
|
1067
|
+
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
1068
|
+
noMatch: (t: string) => theme.fg("warning", t),
|
|
1069
|
+
},
|
|
1070
|
+
);
|
|
1071
|
+
selectList.onSelect = (item) => done(item.value);
|
|
1072
|
+
selectList.onCancel = () => done(null);
|
|
1073
|
+
container.addChild(selectList);
|
|
1074
|
+
|
|
1075
|
+
container.addChild(new Text(
|
|
1076
|
+
`${theme.fg("dim", "↑↓ navigate · enter select · esc back")}`,
|
|
1077
|
+
1, 0,
|
|
1078
|
+
));
|
|
1079
|
+
|
|
1080
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
1081
|
+
|
|
1082
|
+
return {
|
|
1083
|
+
render: (w: number) => container.render(w),
|
|
1084
|
+
invalidate: () => container.invalidate(),
|
|
1085
|
+
handleInput: (data: string) => { selectList.handleInput(data); tui.requestRender(); },
|
|
1086
|
+
};
|
|
1087
|
+
}, { overlay: true });
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Helper: show thread viewer as overlay so editor remains visible.
|
|
1091
|
+
// Uses dynamic thread list + store subscriptions for live progress.
|
|
1092
|
+
// Ctrl+P opens picker overlay to jump to any thread.
|
|
1093
|
+
async function showThreadViewer(
|
|
1094
|
+
ctx: { ui: { custom: <T>(factory: any, opts?: any) => Promise<T> } },
|
|
1095
|
+
_threads: SubagentThread[],
|
|
1096
|
+
startIndex: number,
|
|
1097
|
+
): Promise<void> {
|
|
1098
|
+
let currentIndex = startIndex;
|
|
1099
|
+
|
|
1100
|
+
// Resolve thread list dynamically
|
|
1101
|
+
const getThreads = () => threadStore.getAllThreads();
|
|
1102
|
+
|
|
1103
|
+
// Overlay mode: viewer appears above editor, Esc dismisses
|
|
1104
|
+
await ctx.ui.custom<void>((tui: any, theme: any, _kb: any, done: () => void) => {
|
|
1105
|
+
let unsubscribe: (() => void) | undefined;
|
|
1106
|
+
let closed = false;
|
|
1107
|
+
|
|
1108
|
+
const cleanup = () => {
|
|
1109
|
+
if (unsubscribe) {
|
|
1110
|
+
unsubscribe();
|
|
1111
|
+
unsubscribe = undefined;
|
|
1112
|
+
}
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1115
|
+
const close = () => {
|
|
1116
|
+
if (closed) return;
|
|
1117
|
+
closed = true;
|
|
1118
|
+
cleanup();
|
|
1119
|
+
activeViewerDone = null;
|
|
1120
|
+
done();
|
|
1121
|
+
};
|
|
1122
|
+
|
|
1123
|
+
// Track this viewer so /agent can close it before opening a new one
|
|
1124
|
+
activeViewerDone = close;
|
|
1125
|
+
|
|
1126
|
+
function makeCallbacks(): ThreadViewerCallbacks {
|
|
1127
|
+
const list = getThreads();
|
|
1128
|
+
return {
|
|
1129
|
+
onClose: close,
|
|
1130
|
+
onPrev: () => {
|
|
1131
|
+
const current = getThreads();
|
|
1132
|
+
if (currentIndex > 0) {
|
|
1133
|
+
currentIndex--;
|
|
1134
|
+
viewer.setThread(current[currentIndex], makeCallbacks());
|
|
1135
|
+
tui.requestRender();
|
|
1136
|
+
}
|
|
1137
|
+
},
|
|
1138
|
+
onNext: () => {
|
|
1139
|
+
const current = getThreads();
|
|
1140
|
+
if (currentIndex < current.length - 1) {
|
|
1141
|
+
currentIndex++;
|
|
1142
|
+
viewer.setThread(current[currentIndex], makeCallbacks());
|
|
1143
|
+
tui.requestRender();
|
|
1144
|
+
}
|
|
1145
|
+
},
|
|
1146
|
+
hasPrev: currentIndex > 0,
|
|
1147
|
+
hasNext: currentIndex < list.length - 1,
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const list = getThreads();
|
|
1152
|
+
if (list.length === 0 || currentIndex < 0 || currentIndex >= list.length) {
|
|
1153
|
+
close();
|
|
1154
|
+
return {
|
|
1155
|
+
render: (_w: number) => [],
|
|
1156
|
+
invalidate: () => {},
|
|
1157
|
+
handleInput: (_data: string) => {},
|
|
1158
|
+
dispose: () => {
|
|
1159
|
+
cleanup();
|
|
1160
|
+
if (activeViewerDone === close) activeViewerDone = null;
|
|
1161
|
+
closed = true;
|
|
1162
|
+
},
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const viewer = new ThreadViewer(list[currentIndex], makeCallbacks(), theme);
|
|
1167
|
+
let pickerOpen = false;
|
|
1168
|
+
|
|
1169
|
+
// Subscribe to thread store for live updates (after viewer is created)
|
|
1170
|
+
unsubscribe = threadStore.subscribe(() => {
|
|
1171
|
+
const current = getThreads();
|
|
1172
|
+
if (current.length === 0) {
|
|
1173
|
+
close();
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
currentIndex = Math.min(currentIndex, current.length - 1);
|
|
1177
|
+
viewer.setThread(current[currentIndex], makeCallbacks());
|
|
1178
|
+
tui.requestRender();
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
return {
|
|
1182
|
+
render: (w: number) => viewer.render(w),
|
|
1183
|
+
invalidate: () => viewer.invalidate(),
|
|
1184
|
+
handleInput: (data: string) => {
|
|
1185
|
+
// Ctrl+P opens the picker to jump between threads
|
|
1186
|
+
if (data === "\x10") {
|
|
1187
|
+
if (!pickerOpen) {
|
|
1188
|
+
pickerOpen = true;
|
|
1189
|
+
openThreadPicker().finally(() => { pickerOpen = false; });
|
|
1190
|
+
}
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
viewer.handleInput(data);
|
|
1194
|
+
tui.requestRender();
|
|
1195
|
+
},
|
|
1196
|
+
dispose: () => {
|
|
1197
|
+
cleanup();
|
|
1198
|
+
if (activeViewerDone === close) activeViewerDone = null;
|
|
1199
|
+
closed = true;
|
|
1200
|
+
},
|
|
1201
|
+
};
|
|
1202
|
+
|
|
1203
|
+
// Opens picker overlay on top of viewer to jump to any thread
|
|
1204
|
+
async function openThreadPicker() {
|
|
1205
|
+
const items = buildPickerItems(getThreads());
|
|
1206
|
+
const selectedId = await showAgentPicker(ctx, items);
|
|
1207
|
+
if (!selectedId) return;
|
|
1208
|
+
if (selectedId === "__main__") { close(); return; }
|
|
1209
|
+
const idx = getThreads().findIndex((t) => t.id === selectedId);
|
|
1210
|
+
if (idx >= 0) {
|
|
1211
|
+
currentIndex = idx;
|
|
1212
|
+
viewer.setThread(getThreads()[currentIndex], makeCallbacks());
|
|
1213
|
+
tui.requestRender();
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
}, { overlay: true, overlayOptions: { maxHeight: "70%" } }); // Overlay: editor stays visible below
|
|
1217
|
+
}
|
|
1218
|
+
}
|