pi-subagents 0.56.0 → 0.57.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.
Files changed (83) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/agents/claude-code-writer.md +15 -0
  3. package/agents/claude-code.md +15 -0
  4. package/agents/codex-exec-writer.md +15 -0
  5. package/agents/codex-exec.md +15 -0
  6. package/agents/cursor-agent-writer.md +14 -0
  7. package/agents/cursor-agent.md +14 -0
  8. package/docs/agents.md +111 -15
  9. package/docs/configuration.md +37 -0
  10. package/docs/extension-api.md +2 -2
  11. package/docs/observability.md +8 -7
  12. package/docs/tool-reference.md +11 -1
  13. package/docs/workflows.md +20 -0
  14. package/package.json +1 -1
  15. package/skills/pi-subagents/references/execution-controls.md +1 -1
  16. package/src/agents/agent-management.ts +13 -5
  17. package/src/agents/agent-refinements.ts +4 -4
  18. package/src/agents/agents.ts +219 -48
  19. package/src/agents/builtin-names.ts +6 -0
  20. package/src/agents/runtime-agent-registry.ts +8 -2
  21. package/src/api/preflight.ts +2 -2
  22. package/src/extension/config.ts +70 -0
  23. package/src/extension/doctor.ts +3 -3
  24. package/src/extension/index.ts +18 -4
  25. package/src/extension/public-execution.ts +29 -13
  26. package/src/extension/rpc.ts +20 -5
  27. package/src/extension/schemas.ts +4 -3
  28. package/src/extension/tool-description.ts +11 -9
  29. package/src/inspectors/herdr/actions.ts +2 -1
  30. package/src/inspectors/herdr/inspector-runner.ts +2 -10
  31. package/src/inspectors/herdr/session-roots-codec.ts +42 -0
  32. package/src/integrations/herdr-status.ts +46 -3
  33. package/src/runs/background/active-async-capacity.ts +77 -10
  34. package/src/runs/background/async-execution.ts +100 -9
  35. package/src/runs/background/async-job-tracker.ts +5 -0
  36. package/src/runs/background/async-retention.ts +20 -3
  37. package/src/runs/background/async-status.ts +5 -0
  38. package/src/runs/background/chain-root-attachment.ts +15 -1
  39. package/src/runs/background/fleet-view.ts +16 -10
  40. package/src/runs/background/inspect-rpc.ts +8 -8
  41. package/src/runs/background/result-files.ts +27 -14
  42. package/src/runs/background/result-watcher.ts +9 -5
  43. package/src/runs/background/run-status.ts +33 -6
  44. package/src/runs/background/scheduled-runs.ts +7 -1
  45. package/src/runs/background/subagent-runner.ts +177 -44
  46. package/src/runs/background/wait-completions.ts +4 -0
  47. package/src/runs/foreground/execution.ts +72 -10
  48. package/src/runs/foreground/foreground-control.ts +6 -0
  49. package/src/runs/foreground/foreground-history.ts +22 -1
  50. package/src/runs/foreground/subagent-executor.ts +270 -81
  51. package/src/runs/foreground/workflow-detach-reconcile.ts +16 -1
  52. package/src/runs/shared/child-protocol.ts +21 -7
  53. package/src/runs/shared/claude-code-adapter.ts +129 -0
  54. package/src/runs/shared/codex-exec-adapter.ts +129 -0
  55. package/src/runs/shared/cursor-agent-adapter.ts +114 -0
  56. package/src/runs/shared/external-cli-contract.ts +167 -0
  57. package/src/runs/shared/external-cli-preflight.ts +122 -0
  58. package/src/runs/shared/external-cli-runner.ts +348 -55
  59. package/src/runs/shared/model-exclusions.ts +57 -7
  60. package/src/runs/shared/model-fallback.ts +23 -1
  61. package/src/runs/shared/mutation-evidence.ts +7 -2
  62. package/src/runs/shared/nested-events.ts +3 -1
  63. package/src/runs/shared/nested-render.ts +2 -2
  64. package/src/runs/shared/process-signal.ts +13 -0
  65. package/src/runs/shared/run-history.ts +21 -1
  66. package/src/shared/fork-context.ts +21 -0
  67. package/src/shared/formatters.ts +7 -1
  68. package/src/shared/pruned-fork.ts +450 -0
  69. package/src/shared/session-file-trust.ts +19 -0
  70. package/src/shared/session-tokens.ts +14 -3
  71. package/src/shared/settings.ts +10 -2
  72. package/src/shared/shortcuts.ts +17 -0
  73. package/src/shared/types.ts +118 -9
  74. package/src/shared/workflow-child-permit.ts +116 -0
  75. package/src/slash/delegation-adapters.ts +0 -1
  76. package/src/slash/slash-commands.ts +8 -6
  77. package/src/tui/fleet-status.ts +27 -10
  78. package/src/tui/fleet-transcript.ts +11 -5
  79. package/src/tui/fleet.ts +28 -13
  80. package/src/tui/render.ts +47 -21
  81. package/src/workflows/scripted-workflow.ts +248 -27
  82. package/src/workflows/workflow-child-summary.ts +117 -0
  83. package/src/workflows/workflow-receipt.ts +119 -4
@@ -20,14 +20,30 @@ type RecordModelFailureOptions = ModelExclusionTarget & {
20
20
 
21
21
  let exclusions: ModelExclusion[] = [];
22
22
  let loaded = false;
23
- let defaultTTLMs = 24 * 60 * 60_000; // 24 hours, overridable via setDefaultTTL
23
+ /** Default duration for a new model exclusion when no per-record TTL is supplied. */
24
+ export const DEFAULT_MODEL_EXCLUSION_TTL_MS = 24 * 60 * 60_000;
25
+ /** Keeps a new expiry safely below JavaScript's maximum Date timestamp. */
26
+ export const MAX_MODEL_EXCLUSION_TTL_MS = 8_000_000_000_000_000;
27
+ const MAX_DATE_TIMESTAMP_MS = 8_640_000_000_000_000;
28
+ let defaultTTLMs = DEFAULT_MODEL_EXCLUSION_TTL_MS;
29
+ let loadedTTLCeilingMs: number | undefined;
24
30
  let persistTimer: ReturnType<typeof setTimeout> | null = null;
25
31
  let persistSeq = 0;
26
32
 
27
- /** Override the default exclusion TTL. */
28
- export function setDefaultTTL(ms: number): void {
29
- if (!Number.isFinite(ms) || ms <= 0) throw new Error("Default model exclusion TTL must be a finite positive number.");
33
+ /**
34
+ * Override the default TTL applied to newly recorded model exclusions.
35
+ *
36
+ * @param ms Duration in milliseconds. Must be finite and positive.
37
+ * @returns Nothing.
38
+ */
39
+ // TEST:test/unit/model-exclusions.test.ts[model exclusions — TTL expiry]
40
+ export function setDefaultTTL(ms: number, options?: { shortenExisting?: boolean }): void {
41
+ if (!Number.isFinite(ms) || ms <= 0 || ms > MAX_MODEL_EXCLUSION_TTL_MS) {
42
+ throw new Error(`Default model exclusion TTL must be a finite positive number no greater than ${MAX_MODEL_EXCLUSION_TTL_MS}.`);
43
+ }
30
44
  defaultTTLMs = ms;
45
+ loadedTTLCeilingMs = options?.shortenExisting ? ms : undefined;
46
+ if (loaded && loadedTTLCeilingMs !== undefined && shortenExclusionsToTTL(exclusions, loadedTTLCeilingMs, Date.now())) schedulePersist();
31
47
  }
32
48
 
33
49
  /**
@@ -78,9 +94,23 @@ function ensureLoaded(): void {
78
94
  const raw = fs.readFileSync(getExclusionsFilePath(), "utf-8");
79
95
  const data = JSON.parse(raw);
80
96
  if (data.version === 1) {
97
+ if (!Array.isArray(data.exclusions)) throw new Error("Model exclusion store version 1 must contain an exclusions array.");
98
+ for (let index = 0; index < data.exclusions.length; index++) {
99
+ const entry = data.exclusions[index];
100
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw new Error(`Model exclusion store entry ${index} must be an object.`);
101
+ if (entry.reason !== undefined && typeof entry.reason !== "string") throw new Error(`Model exclusion store entry ${index} has an invalid reason.`);
102
+ for (const field of ["recordedAt", "expiresAt"] as const) {
103
+ const timestamp = entry[field];
104
+ if (typeof timestamp !== "number" || !Number.isFinite(timestamp) || timestamp <= 0 || timestamp > MAX_DATE_TIMESTAMP_MS) {
105
+ throw new Error(`Model exclusion store entry ${index} has an invalid ${field}.`);
106
+ }
107
+ }
108
+ }
81
109
  const now = Date.now();
82
110
  exclusions = (data.exclusions ?? []).filter((e: ModelExclusion) => e.expiresAt > now);
111
+ const shortened = loadedTTLCeilingMs !== undefined && shortenExclusionsToTTL(exclusions, loadedTTLCeilingMs, now);
83
112
  exclusions = deduplicate(exclusions);
113
+ if (shortened) schedulePersist();
84
114
  }
85
115
  } catch (error) {
86
116
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
@@ -205,7 +235,10 @@ export function parseModelKey(fullId: string): { provider?: string; modelId: str
205
235
  * Filter a list of candidate fullIds, removing excluded models/providers and
206
236
  * duplicates while preserving order.
207
237
  */
208
- export function filterFallbackCandidates(candidates: string[], opts?: { now?: number }): string[] {
238
+ export function filterFallbackCandidates(candidates: string[], opts?: {
239
+ now?: number;
240
+ onExcluded?: (candidate: string, exclusion: Readonly<ModelExclusion>) => void;
241
+ }): string[] {
209
242
  ensureLoaded();
210
243
  const timestamp = opts?.now ?? Date.now();
211
244
  const seen = new Set<string>();
@@ -213,8 +246,11 @@ export function filterFallbackCandidates(candidates: string[], opts?: { now?: nu
213
246
  for (const raw of candidates) {
214
247
  if (!raw || seen.has(raw)) continue;
215
248
  const { provider: candidateProvider, modelId: candidateModelId } = parseModelKey(raw);
216
- const excluded = exclusions.some((entry) => entryMatches(entry, candidateModelId, candidateProvider, timestamp));
217
- if (excluded) continue;
249
+ const exclusion = exclusions.find((entry) => entryMatches(entry, candidateModelId, candidateProvider, timestamp));
250
+ if (exclusion) {
251
+ opts?.onExcluded?.(raw, exclusion);
252
+ continue;
253
+ }
218
254
  seen.add(raw);
219
255
  filtered.push(raw);
220
256
  }
@@ -241,3 +277,17 @@ function prune(items: ModelExclusion[], now: number): void {
241
277
  }
242
278
  items.length = write;
243
279
  }
280
+
281
+ function shortenExclusionsToTTL(items: ModelExclusion[], ttlMs: number, now: number): boolean {
282
+ let changed = false;
283
+ for (const entry of items) {
284
+ const configuredExpiry = entry.recordedAt + ttlMs;
285
+ if (entry.expiresAt > configuredExpiry) {
286
+ entry.expiresAt = configuredExpiry;
287
+ changed = true;
288
+ }
289
+ }
290
+ const previousLength = items.length;
291
+ prune(items, now);
292
+ return changed || items.length !== previousLength;
293
+ }
@@ -2,6 +2,7 @@ import { splitKnownThinkingSuffix, type ModelInfo as AvailableModelInfo } from "
2
2
  import type { Usage } from "../../shared/types.ts";
3
3
  import { filterFallbackCandidates, parseModelKey, recordModelFailure } from "./model-exclusions.ts";
4
4
  import { checkModelScope, type ModelScopeCheckRule, type ModelScopeViolation, type ModelSource } from "./model-scope.ts";
5
+ import { redactSecretValues } from "./permissions.ts";
5
6
 
6
7
  export type { AvailableModelInfo };
7
8
 
@@ -394,7 +395,12 @@ export function buildModelCandidates(
394
395
  seen.add(normalized);
395
396
  candidates.push(normalized);
396
397
  }
397
- return filterFallbackCandidates(candidates);
398
+ return filterFallbackCandidates(candidates, {
399
+ onExcluded(candidate, exclusion) {
400
+ const reason = redactSecretValues((exclusion.reason ?? "runtime-failure").replace(/[\u0000-\u001f\u007f]+/g, " ")).slice(0, 240);
401
+ console.warn(`[pi-subagents] Skipping model '${candidate}' due to a cached exclusion (reason: ${reason}; expires: ${new Date(exclusion.expiresAt).toISOString()}).`);
402
+ },
403
+ });
398
404
  }
399
405
 
400
406
  const RETRYABLE_MODEL_FAILURE_PATTERNS = [
@@ -419,6 +425,7 @@ const RETRYABLE_MODEL_FAILURE_PATTERNS = [
419
425
  /overloaded/i,
420
426
  /service unavailable/i,
421
427
  /temporar(?:ily)? unavailable/i,
428
+ /connection\s+(?:error|reset|closed|aborted)/i,
422
429
  /connection refused/i,
423
430
  /fetch failed/i,
424
431
  /network error/i,
@@ -451,6 +458,21 @@ export function isRetryableModelFailure(error: string | undefined): boolean {
451
458
  return RETRYABLE_MODEL_FAILURE_PATTERNS.some((pattern) => pattern.test(error));
452
459
  }
453
460
 
461
+ function messageError(message: unknown): string | undefined {
462
+ if (!message || typeof message !== "object") return undefined;
463
+ const value = (message as { errorMessage?: unknown }).errorMessage;
464
+ return typeof value === "string" ? value : undefined;
465
+ }
466
+
467
+ export function isRetryableModelFailureAttempt(input: { error: string | undefined; messages?: readonly unknown[]; toolCount?: number }): boolean {
468
+ if (!isRetryableModelFailure(input.error)) return false;
469
+ if ((input.toolCount ?? 0) > 0) return false;
470
+ if (input.error === "Subagent produced no output (possible model cold-start or empty response).") return true;
471
+ if ((input.toolCount ?? 0) === 0 && (input.messages?.length ?? 0) === 0) return true;
472
+ const error = input.error?.trim();
473
+ return Boolean(error && input.messages?.some((message) => messageError(message)?.trim() === error));
474
+ }
475
+
454
476
  export function recordRetryableModelFailure(model: string | undefined, error: string | undefined): void {
455
477
  if (!model || !isRetryableModelFailure(error)) return;
456
478
  const { provider, modelId } = parseModelKey(model);
@@ -9,8 +9,13 @@ const MAX_TRACKED_PATHS = 500;
9
9
  const MAX_HASH_BYTES = 1024 * 1024;
10
10
  const MAX_TIMEOUT_FILES = 20;
11
11
 
12
+ function gitArguments(args: string[]): string[] {
13
+ // Mutation evidence must not block child startup on a stale fsmonitor daemon.
14
+ return ["-c", "core.fsmonitor=false", ...args];
15
+ }
16
+
12
17
  function gitOutput(cwd: string, args: string[], maxBuffer = MAX_HASH_BYTES): string {
13
- return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer });
18
+ return execFileSync("git", gitArguments(args), { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer });
14
19
  }
15
20
 
16
21
  function splitNul(output: string): string[] {
@@ -21,7 +26,7 @@ function hashLargeDiff(cwd: string, relativePath: string): string {
21
26
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagents-tracked-diff-"));
22
27
  const diffPath = path.join(tempDir, "diff.patch");
23
28
  try {
24
- execFileSync("git", ["diff", "--no-ext-diff", "--binary", `--output=${diffPath}`, "HEAD", "--", relativePath], { cwd, stdio: "ignore" });
29
+ execFileSync("git", gitArguments(["diff", "--no-ext-diff", "--binary", `--output=${diffPath}`, "HEAD", "--", relativePath]), { cwd, stdio: "ignore" });
25
30
  const hash = createHash("sha256");
26
31
  const buffer = Buffer.allocUnsafe(64 * 1024);
27
32
  const fd = fs.openSync(diffPath, "r");
@@ -199,8 +199,10 @@ function sanitizeTokenUsage(value: unknown): NestedRunSummary["totalTokens"] | u
199
199
  const input = clampNumber(raw.input);
200
200
  const output = clampNumber(raw.output);
201
201
  const total = clampNumber(raw.total);
202
+ const window = clampNumber(raw.window);
203
+ const windowPeak = clampNumber(raw.windowPeak);
202
204
  return input !== undefined && output !== undefined && total !== undefined
203
- ? { input, output, total }
205
+ ? { input, output, total, ...(window !== undefined ? { window } : {}), ...(windowPeak !== undefined ? { windowPeak } : {}) }
204
206
  : undefined;
205
207
  }
206
208
 
@@ -1,4 +1,4 @@
1
- import { formatDuration, formatModelThinking, formatTokens, shortenPath } from "../../shared/formatters.ts";
1
+ import { formatDuration, formatModelThinking, formatTokenUsage, shortenPath } from "../../shared/formatters.ts";
2
2
  import { formatActivityLabel } from "../../shared/status-format.ts";
3
3
  import type { ActivityState, NestedRunSummary, NestedStepSummary } from "../../shared/types.ts";
4
4
 
@@ -68,7 +68,7 @@ function formatNestedActivity(input: {
68
68
  if (input.currentPath) facts.push(shortenPath(input.currentPath));
69
69
  if (input.turnCount !== undefined) facts.push(`${input.turnCount} turns`);
70
70
  if (input.toolCount !== undefined) facts.push(`${input.toolCount} tools`);
71
- if (input.totalTokens) facts.push(`${formatTokens(input.totalTokens.total)} tok`);
71
+ if (input.totalTokens) facts.push(formatTokenUsage(input.totalTokens));
72
72
  const activity = formatActivityLabel(input.lastActivityAt, input.activityState as ActivityState | undefined);
73
73
  return activity || facts.length ? [activity, ...facts].filter(Boolean).join(" | ") : undefined;
74
74
  }
@@ -2,6 +2,19 @@ export function formatProcessSignalError(signal: string): string {
2
2
  return `Subagent process terminated by signal ${signal}.`;
3
3
  }
4
4
 
5
+ export function formatMidToolExitError(input: {
6
+ toolName: string;
7
+ exitCode: number | null;
8
+ processSignal?: string | null;
9
+ }): string {
10
+ const terminal = [`exit ${input.exitCode ?? "unknown"}`, ...(input.processSignal ? [`signal ${input.processSignal}`] : [])].join(", ");
11
+ return `Subagent process exited during '${input.toolName}' tool execution (${terminal}) before the tool completed. Earlier assistant output is not a terminal result.`;
12
+ }
13
+
14
+ export function isOrdinaryToolForMidToolExit(toolName: string): boolean {
15
+ return toolName !== "intercom" && toolName !== "contact_supervisor";
16
+ }
17
+
5
18
  export function isUnexplainedProcessSignal(input: {
6
19
  processSignal?: string | null;
7
20
  interrupted?: boolean;
@@ -2,6 +2,9 @@ import { createHash } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { getAgentDir } from "../../shared/utils.ts";
5
+ import { isUnexplainedProcessSignal } from "./process-signal.ts";
6
+
7
+ export type RunOutcome = "completed" | "failed" | "timed_out" | "stopped" | "interrupted";
5
8
 
6
9
  export interface RunEntry {
7
10
  agent: string;
@@ -9,6 +12,7 @@ export interface RunEntry {
9
12
  taskHash?: string;
10
13
  ts: number;
11
14
  status: "ok" | "error";
15
+ outcome?: RunOutcome;
12
16
  duration: number;
13
17
  exit?: number;
14
18
  }
@@ -129,14 +133,30 @@ function appendPrivateHistoryLine(historyPath: string, line: string): void {
129
133
  }
130
134
  }
131
135
 
132
- export function recordRun(agent: string, task: string, exitCode: number, durationMs: number): void {
136
+ export function recordRun(
137
+ agent: string,
138
+ task: string,
139
+ exitCode: number,
140
+ durationMs: number,
141
+ terminal: { interrupted?: boolean; processSignal?: string | null; stopped?: boolean; timedOut?: boolean; turnBudgetExceeded?: boolean } = {},
142
+ ): void {
133
143
  try {
144
+ const outcome: RunOutcome = terminal.stopped
145
+ ? "stopped"
146
+ : terminal.interrupted
147
+ ? "interrupted"
148
+ : terminal.timedOut
149
+ ? "timed_out"
150
+ : exitCode !== 0 && isUnexplainedProcessSignal(terminal)
151
+ ? "stopped"
152
+ : exitCode === 0 ? "completed" : "failed";
134
153
  const entry: RunEntry = {
135
154
  agent,
136
155
  task: REDACTED_TASK,
137
156
  taskHash: hashTask(task),
138
157
  ts: Math.floor(Date.now() / 1000),
139
158
  status: exitCode === 0 ? "ok" : "error",
159
+ outcome,
140
160
  duration: durationMs,
141
161
  ...(exitCode !== 0 ? { exit: exitCode } : {}),
142
162
  };
@@ -37,6 +37,8 @@ interface ForkableSessionManager {
37
37
 
38
38
  interface ForkContextResolverOptions {
39
39
  openSession?: (path: string, sessionDir?: string) => BranchSessionManager;
40
+ /** Rewrite a created fork before its path can be used to spawn a child. */
41
+ pruneSession?: (sessionFile: string) => Promise<void>;
40
42
  /** Decide per child index whether a sanitized transcript must also disable the child's
41
43
  * thinking. Defaults to true (the pre-existing conservative behavior) when omitted. */
42
44
  forceThinkingOffForIndex?: (index: number) => boolean;
@@ -48,6 +50,7 @@ interface ForkContextResolution {
48
50
  }
49
51
 
50
52
  interface ForkContextResolver {
53
+ prepareSessionForIndex(index?: number): Promise<void>;
51
54
  sessionFileForIndex(index?: number): string | undefined;
52
55
  thinkingOverrideForIndex(index?: number): "off" | undefined;
53
56
  }
@@ -190,6 +193,7 @@ export function createForkContextResolver(
190
193
  ): ForkContextResolver {
191
194
  if (resolveSubagentContext(requestedContext) !== "fork") {
192
195
  return {
196
+ prepareSessionForIndex: async () => {},
193
197
  sessionFileForIndex: () => undefined,
194
198
  thinkingOverrideForIndex: () => undefined,
195
199
  };
@@ -226,6 +230,8 @@ export function createForkContextResolver(
226
230
  "forks",
227
231
  );
228
232
  const cachedResolutions = new Map<number, ForkContextResolution>();
233
+ const preparedIndexes = new Set<number>();
234
+ const preparationPromises = new Map<number, Promise<void>>();
229
235
 
230
236
  const resolveFork = (index = 0): ForkContextResolution => {
231
237
  const cached = cachedResolutions.get(index);
@@ -274,7 +280,22 @@ export function createForkContextResolver(
274
280
  };
275
281
 
276
282
  return {
283
+ async prepareSessionForIndex(index = 0): Promise<void> {
284
+ const resolution = resolveFork(index);
285
+ if (!options.pruneSession || preparedIndexes.has(index)) return;
286
+ let preparation = preparationPromises.get(index);
287
+ if (!preparation) {
288
+ preparation = options.pruneSession(resolution.sessionFile).then(() => {
289
+ preparedIndexes.add(index);
290
+ });
291
+ preparationPromises.set(index, preparation);
292
+ }
293
+ await preparation;
294
+ },
277
295
  sessionFileForIndex(index = 0): string | undefined {
296
+ if (options.pruneSession && !preparedIndexes.has(index)) {
297
+ throw new Error(`Pruned fork session ${index} was used before pruning completed.`);
298
+ }
278
299
  return resolveFork(index).sessionFile;
279
300
  },
280
301
  thinkingOverrideForIndex(index = 0): "off" | undefined {
@@ -4,7 +4,7 @@
4
4
 
5
5
  import * as fs from "node:fs";
6
6
  import * as path from "node:path";
7
- import type { Usage, SingleResult } from "./types.ts";
7
+ import type { Usage, SingleResult, TokenUsage } from "./types.ts";
8
8
  import type { ChainStep } from "./settings.ts";
9
9
  import { isDynamicParallelStep, isParallelStep } from "./settings.ts";
10
10
  import { previewDisplayText, sanitizeDisplayText } from "./display-text.ts";
@@ -17,6 +17,12 @@ export function formatTokens(n: number): string {
17
17
  return n < 1000 ? String(n) : n < 10000 ? `${(n / 1000).toFixed(1)}k` : `${Math.round(n / 1000)}k`;
18
18
  }
19
19
 
20
+ export function formatTokenUsage(usage: TokenUsage, legacyLabel = "tok"): string {
21
+ return usage.window !== undefined
22
+ ? `${formatTokens(usage.window)} window · ${formatTokens(usage.total)} spent`
23
+ : `${formatTokens(usage.total)} ${legacyLabel}`;
24
+ }
25
+
20
26
  export function formatModelThinking(model?: string, thinking?: string): string {
21
27
  const parsed = model ? splitKnownThinkingSuffix(model) : undefined;
22
28
  let displayModel = parsed?.baseModel ?? model;