pi-subagents-lite 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,18 +1,22 @@
1
1
  /**
2
2
  * agent-widget.ts — Persistent widget showing running/completed agents above the editor.
3
3
  *
4
- * Ported from upstream pi-subagents. Adaptations:
5
- * - buildInvocationTags removes inheritContext and isolation: "worktree" (fields
6
- * we cut from AgentInvocation)
7
- * - Import paths use relative imports within our extension
8
- * - addUsage/getLifetimeTotal/getSessionContextPercent imported from ../usage.js
4
+ * Ported from upstream pi-subagents.
5
+ * Import paths use relative imports within our extension.
6
+ * addUsage/getLifetimeTotal/getSessionContextPercent imported from ../usage.js.
9
7
  */
10
8
 
11
9
  import { truncateToWidth } from "@earendil-works/pi-tui";
12
10
  import type { AgentManager } from "../agent-manager.js";
13
11
  import { getConfig } from "../agent-types.js";
14
- import type { AgentInvocation, AgentRecord, SubagentType } from "../types.js";
15
- import { getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type SessionLike } from "../usage.js";
12
+ import type { AgentRecord, SubagentType } from "../types.js";
13
+ import {
14
+ formatTokens,
15
+ getLifetimeTotal,
16
+ getSessionContextPercent,
17
+ type LifetimeUsage,
18
+ type SessionLike,
19
+ } from "../usage.js";
16
20
 
17
21
  // ---- Constants ----
18
22
 
@@ -20,10 +24,10 @@ import { getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type Se
20
24
  const MAX_WIDGET_LINES = 12;
21
25
 
22
26
  /** Braille spinner frames for animated running indicator. */
23
- export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
27
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
24
28
 
25
- /** Statuses that indicate an error/non-success outcome (used for linger behavior and icon rendering). */
26
- export const ERROR_STATUSES = new Set(["error", "aborted", "steered", "stopped"]);
29
+ /** Non-success statuses used for linger behavior and icon rendering. */
30
+ const ERROR_STATUSES = new Set(["error", "aborted", "steered", "stopped"]);
27
31
 
28
32
  /** Tree-drawing connectors used in the widget header/continuation lines. */
29
33
  const BRANCH = "├─";
@@ -58,7 +62,7 @@ const TOOL_DISPLAY: Record<string, string> = {
58
62
 
59
63
  // ---- Types ----
60
64
 
61
- export type Theme = {
65
+ type Theme = {
62
66
  fg(color: string, text: string): string;
63
67
  bold(text: string): string;
64
68
  };
@@ -102,13 +106,6 @@ export interface AgentActivity {
102
106
 
103
107
  // ---- Formatting helpers ----
104
108
 
105
- /** Format a token count compactly: "33.8k", "1.2M". */
106
- export function formatTokens(count: number): string {
107
- if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
108
- if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
109
- return `${count}`;
110
- }
111
-
112
109
  /**
113
110
  * Token count with optional context-fill % and compaction-count annotations.
114
111
  * Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
@@ -119,7 +116,7 @@ export function formatTokens(count: number): string {
119
116
  * "12.3k(↻ 2)" — compactions only (e.g. right after compact)
120
117
  * "12.3k(45%·↻ 2)" — both
121
118
  */
122
- export function formatSessionTokens(
119
+ function formatSessionTokens(
123
120
  tokens: number,
124
121
  percent: number | null,
125
122
  theme: Theme,
@@ -143,35 +140,56 @@ export function formatSessionTokens(
143
140
  }
144
141
 
145
142
  /** Format turn count with optional max limit: "5≤30⟳" or "5⟳". */
146
- export function formatTurns(turnCount: number, maxTurns?: number | null): string {
143
+ function formatTurns(turnCount: number, maxTurns?: number | null): string {
147
144
  return maxTurns != null ? `${turnCount}≤${maxTurns}⟳ ` : `${turnCount}⟳ `;
148
145
  }
149
146
 
150
- /** Format milliseconds as human-readable duration. */
147
+ /** Format milliseconds as a compact human-readable duration: "1h 1m 1s", "5m 37s", "10s", "<1s". */
151
148
  export function formatMs(ms: number): string {
152
- return Number.isFinite(ms) ? `${(ms / 1000).toFixed(1)}s` : "0.0s";
153
- }
149
+ if (!Number.isFinite(ms) || ms < 1000) return "<1s";
154
150
 
155
- /** Get display name for any agent type (built-in or custom). */
156
- export function getDisplayName(type: SubagentType): string {
157
- return getConfig(type).displayName;
151
+ const totalSeconds = Math.floor(ms / 1000);
152
+ const hours = Math.floor(totalSeconds / 3600);
153
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
154
+ const seconds = totalSeconds % 60;
155
+
156
+ const parts: string[] = [];
157
+ if (hours > 0) parts.push(`${hours}h`);
158
+ if (minutes > 0) parts.push(`${minutes}m`);
159
+ if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`);
160
+
161
+ return parts.join(" ");
158
162
  }
159
163
 
160
164
  /**
161
- * Build invocation tags from the invocation record.
162
- * Adapted from upstream: removed inheritContext and isolation: "worktree" checks
163
- * because our AgentInvocation doesn't have those fields.
165
+ * Build common stats parts: toolUses · turns · tokens with context %.
166
+ * Shared by AgentWidget and index.ts for consistent stats display.
164
167
  */
165
- export function buildInvocationTags(
166
- invocation: AgentInvocation | undefined,
167
- ): { modelName?: string; tags: string[] } {
168
- const tags: string[] = [];
169
- if (!invocation) return { tags };
170
- if (invocation.thinking) tags.push(`thinking: ${invocation.thinking}`);
171
- if (invocation.isolated) tags.push("isolated");
172
- if (invocation.runInBackground) tags.push("background");
173
- if (invocation.maxTurns != null) tags.push(`max turns: ${invocation.maxTurns}`);
174
- return { modelName: invocation.modelName, tags };
168
+ export function buildStatsParts(
169
+ args: {
170
+ toolUses: number;
171
+ turnCount?: number;
172
+ maxTurns?: number;
173
+ tokens: number;
174
+ contextPercent: number | null;
175
+ compactions: number;
176
+ },
177
+ theme: Theme,
178
+ ): string[] {
179
+ const parts: string[] = [];
180
+ if (args.toolUses > 0) parts.push(`${args.toolUses}🛠 `);
181
+ if (args.turnCount != null) parts.push(formatTurns(args.turnCount, args.maxTurns));
182
+ if (args.tokens > 0) {
183
+ parts.push(formatSessionTokens(
184
+ args.tokens, args.contextPercent, theme, args.compactions,
185
+ ));
186
+ }
187
+ return parts;
188
+ }
189
+
190
+ /** Get display name for any agent type (built-in or custom). */
191
+ export function getDisplayName(type: SubagentType): string {
192
+ return getConfig(type).displayName;
175
193
  }
176
194
 
177
195
  /**
@@ -194,7 +212,7 @@ function truncateLine(text: string, len = 60): string {
194
212
  }
195
213
 
196
214
  /** Build a human-readable activity string from currently-running tools or response text. */
197
- export function describeActivity(activeTools: Map<string, string>, responseText?: string): string {
215
+ function describeActivity(activeTools: Map<string, string>, responseText?: string): string {
198
216
  if (activeTools.size > 0) {
199
217
  const groups = new Map<string, number>();
200
218
  for (const toolName of activeTools.values()) {
@@ -227,7 +245,7 @@ export class AgentWidget {
227
245
  private uiCtx: UICtx | undefined;
228
246
  private widgetFrame = 0;
229
247
  private widgetInterval: ReturnType<typeof setInterval> | undefined;
230
- /** Tracks how many turns each finished agent has survived. Key: agent ID, Value: turns since finished. */
248
+ /** Finished agents: agent ID turns since finished. */
231
249
  private finishedTurnAge = new Map<string, number>();
232
250
 
233
251
 
@@ -304,8 +322,11 @@ export class AgentWidget {
304
322
  }
305
323
 
306
324
  /** Build the icon and status suffix for a finished agent. */
307
- private finishedIconAndStatus(status: string, error?: string, theme?: Theme): { icon: string; statusText: string } {
308
- if (!theme) return { icon: "?", statusText: "" }; // should not happen
325
+ private finishedIconAndStatus(
326
+ status: string,
327
+ error: string | undefined,
328
+ theme: Theme,
329
+ ): { icon: string; statusText: string } {
309
330
  switch (status) {
310
331
  case "completed":
311
332
  return { icon: theme.fg("success", "✓"), statusText: "" };
@@ -335,31 +356,18 @@ export class AgentWidget {
335
356
  const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt);
336
357
  const { icon, statusText } = this.finishedIconAndStatus(a.status, a.error, theme);
337
358
 
338
- const parts: string[] = [];
339
359
  const activity = this.agentActivity.get(a.id);
340
-
341
- // Tool uses
342
- if (a.toolUses > 0) parts.push(`${a.toolUses}🛠 `);
343
-
344
- // Turn count — prefer activity (live), fall back to record (after cleanup)
345
- if (activity) {
346
- parts.push(formatTurns(activity.turnCount, activity.maxTurns));
347
- } else if (a.turnCount != null) {
348
- parts.push(formatTurns(a.turnCount, a.maxTurns));
349
- }
350
-
351
- // Token usage with context % — read from record if activity was cleaned up
352
- const tokens = getLifetimeTotal(activity?.lifetimeUsage ?? a.lifetimeUsage);
353
- if (tokens > 0) {
354
- const contextPct = getSessionContextPercent(activity?.session ?? a.session);
355
- const tokenText = formatSessionTokens(tokens, contextPct, theme, a.compactionCount);
356
- parts.push(tokenText);
357
- }
358
-
359
- parts.push(duration);
360
-
361
- // Wrap stats in dim, re-applying after any ANSI reset from formatSessionTokens.
362
- const statsLine = parts.join("·");
360
+ const statsParts = buildStatsParts({
361
+ toolUses: a.toolUses,
362
+ turnCount: activity?.turnCount ?? a.turnCount,
363
+ maxTurns: activity?.maxTurns ?? a.maxTurns,
364
+ tokens: getLifetimeTotal(activity?.lifetimeUsage ?? a.lifetimeUsage),
365
+ contextPercent: getSessionContextPercent(activity?.session ?? a.session),
366
+ compactions: a.compactionCount,
367
+ }, theme);
368
+ statsParts.push(duration);
369
+
370
+ const statsLine = statsParts.join("·");
363
371
  return `${icon} ${theme.fg("dim", name)} ${theme.fg("dim", a.description)} ${wrapInDim(theme, statsLine)}${statusText}`;
364
372
  }
365
373
 
@@ -369,20 +377,15 @@ export class AgentWidget {
369
377
  activity: AgentActivity | undefined,
370
378
  theme: Theme,
371
379
  ): string {
372
- const toolUses = activity?.toolUses ?? agent.toolUses;
373
- const elapsed = formatMs(Date.now() - agent.startedAt);
374
-
375
- const tokens = getLifetimeTotal(activity?.lifetimeUsage);
376
- const contextPercent = getSessionContextPercent(activity?.session);
377
- const tokenText = tokens > 0
378
- ? formatSessionTokens(tokens, contextPercent, theme, agent.compactionCount)
379
- : "";
380
-
381
- const parts: string[] = [];
382
- if (toolUses > 0) parts.push(`${toolUses}🛠 `);
383
- if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
384
- if (tokenText) parts.push(tokenText);
385
- parts.push(elapsed);
380
+ const parts = buildStatsParts({
381
+ toolUses: activity?.toolUses ?? agent.toolUses,
382
+ turnCount: activity?.turnCount,
383
+ maxTurns: activity?.maxTurns,
384
+ tokens: getLifetimeTotal(activity?.lifetimeUsage),
385
+ contextPercent: getSessionContextPercent(activity?.session),
386
+ compactions: agent.compactionCount,
387
+ }, theme);
388
+ parts.push(formatMs(Date.now() - agent.startedAt));
386
389
  return parts.join("·");
387
390
  }
388
391
 
@@ -396,7 +399,7 @@ export class AgentWidget {
396
399
  const blocks: RenderBlock[] = [];
397
400
  for (const a of finished) {
398
401
  blocks.push({
399
- header: truncate(theme.fg("dim", BRANCH) + " " + this.renderFinishedLine(a, theme)),
402
+ header: truncate(`${theme.fg("dim", BRANCH)} ${this.renderFinishedLine(a, theme)}`),
400
403
  continuations: a.outputFile
401
404
  ? [truncate(theme.fg("dim", `${VLINE} tail -f ${a.outputFile}`))]
402
405
  : [],
@@ -420,8 +423,9 @@ export class AgentWidget {
420
423
  const statsLine = this.buildStatsLine(a, bg, theme);
421
424
  const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : THINKING_TEXT;
422
425
 
426
+ const headerLine = `${BRANCH} ${theme.fg("accent", frame)} ${theme.bold(name)} ${a.description} ${statsLine}`;
423
427
  blocks.push({
424
- header: truncate(`${BRANCH} ${theme.fg("accent", frame)} ${theme.bold(name)} ${a.description} ${statsLine}`),
428
+ header: truncate(headerLine),
425
429
  continuations: [
426
430
  ...(a.outputFile
427
431
  ? [truncate(`${VLINE} ` + theme.fg("dim", `${VLINE} tail -f ${a.outputFile}`))]
@@ -441,10 +445,8 @@ export class AgentWidget {
441
445
  ): RenderBlock | undefined {
442
446
  if (queued.length === 0) return undefined;
443
447
  const truncate = (line: string) => truncateToWidth(line, w);
444
- return {
445
- header: truncate(theme.fg("dim", BRANCH) + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`),
446
- continuations: [],
447
- };
448
+ const header = `${theme.fg("dim", BRANCH)} ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`;
449
+ return { header: truncate(header), continuations: [] };
448
450
  }
449
451
 
450
452
  /**
@@ -471,7 +473,7 @@ export class AgentWidget {
471
473
  const headingIcon = hasActive ? "●" : "○";
472
474
  const frame = SPINNER[this.widgetFrame % SPINNER.length];
473
475
 
474
- // ---- Build blocks with placeholder connectors (BRANCH for headers, VLINE for continuations) ----
476
+ // Build blocks with placeholder connectors (BRANCH for headers, VLINE for continuations)
475
477
  // Separate arrays so overflow logic can apply priority: running > queued > finished.
476
478
  const finishedBlocks = this.buildFinishedBlocks(finished, theme, w);
477
479
  const runningBlocks = this.buildRunningBlocks(running, theme, w, frame);
@@ -489,7 +491,8 @@ export class AgentWidget {
489
491
  const maxBody = MAX_WIDGET_LINES - 1; // heading takes 1 line
490
492
  const totalBody = blocks.reduce((sum, b) => sum + 1 + b.continuations.length, 0);
491
493
 
492
- const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
494
+ const heading = `${theme.fg(headingColor, headingIcon)} ${theme.fg(headingColor, "Agents")}`;
495
+ const lines: string[] = [truncate(heading)];
493
496
 
494
497
  if (totalBody <= maxBody) {
495
498
  // Everything fits — render all blocks with correct connectors.
@@ -570,7 +573,8 @@ export class AgentWidget {
570
573
  const parts: string[] = [];
571
574
  if (hiddenRunning > 0) parts.push(`${hiddenRunning} running`);
572
575
  if (hiddenFinished > 0) parts.push(`${hiddenFinished} finished`);
573
- return theme.fg("dim", CORNER) + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${parts.join(", ")})`)}`;
576
+ const summary = `+${hiddenRunning + hiddenFinished} more (${parts.join(", ")})`;
577
+ return `${theme.fg("dim", CORNER)} ${theme.fg("dim", summary)}`;
574
578
  })()
575
579
  : undefined;
576
580
 
@@ -588,7 +592,10 @@ export class AgentWidget {
588
592
  this.uiCtx?.setStatus(STATUS_KEY, undefined);
589
593
  this.lastStatusText = undefined;
590
594
  }
591
- if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
595
+ if (this.widgetInterval) {
596
+ clearInterval(this.widgetInterval);
597
+ this.widgetInterval = undefined;
598
+ }
592
599
  // Clean up stale entries
593
600
  const allAgents = this.manager.listAgents();
594
601
  for (const [id] of this.finishedTurnAge) {
package/src/usage.ts CHANGED
@@ -7,11 +7,11 @@
7
7
  * the cumulative cached prefix re-read on that one call — summing across
8
8
  * turns counts the prefix N times. See issue #38.
9
9
  */
10
- export type LifetimeUsage = { input: number; output: number; cacheWrite: number };
10
+ export type LifetimeUsage = { input: number; output: number; cacheWrite: number; cost: number };
11
11
 
12
- /** Sum of lifetime usage components, or 0 if undefined. */
12
+ /** Sum of lifetime usage components (including cost), or 0 if undefined. */
13
13
  export function getLifetimeTotal(u?: LifetimeUsage): number {
14
- return u ? u.input + u.output + u.cacheWrite : 0;
14
+ return u ? u.input + u.output + u.cacheWrite + u.cost : 0;
15
15
  }
16
16
 
17
17
  /** Add a usage delta into a target accumulator (mutates target). */
@@ -19,15 +19,23 @@ export function addUsage(into: LifetimeUsage, delta: LifetimeUsage): void {
19
19
  into.input += delta.input;
20
20
  into.output += delta.output;
21
21
  into.cacheWrite += delta.cacheWrite;
22
+ into.cost += delta.cost;
22
23
  }
23
24
 
24
25
  /** Minimal shape we read from upstream `getSessionStats()`. */
25
- export type SessionStatsLike = {
26
+ type SessionStatsLike = {
26
27
  tokens: { input: number; output: number; cacheWrite: number };
27
28
  contextUsage?: { percent: number | null };
28
29
  };
29
30
  export type SessionLike = { getSessionStats(): SessionStatsLike };
30
31
 
32
+ /** Format a token count compactly: "12.3k", "1.2M", or raw number. */
33
+ export function formatTokens(count: number): string {
34
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
35
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
36
+ return `${count}`;
37
+ }
38
+
31
39
  /**
32
40
  * Context-window utilization (0–100), or null when unavailable
33
41
  * (no model contextWindow, or post-compaction before the next response).
package/src/utils.ts CHANGED
@@ -1,18 +1,21 @@
1
1
  /**
2
- * utils.ts — Security helpers: safe file access, name validation.
2
+ * utils.ts — Security helpers: safe file access, name validation + general utilities.
3
3
  *
4
- * Extracted from upstream memory.ts — pure implementations copied verbatim.
4
+ * Security helpers extracted from upstream memory.ts — pure implementations copied verbatim.
5
+ * General utilities (parseModelKey, findModelInRegistry) moved here so both agent-runner
6
+ * and tool-execution can use them without circular dependencies.
5
7
  */
6
8
 
7
9
  import { lstatSync, readFileSync } from "node:fs";
10
+ import type { Model } from "@earendil-works/pi-ai";
11
+ import type { ThinkingLevel } from "./types.js";
8
12
 
9
13
  /**
10
14
  * Returns true if a name contains characters not allowed in agent/skill names.
11
15
  * Uses a whitelist: only alphanumeric, hyphens, underscores, and dots (no leading dot).
12
16
  */
13
17
  export function isUnsafeName(name: string): boolean {
14
- if (!name || name.length > 128) return true;
15
- return !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
18
+ return !name || name.length > 128 || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
16
19
  }
17
20
 
18
21
  /**
@@ -38,3 +41,49 @@ export function safeReadFile(filePath: string): string | undefined {
38
41
  return undefined;
39
42
  }
40
43
  }
44
+
45
+ /** All valid thinking levels. */
46
+ export const VALID_THINKING_LEVELS: readonly ThinkingLevel[] = [
47
+ "off", "minimal", "low", "medium", "high", "xhigh",
48
+ ] as const;
49
+
50
+ /**
51
+ * Validate and narrow a raw string value to ThinkingLevel.
52
+ * Returns undefined if the value is not a valid thinking level.
53
+ */
54
+ export function parseThinkingLevel(raw: string | undefined): ThinkingLevel | undefined {
55
+ if (raw === undefined) return undefined;
56
+ return VALID_THINKING_LEVELS.includes(raw as ThinkingLevel) ? (raw as ThinkingLevel) : undefined;
57
+ }
58
+
59
+ /**
60
+ * Safely extract a human-readable error message from an unknown exception.
61
+ */
62
+ export function errorMessage(err: unknown): string {
63
+ return err instanceof Error ? err.message : String(err);
64
+ }
65
+
66
+ /**
67
+ * Parse a "provider/model-id" string into { provider, modelId }.
68
+ * Returns null if the format is invalid (no slash or empty provider).
69
+ */
70
+ export function parseModelKey(modelStr: string): { provider: string; modelId: string } | null {
71
+ const slashIdx = modelStr.indexOf("/");
72
+ if (slashIdx <= 0) return null;
73
+ return { provider: modelStr.slice(0, slashIdx), modelId: modelStr.slice(slashIdx + 1) };
74
+ }
75
+
76
+ /**
77
+ * Find a model in the registry by "provider/model-id" string.
78
+ * Returns the found model, or the fallback if the string is unparseable or not in registry.
79
+ */
80
+ export function findModelInRegistry(
81
+ modelStr: string | undefined,
82
+ registry: { find(provider: string, modelId: string): Model<any> | undefined },
83
+ fallback: Model<any> | undefined,
84
+ ): Model<any> | undefined {
85
+ if (!modelStr) return fallback;
86
+ const parsed = parseModelKey(modelStr);
87
+ if (!parsed) return fallback;
88
+ return registry.find(parsed.provider, parsed.modelId) ?? fallback;
89
+ }