@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.
@@ -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
- // /subagent command list available agents
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: `Subagent ${agentName} is still running…` }], details: heartbeatDetails?.() ?? makeDetails("single")([]) });
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, _context) {
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
- text += `\n ${fg(resolveAgentColor(t.agent), t.agent)}${fg("dim", ` ${preview}`)}`;
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
- text += `\n ${fg("dim", preview)}`;
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];
@@ -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