@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.
@@ -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
- // /subagent command list available agents
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
- if (ctx.hasUI) {
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 dir = discovery.projectAgentsDir ?? "(unknown)";
444
- const ok = await ctx.ui.confirm(
445
- "Run project-local agents?",
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 (!ok) {
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: `Subagent ${agentName} is still running…` }], details: heartbeatDetails?.() ?? makeDetails("single")([]) });
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, _context) {
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
- text += `\n ${fg(resolveAgentColor(t.agent), t.agent)}${fg("dim", ` ${preview}`)}`;
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
- text += `\n ${fg("dim", preview)}`;
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];
@@ -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
- // Collapsed renderer
159
- // ---------------------------------------------------------------------------
160
-
161
- const COLLAPSED_ITEM_COUNT = 10;
162
-
163
- function renderDisplayItems(
164
- items: DisplayItem[],
165
- theme: { fg: (c: string, t: string) => string },
166
- limit?: number,
167
- ): string {
168
- const toShow = limit ? items.slice(-limit) : items;
169
- const skipped = limit && items.length > limit ? items.length - limit : 0;
170
- let text = "";
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 text.trimEnd();
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
- else if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
262
- else {
263
- text += `\n${renderDisplayItems(displayItems, theme, COLLAPSED_ITEM_COUNT)}`;
264
- if (displayItems.length > COLLAPSED_ITEM_COUNT) {
265
- text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
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