@xynogen/pix-subagent 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-subagent",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Pi tool — planner-driven sub-agents: spawn, fetch, steer scoped child agents",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -36,7 +36,8 @@
36
36
  "access": "public"
37
37
  },
38
38
  "dependencies": {
39
- "@sinclair/typebox": "^0.34.0"
39
+ "@sinclair/typebox": "^0.34.0",
40
+ "@xynogen/pix-data": "*"
40
41
  },
41
42
  "peerDependencies": {
42
43
  "@earendil-works/pi-coding-agent": "*",
@@ -180,6 +180,8 @@ export class AgentManager {
180
180
  abortController,
181
181
  lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
182
182
  compactionCount: 0,
183
+ turnCount: 0,
184
+ maxTurns: options.maxTurns,
183
185
  invocation: options.invocation,
184
186
  };
185
187
  this.agents.set(id, record);
@@ -258,7 +260,10 @@ export class AgentManager {
258
260
  if (activity.type === "end") record.toolUses++;
259
261
  options.onToolActivity?.(activity);
260
262
  },
261
- onTurnEnd: options.onTurnEnd,
263
+ onTurnEnd: (turnCount) => {
264
+ record.turnCount = turnCount;
265
+ options.onTurnEnd?.(turnCount);
266
+ },
262
267
  onTextDelta: options.onTextDelta,
263
268
  onAssistantUsage: (usage) => {
264
269
  addUsage(record.lifetimeUsage, usage);
package/src/index.ts CHANGED
@@ -86,7 +86,6 @@ export default function registerPixSubagent(pi: ExtensionAPI): void {
86
86
  // onComplete — fire subagent-notification nudge for each finished bg agent
87
87
  (record) => {
88
88
  agentActivity.delete(record.id);
89
- widget.markFinished(record.id);
90
89
 
91
90
  if (record.resultConsumed) {
92
91
  widget.update();
@@ -136,8 +135,11 @@ export default function registerPixSubagent(pi: ExtensionAPI): void {
136
135
  widget.update();
137
136
  },
138
137
  4, // maxConcurrent
139
- // onStart
138
+ // onStart — re-arm the 80ms spinner loop. update() clears the timer when
139
+ // the last agent finishes; a fresh spawn mid-turn (no new turn_start) must
140
+ // restart it or the spinner freezes on one frame.
140
141
  (_record) => {
142
+ widget.ensureTimer();
141
143
  widget.update();
142
144
  },
143
145
  );
@@ -2,6 +2,8 @@
2
2
  * Model resolution: exact match ("provider/modelId") with fuzzy fallback.
3
3
  */
4
4
 
5
+ import { lookupBenchmark, lookupModelsDev } from "@xynogen/pix-data";
6
+
5
7
  export interface ModelEntry {
6
8
  id: string;
7
9
  name: string;
@@ -91,8 +93,71 @@ export function resolveModel(
91
93
  return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`;
92
94
  }
93
95
 
94
- /** List available models as "provider/id" strings (for tool-description injection). */
96
+ // ── Enrichment for orchestrator model choice ────────────────────────────────
97
+ // The orchestrator picks a worker model from the `agent` tool description. Bare
98
+ // ids carry no signal, so each line is annotated with bench score, context,
99
+ // price, and a coarse tier — sourced from pix-data (the shared data layer).
100
+
101
+ const SCORE_FRONTIER = 88;
102
+ const SCORE_STRONG = 75;
103
+ const CHEAP_OUTPUT_PRICE = 3; // $/M output tokens — below this counts as cheap
104
+
105
+ /** Context window → compact "200k" / "1M". */
106
+ function fmtCtx(n: number | undefined): string {
107
+ if (!n || n < 1_000) return n ? `${n}` : "";
108
+ if (n >= 1_000_000) {
109
+ const m = n / 1_000_000;
110
+ return `${Number.isInteger(m) ? m : m.toFixed(1).replace(/\.0$/, "")}M ctx`;
111
+ }
112
+ return `${Math.round(n / 1_000)}k ctx`;
113
+ }
114
+
115
+ /** Cost → "$3/$15" (input/output per Mtok), "free", or "" when unknown. */
116
+ function fmtCost(input?: number, output?: number): string {
117
+ if (input == null && output == null) return "";
118
+ const i = input ?? 0;
119
+ const o = output ?? 0;
120
+ if (i === 0 && o === 0) return "free";
121
+ return `$${i}/$${o}`;
122
+ }
123
+
124
+ /** Coarse decision tier from score + output price. */
125
+ function tier(score: number | null | undefined, output?: number): string {
126
+ if (typeof score !== "number") return "";
127
+ if (score >= SCORE_FRONTIER) return "frontier";
128
+ if (score >= SCORE_STRONG) return "strong";
129
+ if (output != null && output <= CHEAP_OUTPUT_PRICE) return "fast-cheap";
130
+ return "basic";
131
+ }
132
+
133
+ /** One enriched line: "provider/id — ⚡95 · 200k ctx · $3/$15 · frontier". */
134
+ function annotate(m: ModelEntry): { line: string; score: number } {
135
+ const dev = lookupModelsDev(m.provider, m.id);
136
+ const bench = lookupBenchmark(m.name ?? m.id);
137
+ const score = bench?.overallScore ?? null;
138
+ const out = bench?.outputPrice ?? dev?.cost?.output;
139
+ const segs = [
140
+ typeof score === "number" ? `⚡${score}` : "",
141
+ fmtCtx(dev?.limit?.context),
142
+ fmtCost(bench?.inputPrice ?? dev?.cost?.input, out),
143
+ tier(score, out),
144
+ ].filter(Boolean);
145
+ const id = `${m.provider}/${m.id}`;
146
+ return {
147
+ line: segs.length ? `${id} — ${segs.join(" · ")}` : id,
148
+ score: score ?? -1,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * List available models, enriched + ranked for orchestrator decisions.
154
+ * Sorted by bench score desc (best first); unscored fall to the bottom
155
+ * alphabetically (preserved by the stable id tiebreak).
156
+ */
95
157
  export function listAvailable(registry: ModelRegistry): string[] {
96
158
  const all = (registry.getAvailable?.() ?? registry.getAll()) as ModelEntry[];
97
- return all.map((m) => `${m.provider}/${m.id}`).sort();
159
+ return all
160
+ .map((m) => ({ ...annotate(m), id: `${m.provider}/${m.id}` }))
161
+ .sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
162
+ .map((r) => r.line);
98
163
  }
package/src/tools.ts CHANGED
@@ -29,7 +29,11 @@ import {
29
29
  import { resolveAgentInvocationConfig } from "./invocation-config.ts";
30
30
  import { resolveModel } from "./model-resolver.ts";
31
31
  import type { AgentInvocation, LifetimeUsage } from "./types.ts";
32
- import { getLifetimeTotal } from "./usage.ts";
32
+ import {
33
+ getLifetimeTotal,
34
+ getSessionContextPercent,
35
+ type SessionLike,
36
+ } from "./usage.ts";
33
37
 
34
38
  // ── Types shared with ui/widget.ts (widget imports from here to avoid circular) ─
35
39
 
@@ -54,6 +58,10 @@ export interface AgentDetails {
54
58
  subagentType: string;
55
59
  toolUses: number;
56
60
  tokens: string;
61
+ /** Context-window utilization 0–100, or null/undefined when unavailable. */
62
+ contextPercent?: number | null;
63
+ /** Raw output tokens — for t/s = outputTokens / durationMs. */
64
+ outputTokens?: number;
57
65
  durationMs: number;
58
66
  status:
59
67
  | "queued"
@@ -106,6 +114,15 @@ export function formatMs(ms: number): string {
106
114
  return `${(ms / 1000).toFixed(1)}s`;
107
115
  }
108
116
 
117
+ /**
118
+ * Output tokens per second over a duration. "" when either input is
119
+ * non-positive (no work / zero elapsed) so callers can skip the segment.
120
+ */
121
+ export function formatSpeed(outputTokens: number, durationMs: number): string {
122
+ if (outputTokens <= 0 || durationMs <= 0) return "";
123
+ return `${Math.round(outputTokens / (durationMs / 1000))} t/s`;
124
+ }
125
+
109
126
  // ── helpers ──────────────────────────────────────────────────────────────────
110
127
 
111
128
  function textResult(msg: string, details?: AgentDetails) {
@@ -137,7 +154,13 @@ function buildStats(d: AgentDetails, theme: Theme): string {
137
154
  parts.push(
138
155
  theme.fg("dim", `${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`),
139
156
  );
140
- if (d.tokens) parts.push(theme.fg("dim", d.tokens));
157
+ if (d.tokens) {
158
+ const pct =
159
+ d.contextPercent != null
160
+ ? ` ${theme.fg("dim", `(${Math.round(d.contextPercent)}%)`)}`
161
+ : "";
162
+ parts.push(theme.fg("dim", d.tokens) + pct);
163
+ }
141
164
  return parts.join(` ${theme.fg("dim", "·")} `);
142
165
  }
143
166
 
@@ -296,12 +319,10 @@ export function createAgentTool(
296
319
 
297
320
  const stats = buildStats(details, theme);
298
321
 
299
- // Streaming / running
322
+ // Streaming / running — live state shown by the ● Agents widget, so the
323
+ // inline transcript stays empty to avoid stacking one card per agent.
300
324
  if (isPartial || details.status === "running") {
301
- const frame = SPINNER[details.spinnerFrame ?? 0];
302
- let line = theme.fg("accent", frame) + (stats ? ` ${stats}` : "");
303
- line += `\n${theme.fg("dim", ` ⎿ ${details.activity ?? "thinking…"}`)}`;
304
- return new Text(line, 0, 0);
325
+ return new Text("", 0, 0);
305
326
  }
306
327
 
307
328
  // Background launched
@@ -316,21 +337,34 @@ export function createAgentTool(
316
337
  );
317
338
  }
318
339
 
319
- // Completed / steered
340
+ // Completed / steered. Collapsed view stays empty — the ● Agents widget
341
+ // carries the full finished line, so the inline transcript doesn't echo
342
+ // it (caller shouldn't output the result). Expanded view still shows the
343
+ // summary + full output on demand.
320
344
  if (details.status === "completed" || details.status === "steered") {
345
+ if (!expanded) return new Text("", 0, 0);
321
346
  const duration = formatMs(details.durationMs);
322
347
  const isSteered = details.status === "steered";
323
348
  const icon = isSteered
324
349
  ? theme.fg("warning", "✓")
325
350
  : theme.fg("success", "✓");
351
+ const speed = formatSpeed(
352
+ details.outputTokens ?? 0,
353
+ details.durationMs,
354
+ );
326
355
  let line =
327
356
  icon +
328
357
  (stats ? ` ${stats}` : "") +
329
358
  " " +
330
359
  theme.fg("dim", "·") +
331
360
  " " +
332
- theme.fg("dim", duration);
361
+ theme.fg("dim", duration) +
362
+ (speed ? ` ${theme.fg("dim", "·")} ${theme.fg("dim", speed)}` : "") +
363
+ // Steered = stopped at turn limit; keep that note inline since the
364
+ // stats alone don't say why it ended.
365
+ (isSteered ? ` ${theme.fg("warning", "(turn limit)")}` : "");
333
366
 
367
+ // Expanded view appends the full result below the one-line summary.
334
368
  if (expanded) {
335
369
  const resultText =
336
370
  result.content[0]?.type === "text" ? result.content[0].text : "";
@@ -345,13 +379,6 @@ export function createAgentTool(
345
379
  " … (use agent_result with verbose for full output)",
346
380
  );
347
381
  }
348
- } else {
349
- line +=
350
- "\n" +
351
- theme.fg(
352
- "dim",
353
- ` ⎿ ${isSteered ? "Wrapped up (turn limit)" : "Done"}`,
354
- );
355
382
  }
356
383
  return new Text(line, 0, 0);
357
384
  }
@@ -806,10 +833,15 @@ function buildDetails(
806
833
  activity?: AgentActivity & { durationMs?: number },
807
834
  ): AgentDetails {
808
835
  const totalTokens = getLifetimeTotal(record.lifetimeUsage);
836
+ const contextPercent = activity?.session
837
+ ? getSessionContextPercent(activity.session as SessionLike)
838
+ : null;
809
839
  return {
810
840
  ...base,
811
841
  toolUses: record.toolUses,
812
842
  tokens: totalTokens > 0 ? formatTokens(totalTokens) : "",
843
+ contextPercent,
844
+ outputTokens: record.lifetimeUsage.output,
813
845
  turnCount: activity?.turnCount,
814
846
  maxTurns: activity?.maxTurns,
815
847
  durationMs:
package/src/types.ts CHANGED
@@ -89,6 +89,11 @@ export interface AgentRecord {
89
89
  lifetimeUsage: LifetimeUsage;
90
90
  /** Number of times this agent's session has compacted. */
91
91
  compactionCount: number;
92
+ /** Cumulative agentic turns. Persisted on the record so the finished widget
93
+ * line stays full after agentActivity is cleared on completion. */
94
+ turnCount: number;
95
+ /** Turn cap, if any — for the ↻N≤M display. */
96
+ maxTurns?: number;
92
97
  /** Resolved spawn params, captured for UI display. */
93
98
  invocation?: AgentInvocation;
94
99
  }
package/src/ui/widget.ts CHANGED
@@ -12,12 +12,22 @@ import { truncateToWidth } from "@earendil-works/pi-tui";
12
12
  import type { AgentManager } from "../agent-manager.ts";
13
13
  import { getConfig } from "../agent-types.ts";
14
14
  import type { AgentActivity, AgentDetails, Theme } from "../tools.ts";
15
- import { formatMs, formatTokens, formatTurns, SPINNER } from "../tools.ts";
15
+ import {
16
+ formatMs,
17
+ formatSpeed,
18
+ formatTokens,
19
+ formatTurns,
20
+ SPINNER,
21
+ } from "../tools.ts";
16
22
  import type { AgentInvocation, SubagentType } from "../types.ts";
17
- import { getLifetimeTotal, getSessionContextPercent } from "../usage.ts";
23
+ import {
24
+ getLifetimeTotal,
25
+ getSessionContextPercent,
26
+ type SessionLike,
27
+ } from "../usage.ts";
18
28
 
19
29
  export type { AgentActivity, AgentDetails, Theme };
20
- export { formatMs, formatTokens, formatTurns, SPINNER };
30
+ export { formatMs, formatSpeed, formatTokens, formatTurns, SPINNER };
21
31
 
22
32
  // ── constants ─────────────────────────────────────────────────────────────────
23
33
 
@@ -97,14 +107,16 @@ export function buildInvocationTags(invocation: AgentInvocation | undefined): {
97
107
  return { modelName: invocation.modelName, tags };
98
108
  }
99
109
 
100
- function truncateLine(text: string, len = 60): string {
101
- const line =
102
- text
103
- .split("\n")
104
- .find((l) => l.trim())
105
- ?.trim() ?? "";
110
+ /**
111
+ * Live tail of agent output: latest non-empty line, tail-anchored to `len`
112
+ * chars (keeps the moving edge, not the stale first line). Leading … marks
113
+ * the clip. e.g. "…ting batch 6".
114
+ */
115
+ function truncateLine(text: string, len = 16): string {
116
+ const lines = text.split("\n").filter((l) => l.trim());
117
+ const line = lines.length ? lines[lines.length - 1].trim() : "";
106
118
  if (line.length <= len) return line;
107
- return `${line.slice(0, len)}…`;
119
+ return `…${line.slice(-len)}`;
108
120
  }
109
121
 
110
122
  export function describeActivity(
@@ -133,8 +145,8 @@ export class AgentWidget {
133
145
  private uiCtx: UICtx | undefined;
134
146
  private widgetFrame = 0;
135
147
  private widgetInterval: ReturnType<typeof setInterval> | undefined;
136
- private finishedTurnAge = new Map<string, number>();
137
- private static readonly ERROR_LINGER_TURNS = 2;
148
+ private static readonly FINISHED_LINGER_MS = 5_000;
149
+ private static readonly ERROR_LINGER_MS = 15_000;
138
150
  private widgetRegistered = false;
139
151
  private tui: unknown = undefined;
140
152
  private lastStatusText: string | undefined;
@@ -154,9 +166,6 @@ export class AgentWidget {
154
166
  }
155
167
 
156
168
  onTurnStart() {
157
- for (const [id, age] of this.finishedTurnAge) {
158
- this.finishedTurnAge.set(id, age + 1);
159
- }
160
169
  this.update();
161
170
  }
162
171
 
@@ -166,18 +175,14 @@ export class AgentWidget {
166
175
  }
167
176
  }
168
177
 
169
- private shouldShowFinished(agentId: string, status: string): boolean {
170
- const age = this.finishedTurnAge.get(agentId) ?? 0;
171
- const maxAge = ERROR_STATUSES.has(status)
172
- ? AgentWidget.ERROR_LINGER_TURNS
173
- : 1;
174
- return age < maxAge;
175
- }
176
-
177
- markFinished(agentId: string) {
178
- if (!this.finishedTurnAge.has(agentId)) {
179
- this.finishedTurnAge.set(agentId, 0);
180
- }
178
+ private shouldShowFinished(status: string, completedAt: number): boolean {
179
+ // Linger a few seconds after finish, then drop. The ✓ … Done line in the
180
+ // transcript is the permanent record; errors stay longer so failures are
181
+ // noticed. The 80ms widget timer re-evaluates this continuously.
182
+ const linger = ERROR_STATUSES.has(status)
183
+ ? AgentWidget.ERROR_LINGER_MS
184
+ : AgentWidget.FINISHED_LINGER_MS;
185
+ return Date.now() - completedAt < linger;
181
186
  }
182
187
 
183
188
  private renderFinishedLine(
@@ -191,12 +196,18 @@ export class AgentWidget {
191
196
  completedAt?: number;
192
197
  error?: string;
193
198
  invocation?: AgentInvocation;
199
+ lifetimeUsage?: { input: number; output: number; cacheWrite: number };
200
+ session?: unknown;
201
+ compactionCount?: number;
202
+ turnCount?: number;
203
+ maxTurns?: number;
194
204
  },
195
205
  theme: Theme,
196
206
  ): string {
197
207
  const name = getDisplayName(a.type);
198
208
  const modeLabel = getPromptModeLabel(a.type);
199
- const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt);
209
+ const durationMs = (a.completedAt ?? Date.now()) - a.startedAt;
210
+ const duration = formatMs(durationMs);
200
211
 
201
212
  // model label (the pix twist — always shown)
202
213
  const modelLabel = a.invocation?.modelName
@@ -225,12 +236,31 @@ export class AgentWidget {
225
236
  }
226
237
 
227
238
  const parts: string[] = [];
228
- const activity = this.agentActivity.get(a.id);
229
- if (activity)
230
- parts.push(formatTurns(activity.turnCount, activity.maxTurns));
239
+ // Turns read from the record (a.*), not agentActivity — onComplete deletes
240
+ // the activity entry before this line renders, which had dropped ↻N.
241
+ if (a.turnCount != null && a.turnCount > 0)
242
+ parts.push(formatTurns(a.turnCount, a.maxTurns));
231
243
  if (a.toolUses > 0)
232
244
  parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
245
+ // Token + context% + speed read from the record (survives the
246
+ // agentActivity delete that fires in onComplete before this renders).
247
+ const tokens = getLifetimeTotal(a.lifetimeUsage);
248
+ if (tokens > 0) {
249
+ const contextPercent = a.session
250
+ ? getSessionContextPercent(a.session as SessionLike)
251
+ : null;
252
+ parts.push(
253
+ formatSessionTokens(
254
+ tokens,
255
+ contextPercent,
256
+ theme,
257
+ a.compactionCount ?? 0,
258
+ ),
259
+ );
260
+ }
233
261
  parts.push(duration);
262
+ const speed = formatSpeed(a.lifetimeUsage?.output ?? 0, durationMs);
263
+ if (speed) parts.push(speed);
234
264
 
235
265
  return `${icon} ${theme.fg("dim", name)}${modelLabel}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
236
266
  }
@@ -246,8 +276,8 @@ export class AgentWidget {
246
276
  (a) =>
247
277
  a.status !== "running" &&
248
278
  a.status !== "queued" &&
249
- a.completedAt &&
250
- this.shouldShowFinished(a.id, a.status),
279
+ a.completedAt != null &&
280
+ this.shouldShowFinished(a.status, a.completedAt),
251
281
  );
252
282
 
253
283
  if (running.length === 0 && queued.length === 0 && finished.length === 0)
@@ -257,7 +287,8 @@ export class AgentWidget {
257
287
  const truncate = (line: string) => truncateToWidth(line, w);
258
288
  const hasActive = running.length > 0 || queued.length > 0;
259
289
  const headingColor = hasActive ? "accent" : "dim";
260
- const headingIcon = hasActive ? "" : "○";
290
+ // hollow = incomplete (still running), filled disk = complete (all done).
291
+ const headingIcon = hasActive ? "○" : "●";
261
292
  const frame = SPINNER[this.widgetFrame % SPINNER.length];
262
293
 
263
294
  const finishedLines: string[] = [];
@@ -269,7 +300,7 @@ export class AgentWidget {
269
300
  );
270
301
  }
271
302
 
272
- const runningLines: string[][] = [];
303
+ const runningLines: string[] = [];
273
304
  for (const a of running) {
274
305
  const name = getDisplayName(a.type);
275
306
  const modeLabel = getPromptModeLabel(a.type);
@@ -304,19 +335,26 @@ export class AgentWidget {
304
335
  parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
305
336
  if (tokenText) parts.push(tokenText);
306
337
  parts.push(elapsed);
338
+ const liveSpeed = formatSpeed(
339
+ bg?.lifetimeUsage.output ?? 0,
340
+ Date.now() - a.startedAt,
341
+ );
342
+ if (liveSpeed) parts.push(liveSpeed);
307
343
  const statsText = parts.join(" · ");
308
344
 
345
+ // Activity leads (next to the spinner — the moving part by the moving
346
+ // part), then static identity + cumulative stats. One line per agent so
347
+ // many concurrent workers stay readable instead of doubling the height.
309
348
  const activity = bg
310
349
  ? describeActivity(bg.activeTools, bg.responseText)
311
350
  : "thinking…";
312
351
 
313
- runningLines.push([
352
+ runningLines.push(
314
353
  truncate(
315
354
  theme.fg("dim", "├─") +
316
- ` ${theme.fg("accent", frame)} ${theme.bold(name)}${modelLabel}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`,
355
+ ` ${theme.fg("accent", frame)} ${theme.fg("dim", activity)} ${theme.fg("dim", "·")} ${theme.bold(name)}${modelLabel}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`,
317
356
  ),
318
- truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
319
- ]);
357
+ );
320
358
  }
321
359
 
322
360
  const queuedLine =
@@ -329,7 +367,7 @@ export class AgentWidget {
329
367
 
330
368
  const maxBody = MAX_WIDGET_LINES - 1;
331
369
  const totalBody =
332
- finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
370
+ finishedLines.length + runningLines.length + (queuedLine ? 1 : 0);
333
371
 
334
372
  const lines: string[] = [
335
373
  truncate(
@@ -341,27 +379,23 @@ export class AgentWidget {
341
379
 
342
380
  if (totalBody <= maxBody) {
343
381
  lines.push(...finishedLines);
344
- for (const pair of runningLines) lines.push(...pair);
382
+ lines.push(...runningLines);
345
383
  if (queuedLine) lines.push(queuedLine);
346
384
 
347
385
  // Fix last connector ├─ → └─
348
386
  if (lines.length > 1) {
349
387
  const last = lines.length - 1;
350
388
  lines[last] = lines[last].replace("├─", "└─");
351
- if (runningLines.length > 0 && !queuedLine && last >= 2) {
352
- lines[last - 1] = lines[last - 1].replace("├─", "└─");
353
- lines[last] = lines[last].replace("│ ", " ");
354
- }
355
389
  }
356
390
  } else {
357
391
  let budget = maxBody - 1;
358
392
  let hiddenRunning = 0;
359
393
  let hiddenFinished = 0;
360
394
 
361
- for (const pair of runningLines) {
362
- if (budget >= 2) {
363
- lines.push(...pair);
364
- budget -= 2;
395
+ for (const line of runningLines) {
396
+ if (budget >= 1) {
397
+ lines.push(line);
398
+ budget--;
365
399
  } else {
366
400
  hiddenRunning++;
367
401
  }
@@ -403,7 +437,10 @@ export class AgentWidget {
403
437
  for (const a of allAgents) {
404
438
  if (a.status === "running") runningCount++;
405
439
  else if (a.status === "queued") queuedCount++;
406
- else if (a.completedAt && this.shouldShowFinished(a.id, a.status))
440
+ else if (
441
+ a.completedAt != null &&
442
+ this.shouldShowFinished(a.status, a.completedAt)
443
+ )
407
444
  hasFinished = true;
408
445
  }
409
446
  const hasActive = runningCount > 0 || queuedCount > 0;
@@ -422,10 +459,6 @@ export class AgentWidget {
422
459
  clearInterval(this.widgetInterval);
423
460
  this.widgetInterval = undefined;
424
461
  }
425
- for (const [id] of this.finishedTurnAge) {
426
- if (!allAgents.some((a) => a.id === id))
427
- this.finishedTurnAge.delete(id);
428
- }
429
462
  return;
430
463
  }
431
464