@bacnh85/pi-subagent 0.14.0 → 0.15.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/CHANGELOG.md +71 -0
- package/README.md +34 -1
- package/extensions/background.ts +351 -0
- package/extensions/history.ts +117 -0
- package/extensions/index.ts +267 -12
- 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 +13 -9
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
|
|
@@ -111,6 +121,20 @@ const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
|
|
|
111
121
|
});
|
|
112
122
|
|
|
113
123
|
const SubagentParams = Type.Object({
|
|
124
|
+
operation: Type.Optional(
|
|
125
|
+
Type.Union([Type.Literal("status"), Type.Literal("cancel")], {
|
|
126
|
+
description: 'Task control: inspect ("status") or cancel ("cancel") an existing task by taskId, without starting a new agent. Omit for normal start/resume.',
|
|
127
|
+
}),
|
|
128
|
+
),
|
|
129
|
+
taskId: Type.Optional(
|
|
130
|
+
Type.String({ description: "Existing background task id, for operation: status/cancel" }),
|
|
131
|
+
),
|
|
132
|
+
background: Type.Optional(
|
|
133
|
+
Type.Boolean({
|
|
134
|
+
description: "Run async (single mode only). You will be notified on completion — DO NOT poll or sleep. Default: false.",
|
|
135
|
+
default: false,
|
|
136
|
+
}),
|
|
137
|
+
),
|
|
114
138
|
agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (single mode)" })),
|
|
115
139
|
task: Type.Optional(Type.String({ description: "Task to delegate (single mode)" })),
|
|
116
140
|
tasks: Type.Optional(
|
|
@@ -140,6 +164,8 @@ interface SubagentDetails {
|
|
|
140
164
|
agentScope: AgentScope;
|
|
141
165
|
projectAgentsDir: string | null;
|
|
142
166
|
results: SubAgentResult[];
|
|
167
|
+
/** Set when a background task was started (single mode + background:true). */
|
|
168
|
+
backgroundTaskId?: string;
|
|
143
169
|
}
|
|
144
170
|
|
|
145
171
|
// ---------------------------------------------------------------------------
|
|
@@ -149,11 +175,30 @@ interface SubagentDetails {
|
|
|
149
175
|
export default function (pi: ExtensionAPI) {
|
|
150
176
|
let currentCtx: ExtensionContext | undefined;
|
|
151
177
|
|
|
178
|
+
// Live progress widget — fed by threadStore subscriptions (per SDK event).
|
|
179
|
+
const widget: TaskWidgetController = createTaskWidgetController(
|
|
180
|
+
() => threadStore.getAllThreads(),
|
|
181
|
+
(listener) => threadStore.subscribe(listener),
|
|
182
|
+
);
|
|
183
|
+
|
|
152
184
|
// Invalidate agent cache + clear thread store on session replacement.
|
|
153
185
|
pi.on("session_start", (event, ctx) => {
|
|
154
186
|
currentCtx = ctx;
|
|
155
187
|
if (event.reason === "reload") invalidateAgentCache();
|
|
156
188
|
threadStore.clear();
|
|
189
|
+
// Clear any widget from a prior session.
|
|
190
|
+
widget.clearWidgetIfIdle();
|
|
191
|
+
// Mark prior-session running tasks as interrupted (we can't resume them).
|
|
192
|
+
// ponytail: honest about the in-process ceiling — no live-session resume.
|
|
193
|
+
try {
|
|
194
|
+
markInterruptedOnRestart(path.join(ctx.cwd, CONFIG_DIR_NAME));
|
|
195
|
+
} catch { /* history file not writable — non-fatal */ }
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Clear the widget + abort background tasks on shutdown.
|
|
199
|
+
pi.on("session_shutdown", () => {
|
|
200
|
+
widget.dispose();
|
|
201
|
+
clearBackgroundTasks();
|
|
157
202
|
});
|
|
158
203
|
|
|
159
204
|
// Resolve bundled agents directory relative to this extension file
|
|
@@ -203,6 +248,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
203
248
|
return;
|
|
204
249
|
}
|
|
205
250
|
const thread = threadStore.createThread({ agentName: agent.name, task: request.task, mode: "single", color: agent.color ? AGENT_TO_THEME_COLOR[agent.color as AgentColor] : undefined });
|
|
251
|
+
if (ctx.mode === "tui") widget.ensureWidget(ctx);
|
|
206
252
|
void runNamedAgent({
|
|
207
253
|
agent: request.readOnly ? { ...agent, tools: ["read", "grep", "find", "ls"] } : agent,
|
|
208
254
|
task: request.task,
|
|
@@ -231,13 +277,75 @@ export default function (pi: ExtensionAPI) {
|
|
|
231
277
|
});
|
|
232
278
|
});
|
|
233
279
|
|
|
234
|
-
//
|
|
280
|
+
// Register renderer for background-task completion (follow-up turn).
|
|
281
|
+
pi.registerMessageRenderer?.("pi-subagent-complete", (message, _opts, theme) => {
|
|
282
|
+
const d = (message.details ?? {}) as {
|
|
283
|
+
agent?: string; status?: string; summary?: string; full_output?: string;
|
|
284
|
+
elapsed_ms?: number; model?: string; usage?: { turns?: number; cost?: number };
|
|
285
|
+
};
|
|
286
|
+
const fg = theme.fg.bind(theme);
|
|
287
|
+
const isErr = d.status && d.status !== "completed";
|
|
288
|
+
const icon = isErr ? fg("error", "✗") : fg("success", "✓");
|
|
289
|
+
const container = new Container();
|
|
290
|
+
const agentColor = "accent";
|
|
291
|
+
container.addChild(new Text(
|
|
292
|
+
`${icon} ${fg(agentColor, theme.bold(d.agent ?? "subagent"))} ${fg("muted", `[background · ${d.status ?? "done"}]`)}`,
|
|
293
|
+
0, 0,
|
|
294
|
+
));
|
|
295
|
+
if (d.full_output) {
|
|
296
|
+
const md = new Markdown(d.full_output.trim(), 0, 0, getMarkdownTheme());
|
|
297
|
+
for (const line of md.render(100)) {
|
|
298
|
+
container.addChild(new Text(line, 0, 0));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const usageParts: string[] = [];
|
|
302
|
+
if (d.usage?.turns) usageParts.push(`${d.usage.turns} turn${d.usage.turns > 1 ? "s" : ""}`);
|
|
303
|
+
if (d.usage?.cost) usageParts.push(`$${d.usage.cost.toFixed(4)}`);
|
|
304
|
+
if (d.elapsed_ms) {
|
|
305
|
+
const secs = Math.round(d.elapsed_ms / 1000);
|
|
306
|
+
usageParts.push(`${secs}s`);
|
|
307
|
+
}
|
|
308
|
+
if (d.model) usageParts.push(d.model);
|
|
309
|
+
if (usageParts.length > 0) {
|
|
310
|
+
container.addChild(new Text(fg("dim", usageParts.join(" · ")), 0, 0));
|
|
311
|
+
}
|
|
312
|
+
return container;
|
|
313
|
+
});
|
|
235
314
|
pi.registerCommand("subagent", {
|
|
236
315
|
description: "List available sub-agents, reload agent definitions, or show agent details",
|
|
237
316
|
handler: async (args, ctx) => {
|
|
238
317
|
const cmd = args.trim().toLowerCase();
|
|
239
318
|
const discovery = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
|
|
240
319
|
|
|
320
|
+
// /subagent history — list recent task delegations (durable metadata).
|
|
321
|
+
if (cmd === "history" || cmd === "hist") {
|
|
322
|
+
const piDir = path.join(ctx.cwd, CONFIG_DIR_NAME);
|
|
323
|
+
const entries = readHistory(piDir)
|
|
324
|
+
.sort((a, b) => (b.completedAt ?? b.startedAt) - (a.completedAt ?? a.startedAt))
|
|
325
|
+
.slice(0, 20);
|
|
326
|
+
if (entries.length === 0) {
|
|
327
|
+
pi.sendMessage({
|
|
328
|
+
customType: "pi-subagent",
|
|
329
|
+
content: "No task history yet. History is recorded when subagent tasks complete.",
|
|
330
|
+
display: true,
|
|
331
|
+
});
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const lines = entries.map((e) => {
|
|
335
|
+
const time = new Date(e.startedAt).toLocaleString();
|
|
336
|
+
const statusIcon = e.status === "completed" ? "✓" : e.status === "interrupted" ? "⚠" : "✗";
|
|
337
|
+
const bg = e.background ? " [bg]" : "";
|
|
338
|
+
const summary = e.summary ? ` — ${e.summary.slice(0, 60)}` : "";
|
|
339
|
+
return ` ${statusIcon} ${e.agent}${bg} · ${time}${summary}`;
|
|
340
|
+
});
|
|
341
|
+
pi.sendMessage({
|
|
342
|
+
customType: "pi-subagent",
|
|
343
|
+
content: `Recent task history (${entries.length}${entries.length === 20 ? "+" : ""}):\n${lines.join("\n")}\n\nFile: ${getHistoryPath(piDir)}`,
|
|
344
|
+
display: true,
|
|
345
|
+
});
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
241
349
|
if (cmd === "reload" || cmd === "refresh") {
|
|
242
350
|
invalidateAgentCache();
|
|
243
351
|
const fresh = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
|
|
@@ -349,6 +457,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
349
457
|
description: [
|
|
350
458
|
"Delegate tasks to specialized subagents with isolated context (SDK-based, minimal overhead).",
|
|
351
459
|
"Modes: single (agent + task), parallel (tasks array, max 8, 4 concurrent), chain (sequential with {previous}).",
|
|
460
|
+
"Task control: operation \"status\" or \"cancel\" with taskId inspects/cancels an existing background task without starting an agent.",
|
|
461
|
+
"Background: single mode accepts background:true to run detached; completion arrives as a follow-up turn.",
|
|
352
462
|
`Default agent scope is "user" (from ${path.join(getAgentDir(), "agents")}).`,
|
|
353
463
|
`To enable project-local agents in ${CONFIG_DIR_NAME}/agents, set agentScope: "both" or "project".`,
|
|
354
464
|
].join(" "),
|
|
@@ -358,6 +468,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
358
468
|
"Use subagent to delegate work that would flood the main context with search results or file contents.",
|
|
359
469
|
"Modes: single {agent, task}, parallel {tasks: [...]} (max 8, 4 concurrent), chain {chain: [...]} (sequential with {previous}).",
|
|
360
470
|
"Bundled agents: scout (fast recon), tester (verification), worker (implementation), general-purpose (fallback), planner (planning), reviewer (review).",
|
|
471
|
+
"For background single tasks use background:true — you will be notified on completion; DO NOT poll or sleep.",
|
|
472
|
+
"Use operation: \"status\" with taskId to inspect a running/completed background task; operation: \"cancel\" to abort one.",
|
|
361
473
|
"Use /subagent to list all available agents or /subagent <name> for agent details.",
|
|
362
474
|
],
|
|
363
475
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
@@ -404,6 +516,47 @@ export default function (pi: ExtensionAPI) {
|
|
|
404
516
|
};
|
|
405
517
|
}
|
|
406
518
|
|
|
519
|
+
// Control requests (status/cancel) legitimately have no mode — handle
|
|
520
|
+
// them before the mode-count validation rejects them.
|
|
521
|
+
if (params.operation === "status" || params.operation === "cancel") {
|
|
522
|
+
const taskId = params.taskId;
|
|
523
|
+
if (!taskId) {
|
|
524
|
+
return {
|
|
525
|
+
content: [{ type: "text" as const, text: `Missing taskId for operation "${params.operation}". Provide the taskId returned when the task was started.` }],
|
|
526
|
+
details: makeDetails("single")([]),
|
|
527
|
+
isError: true,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
const bgTask = getBackgroundTask(taskId);
|
|
531
|
+
if (params.operation === "status") {
|
|
532
|
+
if (!bgTask) {
|
|
533
|
+
return { content: [{ type: "text" as const, text: `No background task with id "${taskId}".` }], details: makeDetails("single")([]) };
|
|
534
|
+
}
|
|
535
|
+
const snap = snapshotTask(bgTask);
|
|
536
|
+
const lines = [
|
|
537
|
+
`Task ${snap.id} (${snap.agent}): ${snap.status}`,
|
|
538
|
+
`Elapsed: ${Math.round(snap.elapsedMs / 1000)}s`,
|
|
539
|
+
`Task: ${snap.task}`,
|
|
540
|
+
];
|
|
541
|
+
if (snap.result) {
|
|
542
|
+
lines.push(`Output: ${String(snap.result.output).slice(0, 2000)}`);
|
|
543
|
+
} else {
|
|
544
|
+
lines.push("(still running — no final output yet)");
|
|
545
|
+
}
|
|
546
|
+
return { content: [{ type: "text" as const, text: lines.join("\n") }], details: makeDetails("single")([]) };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// cancel
|
|
550
|
+
const result = cancelBackgroundTask(taskId);
|
|
551
|
+
if (result.outcome === "not_found") {
|
|
552
|
+
return { content: [{ type: "text" as const, text: `No background task with id "${taskId}".` }], details: makeDetails("single")([]), isError: true };
|
|
553
|
+
}
|
|
554
|
+
if (result.outcome === "already_done") {
|
|
555
|
+
return { content: [{ type: "text" as const, text: `Task ${taskId} already finished (${result.task?.status}).` }], details: makeDetails("single")([]) };
|
|
556
|
+
}
|
|
557
|
+
return { content: [{ type: "text" as const, text: `Cancelled background task ${taskId}.` }], details: makeDetails("single")([]) };
|
|
558
|
+
}
|
|
559
|
+
|
|
407
560
|
// Validate: exactly one mode
|
|
408
561
|
if (modeCount !== 1) {
|
|
409
562
|
const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
|
|
@@ -486,6 +639,39 @@ export default function (pi: ExtensionAPI) {
|
|
|
486
639
|
return safe.path;
|
|
487
640
|
}
|
|
488
641
|
|
|
642
|
+
// Helper: record a completed foreground task to the history registry.
|
|
643
|
+
// ponytail: best-effort — history is non-fatal metadata for /subagent history.
|
|
644
|
+
function recordForegroundHistory(
|
|
645
|
+
agentName: string,
|
|
646
|
+
taskText: string,
|
|
647
|
+
result: SubAgentResult,
|
|
648
|
+
startedAt: number,
|
|
649
|
+
background = false,
|
|
650
|
+
): void {
|
|
651
|
+
try {
|
|
652
|
+
const output = getFinalOutput(result.messages) || getResultOutput(result) || "";
|
|
653
|
+
const structured = parseStructuredResult(output);
|
|
654
|
+
const status = isFailedResult(result)
|
|
655
|
+
? result.stopReason === "timeout"
|
|
656
|
+
? "timeout"
|
|
657
|
+
: result.stopReason === "aborted"
|
|
658
|
+
? "aborted"
|
|
659
|
+
: "failed"
|
|
660
|
+
: "completed";
|
|
661
|
+
appendHistory(path.join(ctx.cwd, CONFIG_DIR_NAME), {
|
|
662
|
+
id: `fg-${startedAt.toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
|
|
663
|
+
agent: agentName,
|
|
664
|
+
task: taskText,
|
|
665
|
+
status,
|
|
666
|
+
startedAt,
|
|
667
|
+
completedAt: Date.now(),
|
|
668
|
+
summary: structured.summary,
|
|
669
|
+
background,
|
|
670
|
+
model: result.model,
|
|
671
|
+
});
|
|
672
|
+
} catch { /* history file not writable — non-fatal */ }
|
|
673
|
+
}
|
|
674
|
+
|
|
489
675
|
// Helper: validate and normalise tools for an agent. Returns the effective
|
|
490
676
|
// tool list and whether extensions must be loaded (any non-built-in tool).
|
|
491
677
|
function resolveChildTools(agentTools: string[] | undefined, sandbox?: string, readOnly?: boolean): { tools: string[]; loadExtensions: boolean } {
|
|
@@ -591,9 +777,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
591
777
|
const candidates = getModelCandidates(agent);
|
|
592
778
|
const triedModels: string[] = [];
|
|
593
779
|
|
|
780
|
+
// Transport keep-alive only: resets parent idle timeout so a long child
|
|
781
|
+
// run isn't killed. The visible progress now lives in the live widget;
|
|
782
|
+
// we no longer push the plain "still running…" text.
|
|
594
783
|
const stopHeartbeat = onUpdate ? startHeartbeat(() => {
|
|
595
784
|
onHeartbeat?.();
|
|
596
|
-
onUpdate({ content: [{ type: "text", text:
|
|
785
|
+
onUpdate({ content: [{ type: "text", text: "" }], details: heartbeatDetails?.() ?? makeDetails("single")([]) });
|
|
786
|
+
widget.requestRender();
|
|
597
787
|
}) : undefined;
|
|
598
788
|
try {
|
|
599
789
|
const tryWithFallback = async (): Promise<SubAgentResult> => {
|
|
@@ -713,6 +903,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
713
903
|
toolCallId: _toolCallId,
|
|
714
904
|
color: agentToThemeColor(step.agent),
|
|
715
905
|
});
|
|
906
|
+
if (ctx.mode === "tui") widget.ensureWidget(ctx);
|
|
716
907
|
const result = await runOne(
|
|
717
908
|
step.agent, taskWithContext, step.cwd,
|
|
718
909
|
signal, step.timeout ?? params.timeout,
|
|
@@ -725,6 +916,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
725
916
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
726
917
|
result,
|
|
727
918
|
});
|
|
919
|
+
recordForegroundHistory(step.agent, taskWithContext, result, thread.createdAt);
|
|
728
920
|
results.push(result);
|
|
729
921
|
|
|
730
922
|
const isError = isFailedResult(result);
|
|
@@ -809,6 +1001,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
809
1001
|
color: agentToThemeColor(t.agent),
|
|
810
1002
|
}),
|
|
811
1003
|
);
|
|
1004
|
+
if (ctx.mode === "tui") widget.ensureWidget(ctx);
|
|
812
1005
|
|
|
813
1006
|
const allResults: SubAgentResult[] = new Array(params.tasks.length);
|
|
814
1007
|
// Initialize placeholder results for streaming
|
|
@@ -882,6 +1075,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
882
1075
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
883
1076
|
result,
|
|
884
1077
|
});
|
|
1078
|
+
recordForegroundHistory(t.agent, t.task, result, parallelThreads[index].createdAt);
|
|
885
1079
|
// Early-abort: if this task failed and abortOnFailure is set
|
|
886
1080
|
if (abortOnFailure && isFailedResult(result) && !abortCause) {
|
|
887
1081
|
abortCause = result.stopReason === "timeout" ? "timeout" : "sibling";
|
|
@@ -920,6 +1114,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
920
1114
|
|
|
921
1115
|
// --- Single mode ---
|
|
922
1116
|
if (params.agent && params.task) {
|
|
1117
|
+
// Background: run detached, return receipt immediately, notify on completion.
|
|
1118
|
+
if (params.background) {
|
|
1119
|
+
const { taskId, receipt } = startBackgroundTask({
|
|
1120
|
+
agent: params.agent,
|
|
1121
|
+
task: params.task,
|
|
1122
|
+
cwd: params.cwd,
|
|
1123
|
+
timeout: params.timeout,
|
|
1124
|
+
agentColor: agentToThemeColor(params.agent),
|
|
1125
|
+
toolCallId: _toolCallId,
|
|
1126
|
+
deps: { pi, ctx, runOne, threadStore },
|
|
1127
|
+
});
|
|
1128
|
+
if (ctx.mode === "tui") widget.ensureWidget(ctx);
|
|
1129
|
+
return {
|
|
1130
|
+
content: [{ type: "text", text: receipt }],
|
|
1131
|
+
details: { ...makeDetails("single")([]), backgroundTaskId: taskId },
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
923
1134
|
const thread = threadStore.createThread({
|
|
924
1135
|
agentName: params.agent,
|
|
925
1136
|
task: params.task,
|
|
@@ -927,6 +1138,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
927
1138
|
toolCallId: _toolCallId,
|
|
928
1139
|
color: agentToThemeColor(params.agent),
|
|
929
1140
|
});
|
|
1141
|
+
if (ctx.mode === "tui") widget.ensureWidget(ctx);
|
|
930
1142
|
const result = await runOne(
|
|
931
1143
|
params.agent, params.task, params.cwd,
|
|
932
1144
|
signal, params.timeout,
|
|
@@ -939,6 +1151,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
939
1151
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
940
1152
|
result,
|
|
941
1153
|
});
|
|
1154
|
+
recordForegroundHistory(params.agent, params.task, result, thread.createdAt);
|
|
942
1155
|
const isError = isFailedResult(result);
|
|
943
1156
|
|
|
944
1157
|
if (onUpdate) {
|
|
@@ -981,48 +1194,79 @@ export default function (pi: ExtensionAPI) {
|
|
|
981
1194
|
// TUI rendering
|
|
982
1195
|
// ------------------------------------------------------------------
|
|
983
1196
|
|
|
984
|
-
renderCall(args, theme,
|
|
1197
|
+
renderCall(args, theme, context) {
|
|
985
1198
|
const scope: AgentScope = args.agentScope ?? "user";
|
|
986
1199
|
const fg = theme.fg.bind(theme);
|
|
1200
|
+
const now = Date.now();
|
|
1201
|
+
|
|
1202
|
+
// Live-render driver: while the tool executes, re-render every second
|
|
1203
|
+
// (bash.js pattern) so elapsed + tool-call count stay fresh in the TUI.
|
|
1204
|
+
// The interval lives in shared renderer state, cleared by renderResult.
|
|
1205
|
+
const state = context.state as { interval?: ReturnType<typeof setInterval> };
|
|
1206
|
+
if (context.executionStarted && !state.interval) {
|
|
1207
|
+
state.interval = setInterval(() => context.invalidate(), 1000);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Look up threads for this tool call (stable toolCallId).
|
|
1211
|
+
const threads = threadStore
|
|
1212
|
+
.getAllThreads()
|
|
1213
|
+
.filter((t) => t.toolCallId === context.toolCallId);
|
|
1214
|
+
const runningThread = threads.find((t) => t.status === "running");
|
|
1215
|
+
|
|
1216
|
+
// Task control (status/cancel) — no agent/task; show the operation.
|
|
1217
|
+
if (args.operation) {
|
|
1218
|
+
return new Text(
|
|
1219
|
+
fg("accent", String(args.operation)) +
|
|
1220
|
+
fg("muted", args.taskId ? ` [${args.taskId}]` : ""),
|
|
1221
|
+
0, 0,
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
987
1224
|
|
|
988
1225
|
// Chain
|
|
989
1226
|
if (args.chain && args.chain.length > 0) {
|
|
990
1227
|
let text =
|
|
991
|
-
fg("toolTitle", theme.bold("subagent ")) +
|
|
992
1228
|
fg("accent", `chain (${args.chain.length} steps)`) +
|
|
993
1229
|
fg("muted", ` [${scope}]`);
|
|
994
1230
|
for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
|
|
995
1231
|
const step = args.chain[i];
|
|
996
1232
|
const cleanTask = step.task.replace(/\{previous\}/g, "").trim();
|
|
997
1233
|
const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask;
|
|
1234
|
+
const stepThread = threads[i];
|
|
1235
|
+
const live = stepThread && stepThread.status === "running"
|
|
1236
|
+
? "\n " + renderLiveThreadLine(stepThread, theme, now, resolveAgentColor(step.agent))
|
|
1237
|
+
: "";
|
|
998
1238
|
text +=
|
|
999
1239
|
"\n " +
|
|
1000
1240
|
fg("muted", `${i + 1}.`) +
|
|
1001
1241
|
" " +
|
|
1002
1242
|
fg(resolveAgentColor(step.agent), step.agent) +
|
|
1003
|
-
fg("dim", ` ${preview}`)
|
|
1243
|
+
fg("dim", ` ${preview}`) +
|
|
1244
|
+
live;
|
|
1004
1245
|
}
|
|
1005
1246
|
if (args.chain.length > 3)
|
|
1006
1247
|
text += `\n ${fg("muted", `... +${args.chain.length - 3} more`)}`;
|
|
1007
1248
|
return new Text(text, 0, 0);
|
|
1008
1249
|
}
|
|
1009
1250
|
|
|
1010
|
-
// Parallel
|
|
1251
|
+
// Parallel — live line per task with a running thread.
|
|
1011
1252
|
if (args.tasks && args.tasks.length > 0) {
|
|
1012
1253
|
let text =
|
|
1013
|
-
fg("toolTitle", theme.bold("subagent ")) +
|
|
1014
1254
|
fg("accent", `parallel (${args.tasks.length} tasks)`) +
|
|
1015
1255
|
fg("muted", ` [${scope}]`);
|
|
1016
1256
|
for (const t of args.tasks.slice(0, 3)) {
|
|
1017
1257
|
const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task;
|
|
1018
|
-
|
|
1258
|
+
const taskThread = threads.find((th) => th.agentName === t.agent && th.task === t.task);
|
|
1259
|
+
const live = taskThread && taskThread.status === "running"
|
|
1260
|
+
? "\n " + renderLiveThreadLine(taskThread, theme, now, resolveAgentColor(t.agent))
|
|
1261
|
+
: "";
|
|
1262
|
+
text += `\n ${fg(resolveAgentColor(t.agent), t.agent)}${fg("dim", ` ${preview}`)}${live}`;
|
|
1019
1263
|
}
|
|
1020
1264
|
if (args.tasks.length > 3)
|
|
1021
1265
|
text += `\n ${fg("muted", `... +${args.tasks.length - 3} more`)}`;
|
|
1022
1266
|
return new Text(text, 0, 0);
|
|
1023
1267
|
}
|
|
1024
1268
|
|
|
1025
|
-
// Single
|
|
1269
|
+
// Single — live header while running, static summary otherwise.
|
|
1026
1270
|
const agentName = args.agent || "...";
|
|
1027
1271
|
const preview = args.task
|
|
1028
1272
|
? args.task.length > 60
|
|
@@ -1030,14 +1274,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
1030
1274
|
: args.task
|
|
1031
1275
|
: "...";
|
|
1032
1276
|
let text =
|
|
1033
|
-
fg("toolTitle", theme.bold("subagent ")) +
|
|
1034
1277
|
fg(resolveAgentColor(agentName), agentName) +
|
|
1035
|
-
fg("muted", ` [${scope}]`)
|
|
1036
|
-
|
|
1278
|
+
fg("muted", ` [${scope}]`) +
|
|
1279
|
+
(args.background ? fg("dim", " bg") : "");
|
|
1280
|
+
if (runningThread) {
|
|
1281
|
+
text += "\n" + renderLiveThreadLine(runningThread, theme, now, resolveAgentColor(agentName));
|
|
1282
|
+
} else {
|
|
1283
|
+
text += `\n ${fg("dim", preview)}`;
|
|
1284
|
+
}
|
|
1037
1285
|
return new Text(text, 0, 0);
|
|
1038
1286
|
},
|
|
1039
1287
|
|
|
1040
1288
|
renderResult(result, { expanded }, theme, _context) {
|
|
1289
|
+
// Stop the live-render interval started by renderCall (shared state).
|
|
1290
|
+
const state = _context.state as { interval?: ReturnType<typeof setInterval> };
|
|
1291
|
+
if (state?.interval) {
|
|
1292
|
+
clearInterval(state.interval);
|
|
1293
|
+
state.interval = undefined;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1041
1296
|
const details = result.details as SubagentDetails | undefined;
|
|
1042
1297
|
if (!details || details.results.length === 0) {
|
|
1043
1298
|
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
|
|