@bacnh85/pi-subagent 0.14.1 → 0.15.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/CHANGELOG.md +71 -0
- package/README.md +35 -2
- package/agents/planner.md +1 -1
- package/agents/reviewer.md +1 -1
- package/extensions/background.ts +351 -0
- package/extensions/history.ts +117 -0
- package/extensions/index.ts +280 -18
- package/extensions/render.ts +62 -31
- package/extensions/result.ts +109 -0
- package/extensions/runner.ts +3 -0
- package/extensions/security.ts +24 -1
- package/extensions/widget.ts +338 -0
- package/package.json +5 -1
package/extensions/index.ts
CHANGED
|
@@ -61,6 +61,16 @@ import { type SubagentThread, threadStore } from "./threads.ts";
|
|
|
61
61
|
import { SUBAGENT_REQUEST_EVENT, runNamedAgent, type SubagentRunRequest } from "./service.ts";
|
|
62
62
|
import { resolveModel } from "./model.ts";
|
|
63
63
|
import { ThreadViewer, type ThreadViewerCallbacks } from "./thread-viewer.ts";
|
|
64
|
+
import { createTaskWidgetController, renderLiveThreadLine, type TaskWidgetController } from "./widget.ts";
|
|
65
|
+
import {
|
|
66
|
+
startBackgroundTask,
|
|
67
|
+
cancelBackgroundTask,
|
|
68
|
+
getBackgroundTask,
|
|
69
|
+
snapshotTask,
|
|
70
|
+
clearBackgroundTasks,
|
|
71
|
+
} from "./background.ts";
|
|
72
|
+
import { parseStructuredResult } from "./result.ts";
|
|
73
|
+
import { appendHistory, readHistory, markInterruptedOnRestart, trimHistory, getHistoryPath } from "./history.ts";
|
|
64
74
|
|
|
65
75
|
// ---------------------------------------------------------------------------
|
|
66
76
|
// Constants
|
|
@@ -85,6 +95,9 @@ function getTrustedConfig(ctx: ExtensionContext): { allowUnconfirmedProjectAgent
|
|
|
85
95
|
};
|
|
86
96
|
}
|
|
87
97
|
|
|
98
|
+
/** Session-scoped approvals for project-local agents ("Trust for this session"). */
|
|
99
|
+
const trustedProjectAgentDirs = new Set<string>();
|
|
100
|
+
|
|
88
101
|
|
|
89
102
|
// ---------------------------------------------------------------------------
|
|
90
103
|
// Tool parameter schema
|
|
@@ -111,6 +124,20 @@ const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
|
|
|
111
124
|
});
|
|
112
125
|
|
|
113
126
|
const SubagentParams = Type.Object({
|
|
127
|
+
operation: Type.Optional(
|
|
128
|
+
Type.Union([Type.Literal("status"), Type.Literal("cancel")], {
|
|
129
|
+
description: 'Task control: inspect ("status") or cancel ("cancel") an existing task by taskId, without starting a new agent. Omit for normal start/resume.',
|
|
130
|
+
}),
|
|
131
|
+
),
|
|
132
|
+
taskId: Type.Optional(
|
|
133
|
+
Type.String({ description: "Existing background task id, for operation: status/cancel" }),
|
|
134
|
+
),
|
|
135
|
+
background: Type.Optional(
|
|
136
|
+
Type.Boolean({
|
|
137
|
+
description: "Run async (single mode only). You will be notified on completion — DO NOT poll or sleep. Default: false.",
|
|
138
|
+
default: false,
|
|
139
|
+
}),
|
|
140
|
+
),
|
|
114
141
|
agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (single mode)" })),
|
|
115
142
|
task: Type.Optional(Type.String({ description: "Task to delegate (single mode)" })),
|
|
116
143
|
tasks: Type.Optional(
|
|
@@ -140,6 +167,8 @@ interface SubagentDetails {
|
|
|
140
167
|
agentScope: AgentScope;
|
|
141
168
|
projectAgentsDir: string | null;
|
|
142
169
|
results: SubAgentResult[];
|
|
170
|
+
/** Set when a background task was started (single mode + background:true). */
|
|
171
|
+
backgroundTaskId?: string;
|
|
143
172
|
}
|
|
144
173
|
|
|
145
174
|
// ---------------------------------------------------------------------------
|
|
@@ -149,11 +178,31 @@ interface SubagentDetails {
|
|
|
149
178
|
export default function (pi: ExtensionAPI) {
|
|
150
179
|
let currentCtx: ExtensionContext | undefined;
|
|
151
180
|
|
|
181
|
+
// Live progress widget — fed by threadStore subscriptions (per SDK event).
|
|
182
|
+
const widget: TaskWidgetController = createTaskWidgetController(
|
|
183
|
+
() => threadStore.getAllThreads(),
|
|
184
|
+
(listener) => threadStore.subscribe(listener),
|
|
185
|
+
);
|
|
186
|
+
|
|
152
187
|
// Invalidate agent cache + clear thread store on session replacement.
|
|
153
188
|
pi.on("session_start", (event, ctx) => {
|
|
154
189
|
currentCtx = ctx;
|
|
155
190
|
if (event.reason === "reload") invalidateAgentCache();
|
|
156
191
|
threadStore.clear();
|
|
192
|
+
trustedProjectAgentDirs.clear();
|
|
193
|
+
// Clear any widget from a prior session.
|
|
194
|
+
widget.clearWidgetIfIdle();
|
|
195
|
+
// Mark prior-session running tasks as interrupted (we can't resume them).
|
|
196
|
+
// ponytail: honest about the in-process ceiling — no live-session resume.
|
|
197
|
+
try {
|
|
198
|
+
markInterruptedOnRestart(path.join(ctx.cwd, CONFIG_DIR_NAME));
|
|
199
|
+
} catch { /* history file not writable — non-fatal */ }
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Clear the widget + abort background tasks on shutdown.
|
|
203
|
+
pi.on("session_shutdown", () => {
|
|
204
|
+
widget.dispose();
|
|
205
|
+
clearBackgroundTasks();
|
|
157
206
|
});
|
|
158
207
|
|
|
159
208
|
// Resolve bundled agents directory relative to this extension file
|
|
@@ -203,6 +252,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
203
252
|
return;
|
|
204
253
|
}
|
|
205
254
|
const thread = threadStore.createThread({ agentName: agent.name, task: request.task, mode: "single", color: agent.color ? AGENT_TO_THEME_COLOR[agent.color as AgentColor] : undefined });
|
|
255
|
+
if (ctx.mode === "tui") widget.ensureWidget(ctx);
|
|
206
256
|
void runNamedAgent({
|
|
207
257
|
agent: request.readOnly ? { ...agent, tools: ["read", "grep", "find", "ls"] } : agent,
|
|
208
258
|
task: request.task,
|
|
@@ -231,13 +281,75 @@ export default function (pi: ExtensionAPI) {
|
|
|
231
281
|
});
|
|
232
282
|
});
|
|
233
283
|
|
|
234
|
-
//
|
|
284
|
+
// Register renderer for background-task completion (follow-up turn).
|
|
285
|
+
pi.registerMessageRenderer?.("pi-subagent-complete", (message, _opts, theme) => {
|
|
286
|
+
const d = (message.details ?? {}) as {
|
|
287
|
+
agent?: string; status?: string; summary?: string; full_output?: string;
|
|
288
|
+
elapsed_ms?: number; model?: string; usage?: { turns?: number; cost?: number };
|
|
289
|
+
};
|
|
290
|
+
const fg = theme.fg.bind(theme);
|
|
291
|
+
const isErr = d.status && d.status !== "completed";
|
|
292
|
+
const icon = isErr ? fg("error", "✗") : fg("success", "✓");
|
|
293
|
+
const container = new Container();
|
|
294
|
+
const agentColor = "accent";
|
|
295
|
+
container.addChild(new Text(
|
|
296
|
+
`${icon} ${fg(agentColor, theme.bold(d.agent ?? "subagent"))} ${fg("muted", `[background · ${d.status ?? "done"}]`)}`,
|
|
297
|
+
0, 0,
|
|
298
|
+
));
|
|
299
|
+
if (d.full_output) {
|
|
300
|
+
const md = new Markdown(d.full_output.trim(), 0, 0, getMarkdownTheme());
|
|
301
|
+
for (const line of md.render(100)) {
|
|
302
|
+
container.addChild(new Text(line, 0, 0));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const usageParts: string[] = [];
|
|
306
|
+
if (d.usage?.turns) usageParts.push(`${d.usage.turns} turn${d.usage.turns > 1 ? "s" : ""}`);
|
|
307
|
+
if (d.usage?.cost) usageParts.push(`$${d.usage.cost.toFixed(4)}`);
|
|
308
|
+
if (d.elapsed_ms) {
|
|
309
|
+
const secs = Math.round(d.elapsed_ms / 1000);
|
|
310
|
+
usageParts.push(`${secs}s`);
|
|
311
|
+
}
|
|
312
|
+
if (d.model) usageParts.push(d.model);
|
|
313
|
+
if (usageParts.length > 0) {
|
|
314
|
+
container.addChild(new Text(fg("dim", usageParts.join(" · ")), 0, 0));
|
|
315
|
+
}
|
|
316
|
+
return container;
|
|
317
|
+
});
|
|
235
318
|
pi.registerCommand("subagent", {
|
|
236
319
|
description: "List available sub-agents, reload agent definitions, or show agent details",
|
|
237
320
|
handler: async (args, ctx) => {
|
|
238
321
|
const cmd = args.trim().toLowerCase();
|
|
239
322
|
const discovery = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
|
|
240
323
|
|
|
324
|
+
// /subagent history — list recent task delegations (durable metadata).
|
|
325
|
+
if (cmd === "history" || cmd === "hist") {
|
|
326
|
+
const piDir = path.join(ctx.cwd, CONFIG_DIR_NAME);
|
|
327
|
+
const entries = readHistory(piDir)
|
|
328
|
+
.sort((a, b) => (b.completedAt ?? b.startedAt) - (a.completedAt ?? a.startedAt))
|
|
329
|
+
.slice(0, 20);
|
|
330
|
+
if (entries.length === 0) {
|
|
331
|
+
pi.sendMessage({
|
|
332
|
+
customType: "pi-subagent",
|
|
333
|
+
content: "No task history yet. History is recorded when subagent tasks complete.",
|
|
334
|
+
display: true,
|
|
335
|
+
});
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const lines = entries.map((e) => {
|
|
339
|
+
const time = new Date(e.startedAt).toLocaleString();
|
|
340
|
+
const statusIcon = e.status === "completed" ? "✓" : e.status === "interrupted" ? "⚠" : "✗";
|
|
341
|
+
const bg = e.background ? " [bg]" : "";
|
|
342
|
+
const summary = e.summary ? ` — ${e.summary.slice(0, 60)}` : "";
|
|
343
|
+
return ` ${statusIcon} ${e.agent}${bg} · ${time}${summary}`;
|
|
344
|
+
});
|
|
345
|
+
pi.sendMessage({
|
|
346
|
+
customType: "pi-subagent",
|
|
347
|
+
content: `Recent task history (${entries.length}${entries.length === 20 ? "+" : ""}):\n${lines.join("\n")}\n\nFile: ${getHistoryPath(piDir)}`,
|
|
348
|
+
display: true,
|
|
349
|
+
});
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
241
353
|
if (cmd === "reload" || cmd === "refresh") {
|
|
242
354
|
invalidateAgentCache();
|
|
243
355
|
const fresh = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
|
|
@@ -349,6 +461,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
349
461
|
description: [
|
|
350
462
|
"Delegate tasks to specialized subagents with isolated context (SDK-based, minimal overhead).",
|
|
351
463
|
"Modes: single (agent + task), parallel (tasks array, max 8, 4 concurrent), chain (sequential with {previous}).",
|
|
464
|
+
"Task control: operation \"status\" or \"cancel\" with taskId inspects/cancels an existing background task without starting an agent.",
|
|
465
|
+
"Background: single mode accepts background:true to run detached; completion arrives as a follow-up turn.",
|
|
352
466
|
`Default agent scope is "user" (from ${path.join(getAgentDir(), "agents")}).`,
|
|
353
467
|
`To enable project-local agents in ${CONFIG_DIR_NAME}/agents, set agentScope: "both" or "project".`,
|
|
354
468
|
].join(" "),
|
|
@@ -358,6 +472,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
358
472
|
"Use subagent to delegate work that would flood the main context with search results or file contents.",
|
|
359
473
|
"Modes: single {agent, task}, parallel {tasks: [...]} (max 8, 4 concurrent), chain {chain: [...]} (sequential with {previous}).",
|
|
360
474
|
"Bundled agents: scout (fast recon), tester (verification), worker (implementation), general-purpose (fallback), planner (planning), reviewer (review).",
|
|
475
|
+
"For background single tasks use background:true — you will be notified on completion; DO NOT poll or sleep.",
|
|
476
|
+
"Use operation: \"status\" with taskId to inspect a running/completed background task; operation: \"cancel\" to abort one.",
|
|
361
477
|
"Use /subagent to list all available agents or /subagent <name> for agent details.",
|
|
362
478
|
],
|
|
363
479
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
@@ -404,6 +520,47 @@ export default function (pi: ExtensionAPI) {
|
|
|
404
520
|
};
|
|
405
521
|
}
|
|
406
522
|
|
|
523
|
+
// Control requests (status/cancel) legitimately have no mode — handle
|
|
524
|
+
// them before the mode-count validation rejects them.
|
|
525
|
+
if (params.operation === "status" || params.operation === "cancel") {
|
|
526
|
+
const taskId = params.taskId;
|
|
527
|
+
if (!taskId) {
|
|
528
|
+
return {
|
|
529
|
+
content: [{ type: "text" as const, text: `Missing taskId for operation "${params.operation}". Provide the taskId returned when the task was started.` }],
|
|
530
|
+
details: makeDetails("single")([]),
|
|
531
|
+
isError: true,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
const bgTask = getBackgroundTask(taskId);
|
|
535
|
+
if (params.operation === "status") {
|
|
536
|
+
if (!bgTask) {
|
|
537
|
+
return { content: [{ type: "text" as const, text: `No background task with id "${taskId}".` }], details: makeDetails("single")([]) };
|
|
538
|
+
}
|
|
539
|
+
const snap = snapshotTask(bgTask);
|
|
540
|
+
const lines = [
|
|
541
|
+
`Task ${snap.id} (${snap.agent}): ${snap.status}`,
|
|
542
|
+
`Elapsed: ${Math.round(snap.elapsedMs / 1000)}s`,
|
|
543
|
+
`Task: ${snap.task}`,
|
|
544
|
+
];
|
|
545
|
+
if (snap.result) {
|
|
546
|
+
lines.push(`Output: ${String(snap.result.output).slice(0, 2000)}`);
|
|
547
|
+
} else {
|
|
548
|
+
lines.push("(still running — no final output yet)");
|
|
549
|
+
}
|
|
550
|
+
return { content: [{ type: "text" as const, text: lines.join("\n") }], details: makeDetails("single")([]) };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// cancel
|
|
554
|
+
const result = cancelBackgroundTask(taskId);
|
|
555
|
+
if (result.outcome === "not_found") {
|
|
556
|
+
return { content: [{ type: "text" as const, text: `No background task with id "${taskId}".` }], details: makeDetails("single")([]), isError: true };
|
|
557
|
+
}
|
|
558
|
+
if (result.outcome === "already_done") {
|
|
559
|
+
return { content: [{ type: "text" as const, text: `Task ${taskId} already finished (${result.task?.status}).` }], details: makeDetails("single")([]) };
|
|
560
|
+
}
|
|
561
|
+
return { content: [{ type: "text" as const, text: `Cancelled background task ${taskId}.` }], details: makeDetails("single")([]) };
|
|
562
|
+
}
|
|
563
|
+
|
|
407
564
|
// Validate: exactly one mode
|
|
408
565
|
if (modeCount !== 1) {
|
|
409
566
|
const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
|
|
@@ -438,19 +595,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
438
595
|
|
|
439
596
|
if (projectAgentsRequested.length > 0) {
|
|
440
597
|
if (confirmProjectAgents) {
|
|
441
|
-
|
|
598
|
+
const dir = discovery.projectAgentsDir ?? "(unknown)";
|
|
599
|
+
if (trustedProjectAgentDirs.has(dir)) {
|
|
600
|
+
// Previously approved "Trust for this session" for this agents dir.
|
|
601
|
+
} else if (ctx.hasUI) {
|
|
442
602
|
const names = projectAgentsRequested.map((a) => a.name).join(", ");
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
"
|
|
446
|
-
`Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
|
|
603
|
+
const choice = await ctx.ui.select(
|
|
604
|
+
`Run project-local agents?\n\nAgents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
|
|
605
|
+
["Allow once", "Trust for this session", "Deny"],
|
|
447
606
|
);
|
|
448
|
-
if (
|
|
607
|
+
if (choice !== "Allow once" && choice !== "Trust for this session") {
|
|
449
608
|
return {
|
|
450
609
|
content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
|
|
451
610
|
details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
|
|
452
611
|
};
|
|
453
612
|
}
|
|
613
|
+
if (choice === "Trust for this session") trustedProjectAgentDirs.add(dir);
|
|
454
614
|
} else {
|
|
455
615
|
// Fail closed in headless sessions.
|
|
456
616
|
return {
|
|
@@ -486,6 +646,39 @@ export default function (pi: ExtensionAPI) {
|
|
|
486
646
|
return safe.path;
|
|
487
647
|
}
|
|
488
648
|
|
|
649
|
+
// Helper: record a completed foreground task to the history registry.
|
|
650
|
+
// ponytail: best-effort — history is non-fatal metadata for /subagent history.
|
|
651
|
+
function recordForegroundHistory(
|
|
652
|
+
agentName: string,
|
|
653
|
+
taskText: string,
|
|
654
|
+
result: SubAgentResult,
|
|
655
|
+
startedAt: number,
|
|
656
|
+
background = false,
|
|
657
|
+
): void {
|
|
658
|
+
try {
|
|
659
|
+
const output = getFinalOutput(result.messages) || getResultOutput(result) || "";
|
|
660
|
+
const structured = parseStructuredResult(output);
|
|
661
|
+
const status = isFailedResult(result)
|
|
662
|
+
? result.stopReason === "timeout"
|
|
663
|
+
? "timeout"
|
|
664
|
+
: result.stopReason === "aborted"
|
|
665
|
+
? "aborted"
|
|
666
|
+
: "failed"
|
|
667
|
+
: "completed";
|
|
668
|
+
appendHistory(path.join(ctx.cwd, CONFIG_DIR_NAME), {
|
|
669
|
+
id: `fg-${startedAt.toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
|
|
670
|
+
agent: agentName,
|
|
671
|
+
task: taskText,
|
|
672
|
+
status,
|
|
673
|
+
startedAt,
|
|
674
|
+
completedAt: Date.now(),
|
|
675
|
+
summary: structured.summary,
|
|
676
|
+
background,
|
|
677
|
+
model: result.model,
|
|
678
|
+
});
|
|
679
|
+
} catch { /* history file not writable — non-fatal */ }
|
|
680
|
+
}
|
|
681
|
+
|
|
489
682
|
// Helper: validate and normalise tools for an agent. Returns the effective
|
|
490
683
|
// tool list and whether extensions must be loaded (any non-built-in tool).
|
|
491
684
|
function resolveChildTools(agentTools: string[] | undefined, sandbox?: string, readOnly?: boolean): { tools: string[]; loadExtensions: boolean } {
|
|
@@ -591,9 +784,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
591
784
|
const candidates = getModelCandidates(agent);
|
|
592
785
|
const triedModels: string[] = [];
|
|
593
786
|
|
|
787
|
+
// Transport keep-alive only: resets parent idle timeout so a long child
|
|
788
|
+
// run isn't killed. The visible progress now lives in the live widget;
|
|
789
|
+
// we no longer push the plain "still running…" text.
|
|
594
790
|
const stopHeartbeat = onUpdate ? startHeartbeat(() => {
|
|
595
791
|
onHeartbeat?.();
|
|
596
|
-
onUpdate({ content: [{ type: "text", text:
|
|
792
|
+
onUpdate({ content: [{ type: "text", text: "" }], details: heartbeatDetails?.() ?? makeDetails("single")([]) });
|
|
793
|
+
widget.requestRender();
|
|
597
794
|
}) : undefined;
|
|
598
795
|
try {
|
|
599
796
|
const tryWithFallback = async (): Promise<SubAgentResult> => {
|
|
@@ -713,6 +910,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
713
910
|
toolCallId: _toolCallId,
|
|
714
911
|
color: agentToThemeColor(step.agent),
|
|
715
912
|
});
|
|
913
|
+
if (ctx.mode === "tui") widget.ensureWidget(ctx);
|
|
716
914
|
const result = await runOne(
|
|
717
915
|
step.agent, taskWithContext, step.cwd,
|
|
718
916
|
signal, step.timeout ?? params.timeout,
|
|
@@ -725,6 +923,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
725
923
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
726
924
|
result,
|
|
727
925
|
});
|
|
926
|
+
recordForegroundHistory(step.agent, taskWithContext, result, thread.createdAt);
|
|
728
927
|
results.push(result);
|
|
729
928
|
|
|
730
929
|
const isError = isFailedResult(result);
|
|
@@ -809,6 +1008,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
809
1008
|
color: agentToThemeColor(t.agent),
|
|
810
1009
|
}),
|
|
811
1010
|
);
|
|
1011
|
+
if (ctx.mode === "tui") widget.ensureWidget(ctx);
|
|
812
1012
|
|
|
813
1013
|
const allResults: SubAgentResult[] = new Array(params.tasks.length);
|
|
814
1014
|
// Initialize placeholder results for streaming
|
|
@@ -882,6 +1082,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
882
1082
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
883
1083
|
result,
|
|
884
1084
|
});
|
|
1085
|
+
recordForegroundHistory(t.agent, t.task, result, parallelThreads[index].createdAt);
|
|
885
1086
|
// Early-abort: if this task failed and abortOnFailure is set
|
|
886
1087
|
if (abortOnFailure && isFailedResult(result) && !abortCause) {
|
|
887
1088
|
abortCause = result.stopReason === "timeout" ? "timeout" : "sibling";
|
|
@@ -920,6 +1121,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
920
1121
|
|
|
921
1122
|
// --- Single mode ---
|
|
922
1123
|
if (params.agent && params.task) {
|
|
1124
|
+
// Background: run detached, return receipt immediately, notify on completion.
|
|
1125
|
+
if (params.background) {
|
|
1126
|
+
const { taskId, receipt } = startBackgroundTask({
|
|
1127
|
+
agent: params.agent,
|
|
1128
|
+
task: params.task,
|
|
1129
|
+
cwd: params.cwd,
|
|
1130
|
+
timeout: params.timeout,
|
|
1131
|
+
agentColor: agentToThemeColor(params.agent),
|
|
1132
|
+
toolCallId: _toolCallId,
|
|
1133
|
+
deps: { pi, ctx, runOne, threadStore },
|
|
1134
|
+
});
|
|
1135
|
+
if (ctx.mode === "tui") widget.ensureWidget(ctx);
|
|
1136
|
+
return {
|
|
1137
|
+
content: [{ type: "text", text: receipt }],
|
|
1138
|
+
details: { ...makeDetails("single")([]), backgroundTaskId: taskId },
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
923
1141
|
const thread = threadStore.createThread({
|
|
924
1142
|
agentName: params.agent,
|
|
925
1143
|
task: params.task,
|
|
@@ -927,6 +1145,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
927
1145
|
toolCallId: _toolCallId,
|
|
928
1146
|
color: agentToThemeColor(params.agent),
|
|
929
1147
|
});
|
|
1148
|
+
if (ctx.mode === "tui") widget.ensureWidget(ctx);
|
|
930
1149
|
const result = await runOne(
|
|
931
1150
|
params.agent, params.task, params.cwd,
|
|
932
1151
|
signal, params.timeout,
|
|
@@ -939,6 +1158,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
939
1158
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
940
1159
|
result,
|
|
941
1160
|
});
|
|
1161
|
+
recordForegroundHistory(params.agent, params.task, result, thread.createdAt);
|
|
942
1162
|
const isError = isFailedResult(result);
|
|
943
1163
|
|
|
944
1164
|
if (onUpdate) {
|
|
@@ -981,48 +1201,79 @@ export default function (pi: ExtensionAPI) {
|
|
|
981
1201
|
// TUI rendering
|
|
982
1202
|
// ------------------------------------------------------------------
|
|
983
1203
|
|
|
984
|
-
renderCall(args, theme,
|
|
1204
|
+
renderCall(args, theme, context) {
|
|
985
1205
|
const scope: AgentScope = args.agentScope ?? "user";
|
|
986
1206
|
const fg = theme.fg.bind(theme);
|
|
1207
|
+
const now = Date.now();
|
|
1208
|
+
|
|
1209
|
+
// Live-render driver: while the tool executes, re-render every second
|
|
1210
|
+
// (bash.js pattern) so elapsed + tool-call count stay fresh in the TUI.
|
|
1211
|
+
// The interval lives in shared renderer state, cleared by renderResult.
|
|
1212
|
+
const state = context.state as { interval?: ReturnType<typeof setInterval> };
|
|
1213
|
+
if (context.executionStarted && !state.interval) {
|
|
1214
|
+
state.interval = setInterval(() => context.invalidate(), 1000);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// Look up threads for this tool call (stable toolCallId).
|
|
1218
|
+
const threads = threadStore
|
|
1219
|
+
.getAllThreads()
|
|
1220
|
+
.filter((t) => t.toolCallId === context.toolCallId);
|
|
1221
|
+
const runningThread = threads.find((t) => t.status === "running");
|
|
1222
|
+
|
|
1223
|
+
// Task control (status/cancel) — no agent/task; show the operation.
|
|
1224
|
+
if (args.operation) {
|
|
1225
|
+
return new Text(
|
|
1226
|
+
fg("accent", String(args.operation)) +
|
|
1227
|
+
fg("muted", args.taskId ? ` [${args.taskId}]` : ""),
|
|
1228
|
+
0, 0,
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
987
1231
|
|
|
988
1232
|
// Chain
|
|
989
1233
|
if (args.chain && args.chain.length > 0) {
|
|
990
1234
|
let text =
|
|
991
|
-
fg("toolTitle", theme.bold("subagent ")) +
|
|
992
1235
|
fg("accent", `chain (${args.chain.length} steps)`) +
|
|
993
1236
|
fg("muted", ` [${scope}]`);
|
|
994
1237
|
for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
|
|
995
1238
|
const step = args.chain[i];
|
|
996
1239
|
const cleanTask = step.task.replace(/\{previous\}/g, "").trim();
|
|
997
1240
|
const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask;
|
|
1241
|
+
const stepThread = threads[i];
|
|
1242
|
+
const live = stepThread && stepThread.status === "running"
|
|
1243
|
+
? "\n " + renderLiveThreadLine(stepThread, theme, now, resolveAgentColor(step.agent))
|
|
1244
|
+
: "";
|
|
998
1245
|
text +=
|
|
999
1246
|
"\n " +
|
|
1000
1247
|
fg("muted", `${i + 1}.`) +
|
|
1001
1248
|
" " +
|
|
1002
1249
|
fg(resolveAgentColor(step.agent), step.agent) +
|
|
1003
|
-
fg("dim", ` ${preview}`)
|
|
1250
|
+
fg("dim", ` ${preview}`) +
|
|
1251
|
+
live;
|
|
1004
1252
|
}
|
|
1005
1253
|
if (args.chain.length > 3)
|
|
1006
1254
|
text += `\n ${fg("muted", `... +${args.chain.length - 3} more`)}`;
|
|
1007
1255
|
return new Text(text, 0, 0);
|
|
1008
1256
|
}
|
|
1009
1257
|
|
|
1010
|
-
// Parallel
|
|
1258
|
+
// Parallel — live line per task with a running thread.
|
|
1011
1259
|
if (args.tasks && args.tasks.length > 0) {
|
|
1012
1260
|
let text =
|
|
1013
|
-
fg("toolTitle", theme.bold("subagent ")) +
|
|
1014
1261
|
fg("accent", `parallel (${args.tasks.length} tasks)`) +
|
|
1015
1262
|
fg("muted", ` [${scope}]`);
|
|
1016
1263
|
for (const t of args.tasks.slice(0, 3)) {
|
|
1017
1264
|
const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task;
|
|
1018
|
-
|
|
1265
|
+
const taskThread = threads.find((th) => th.agentName === t.agent && th.task === t.task);
|
|
1266
|
+
const live = taskThread && taskThread.status === "running"
|
|
1267
|
+
? "\n " + renderLiveThreadLine(taskThread, theme, now, resolveAgentColor(t.agent))
|
|
1268
|
+
: "";
|
|
1269
|
+
text += `\n ${fg(resolveAgentColor(t.agent), t.agent)}${fg("dim", ` ${preview}`)}${live}`;
|
|
1019
1270
|
}
|
|
1020
1271
|
if (args.tasks.length > 3)
|
|
1021
1272
|
text += `\n ${fg("muted", `... +${args.tasks.length - 3} more`)}`;
|
|
1022
1273
|
return new Text(text, 0, 0);
|
|
1023
1274
|
}
|
|
1024
1275
|
|
|
1025
|
-
// Single
|
|
1276
|
+
// Single — live header while running, static summary otherwise.
|
|
1026
1277
|
const agentName = args.agent || "...";
|
|
1027
1278
|
const preview = args.task
|
|
1028
1279
|
? args.task.length > 60
|
|
@@ -1030,14 +1281,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
1030
1281
|
: args.task
|
|
1031
1282
|
: "...";
|
|
1032
1283
|
let text =
|
|
1033
|
-
fg("toolTitle", theme.bold("subagent ")) +
|
|
1034
1284
|
fg(resolveAgentColor(agentName), agentName) +
|
|
1035
|
-
fg("muted", ` [${scope}]`)
|
|
1036
|
-
|
|
1285
|
+
fg("muted", ` [${scope}]`) +
|
|
1286
|
+
(args.background ? fg("dim", " bg") : "");
|
|
1287
|
+
if (runningThread) {
|
|
1288
|
+
text += "\n" + renderLiveThreadLine(runningThread, theme, now, resolveAgentColor(agentName));
|
|
1289
|
+
} else {
|
|
1290
|
+
text += `\n ${fg("dim", preview)}`;
|
|
1291
|
+
}
|
|
1037
1292
|
return new Text(text, 0, 0);
|
|
1038
1293
|
},
|
|
1039
1294
|
|
|
1040
1295
|
renderResult(result, { expanded }, theme, _context) {
|
|
1296
|
+
// Stop the live-render interval started by renderCall (shared state).
|
|
1297
|
+
const state = _context.state as { interval?: ReturnType<typeof setInterval> };
|
|
1298
|
+
if (state?.interval) {
|
|
1299
|
+
clearInterval(state.interval);
|
|
1300
|
+
state.interval = undefined;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1041
1303
|
const details = result.details as SubagentDetails | undefined;
|
|
1042
1304
|
if (!details || details.results.length === 0) {
|
|
1043
1305
|
const text = result.content[0];
|
package/extensions/render.ts
CHANGED
|
@@ -10,7 +10,7 @@ import * as os from "node:os";
|
|
|
10
10
|
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
12
12
|
import type { Message } from "@earendil-works/pi-ai";
|
|
13
|
-
import { type SubAgentResult, isFailedResult, getResultOutput } from "./runner.ts";
|
|
13
|
+
import { type SubAgentResult, isFailedResult, getResultOutput, getFinalOutput } from "./runner.ts";
|
|
14
14
|
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
16
16
|
// Safe type guards
|
|
@@ -41,11 +41,24 @@ function formatTokens(count: number): string {
|
|
|
41
41
|
return `${(count / 1000000).toFixed(1)}M`;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Duration formatting (inline to avoid a widget↔render import cycle)
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
function formatMs(ms: number): string {
|
|
49
|
+
if (ms >= 60_000) return `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1_000)}s`;
|
|
50
|
+
if (ms >= 1_000) return `${(ms / 1_000).toFixed(1)}s`;
|
|
51
|
+
return `${ms}ms`;
|
|
52
|
+
}
|
|
53
|
+
|
|
44
54
|
export function formatUsageStats(
|
|
45
55
|
usage: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; contextTokens?: number; turns?: number },
|
|
46
56
|
model?: string,
|
|
57
|
+
opts?: { toolCount?: number; durationMs?: number },
|
|
47
58
|
): string {
|
|
48
59
|
const parts: string[] = [];
|
|
60
|
+
if (opts?.toolCount) parts.push(`${opts.toolCount} toolcall${opts.toolCount > 1 ? "s" : ""}`);
|
|
61
|
+
if (opts?.durationMs && opts.durationMs > 0) parts.push(formatMs(opts.durationMs));
|
|
49
62
|
if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
|
|
50
63
|
if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
|
|
51
64
|
if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
|
|
@@ -154,30 +167,27 @@ export function getDisplayItems(messages: Message[]): DisplayItem[] {
|
|
|
154
167
|
return items;
|
|
155
168
|
}
|
|
156
169
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`);
|
|
172
|
-
for (const item of toShow) {
|
|
173
|
-
if (item.type === "text") {
|
|
174
|
-
const preview = item.text.split("\n").slice(0, 3).join("\n");
|
|
175
|
-
text += `${theme.fg("toolOutput", preview)}\n`;
|
|
176
|
-
} else {
|
|
177
|
-
text += `${theme.fg("muted", "→ ")}${formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`;
|
|
170
|
+
/**
|
|
171
|
+
* First prose line of a markdown answer: skips code fences (delimiter AND
|
|
172
|
+
* interior), ATX headings, blockquotes, bullets, ordered lists, and tables.
|
|
173
|
+
* Returns "" when no prose line exists.
|
|
174
|
+
*/
|
|
175
|
+
function firstProseLine(raw: string): string {
|
|
176
|
+
let inFence = false;
|
|
177
|
+
for (const rawLine of raw.split("\n")) {
|
|
178
|
+
const line = rawLine.trim();
|
|
179
|
+
if (!line) continue;
|
|
180
|
+
// Toggle fenced code blocks (``` or ~~~, optionally with a language tag).
|
|
181
|
+
if (/^[`~]{3,}/.test(line)) {
|
|
182
|
+
inFence = !inFence;
|
|
183
|
+
continue;
|
|
178
184
|
}
|
|
185
|
+
if (inFence) continue;
|
|
186
|
+
// Skip structural markdown lines.
|
|
187
|
+
if (/^(#{1,6}\s|\s*[>|*+-]\s|\s*\d+\.\s|\|)/.test(line)) continue;
|
|
188
|
+
return line;
|
|
179
189
|
}
|
|
180
|
-
return
|
|
190
|
+
return "";
|
|
181
191
|
}
|
|
182
192
|
|
|
183
193
|
// ---------------------------------------------------------------------------
|
|
@@ -192,8 +202,9 @@ export function renderSingleResult(
|
|
|
192
202
|
): Container | Text {
|
|
193
203
|
const isError = isFailedResult(result);
|
|
194
204
|
const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
|
195
|
-
const displayItems = getDisplayItems(result.messages);
|
|
205
|
+
const displayItems = expanded ? getDisplayItems(result.messages) : [];
|
|
196
206
|
const finalOutput = getResultOutput(result);
|
|
207
|
+
const toolCount = result.messages.filter((m) => m.role === "toolResult").length;
|
|
197
208
|
|
|
198
209
|
if (expanded) {
|
|
199
210
|
const mdTheme = getMarkdownTheme();
|
|
@@ -248,7 +259,9 @@ export function renderSingleResult(
|
|
|
248
259
|
return container;
|
|
249
260
|
}
|
|
250
261
|
|
|
251
|
-
// Collapsed
|
|
262
|
+
// Collapsed — compact: icon + agent, answer preview, usage, hint.
|
|
263
|
+
// No tool-call trace here (Claude Code / pi-task style) — the trace lives
|
|
264
|
+
// in Ctrl+O (expanded) and /agent (thread viewer).
|
|
252
265
|
let text = `${icon} ${theme.fg(agentColor ?? "toolTitle", theme.bold(result.agent))}`;
|
|
253
266
|
if (isError && result.stopReason) {
|
|
254
267
|
const reasonColor = result.stopReason === "timeout" ? "warning" : "error";
|
|
@@ -258,16 +271,34 @@ export function renderSingleResult(
|
|
|
258
271
|
const messageColor = result.stopReason === "timeout" ? "warning" : "error";
|
|
259
272
|
text += `\n${theme.fg(messageColor, `Error: ${result.errorMessage}`)}`;
|
|
260
273
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
274
|
+
// Success preview: first prose line of the final answer, skipping markdown
|
|
275
|
+
// structural lines (fences + interior, headings, bullets, tables). Error
|
|
276
|
+
// results show their message once on the Error: line; never echo under ⎿.
|
|
277
|
+
const rawOutput = getFinalOutput(result.messages);
|
|
278
|
+
if (!isError) {
|
|
279
|
+
const prose = firstProseLine(rawOutput);
|
|
280
|
+
if (prose) {
|
|
281
|
+
const preview = prose.length > 200 ? `${prose.slice(0, 197)}...` : prose;
|
|
282
|
+
text += `\n${theme.fg("dim", `⎿ ${preview}`)}`;
|
|
283
|
+
} else if (rawOutput.trim() === "" && displayItems.length === 0) {
|
|
284
|
+
text += `\n${theme.fg("muted", "(no output)")}`;
|
|
285
|
+
} else if (rawOutput.trim() !== "") {
|
|
286
|
+
// All-structural output (headings/bullets/fences only) — still say so.
|
|
287
|
+
text += `\n${theme.fg("dim", "⎿ (markdown answer — Ctrl+O to view)")}`;
|
|
266
288
|
}
|
|
289
|
+
} else if (!result.errorMessage && rawOutput.trim() === "" && !result.stderr) {
|
|
290
|
+
// Error with no message, no stderr, no assistant output — say so.
|
|
291
|
+
// (When errorMessage IS set, the Error: line above already conveys it.)
|
|
292
|
+
text += `\n${theme.fg("muted", "(no output)")}`;
|
|
267
293
|
}
|
|
268
|
-
const usageStr = formatUsageStats(result.usage, result.model);
|
|
294
|
+
const usageStr = formatUsageStats(result.usage, result.model, { toolCount, durationMs: result.durationMs });
|
|
269
295
|
if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
|
|
270
296
|
if (result.patch) text += `\n${theme.fg("success", "🌿 worktree")} (${result.patch.split("\n").length} diff lines)`;
|
|
297
|
+
// Hint: getResultOutput always returns at least "(no output)", so finalOutput
|
|
298
|
+
// is always truthy — show the hint whenever there's any trace to expand.
|
|
299
|
+
if (displayItems.length > 0 || (finalOutput && finalOutput !== "(no output)") || result.messages.length > 0) {
|
|
300
|
+
text += `\n${theme.fg("muted", "(Ctrl+O to expand · /agent for full thread)")}`;
|
|
301
|
+
}
|
|
271
302
|
return new Text(text, 0, 0);
|
|
272
303
|
}
|
|
273
304
|
|