@xynogen/pix-subagent 0.1.3 → 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.3",
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
@@ -135,8 +135,11 @@ export default function registerPixSubagent(pi: ExtensionAPI): void {
135
135
  widget.update();
136
136
  },
137
137
  4, // maxConcurrent
138
- // 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.
139
141
  (_record) => {
142
+ widget.ensureTimer();
140
143
  widget.update();
141
144
  },
142
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
 
@@ -314,21 +337,34 @@ export function createAgentTool(
314
337
  );
315
338
  }
316
339
 
317
- // 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.
318
344
  if (details.status === "completed" || details.status === "steered") {
345
+ if (!expanded) return new Text("", 0, 0);
319
346
  const duration = formatMs(details.durationMs);
320
347
  const isSteered = details.status === "steered";
321
348
  const icon = isSteered
322
349
  ? theme.fg("warning", "✓")
323
350
  : theme.fg("success", "✓");
351
+ const speed = formatSpeed(
352
+ details.outputTokens ?? 0,
353
+ details.durationMs,
354
+ );
324
355
  let line =
325
356
  icon +
326
357
  (stats ? ` ${stats}` : "") +
327
358
  " " +
328
359
  theme.fg("dim", "·") +
329
360
  " " +
330
- 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)")}` : "");
331
366
 
367
+ // Expanded view appends the full result below the one-line summary.
332
368
  if (expanded) {
333
369
  const resultText =
334
370
  result.content[0]?.type === "text" ? result.content[0].text : "";
@@ -343,13 +379,6 @@ export function createAgentTool(
343
379
  " … (use agent_result with verbose for full output)",
344
380
  );
345
381
  }
346
- } else {
347
- line +=
348
- "\n" +
349
- theme.fg(
350
- "dim",
351
- ` ⎿ ${isSteered ? "Wrapped up (turn limit)" : "Done"}`,
352
- );
353
382
  }
354
383
  return new Text(line, 0, 0);
355
384
  }
@@ -804,10 +833,15 @@ function buildDetails(
804
833
  activity?: AgentActivity & { durationMs?: number },
805
834
  ): AgentDetails {
806
835
  const totalTokens = getLifetimeTotal(record.lifetimeUsage);
836
+ const contextPercent = activity?.session
837
+ ? getSessionContextPercent(activity.session as SessionLike)
838
+ : null;
807
839
  return {
808
840
  ...base,
809
841
  toolUses: record.toolUses,
810
842
  tokens: totalTokens > 0 ? formatTokens(totalTokens) : "",
843
+ contextPercent,
844
+ outputTokens: record.lifetimeUsage.output,
811
845
  turnCount: activity?.turnCount,
812
846
  maxTurns: activity?.maxTurns,
813
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(
@@ -184,12 +196,18 @@ export class AgentWidget {
184
196
  completedAt?: number;
185
197
  error?: string;
186
198
  invocation?: AgentInvocation;
199
+ lifetimeUsage?: { input: number; output: number; cacheWrite: number };
200
+ session?: unknown;
201
+ compactionCount?: number;
202
+ turnCount?: number;
203
+ maxTurns?: number;
187
204
  },
188
205
  theme: Theme,
189
206
  ): string {
190
207
  const name = getDisplayName(a.type);
191
208
  const modeLabel = getPromptModeLabel(a.type);
192
- const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt);
209
+ const durationMs = (a.completedAt ?? Date.now()) - a.startedAt;
210
+ const duration = formatMs(durationMs);
193
211
 
194
212
  // model label (the pix twist — always shown)
195
213
  const modelLabel = a.invocation?.modelName
@@ -218,12 +236,31 @@ export class AgentWidget {
218
236
  }
219
237
 
220
238
  const parts: string[] = [];
221
- const activity = this.agentActivity.get(a.id);
222
- if (activity)
223
- 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));
224
243
  if (a.toolUses > 0)
225
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
+ }
226
261
  parts.push(duration);
262
+ const speed = formatSpeed(a.lifetimeUsage?.output ?? 0, durationMs);
263
+ if (speed) parts.push(speed);
227
264
 
228
265
  return `${icon} ${theme.fg("dim", name)}${modelLabel}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
229
266
  }
@@ -250,7 +287,8 @@ export class AgentWidget {
250
287
  const truncate = (line: string) => truncateToWidth(line, w);
251
288
  const hasActive = running.length > 0 || queued.length > 0;
252
289
  const headingColor = hasActive ? "accent" : "dim";
253
- const headingIcon = hasActive ? "" : "○";
290
+ // hollow = incomplete (still running), filled disk = complete (all done).
291
+ const headingIcon = hasActive ? "○" : "●";
254
292
  const frame = SPINNER[this.widgetFrame % SPINNER.length];
255
293
 
256
294
  const finishedLines: string[] = [];
@@ -262,7 +300,7 @@ export class AgentWidget {
262
300
  );
263
301
  }
264
302
 
265
- const runningLines: string[][] = [];
303
+ const runningLines: string[] = [];
266
304
  for (const a of running) {
267
305
  const name = getDisplayName(a.type);
268
306
  const modeLabel = getPromptModeLabel(a.type);
@@ -297,19 +335,26 @@ export class AgentWidget {
297
335
  parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
298
336
  if (tokenText) parts.push(tokenText);
299
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);
300
343
  const statsText = parts.join(" · ");
301
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.
302
348
  const activity = bg
303
349
  ? describeActivity(bg.activeTools, bg.responseText)
304
350
  : "thinking…";
305
351
 
306
- runningLines.push([
352
+ runningLines.push(
307
353
  truncate(
308
354
  theme.fg("dim", "├─") +
309
- ` ${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)}`,
310
356
  ),
311
- truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
312
- ]);
357
+ );
313
358
  }
314
359
 
315
360
  const queuedLine =
@@ -322,7 +367,7 @@ export class AgentWidget {
322
367
 
323
368
  const maxBody = MAX_WIDGET_LINES - 1;
324
369
  const totalBody =
325
- finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
370
+ finishedLines.length + runningLines.length + (queuedLine ? 1 : 0);
326
371
 
327
372
  const lines: string[] = [
328
373
  truncate(
@@ -334,27 +379,23 @@ export class AgentWidget {
334
379
 
335
380
  if (totalBody <= maxBody) {
336
381
  lines.push(...finishedLines);
337
- for (const pair of runningLines) lines.push(...pair);
382
+ lines.push(...runningLines);
338
383
  if (queuedLine) lines.push(queuedLine);
339
384
 
340
385
  // Fix last connector ├─ → └─
341
386
  if (lines.length > 1) {
342
387
  const last = lines.length - 1;
343
388
  lines[last] = lines[last].replace("├─", "└─");
344
- if (runningLines.length > 0 && !queuedLine && last >= 2) {
345
- lines[last - 1] = lines[last - 1].replace("├─", "└─");
346
- lines[last] = lines[last].replace("│ ", " ");
347
- }
348
389
  }
349
390
  } else {
350
391
  let budget = maxBody - 1;
351
392
  let hiddenRunning = 0;
352
393
  let hiddenFinished = 0;
353
394
 
354
- for (const pair of runningLines) {
355
- if (budget >= 2) {
356
- lines.push(...pair);
357
- budget -= 2;
395
+ for (const line of runningLines) {
396
+ if (budget >= 1) {
397
+ lines.push(line);
398
+ budget--;
358
399
  } else {
359
400
  hiddenRunning++;
360
401
  }