@ferris1225/pi-subagents 4.1.20 → 4.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -184,13 +184,12 @@ that delivers without another gate.
184
184
  where they are in `~/.pi/agent/pi-subagents-recovery.json`. Every later session
185
185
  start repeats that notice until you remove the artifacts.
186
186
 
187
- ## Threads: wait, resume, stop
187
+ ## Threads: resume, stop
188
188
 
189
189
  Every dispatch returns a stable `#id`, which is the handle for the thread tools:
190
190
 
191
191
  | Tool | What it does |
192
192
  | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
193
- | `subagent_wait` | Block in-turn until already-dispatched run(s) settle and return their results (`id` or prefix; omit for all active runs). |
194
193
  | `subagent_control` | `resume` a parked or settled thread with its full retained context, optionally appending a new `objective`. |
195
194
  | `subagent_stop` | Destructively cancel, deliver the partial output, and retire the thread. Steering and follow-up messages still queued in the child are dropped so nothing can revive it later. |
196
195
 
@@ -198,14 +197,14 @@ Every dispatch returns a stable `#id`, which is the handle for the thread tools:
198
197
  subagent_control({ action: "resume", id: 7, objective: "Finish the tests." });
199
198
  ```
200
199
 
201
- There is deliberately no status or polling tool. Every result delivers itself,
202
- the TUI widget shows what is live, and blocking is event-driven only: `wait:
203
- true` on a dispatch holds the turn for that call's results — the escape hatch
204
- for one-shot `pi -p` parents, which exit at end of turn and would otherwise
205
- never see them and `subagent_wait` does the same for a run that is already
206
- in flight when the turn cannot proceed without it. Neither wait runs on a
207
- timer or a timeout the model picks: a waiter resolves the instant its run
208
- settles, a parked run answers immediately with its resume handle, and
200
+ There is deliberately no status, polling, or wait tool. Every result delivers
201
+ itself as a completion that wakes the main model, so a turn never blocks on a
202
+ running subagent keep working or end the turn, and the completion continues
203
+ it. The one in-turn block is `wait: true` on a dispatch, which holds that call
204
+ until the runs it started settle: the escape hatch for one-shot `pi -p`
205
+ parents, which exit at end of turn and would otherwise never see them. That
206
+ wait runs on no timer and no timeout the model picks: it resolves the instant
207
+ its run settles, a parked run answers immediately with its resume handle, and
209
208
  aborting the turn is the escape hatch. Control operations are all bounded, so
210
209
  they never hang on a generation that is still settling.
211
210
 
@@ -224,28 +223,41 @@ threads that had already finished keep only their delivered result.
224
223
 
225
224
  ## Live status and results
226
225
 
227
- The TUI widget renders one compact line per run in fixed columns — status icon,
228
- right-aligned `#id`, padded agent name, then the task label — so every label
229
- starts at the same column, with the live activity dimmed after ` — ` and one
230
- right-aligned telemetry column (worktree badge, model/thinking, wait state,
231
- elapsed) so times line up at the right edge. A managed workflow adds exactly
232
- one more line: a `└`-connected stage timeline that carries the live stage's
233
- telemetry instead of extra child rows:
226
+ The TUI widget renders one line per participant in fixed identity columns —
227
+ status icon, right-aligned `#id`, padded agent name, then the task label — so
228
+ every label starts at the same column, with the live activity dimmed after
229
+ ` — ` and the rest of the telemetry flowing inline after ` · `: the worktree
230
+ badge, the token flow in the footer vocabulary (`↑` input, `↓` output,
231
+ `R`/`W` cache read/write), cost, the full `provider/model/thinking` ref, the
232
+ wait state, and an elapsed time that always carries seconds. The first line is
233
+ the parent session itself — what the current model is doing right now while
234
+ its agent loop runs. A managed workflow (the automatic review / fix / re-review
235
+ chain) renders as a tree: the parent line carries the workflow-wide token/cost
236
+ totals and total elapsed, and every stage gets its own `├`/`└`-connected row
237
+ with its own model, token flow, and elapsed — settled stages keep the
238
+ telemetry frozen at settlement, the live stage shows its child's model and
239
+ current activity:
234
240
 
235
241
  ```text
236
- #12 worker src/cache.ts wt:a91f3c · 1m42s
237
- implement review read src/auth.ts · 10s
238
- #15 explorer src/models.ts grep fallback haiku-4-5/low · 22s
239
- #23 worker src/config.ts repo lane
240
- #24 worker ↻ tests/config.test.ts queued · 5m02s
242
+ ● pi subagent Implement the login redirect fix · openai/gpt-5/max · 12m06s
243
+ #12 worker src/cache.ts · wt:a91f3c · ↑5.2k ↓41.0k R210.0k W6.1k $1.9400 · 12m06s
244
+ implement · ↑1.0k ↓12.0k R40.0k W1.2k $0.5100 · xai/grok-4/xhigh · 2m41s
245
+ ! review · ↑0.9k ↓6.0k R38.0k W0.9k $0.3300 · openai/gpt-5 · 1m12s
246
+ review fix — edit src/auth.ts · ↑0.2k ↓3.0k R12.0k $0.1200 · openai/gpt-5/medium · 41s
247
+ └ ○ re-review
248
+ ● #15 explorer src/models.ts — grep fallback · ↑1.2k ↓8.4k R31.0k W1.1k $0.0900 · openai/gpt-5-mini/low · 3m07s
249
+ ○ #23 worker src/config.ts · repo lane
250
+ ○ #24 worker ↻ tests/config.test.ts · queued · 5m02s
241
251
  ```
242
252
 
243
- Queued rows state what they actually wait for in the telemetry column —
244
- `queued` for a free process slot, `repo lane` for shared-checkout write
245
- serialization, or `starting` and a resumed thread carries a dim `↻` in its
246
- agent column with its cumulative time. The widget is capped at ten lines: when
247
- many runs are live, the extra ones collapse into a `… +N more` marker so the
248
- editor keeps its space.
253
+ Telemetry drops leftmost-first when a row runs out of width (badge, wait
254
+ state, usage, model) while the elapsed survives every width. Queued rows state
255
+ what they actually wait for `queued` for a free process slot, `repo lane`
256
+ for shared-checkout write serialization, or `starting` and a resumed thread
257
+ carries a dim `↻` in its agent column with its cumulative time. The widget is
258
+ capped at ten lines: when many runs are live, extra roots collapse into a
259
+ `… +N more` marker, and an oversized stage chain keeps a window anchored on
260
+ the live stage so the editor keeps its space.
249
261
 
250
262
  Completions resume the main agent on their own, with a compact block of at most 40
251
263
  lines by default; longer output lands unchanged in a Markdown artifact whose path
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferris1225/pi-subagents",
3
- "version": "4.1.20",
3
+ "version": "4.1.23",
4
4
  "description": "A managed sub-agent team for pi: specialized roles, pre-commit documentation sync, retained threads, auto-fix chains, model fallback, and Git worktree isolation.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -4,6 +4,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { existsSync } from "node:fs";
5
5
  import { loadConfig, saveConfig } from "./config.ts";
6
6
  import { availableModelsInScope, filterUnavailableModelOverrides } from "./models.ts";
7
+ import { formatToolActivity, monitor } from "./monitor.ts";
7
8
  import { announceRecoveryRecords } from "./recovery.ts";
8
9
  import type { SubagentRuntime } from "./runtime.ts";
9
10
  import { installActiveRunsWidget } from "./widget.ts";
@@ -35,7 +36,32 @@ async function migrateUnavailableAgentModels(
35
36
  }
36
37
  }
37
38
 
39
+ /** Track the parent pi session itself as the widget's first row: what the
40
+ * current model is doing while its agent loop runs. Same activity vocabulary
41
+ * as subagent rows (thinking / responding / tool + target), fed by the
42
+ * session's own extension events; the row disappears when the loop settles. */
43
+ function trackMainActivity(pi: ExtensionAPI): void {
44
+ pi.on("agent_start", () => monitor.setMainAgentActive(true));
45
+ pi.on("agent_end", () => monitor.setMainAgentActive(false));
46
+ pi.on("agent_settled", () => monitor.setMainAgentActive(false));
47
+ pi.on("model_select", (event) => monitor.setMainModel(event.model?.id));
48
+ pi.on("thinking_level_select", (event) => monitor.setMainThinking(event.level));
49
+ pi.on("message_update", (event) => {
50
+ if (event.message.role !== "assistant") return;
51
+ const kind = event.assistantMessageEvent.type;
52
+ if (kind === "text_start" || kind === "text_delta") monitor.setMainActivity("responding");
53
+ else if (kind === "thinking_start" || kind === "thinking_delta") monitor.setMainActivity("thinking");
54
+ });
55
+ pi.on("tool_execution_start", (event) =>
56
+ monitor.recordMainToolStart(event.toolName, formatToolActivity(event.toolName, event.args)));
57
+ pi.on("tool_execution_end", (event) => monitor.recordMainToolEnd(event.toolName, event.isError));
58
+ }
59
+
38
60
  export function registerAnnouncements(pi: ExtensionAPI, runtime: SubagentRuntime): void {
61
+ // Registered at extension load (not session_start) so a model selection
62
+ // made during restore is already captured when the widget appears.
63
+ trackMainActivity(pi);
64
+
39
65
  pi.on("session_start", async (_event, ctx) => {
40
66
  if (!existsSync(runtime.configPath)) {
41
67
  ctx.ui.notify(
package/src/dispatch.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  monitor,
31
31
  statusIcon,
32
32
  type RunChainMeta,
33
+ type RunView,
33
34
  type RunWaitReason,
34
35
  type WorkflowStage,
35
36
  type WorkflowStageStatus,
@@ -151,12 +152,14 @@ function withReviewerFixStageAgent(agent: AgentConfig): AgentConfig {
151
152
  };
152
153
  }
153
154
 
154
- /** Event-driven in-turn wait shared by dispatch `wait: true` and
155
- * subagent_wait: hold the turn until every listed run settles, then hand back
156
- * their result blocks. No timer: a waiter resolves the moment its run's result
157
- * registers (children are bounded by the idle watchdog), an already-parked run
158
- * answers immediately with its resume handle, and the turn's abort signal
159
- * remains the escape hatch. */
155
+ /** In-turn wait behind dispatch `wait: true` — the escape hatch for one-shot
156
+ * `pi -p` parents that exit at end of turn: hold the call until every run it
157
+ * started settles, then hand back their result blocks. Interactive sessions
158
+ * never take this path; their results arrive as completion wake-ups. No
159
+ * timer: a waiter resolves the moment its run's result registers (children
160
+ * are bounded by the idle watchdog), an already-parked run answers
161
+ * immediately with its resume handle, and the turn's abort signal remains the
162
+ * escape hatch. */
160
163
  export async function awaitRunResults(
161
164
  runtime: SubagentRuntime,
162
165
  runIds: number[],
@@ -237,18 +240,21 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
237
240
  const environmentRef: { current: DispatchEnvironment | undefined } = { current: undefined };
238
241
 
239
242
  // Finished runs leave the active monitor immediately. Their final findings
240
- // are sent as a custom message that starts a follow-up turn.
243
+ // are sent as a custom message that starts a follow-up turn. Returns the
244
+ // removed row so workflow callers can freeze its exact elapsed time onto
245
+ // the stage projection before the row is gone.
241
246
  const finishRun = (
242
247
  runId: number,
243
248
  status: "done" | "failed",
244
249
  opts?: { silent?: boolean },
245
- ): void => {
250
+ ): RunView | undefined => {
246
251
  monitor.setStatus(runId, status); // stamps endedAt for the elapsed time
247
252
  const run = monitor.removeRun(runId);
248
- if (!run) return; // already finished — stay idempotent
249
- if (opts?.silent || !runtime.sessionActive) return;
253
+ if (!run) return undefined; // already finished — stay idempotent
254
+ if (opts?.silent || !runtime.sessionActive) return run;
250
255
  const icon = status === "done" ? "✓" : "✗";
251
256
  environmentRef.current?.ctx.ui.notify(`${icon} #${run.id} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");
257
+ return run;
252
258
  };
253
259
 
254
260
  // Live sub-agent activity → concise one-line status ("thinking",
@@ -352,7 +358,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
352
358
  agentOverride?: AgentConfig;
353
359
  session?: { sessionId: string; sessionDir: string };
354
360
  } = {},
355
- ): Promise<{ runId: number; result: SingleResult }> => {
361
+ ): Promise<{ runId: number; result: SingleResult; elapsedMs?: number }> => {
356
362
  const discoveredAgent = request.agents.find((candidate) => candidate.name === agentName);
357
363
  if (!discoveredAgent) {
358
364
  throw new Error(`Managed workflow requires enabled agent "${agentName}", but discovery did not provide it.`);
@@ -409,11 +415,11 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
409
415
  runtime.retainSession(result);
410
416
  monitor.setModel(runId, result.model, result.modelFallbackFrom);
411
417
  monitor.setThinking(runId, result.thinking);
412
- finishRun(runId, isFailedResult(result) ? "failed" : "done", { silent: true });
418
+ const finished = finishRun(runId, isFailedResult(result) ? "failed" : "done", { silent: true });
413
419
  runtime.registerRunResult(runId, result);
414
- return { runId, result };
420
+ return { runId, result, elapsedMs: finished?.elapsedMs };
415
421
  } catch (error) {
416
- finishRun(runId, "failed", { silent: true });
422
+ const finished = finishRun(runId, "failed", { silent: true });
417
423
  const errorMessage = error instanceof Error ? error.message : String(error);
418
424
  const crashed: SingleResult = {
419
425
  ...queuedResult(route.agent, task, thinkingLevel),
@@ -471,6 +477,11 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
471
477
  agent: initialStepResult.agent,
472
478
  relation: initialStageRelation,
473
479
  status: workflowStageStatus(initialStepResult),
480
+ // The initial stage is the parent's own run; freeze its telemetry now,
481
+ // before the reopened parent row starts counting workflow-wide time.
482
+ model: initialStepResult.model,
483
+ usage: initialStepResult.usage,
484
+ elapsedMs: monitor.getElapsedMs(request.parentRunId),
474
485
  }];
475
486
  let reviewStage: WorkflowStage | undefined;
476
487
  if (enabled("reviewer")) {
@@ -482,6 +493,14 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
482
493
  };
483
494
  publishWorkflowStages();
484
495
 
496
+ /** Freeze the settled step's telemetry onto its stage: once the child row
497
+ * leaves the monitor, this snapshot is the only per-stage record. */
498
+ const settleStage = (stage: WorkflowStage, step: { result: SingleResult; elapsedMs?: number }): void => {
499
+ stage.model = step.result.model;
500
+ stage.usage = step.result.usage;
501
+ if (step.elapsedMs !== undefined) stage.elapsedMs = step.elapsedMs;
502
+ };
503
+
485
504
  const launchStep = async (
486
505
  agentName: string,
487
506
  task: string,
@@ -504,6 +523,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
504
523
  parentRunId: request.parentRunId,
505
524
  }, stageOptions);
506
525
  stage.status = workflowStageStatus(step.result, relation);
526
+ settleStage(stage, step);
507
527
  publishWorkflowStages();
508
528
  request.rememberLatest(step.result);
509
529
  steps.push({ ...step, relation });
package/src/monitor.ts CHANGED
@@ -38,6 +38,11 @@ export interface WorkflowStage {
38
38
  agent: string;
39
39
  relation: string;
40
40
  status: WorkflowStageStatus;
41
+ /** Telemetry snapshot frozen when the stage settled (the live child row
42
+ * leaves the monitor at that moment); the active stage reads its live child. */
43
+ model?: string;
44
+ usage?: UsageStats;
45
+ elapsedMs?: number;
41
46
  }
42
47
 
43
48
  export function isRunActiveStatus(status: RunStatus): boolean {
@@ -111,6 +116,17 @@ export interface RunChainMeta {
111
116
  waitReason?: RunWaitReason;
112
117
  }
113
118
 
119
+ /** Ephemeral activity of the parent pi model while its agent loop runs: the
120
+ * live model/thinking ref and a one-line "what is it doing now". Not a run —
121
+ * no id, usage, or chain machinery; the view disappears when the loop settles. */
122
+ export interface MainActivity {
123
+ model?: string;
124
+ thinking?: string;
125
+ activity?: string;
126
+ /** Epoch ms when the current agent loop started. */
127
+ activeSince: number;
128
+ }
129
+
114
130
  // ---------------------------------------------------------------------------
115
131
  // Formatting helpers
116
132
  // ---------------------------------------------------------------------------
@@ -289,14 +305,26 @@ function formatTokens(count: number): string {
289
305
  return String(count);
290
306
  }
291
307
 
292
- export function formatUsageCompact(usage: UsageStats): string {
308
+ /** Token flow in the pi-footer vocabulary: ↑input ↓output, R cache-read,
309
+ * W cache-write; zero components are omitted. No cost — callers place it as
310
+ * its own droppable part. */
311
+ export function formatUsageTokens(usage: UsageStats | undefined): string | undefined {
312
+ if (!usage) return undefined;
293
313
  const parts: string[] = [];
294
314
  if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
295
315
  if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
296
316
  if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
297
317
  if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
298
- if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
299
- return parts.join(" ");
318
+ return parts.length > 0 ? parts.join(" ") : undefined;
319
+ }
320
+
321
+ export function formatUsageCompact(usage: UsageStats): string {
322
+ return [formatUsageTokens(usage), usageCostPart(usage)].filter(Boolean).join(" ");
323
+ }
324
+
325
+ /** Cost as its own droppable part: `$0.0421`, undefined when nothing accrued. */
326
+ export function usageCostPart(usage: UsageStats | undefined): string | undefined {
327
+ return usage?.cost ? `$${usage.cost.toFixed(4)}` : undefined;
300
328
  }
301
329
 
302
330
  /** Aggregate usage across several runs (chain steps or a completion group). */
@@ -319,9 +347,11 @@ export function formatDuration(ms: number): string {
319
347
  if (totalSeconds < 60) return `${totalSeconds}s`;
320
348
  const minutes = Math.floor(totalSeconds / 60);
321
349
  const seconds = totalSeconds % 60;
350
+ // Seconds stay visible at every magnitude: a long-running chain is judged
351
+ // by whether it is still moving, and "5s ago" is exactly that signal.
322
352
  if (minutes < 60) return `${minutes}m${String(seconds).padStart(2, "0")}s`;
323
353
  const hours = Math.floor(minutes / 60);
324
- return `${hours}h${String(minutes % 60).padStart(2, "0")}m`;
354
+ return `${hours}h${String(minutes % 60).padStart(2, "0")}m${String(seconds).padStart(2, "0")}s`;
325
355
  }
326
356
 
327
357
  /** Cumulative active time across generations; parked gaps never count. */
@@ -453,6 +483,71 @@ export class MonitorStore {
453
483
  private runs: RunView[] = [];
454
484
  private nextId = 1;
455
485
  private subscribers = new Set<() => void>();
486
+ private mainModel?: string;
487
+ private mainThinking?: string;
488
+ private mainActivity?: string;
489
+ private mainActiveSince?: number;
490
+
491
+ // --- parent pi model activity ------------------------------------------
492
+ // Fed by the parent session's extension events (agent loop, streaming,
493
+ // tool executions); rendered as the widget's first line. Change-guarded so
494
+ // per-token streaming deltas do not flood subscribers.
495
+
496
+ setMainModel(model?: string): void {
497
+ if (!model || this.mainModel === model) return;
498
+ this.mainModel = model;
499
+ this.notify();
500
+ }
501
+
502
+ setMainThinking(thinking?: string): void {
503
+ if (!thinking || this.mainThinking === thinking) return;
504
+ this.mainThinking = thinking;
505
+ this.notify();
506
+ }
507
+
508
+ setMainActivity(text: string): void {
509
+ const activity = sanitizeActivityText(text) || undefined;
510
+ if (!activity || this.mainActivity === activity) return;
511
+ this.mainActivity = activity;
512
+ this.notify();
513
+ }
514
+
515
+ /** Record the main model starting a tool; the activity shows the tool's
516
+ * most telling argument, same vocabulary as subagent rows. */
517
+ recordMainToolStart(toolName: string, activity: string): void {
518
+ const safeToolName = sanitizeActivityText(toolName) || "tool";
519
+ this.setMainActivity(activity || safeToolName);
520
+ }
521
+
522
+ /** Record a failed main-model tool; successful completions keep their last
523
+ * activity until the next model event supplies a better description. */
524
+ recordMainToolEnd(toolName: string, isError: boolean): void {
525
+ if (isError) this.setMainActivity(`✗ ${sanitizeActivityText(toolName) || "tool"} failed`);
526
+ }
527
+
528
+ /** Track the parent agent loop: started at agent_start, cleared when the
529
+ * loop settles (agent_end / agent_settled). */
530
+ setMainAgentActive(active: boolean): void {
531
+ if ((this.mainActiveSince !== undefined) === active) return;
532
+ this.mainActiveSince = active ? Date.now() : undefined;
533
+ this.mainActivity = undefined;
534
+ this.notify();
535
+ }
536
+
537
+ /** Live view of the parent model while its agent loop runs; undefined when idle. */
538
+ getMainActivity(): MainActivity | undefined {
539
+ if (this.mainActiveSince === undefined) return undefined;
540
+ return {
541
+ ...(this.mainModel ? { model: this.mainModel } : {}),
542
+ ...(this.mainThinking ? { thinking: this.mainThinking } : {}),
543
+ ...(this.mainActivity ? { activity: this.mainActivity } : {}),
544
+ activeSince: this.mainActiveSince,
545
+ };
546
+ }
547
+
548
+ isMainAgentActive(): boolean {
549
+ return this.mainActiveSince !== undefined;
550
+ }
456
551
 
457
552
  beginTurn(): void {
458
553
  // Clear finished runs from a previous turn, but keep active and parked
@@ -719,6 +814,10 @@ export class MonitorStore {
719
814
  * finishRun calls from the old session remain safe no-ops. */
720
815
  clear(): void {
721
816
  this.runs = [];
817
+ this.mainModel = undefined;
818
+ this.mainThinking = undefined;
819
+ this.mainActivity = undefined;
820
+ this.mainActiveSince = undefined;
722
821
  this.notify();
723
822
  }
724
823
 
package/src/tools.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  /**
2
- * Thread controls around the subagent runtime: subagent_control (resume),
3
- * subagent_wait (event-driven in-turn wait), and destructive subagent_stop.
4
- * There is no status/poll tool completions carry each result (with an
5
- * on-disk artifact when truncated); `wait: true` on dispatch blocks for runs
6
- * it starts, and subagent_wait blocks for runs already dispatched. Both waits
7
- * resolve the moment a run settles, never on a timer.
2
+ * Thread controls around the subagent runtime: subagent_control (resume) and
3
+ * destructive subagent_stop. There is no status/poll tool — completions carry
4
+ * each result (with an on-disk artifact when truncated) and wake the main
5
+ * model, so waiting is never a tool call; the only in-turn block is `wait:
6
+ * true` on a dispatch, for one-shot parents that exit at end of turn.
8
7
  */
9
8
 
10
9
  import { StringEnum } from "@earendil-works/pi-ai";
@@ -13,11 +12,10 @@ import { Text } from "@earendil-works/pi-tui";
13
12
  import { existsSync } from "node:fs";
14
13
  import { Type } from "typebox";
15
14
  import { DEFAULT_MAX_RESULT_LINES, loadConfig } from "./config.ts";
16
- import { awaitRunResults } from "./dispatch.ts";
17
15
  import { removeThreadRecord } from "./durable.ts";
18
16
  import { formatCompletionBlock, matchRunIds } from "./format.ts";
19
17
  import { emptyUsage } from "./rpc-run.ts";
20
- import { formatTaskSummary, isRunActiveStatus, monitor } from "./monitor.ts";
18
+ import { formatTaskSummary, monitor } from "./monitor.ts";
21
19
  import { persistRecoveryRecords, recoveryRecordFromFinalization } from "./recovery.ts";
22
20
  import type { SubagentRuntime, SubagentThread } from "./runtime.ts";
23
21
  import { CONTROL_QUIESCE_TIMEOUT_MS, projectResultsRoot, quiesced } from "./thread-lifecycle.ts";
@@ -112,56 +110,6 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
112
110
  },
113
111
  });
114
112
 
115
- const SubagentWaitParams = Type.Object({
116
- id: Type.Optional(
117
- Type.String({ description: "Run id or prefix to wait for (see dispatch output). Omit to wait for every active run." }),
118
- ),
119
- });
120
-
121
- pi.registerTool({
122
- name: "subagent_wait",
123
- label: "Subagent Wait",
124
- description:
125
- "Block until dispatched background run(s) settle, then return their results — resolves the moment a run settles, never on a timer.",
126
- promptSnippet: "Block until dispatched runs settle; returns results the moment they finish.",
127
- parameters: SubagentWaitParams,
128
-
129
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
130
- await runtime.durableRestore;
131
- const config = await loadConfig(runtime.configPath);
132
- const active = monitor.getRuns()
133
- .filter((run) => isRunActiveStatus(run.status) || run.status === "parked")
134
- .map((run) => run.id);
135
- const requested = params.id?.trim();
136
- // Settled runs stay addressable: a wait issued just after completion
137
- // returns that result immediately instead of missing the run.
138
- const targets = requested
139
- ? matchRunIds([...new Set([...active, ...runtime.settledRuns.keys()])], requested)
140
- : active;
141
- if (targets.length === 0) {
142
- const activeList = active.map((id) => `#${id}`).join(", ");
143
- return {
144
- content: [{
145
- type: "text",
146
- text: requested
147
- ? `No subagent run matches "${requested}".${activeList ? ` Active runs: ${activeList}.` : ""}`
148
- : "No active subagent runs to wait for.",
149
- }],
150
- details: {},
151
- };
152
- }
153
- const blocks = await awaitRunResults(runtime, targets, signal, config.maxResultLines, ctx.cwd);
154
- return { content: [{ type: "text", text: blocks }], details: {} };
155
- },
156
-
157
- renderCall(args, theme) {
158
- return new Text(`${theme.fg("toolTitle", theme.bold("subagent_wait "))}${theme.fg("accent", args.id ? `#${args.id}` : "all")}`, 0, 0);
159
- },
160
- renderResult(result, _options, theme) {
161
- return renderFirstLine(result, "subagent_wait ", theme);
162
- },
163
- });
164
-
165
113
  // Cancel one or more active runs: aborts the queue controller, which
166
114
  // terminates the child and delivers an aborted result (with whatever partial
167
115
  // output it produced) so the main agent always knows the run stopped.
package/src/widget.ts CHANGED
@@ -2,16 +2,23 @@
2
2
  * Compact, glanceable active-run widget for interactive Pi sessions.
3
3
  *
4
4
  * Layout contract (redesign):
5
- * - Aligned columns: `icon #id agent` pad to the widest displayed id and
6
- * agent so every label starts at the same column; a resumed thread carries
7
- * a dim `↻` inside the agent column.
8
- * - Visual hierarchy: the label (what the run owns) is plain, the live
9
- * activity after ` ` is dim, and all telemetry worktree badge,
10
- * model/thinking, wait state, elapsed is one dim right-aligned column,
11
- * so times and states line up at the right edge.
12
- * - Two lines per managed workflow: the stable parent line plus a `└`-connected
13
- * stage timeline; the live stage's activity/model/elapsed rides right-aligned
14
- * on the timeline line. Internal child rows are not repeated as extra lines.
5
+ * - Aligned identity columns: `icon #id agent` pad to the widest displayed
6
+ * id and agent so every label starts at the same column; a resumed thread
7
+ * carries a dim `↻` inside the agent column.
8
+ * - Multi-line rows flow inline: the telemetry (`provider/model/thinking`,
9
+ * token flow in the pi-footer vocabulary `↑in ↓out R/W cache`, cost, wait
10
+ * state, seconds-precision elapsed) follows the content after ` · ` instead
11
+ * of a right-aligned column no blank padding across a multi-line chain.
12
+ * - Telemetry drops leftmost-first under width pressure (badge, wait, usage,
13
+ * model); the elapsed survives every width.
14
+ * - First line is the parent pi session itself: what the current model is
15
+ * doing right now while its agent loop runs (model/thinking, live activity,
16
+ * loop elapsed), fed by the session's own extension events.
17
+ * - A managed workflow renders as a tree chain under its parent line: one
18
+ * `├`/`└`-connected row per stage, each carrying its own model, token flow,
19
+ * and elapsed — settled stages from the snapshot frozen at settlement, the
20
+ * live stage from its child row. An oversized chain keeps a window anchored
21
+ * on the live stage.
15
22
  * - Queued rows say what they actually wait for ("queued" for a process slot,
16
23
  * "repo lane" for shared-writer serialization, "starting" while the child
17
24
  * process launches) instead of one catch-all "queued".
@@ -20,14 +27,21 @@
20
27
  import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
21
28
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
22
29
  import {
30
+ formatDuration,
23
31
  formatElapsed,
24
32
  formatTaskSummary,
33
+ formatUsageTokens,
25
34
  isRunActiveStatus,
26
35
  monitor,
27
36
  statusIcon,
37
+ sumUsage,
38
+ usageCostPart,
39
+ type MainActivity,
28
40
  type RunView,
29
41
  type WorkflowStage,
42
+ type WorkflowStageStatus,
30
43
  } from "./monitor.ts";
44
+ import type { UsageStats } from "./rpc-run.ts";
31
45
 
32
46
  export const SUBAGENTS_WIDGET_ID = "pi-subagents";
33
47
 
@@ -53,17 +67,13 @@ interface ColumnLayout {
53
67
  agentWidth: number;
54
68
  }
55
69
 
56
- /** Compose one widget line with a right-aligned telemetry column: the left
57
- * side truncates first, the right side stays put so elapsed times and badges
58
- * line up across rows. */
59
- function layoutLine(left: string, right: string, width: number): string {
60
- if (width <= 0) return "";
61
- if (!right) return truncateToWidth(left, width, "…");
62
- const rightWidth = visibleWidth(right);
63
- if (rightWidth >= width) return truncateToWidth(right, width, "");
64
- const leftBudget = width - rightWidth - 1;
65
- const leftText = visibleWidth(left) > leftBudget ? truncateToWidth(left, leftBudget, "…") : left;
66
- return `${leftText}${" ".repeat(Math.max(1, width - visibleWidth(leftText) - rightWidth))}${right}`;
70
+ /** Join left content and telemetry inline `left · telemetry` so a
71
+ * multi-line chain reads as one flowing sentence instead of leaving blank
72
+ * padding across the width; the whole line truncates as a last resort. */
73
+ function composeLine(left: string, tail: string, theme: Theme, width: number): string {
74
+ if (!tail) return truncateToWidth(left, width, "");
75
+ const line = `${left}${theme.fg("dim", SEPARATOR)}${theme.fg("dim", tail)}`;
76
+ return visibleWidth(line) <= width ? line : truncateToWidth(line, width, "…");
67
77
  }
68
78
 
69
79
  /** Short truthful wait word for a queued row, shown in the telemetry column. */
@@ -124,27 +134,48 @@ function identitySegment(run: RunView, theme: Theme, layout: ColumnLayout): stri
124
134
  return `${icon} ${theme.fg("dim", id)} ${name}${resumed}${pad}`;
125
135
  }
126
136
 
127
- /** One primary line per run. Left: identity label activity, where the
128
- * label stays plain and the live activity is dim. Right: one dim telemetry
129
- * column (worktree badge, model/thinking, wait state, elapsed). The activity
130
- * outranks the label when space runs out; the identity and elapsed survive
131
- * every width. */
132
- function primaryLine(run: RunView, theme: Theme, width: number, now: number, layout: ColumnLayout): string {
133
- const dim = (text: string): string => theme.fg("dim", text);
134
- const identity = identitySegment(run, theme, layout);
135
- const elapsed = formatElapsed(run, now);
136
- const modelId = run.model?.split("/").at(-1);
137
+ /** One footer-style usage part: token flow plus accrued cost, dropped as a
138
+ * unit before the model under width pressure. */
139
+ function usagePart(usage: UsageStats | undefined): string | undefined {
140
+ return [formatUsageTokens(usage), usageCostPart(usage)].filter(Boolean).join(" ") || undefined;
141
+ }
142
+
143
+ /** Telemetry tail parts shared by primary and stage rows: badge and wait word
144
+ * first (dropped first under pressure), then the usage part, the model, and
145
+ * the always-surviving elapsed. `usage` defaults to the run's own; the managed
146
+ * workflow parent overrides it with the workflow-wide aggregate. */
147
+ function telemetryTailParts(run: RunView, now: number, usage: UsageStats = run.usage): Array<string | undefined> {
137
148
  // Queued rows omit the model (the route is re-resolved at actual start);
138
- // workflow parents omit it too (each stage owns its own model).
139
- const modelPart = run.status === "queued" || run.managedWorkflow || !modelId
149
+ // workflow parents omit it too (each stage row owns its own model). The
150
+ // full provider/model ref is kept — "which provider served this run" is
151
+ // exactly what a multi-provider session needs to see.
152
+ const modelPart = run.status === "queued" || run.managedWorkflow || !run.model
140
153
  ? undefined
141
- : `${modelId}${run.thinking ? `/${run.thinking}` : ""}`;
154
+ : `${run.model}${run.thinking ? `/${run.thinking}` : ""}`;
142
155
  const badge = run.isolation === "worktree" ? worktreeBadge(run) : undefined;
143
156
  const wait = run.status === "queued" ? waitWord(run) : undefined;
144
- const tailBudget = Math.max(0, width - visibleWidth(identity) - LEFT_MIN_CONTENT);
145
- // Drop order under pressure: badge, then model, then wait word; elapsed
157
+ // Drop order under pressure: badge, wait word, usage, model; elapsed
146
158
  // survives every width the identity leaves room for.
147
- const tail = composeTail([badge, modelPart, wait, elapsed || undefined], tailBudget);
159
+ return [badge, wait, usagePart(usage), modelPart, formatElapsed(run, now) || undefined];
160
+ }
161
+
162
+ /** One primary line per run. Left: identity label — activity, where the
163
+ * label stays plain and the live activity is dim. Telemetry flows inline
164
+ * after ` · ` (worktree badge, token flow, cost, model/thinking, wait state,
165
+ * elapsed). The activity outranks the label when space runs out; the identity
166
+ * and elapsed survive every width. */
167
+ function primaryLine(
168
+ run: RunView,
169
+ theme: Theme,
170
+ width: number,
171
+ now: number,
172
+ layout: ColumnLayout,
173
+ usage: UsageStats = run.usage,
174
+ ): string {
175
+ const dim = (text: string): string => theme.fg("dim", text);
176
+ const identity = identitySegment(run, theme, layout);
177
+ const tailBudget = Math.max(0, width - visibleWidth(identity) - LEFT_MIN_CONTENT);
178
+ const tail = composeTail(telemetryTailParts(run, now, usage), tailBudget);
148
179
 
149
180
  // A chain child rendered at root level (its parent row is gone) keeps its
150
181
  // workflow relation; the templated brief itself would only repeat content.
@@ -159,7 +190,7 @@ function primaryLine(run: RunView, theme: Theme, width: number, now: number, lay
159
190
 
160
191
  const contentBudget = width
161
192
  - visibleWidth(identity)
162
- - (tail ? visibleWidth(tail) + 1 : 0)
193
+ - (tail ? visibleWidth(tail) + visibleWidth(SEPARATOR) : 0)
163
194
  - visibleWidth(IDENTITY_GAP);
164
195
  let content = "";
165
196
  if (contentBudget > 0) {
@@ -185,84 +216,156 @@ function primaryLine(run: RunView, theme: Theme, width: number, now: number, lay
185
216
  }
186
217
 
187
218
  const left = content ? `${identity}${IDENTITY_GAP}${content}` : identity;
188
- return layoutLine(left, tail ? dim(tail) : "", width);
219
+ return composeLine(left, tail, theme, width);
189
220
  }
190
221
 
191
- function workflowStageToken(stage: WorkflowStage, theme: Theme): string {
192
- const content = (icon: string): string => `${icon} ${stage.relation}`;
193
- switch (stage.status) {
222
+ function stageIcon(status: WorkflowStageStatus, theme: Theme): string {
223
+ switch (status) {
194
224
  case "done":
195
- return theme.fg("success", content("✓"));
225
+ return theme.fg("success", "✓");
196
226
  case "active":
197
- return theme.fg("accent", theme.bold(content("●")));
227
+ return theme.fg("accent", theme.bold("●"));
198
228
  case "changes":
199
- return theme.fg("warning", content("!"));
229
+ return theme.fg("warning", "!");
200
230
  case "failed":
201
- return theme.fg("error", content("✗"));
231
+ return theme.fg("error", "✗");
202
232
  default:
203
- return theme.fg("dim", content("○"));
233
+ return theme.fg("dim", "○");
204
234
  }
205
235
  }
206
236
 
207
- /** Stage timeline, sliced from the active (or next actionable) stage when the
208
- * full sequence does not fit, so the current position always survives. */
209
- function timelineSegment(stages: readonly WorkflowStage[], theme: Theme, width: number): string {
210
- const separator = theme.fg("dim", " ─ ");
211
- const render = (items: readonly WorkflowStage[]): string =>
212
- items.map((stage) => workflowStageToken(stage, theme)).join(separator);
213
- const full = render(stages);
214
- if (visibleWidth(full) <= width) return full;
215
- let focusIndex = stages.findIndex((stage) => stage.status === "active");
216
- if (focusIndex === -1) {
217
- focusIndex = stages.findLastIndex((stage) => stage.status === "changes" || stage.status === "failed");
237
+ /** Stage telemetry source: the live child while the stage runs, the frozen
238
+ * snapshot once it has settled. */
239
+ interface StageTelemetry {
240
+ model?: string;
241
+ thinking?: string;
242
+ usage?: UsageStats;
243
+ elapsed: string;
244
+ activity?: string;
245
+ }
246
+
247
+ function stageTelemetry(
248
+ stage: WorkflowStage,
249
+ live: RunView | undefined,
250
+ now: number,
251
+ ): StageTelemetry {
252
+ if (live) {
253
+ return {
254
+ model: live.model,
255
+ thinking: live.thinking,
256
+ usage: live.usage,
257
+ elapsed: formatElapsed(live, now),
258
+ activity: live.activity?.trim() || undefined,
259
+ };
218
260
  }
219
- if (focusIndex === -1) focusIndex = stages.findIndex((stage) => stage.status === "pending");
220
- if (focusIndex === -1) focusIndex = stages.length - 1;
221
- const omittedPrefix = focusIndex > 0 ? theme.fg("dim", "… ─ ") : "";
222
- return truncateToWidth(`${omittedPrefix}${render(stages.slice(focusIndex))}`, width, "…");
261
+ return { model: stage.model, usage: stage.usage, elapsed: stage.elapsedMs !== undefined ? formatDuration(stage.elapsedMs) : "" };
223
262
  }
224
263
 
225
- /** Timeline line under a managed workflow parent, tied to it with a dim `└`.
226
- * The live stage's telemetry (its activity or model, plus stage elapsed) rides
227
- * right-aligned, so the two workflow lines replace what used to be four
228
- * (parent, timeline, child row, child activity). */
229
- function workflowTimelineLine(
230
- run: RunView,
264
+ /** The parent pi session's own line what the current model is doing right
265
+ * now while its agent loop runs: full model/thinking ref, live activity, loop
266
+ * elapsed. Not a run, so it owns no `#id`; it joins the identity layout so its
267
+ * label column lines up with the run rows. */
268
+ function mainLine(main: MainActivity, theme: Theme, width: number, now: number, layout: ColumnLayout): string {
269
+ const dim = (text: string): string => theme.fg("dim", text);
270
+ const icon = theme.fg("accent", "●");
271
+ const name = theme.fg("accent", theme.bold("pi"));
272
+ const pad = " ".repeat(Math.max(0, layout.agentWidth - visibleWidth("pi")));
273
+ const identity = `${icon} ${" ".repeat(layout.idWidth + 1)}${name}${pad}`;
274
+ const modelPart = main.model ? `${main.model}${main.thinking ? `/${main.thinking}` : ""}` : undefined;
275
+ const elapsed = formatDuration(Math.max(0, now - main.activeSince));
276
+ const tail = composeTail([modelPart, elapsed], Math.max(0, width - visibleWidth(identity) - LEFT_MIN_CONTENT));
277
+ const contentBudget = width
278
+ - visibleWidth(identity)
279
+ - (tail ? visibleWidth(tail) + visibleWidth(SEPARATOR) : 0)
280
+ - visibleWidth(IDENTITY_GAP);
281
+ let left = identity;
282
+ if (main.activity?.trim() && contentBudget >= ACTIVITY_MIN_WIDTH) {
283
+ left = `${identity}${IDENTITY_GAP}${dim(formatTaskSummary(main.activity.trim(), contentBudget))}`;
284
+ }
285
+ return composeLine(left, tail, theme, width);
286
+ }
287
+
288
+ /** One `├`/`└`-connected row per managed-workflow stage: status icon,
289
+ * relation, live activity for the running stage, and its own model/token/
290
+ * cost/elapsed telemetry flowing inline. The chain hangs off the parent line,
291
+ * so who dispatched what stays visible while the auto-fix workflow progresses. */
292
+ function workflowStageLines(
293
+ stages: readonly WorkflowStage[],
231
294
  children: readonly RunView[],
232
295
  theme: Theme,
233
296
  width: number,
234
297
  now: number,
235
- ): string | undefined {
236
- const stages = run.workflowStages;
237
- const indent = ` ${theme.fg("dim", "└")} `;
238
- const budget = width - visibleWidth(indent);
239
- if (!stages || stages.length === 0 || budget <= 0) return undefined;
240
- const child = children.find((candidate) => candidate.status === "running" || candidate.status === "interrupting")
298
+ ): { lines: string[]; activeIndex: number } {
299
+ const live = children.find((candidate) => candidate.status === "running" || candidate.status === "interrupting")
241
300
  ?? children.at(-1);
242
- const childDoing = child?.activity?.trim() || child?.model?.split("/").at(-1) || "";
243
- const childElapsed = child ? formatElapsed(child, now) : "";
244
- const timeline = timelineSegment(stages, theme, budget);
245
- const room = budget - visibleWidth(timeline) - 1;
246
- let tail = "";
247
- if (room >= ACTIVITY_MIN_WIDTH) {
248
- const doingBudget = room - (childElapsed ? visibleWidth(childElapsed) + (childDoing ? visibleWidth(SEPARATOR) : 0) : 0);
249
- const doingText = childDoing && doingBudget >= ACTIVITY_MIN_WIDTH
250
- ? formatTaskSummary(childDoing, doingBudget)
251
- : "";
252
- const parts = [doingText, childElapsed].filter(Boolean);
253
- if (parts.length > 0) tail = theme.fg("dim", parts.join(SEPARATOR));
301
+ const activeIndex = stages.findIndex((stage) => stage.status === "active");
302
+ const lines: string[] = [];
303
+ for (const [index, stage] of stages.entries()) {
304
+ const telemetry = stageTelemetry(stage, index === activeIndex ? live : undefined, now);
305
+ const connector = theme.fg("dim", index === stages.length - 1 ? "└" : "├");
306
+ const indent = ` ${connector} `;
307
+ const budget = width - visibleWidth(indent);
308
+ if (budget <= 0) break;
309
+ const icon = stageIcon(stage.status, theme);
310
+ const label = `${icon} ${stage.relation}`;
311
+ const modelPart = telemetry.model
312
+ ? `${telemetry.model}${telemetry.thinking ? `/${telemetry.thinking}` : ""}`
313
+ : undefined;
314
+ const parts = [
315
+ usagePart(telemetry.usage),
316
+ modelPart,
317
+ telemetry.elapsed || undefined,
318
+ ];
319
+ const tailBudget = Math.max(0, budget - visibleWidth(label) - ACTIVITY_MIN_WIDTH);
320
+ const tail = composeTail(parts, tailBudget);
321
+ const contentBudget = budget - (tail ? visibleWidth(tail) + visibleWidth(SEPARATOR) : 0);
322
+ let left = label;
323
+ if (telemetry.activity && contentBudget - visibleWidth(label) - visibleWidth(ACTIVITY_SEPARATOR) >= ACTIVITY_MIN_WIDTH) {
324
+ const activityBudget = contentBudget - visibleWidth(label) - visibleWidth(ACTIVITY_SEPARATOR);
325
+ left = `${label}${theme.fg("dim", ACTIVITY_SEPARATOR)}${theme.fg("dim", formatTaskSummary(telemetry.activity, activityBudget))}`;
326
+ }
327
+ lines.push(`${indent}${composeLine(left, tail, theme, budget)}`);
254
328
  }
255
- return `${indent}${layoutLine(timeline, tail, budget)}`;
329
+ return { lines, activeIndex };
330
+ }
331
+
332
+ /** Fit one workflow group into the remaining line budget: the primary line
333
+ * always survives, and the stage window anchors on the live stage — settled
334
+ * stages above the window collapse into one `… +N` marker, never the live
335
+ * stage itself. */
336
+ function fitGroupLines(
337
+ primary: string,
338
+ stages: readonly string[],
339
+ activeIndex: number,
340
+ remaining: number,
341
+ theme: Theme,
342
+ ): string[] {
343
+ if (stages.length === 0 || stages.length + 1 <= remaining) return [primary, ...stages];
344
+ const slots = remaining - 1;
345
+ if (slots <= 0) return [primary];
346
+ // Reserve one line for an overflow marker so a cut is always announced.
347
+ const room = Math.max(1, slots - 1);
348
+ const anchor = activeIndex >= 0 ? activeIndex : 0;
349
+ const start = Math.max(0, Math.min(anchor, stages.length - room));
350
+ const window = stages.slice(start, start + room);
351
+ const parts = [
352
+ ...(start > 0 ? [theme.fg("dim", `… +${start}`)] : []),
353
+ ...window,
354
+ ...(start + room < stages.length ? [theme.fg("dim", `… +${stages.length - start - room}`)] : []),
355
+ ];
356
+ while (parts.length > slots) parts.pop();
357
+ return [primary, ...parts];
256
358
  }
257
359
 
258
- /** Render active runs as compact per-run line groups: one line per simple run,
259
- * two per managed workflow. Internal stage children fold into their parent's
260
- * timeline instead of adding rows, and all rows share one column layout. */
360
+ /** Render active runs as compact per-run line groups: the parent model's own
361
+ * line first, then one line per simple run, a tree chain per managed workflow
362
+ * (parent line + one row per stage). All rows share one column layout. */
261
363
  export function formatActiveRunLines(
262
364
  runs: readonly RunView[],
263
365
  theme: Theme,
264
366
  width: number,
265
367
  now: number = Date.now(),
368
+ main?: MainActivity,
266
369
  ): string[] {
267
370
  const active = runs.filter((run) => isRunActiveStatus(run.status));
268
371
  const activeIds = new Set(active.map((run) => run.id));
@@ -279,24 +382,38 @@ export function formatActiveRunLines(
279
382
  }
280
383
  const layout: ColumnLayout = {
281
384
  idWidth: Math.max(...roots.map((root) => visibleWidth(`#${root.id}`)), 0),
282
- agentWidth: Math.max(...roots.map((root) => visibleWidth(agentColumnText(root))), 0),
385
+ agentWidth: Math.max(...roots.map((root) => visibleWidth(agentColumnText(root))), main ? visibleWidth("pi") : 0),
283
386
  };
284
- const groups: string[][] = roots.map((root) => {
285
- const lines = [primaryLine(root, theme, width, now, layout)];
286
- const timeline = root.managedWorkflow
287
- ? workflowTimelineLine(root, childrenOf.get(root.id) ?? [], theme, width, now)
288
- : undefined;
289
- if (timeline) lines.push(timeline);
290
- return lines;
387
+ const groups: Array<{ lines: string[]; activeIndex: number }> = roots.map((root) => {
388
+ const children = childrenOf.get(root.id) ?? [];
389
+ // Workflow-wide tokens/cost on the parent line: every stage snapshot plus
390
+ // the live child (whose snapshot is frozen only at settlement). Without
391
+ // a stage projection the parent's own usage stands in.
392
+ let usage = root.usage;
393
+ if (root.managedWorkflow && root.workflowStages) {
394
+ const settled = root.workflowStages.map((stage) => stage.usage).filter((u): u is UsageStats => Boolean(u));
395
+ const live = children.find((candidate) => candidate.usage.input || candidate.usage.output || candidate.usage.cost);
396
+ usage = sumUsage([...(settled.length > 0 ? settled : [root.usage]), ...(live ? [live.usage] : [])]);
397
+ }
398
+ const lines = [primaryLine(root, theme, width, now, layout, usage)];
399
+ let activeIndex = -1;
400
+ if (root.managedWorkflow && root.workflowStages && root.workflowStages.length > 0) {
401
+ const rendered = workflowStageLines(root.workflowStages, children, theme, width, now);
402
+ lines.push(...rendered.lines);
403
+ activeIndex = rendered.activeIndex;
404
+ }
405
+ return { lines, activeIndex };
291
406
  });
292
407
 
293
408
  const lines: string[] = [];
409
+ if (main && MAX_WIDGET_LINES > 1) {
410
+ lines.push(mainLine(main, theme, width, now, layout));
411
+ }
294
412
  let shownRoots = 0;
295
413
  for (const group of groups) {
296
414
  const remaining = MAX_WIDGET_LINES - 1 - lines.length;
297
415
  if (remaining <= 0) break;
298
- // A group that no longer fits whole keeps its primary line only.
299
- lines.push(...(group.length <= remaining ? group : group.slice(0, remaining)));
416
+ lines.push(...fitGroupLines(group.lines[0]!, group.lines.slice(1), group.activeIndex, remaining, theme));
300
417
  shownRoots++;
301
418
  }
302
419
  const hiddenRoots = roots.length - shownRoots;
@@ -307,13 +424,13 @@ export function formatActiveRunLines(
307
424
  }
308
425
 
309
426
  function hasTickingRun(): boolean {
310
- return monitor.getRuns().some(
311
- (run) => isRunActiveStatus(run.status) && run.activeSince !== undefined,
312
- );
427
+ return monitor.isMainAgentActive()
428
+ || monitor.getRuns().some((run) => isRunActiveStatus(run.status) && run.activeSince !== undefined);
313
429
  }
314
430
 
315
- /** Install the widget for one TUI session. Its timer exists only while at least
316
- * one active run has started and is disposed with the widget. */
431
+ /** Install the widget for one TUI session. Its timer exists only while at
432
+ * least one active run has started or the parent agent loop is running, and
433
+ * is disposed with the widget. */
317
434
  export function installActiveRunsWidget(ctx: Pick<ExtensionContext, "mode" | "ui">): void {
318
435
  if (ctx.mode !== "tui") return;
319
436
  ctx.ui.setWidget(
@@ -341,7 +458,7 @@ export function installActiveRunsWidget(ctx: Pick<ExtensionContext, "mode" | "ui
341
458
  syncTimer();
342
459
 
343
460
  return {
344
- render: (width: number) => formatActiveRunLines(monitor.getRuns(), theme, width),
461
+ render: (width: number) => formatActiveRunLines(monitor.getRuns(), theme, width, Date.now(), monitor.getMainActivity()),
345
462
  invalidate() {},
346
463
  dispose() {
347
464
  disposed = true;