@herbertgao/pi-subagents 0.15.5 → 0.16.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,3 +1,4 @@
1
+ import { Type } from "@sinclair/typebox"
1
2
  import type {
2
3
  AgentConfig,
3
4
  IsolationMode,
@@ -5,6 +6,61 @@ import type {
5
6
  ThinkingLevel,
6
7
  } from "./types.js"
7
8
 
9
+ /**
10
+ * The model-facing `isolation` parameter, shared by the `Agent` tool and the
11
+ * nested delegation tool so the two cannot drift.
12
+ *
13
+ * Shape matters more than wording here. As a single-value optional literal,
14
+ * models that fill every optional parameter — the transcript on #231 shows one
15
+ * emitting `resume: ""`, `schedule: ""` and `model: "default"` alongside it —
16
+ * had only `"worktree"` available to fill it with, and kept spawning worktrees
17
+ * across three turns while their own reasoning said to omit the field. Every
18
+ * other optional parameter has an inert filler; this one did not. `"off"` is
19
+ * listed first and described as the default so the harmless value is the
20
+ * obvious one to reach for.
21
+ *
22
+ * The wording tracks Claude Code's own `isolation` parameter, whose phrasing
23
+ * models have the most exposure to: one description on the union rather than
24
+ * per-value ones, opening "Isolation mode.", then a sentence per value in
25
+ * schema order, each with its caveats in a trailing parenthetical. Two clauses
26
+ * are ours, because our shape is not theirs — `"off"` has no counterpart there
27
+ * (their enum is `worktree | remote`, so both of their values do something),
28
+ * and neither does the uncommitted-work warning, which is the specific trap
29
+ * #231 fell into. Deliberately absent is any "only use a worktree when…"
30
+ * restriction: Claude Code's `Agent` tool states the capability and stops, and
31
+ * a second legal value is what lets a model decline one, not being told to.
32
+ */
33
+ const isolationParamShape = {
34
+ isolation: Type.Optional(
35
+ Type.Union([Type.Literal("off"), Type.Literal("worktree")], {
36
+ description:
37
+ 'Isolation mode. Default "off". "off" runs the agent in the current checkout, the same as omitting the field. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo (a copy cannot see uncommitted or staged changes in the main checkout).',
38
+ }),
39
+ ),
40
+ }
41
+
42
+ /**
43
+ * Build the `isolation` parameter for a tool schema, or nothing when the
44
+ * project disabled worktrees (`worktreeIsolation: false`).
45
+ *
46
+ * Dropping the field beats accepting it and quietly downgrading. The setting is
47
+ * for a project whose model passes `"worktree"` on *every* call, so a
48
+ * per-result "isolation was disabled" note would be noise on every result and
49
+ * would keep raising the salience of a capability that isn't there. With no
50
+ * field there is nothing to pass, nothing to drop, and nothing to explain — the
51
+ * same trade `scheduleParam` makes for disabled scheduling, at zero LLM-context
52
+ * cost. The resolver gate and the `agent-manager` check still cover the paths a
53
+ * schema can't reach: agent files, the scheduler, and cross-extension RPC.
54
+ *
55
+ * Like `scheduleParam`, this is read once at tool registration — flipping the
56
+ * setting needs a new pi session for the schema to change.
57
+ */
58
+ export function isolationParam(
59
+ enabled: boolean,
60
+ ): Partial<typeof isolationParamShape> {
61
+ return enabled ? isolationParamShape : {}
62
+ }
63
+
8
64
  interface AgentInvocationParams {
9
65
  model?: string
10
66
  thinking?: string
@@ -12,12 +68,42 @@ interface AgentInvocationParams {
12
68
  run_in_background?: boolean
13
69
  inherit_context?: boolean
14
70
  isolated?: boolean
15
- isolation?: IsolationMode
71
+ /**
72
+ * Untyped on purpose. Both tool schemas now build this field conditionally
73
+ * and spread it, which erases TypeBox's literal inference to `unknown` (the
74
+ * `schedule` param has the same shape). The resolver below narrows by
75
+ * comparison rather than trusting the declaration, which also makes it safe
76
+ * for the cross-extension RPC path, where options arrive unvalidated.
77
+ */
78
+ isolation?: unknown
79
+ }
80
+
81
+ interface ResolveOptions {
82
+ /**
83
+ * Whether worktree isolation is permitted at all. False when the project set
84
+ * `worktreeIsolation: false`, which drops a requested worktree rather than
85
+ * failing the call: the fail-loud precedent covers spawns that *cannot* work,
86
+ * while this one is the user opting out, and throwing would break exactly the
87
+ * calls the `"off"` value exists to tolerate. Defaults to allowed.
88
+ */
89
+ worktreeAllowed?: boolean
90
+ /**
91
+ * What an unqualified spawn means — neither the call nor the agent file said.
92
+ *
93
+ * Top-level callers pass the `backgroundByDefault` setting (default `true`,
94
+ * following Claude Code). Nested callers pass `false` unconditionally: a
95
+ * detached child is killed by `abortOwnedChildren` when its parent settles
96
+ * and has no notification path of its own, so backgrounding one loses its
97
+ * work. Both call sites pass it explicitly; the `false` fallback only covers
98
+ * a caller that supplies no options at all, which in-tree means tests.
99
+ */
100
+ defaultRunInBackground?: boolean
16
101
  }
17
102
 
18
103
  export function resolveAgentInvocationConfig(
19
104
  agentConfig: AgentConfig | undefined,
20
105
  params: AgentInvocationParams,
106
+ opts?: ResolveOptions,
21
107
  ): {
22
108
  modelInput?: string
23
109
  modelFromParams: boolean
@@ -28,6 +114,15 @@ export function resolveAgentInvocationConfig(
28
114
  isolated: boolean
29
115
  isolation?: IsolationMode
30
116
  } {
117
+ // Precedence first, collapse second — reversing these loses the veto, since
118
+ // an agent file's "off" only outranks a caller's "worktree" while it is still
119
+ // a value. Everything downstream then sees "worktree" or nothing at all.
120
+ const requested = agentConfig?.isolation ?? params.isolation
121
+ const isolation =
122
+ requested === "worktree" && opts?.worktreeAllowed !== false
123
+ ? "worktree"
124
+ : undefined
125
+
31
126
  return {
32
127
  modelInput: agentConfig?.model ?? params.model,
33
128
  modelFromParams: agentConfig?.model == null && params.model != null,
@@ -38,9 +133,12 @@ export function resolveAgentInvocationConfig(
38
133
  inheritContext:
39
134
  agentConfig?.inheritContext ?? params.inherit_context ?? false,
40
135
  runInBackground:
41
- agentConfig?.runInBackground ?? params.run_in_background ?? false,
136
+ agentConfig?.runInBackground ??
137
+ params.run_in_background ??
138
+ opts?.defaultRunInBackground ??
139
+ false,
42
140
  isolated: agentConfig?.isolated ?? params.isolated ?? false,
43
- isolation: agentConfig?.isolation ?? params.isolation,
141
+ isolation,
44
142
  }
45
143
  }
46
144
 
@@ -16,7 +16,10 @@ import {
16
16
  resolveTypeIn,
17
17
  } from "./agent-types.js"
18
18
  import { loadCustomAgents } from "./custom-agents.js"
19
- import { resolveAgentInvocationConfig } from "./invocation-config.js"
19
+ import {
20
+ isolationParam,
21
+ resolveAgentInvocationConfig,
22
+ } from "./invocation-config.js"
20
23
  import { resolveModel } from "./model-resolver.js"
21
24
  import { checkModelScope } from "./model-scope.js"
22
25
  import {
@@ -38,6 +41,7 @@ import type {
38
41
  ThinkingLevel,
39
42
  } from "./types.js"
40
43
  import { addUsage } from "./usage.js"
44
+ import { isWorktreeIsolationEnabled } from "./worktree.js"
41
45
 
42
46
  /**
43
47
  * Hard ceiling on nesting for every branch: main session = 0, its subagents = 1,
@@ -212,7 +216,12 @@ export function createNestedSubagentTools(
212
216
  Type.String({ description: "Optional thinking level." }),
213
217
  ),
214
218
  max_turns: Type.Optional(Type.Number({ minimum: 1 })),
215
- run_in_background: Type.Optional(Type.Boolean()),
219
+ run_in_background: Type.Optional(
220
+ Type.Boolean({
221
+ description:
222
+ "Defaults to false for nested spawns — the call blocks and returns the child's result inline. Set true only for work you will collect later with get_subagent_result; a detached child is stopped when you finish.",
223
+ }),
224
+ ),
216
225
  resume: Type.Optional(
217
226
  Type.String({
218
227
  description: "Resume a nested agent owned by this parent.",
@@ -220,7 +229,7 @@ export function createNestedSubagentTools(
220
229
  ),
221
230
  isolated: Type.Optional(Type.Boolean()),
222
231
  inherit_context: Type.Optional(Type.Boolean()),
223
- isolation: Type.Optional(Type.Literal("worktree")),
232
+ ...isolationParam(isWorktreeIsolationEnabled()),
224
233
  }),
225
234
  execute: async (_toolCallId, params, signal, _onUpdate, ctx) => {
226
235
  if (params.resume) {
@@ -277,7 +286,12 @@ export function createNestedSubagentTools(
277
286
  }
278
287
 
279
288
  const config = getAgentConfigIn(registry, resolvedType)
280
- const invocation = resolveAgentInvocationConfig(config, params)
289
+ // Foreground regardless of `backgroundByDefault` — see the reasoning on
290
+ // ResolveOptions. An explicit `true` here still opts in.
291
+ const invocation = resolveAgentInvocationConfig(config, params, {
292
+ worktreeAllowed: isWorktreeIsolationEnabled(),
293
+ defaultRunInBackground: false,
294
+ })
281
295
  let model = ctx.model
282
296
  if (invocation.modelInput) {
283
297
  const resolvedModel = resolveModel(
package/src/settings.ts CHANGED
@@ -18,6 +18,23 @@ export interface SubagentsSettings {
18
18
  defaultMaxTurns?: number
19
19
  graceTurns?: number
20
20
  defaultJoinMode?: JoinMode
21
+ /**
22
+ * Whether a top-level `Agent` spawn that doesn't say runs detached.
23
+ * Defaults to `true`, following Claude Code, where the agent backgrounds
24
+ * unless the caller passes `run_in_background: false`. Set `false` to restore
25
+ * the previous behaviour, where an unqualified spawn blocked the turn and
26
+ * returned its result inline.
27
+ *
28
+ * Top-level only. Nested spawns (a subagent spawning its own) always default
29
+ * to foreground regardless of this setting — see `nested-tools.ts`, where a
30
+ * detached child would be killed by `abortOwnedChildren` when its parent
31
+ * settles, with no notification path to deliver its result.
32
+ *
33
+ * An explicit `run_in_background` on the call, or in the agent file's
34
+ * frontmatter, overrides this in both directions; the setting only decides
35
+ * what "unspecified" means.
36
+ */
37
+ backgroundByDefault?: boolean
21
38
  /**
22
39
  * Master switch for the schedule subagent feature. Defaults to `true`.
23
40
  * When `false`: the `Agent` tool's `schedule` param + its guideline are
@@ -101,6 +118,29 @@ export interface SubagentsSettings {
101
118
  * (`isolation: worktree`), or memory files.
102
119
  */
103
120
  outputTranscript?: boolean
121
+ /**
122
+ * Whether `isolation: "worktree"` may create a worktree at all. Defaults to
123
+ * `true`. Set `false` on a repo where worktrees are too slow or too large to
124
+ * be worth it (#184): a requested worktree is then dropped and the agent runs
125
+ * in the main checkout.
126
+ *
127
+ * The drop is deliberately silent — there is no per-result note, because the
128
+ * setting exists for projects whose model asks for a worktree on every call,
129
+ * where a note would be noise on every result. What keeps the orchestrator
130
+ * from claiming a `pi-agent-*` branch anyway is that it is never told the
131
+ * capability exists: `isolationParam` (invocation-config.ts) drops the field
132
+ * from both tool schemas, and `isolationGuideline` (index.ts) drops the
133
+ * matching prose from the full and compact descriptions — a custom one opts
134
+ * in via the `{{isolationGuideline}}` placeholder. Anything that
135
+ * reintroduces the prose has to reintroduce a note with it.
136
+ *
137
+ * Deliberately a downgrade rather than an error. The fail-loud rule covers
138
+ * worktrees that *cannot* be created; this is the user declining one, and
139
+ * throwing would reject exactly the calls that the `isolation: "off"` value
140
+ * exists to tolerate. Enforced below the tool boundary, so it also covers the
141
+ * scheduler and the unvalidated cross-extension RPC path.
142
+ */
143
+ worktreeIsolation?: boolean
104
144
  /**
105
145
  * Hard ceiling on nested subagent delegation, counted from the main session:
106
146
  * main = 0, its subagents = 1, their children = 2. Defaults to `2`; `0` or `1`
@@ -123,6 +163,43 @@ export interface SubagentsSettings {
123
163
  * meaning one thing here and another in the resolver.
124
164
  */
125
165
  fallbackSubagent?: string
166
+ /**
167
+ * Whether this extension's tool results carry a `usage` field, so subagent
168
+ * spend reaches the parent session's own accounting. Defaults to `false`.
169
+ *
170
+ * Subagents run in their own pi sessions, so by default the parent's footer,
171
+ * statusline and `/cost` show only what the main model spent — a session that
172
+ * delegated most of its work reads as nearly free. Pi folds
173
+ * `toolResult.usage` into `getSessionStats()`, so attaching it makes those
174
+ * surfaces count subagents too, under `/cost`'s "Tools/summaries" bucket.
175
+ *
176
+ * Off by default because it changes numbers the user may already be tracking
177
+ * (a statusline reading session cost will step up), not because the numbers
178
+ * are wrong.
179
+ *
180
+ * Three properties of what gets reported:
181
+ * - Tokens exclude `cacheRead`, for the reason in `usage.ts` — the parent's
182
+ * token total therefore rises by billed tokens only.
183
+ * - Cost is pi's own per-message `usage.cost.total`; we price nothing, and
184
+ * a model pi has no rates for contributes 0.
185
+ * - The context-window percentage is untouched. Pi derives it from assistant
186
+ * messages alone (`getContextUsage`), so a delegating session's context
187
+ * does not appear to fill up faster.
188
+ */
189
+ reportUsage?: boolean
190
+ /**
191
+ * Whether the subagent surfaces show an estimated dollar cost next to their
192
+ * token counts (widget, FleetView, conversation viewer, foreground results,
193
+ * completion notifications). Defaults to `false`. Applied live.
194
+ *
195
+ * Rendered as `~$0.0042` — the tilde marks it as pi's reported estimate
196
+ * rather than a billed figure, and it is omitted entirely when the model has
197
+ * no pricing data, so a local model shows tokens and no dollars.
198
+ *
199
+ * Independent of `reportUsage`: this one is what a human reads, that one is
200
+ * what the parent session counts.
201
+ */
202
+ showCost?: boolean
126
203
  }
127
204
 
128
205
  export type ToolDescriptionMode = "full" | "compact" | "custom"
@@ -133,6 +210,7 @@ export interface SettingsAppliers {
133
210
  setDefaultMaxTurns: (n: number) => void
134
211
  setGraceTurns: (n: number) => void
135
212
  setDefaultJoinMode: (mode: JoinMode) => void
213
+ setBackgroundByDefault: (b: boolean) => void
136
214
  setSchedulingEnabled: (b: boolean) => void
137
215
  setScopeModels: (enabled: boolean) => void
138
216
  setStrictAgentFiles: (b: boolean) => void
@@ -141,8 +219,11 @@ export interface SettingsAppliers {
141
219
  setFleetView: (b: boolean) => void
142
220
  setWidgetMode: (mode: WidgetMode) => void
143
221
  setOutputTranscript: (b: boolean) => void
222
+ setWorktreeIsolation: (b: boolean) => void
144
223
  setMaxSubagentDepth: (n: number) => void
145
224
  setFallbackSubagent: (v: string | undefined) => void
225
+ setReportUsage: (b: boolean) => void
226
+ setShowCost: (b: boolean) => void
146
227
  }
147
228
 
148
229
  /** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
@@ -208,6 +289,9 @@ function sanitize(raw: unknown): SubagentsSettings {
208
289
  ) {
209
290
  out.defaultJoinMode = r.defaultJoinMode as JoinMode
210
291
  }
292
+ if (typeof r.backgroundByDefault === "boolean") {
293
+ out.backgroundByDefault = r.backgroundByDefault
294
+ }
211
295
  if (typeof r.schedulingEnabled === "boolean") {
212
296
  out.schedulingEnabled = r.schedulingEnabled
213
297
  }
@@ -238,6 +322,15 @@ function sanitize(raw: unknown): SubagentsSettings {
238
322
  if (typeof r.outputTranscript === "boolean") {
239
323
  out.outputTranscript = r.outputTranscript
240
324
  }
325
+ if (typeof r.worktreeIsolation === "boolean") {
326
+ out.worktreeIsolation = r.worktreeIsolation
327
+ }
328
+ if (typeof r.reportUsage === "boolean") {
329
+ out.reportUsage = r.reportUsage
330
+ }
331
+ if (typeof r.showCost === "boolean") {
332
+ out.showCost = r.showCost
333
+ }
241
334
  if (r.fallbackSubagent === false) {
242
335
  // The only non-string spelling worth accepting: a boolean would otherwise be
243
336
  // dropped, silently leaving the PERMISSIVE default in place. Every string is
@@ -322,6 +415,8 @@ export function applySettings(
322
415
  if (typeof s.fallbackSubagent === "string")
323
416
  appliers.setFallbackSubagent(s.fallbackSubagent)
324
417
  if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode)
418
+ if (typeof s.backgroundByDefault === "boolean")
419
+ appliers.setBackgroundByDefault(s.backgroundByDefault)
325
420
  if (typeof s.schedulingEnabled === "boolean")
326
421
  appliers.setSchedulingEnabled(s.schedulingEnabled)
327
422
  if (typeof s.scopeModels === "boolean") appliers.setScopeModels(s.scopeModels)
@@ -335,6 +430,10 @@ export function applySettings(
335
430
  if (s.widgetMode) appliers.setWidgetMode(s.widgetMode)
336
431
  if (typeof s.outputTranscript === "boolean")
337
432
  appliers.setOutputTranscript(s.outputTranscript)
433
+ if (typeof s.worktreeIsolation === "boolean")
434
+ appliers.setWorktreeIsolation(s.worktreeIsolation)
435
+ if (typeof s.reportUsage === "boolean") appliers.setReportUsage(s.reportUsage)
436
+ if (typeof s.showCost === "boolean") appliers.setShowCost(s.showCost)
338
437
  }
339
438
 
340
439
  /**
package/src/types.ts CHANGED
@@ -21,8 +21,18 @@ export const DEFAULT_AGENT_NAMES = [
21
21
  /** Memory scope for persistent agent memory. */
22
22
  export type MemoryScope = "user" | "project" | "local"
23
23
 
24
- /** Isolation mode for agent execution. */
25
- export type IsolationMode = "worktree"
24
+ /**
25
+ * Isolation mode for agent execution.
26
+ *
27
+ * `"off"` exists for the caller's benefit, not the runtime's: models that fill
28
+ * every optional parameter had no legal way to decline a single-value
29
+ * `isolation` field and kept spawning worktrees they had just reasoned their
30
+ * way out of (#231, #184). It is an input spelling only —
31
+ * `resolveAgentInvocationConfig` collapses it to `undefined`, so nothing
32
+ * downstream sees a value other than `"worktree"`. In an agent file it is a
33
+ * genuine veto, since agent config outranks tool-call params.
34
+ */
35
+ export type IsolationMode = "worktree" | "off"
26
36
 
27
37
  /** Unified agent configuration — used for both default and user-defined agents. */
28
38
  export interface AgentConfig {
@@ -69,7 +79,10 @@ export interface AgentConfig {
69
79
  isolated?: boolean
70
80
  /** Persistent memory scope — agents with memory get a persistent directory and MEMORY.md */
71
81
  memory?: MemoryScope
72
- /** Isolation mode — "worktree" runs the agent in a temporary git worktree */
82
+ /**
83
+ * Isolation mode — "worktree" runs the agent in a temporary git worktree,
84
+ * "off" refuses one even when the caller asks (frontmatter outranks params).
85
+ */
73
86
  isolation?: IsolationMode
74
87
  /** true = this is an embedded default agent (informational) */
75
88
  isDefault?: boolean
@@ -183,6 +196,12 @@ export interface NotificationDetails {
183
196
  turnCount: number
184
197
  maxTurns?: number
185
198
  totalTokens: number
199
+ /**
200
+ * Estimated cost in USD, from pi's per-message `usage.cost.total`. Always
201
+ * populated (0 when the model has no pricing); the renderer decides whether
202
+ * to show it, per the `showCost` setting.
203
+ */
204
+ totalCost?: number
186
205
  durationMs: number
187
206
  outputFile?: string
188
207
  error?: string
@@ -11,6 +11,7 @@ import type { AgentManager } from "../agent-manager.js"
11
11
  import { getConfig } from "../agent-types.js"
12
12
  import type { AgentInvocation, SubagentType, WidgetMode } from "../types.js"
13
13
  import {
14
+ getLifetimeCost,
14
15
  getLifetimeTotal,
15
16
  getSessionContextPercent,
16
17
  type LifetimeUsage,
@@ -75,8 +76,6 @@ export interface AgentActivity {
75
76
  turnCount: number
76
77
  /** Effective max turns for this agent (undefined = unlimited). */
77
78
  maxTurns?: number
78
- /** Lifetime usage breakdown — see LifetimeUsage docs. */
79
- lifetimeUsage: LifetimeUsage
80
79
  }
81
80
 
82
81
  /** Metadata attached to Agent tool results for custom rendering. */
@@ -108,6 +107,8 @@ export interface AgentDetails {
108
107
  turnCount?: number
109
108
  /** Effective max turns (undefined = unlimited). */
110
109
  maxTurns?: number
110
+ /** Estimated cost in USD; 0 when the model has no pricing data. */
111
+ cost?: number
111
112
  agentId?: string
112
113
  error?: string
113
114
  }
@@ -135,6 +136,31 @@ export function formatTokens(count: number): string {
135
136
  return `${count} token`
136
137
  }
137
138
 
139
+ /**
140
+ * Format a cost as `~$0.0042`, or "" when there is nothing to show.
141
+ *
142
+ * The tilde is load-bearing: this is pi's own estimate from the model's listed
143
+ * rates, not a billed figure, and the surfaces that print it sit next to token
144
+ * counts that ARE exact.
145
+ *
146
+ * Nothing is printed for zero, which is also what a model with no pricing data
147
+ * reports: `$0.00` beside a local model's tokens would claim its cost was
148
+ * measured and found to be nothing, rather than never measured at all. For the
149
+ * same reason a real cost too small for four decimals reads `<$0.0001` — it was
150
+ * measured, and rounding it to `~$0.0000` would say the opposite.
151
+ */
152
+ export function formatCost(cost: number): string {
153
+ if (!(cost > 0)) return "" // also catches NaN
154
+ if (cost < 0.0001) return "<$0.0001"
155
+ if (cost >= 1) return `~$${cost.toFixed(2)}`
156
+ // Under a dollar: cents at minimum, four decimals at most, nothing trailing.
157
+ // Most single runs land between a tenth of a cent and a dime, where rounding
158
+ // to cents would collapse a 4x difference in spend into the same figure.
159
+ const rounded = Number(cost.toFixed(4))
160
+ const decimals = (String(rounded).split(".")[1] ?? "").length
161
+ return `~$${rounded.toFixed(Math.max(2, decimals))}`
162
+ }
163
+
138
164
  /**
139
165
  * Token count with optional context-fill % and compaction-count annotations.
140
166
  * Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
@@ -285,6 +311,12 @@ export class AgentWidget {
285
311
  * extension supplies one defaulting to `"background"`.
286
312
  */
287
313
  private mode: () => WidgetMode = () => "all",
314
+ /**
315
+ * Read live at render time, like `mode`. Whether running agents show an
316
+ * estimated cost beside their token count. Defaults to off — the extension
317
+ * supplies the user's `showCost` setting.
318
+ */
319
+ private showCost: () => boolean = () => false,
288
320
  ) {}
289
321
 
290
322
  /**
@@ -380,6 +412,7 @@ export class AgentWidget {
380
412
  startedAt: number
381
413
  completedAt?: number
382
414
  error?: string
415
+ lifetimeUsage?: LifetimeUsage
383
416
  },
384
417
  theme: Theme,
385
418
  ): string {
@@ -412,6 +445,13 @@ export class AgentWidget {
412
445
  if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns))
413
446
  if (a.toolUses > 0)
414
447
  parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`)
448
+ // From the record, not the activity tracker: that entry is deleted the
449
+ // moment an agent finishes, and "what did it cost" is a question asked
450
+ // about finished agents.
451
+ const costText = this.showCost()
452
+ ? formatCost(getLifetimeCost(a.lifetimeUsage))
453
+ : ""
454
+ if (costText) parts.push(costText)
415
455
  parts.push(duration)
416
456
 
417
457
  const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : ""
@@ -466,7 +506,11 @@ export class AgentWidget {
466
506
 
467
507
  const bg = this.agentActivity.get(a.id)
468
508
  const toolUses = bg?.toolUses ?? a.toolUses
469
- const tokens = getLifetimeTotal(bg?.lifetimeUsage)
509
+ // Spend comes from the record, never from the activity tracker: the record
510
+ // is the one that survives the agent finishing, and the one nested-tools
511
+ // folds a hidden child's spend into. Reading the tracker while an agent
512
+ // runs and the record once it stops made the figure jump at completion.
513
+ const tokens = getLifetimeTotal(a.lifetimeUsage)
470
514
  const contextPercent = getSessionContextPercent(bg?.session)
471
515
  const tokenText =
472
516
  tokens > 0
@@ -477,12 +521,16 @@ export class AgentWidget {
477
521
  a.compactionCount,
478
522
  )
479
523
  : ""
524
+ const costText = this.showCost()
525
+ ? formatCost(getLifetimeCost(a.lifetimeUsage))
526
+ : ""
480
527
 
481
528
  const parts: string[] = []
482
529
  if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns))
483
530
  if (toolUses > 0)
484
531
  parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`)
485
532
  if (tokenText) parts.push(tokenText)
533
+ if (costText) parts.push(costText)
486
534
  parts.push(elapsed)
487
535
  const statsText = parts.join(" · ")
488
536
 
@@ -18,13 +18,18 @@ import {
18
18
  import { renderAgentName } from "../agent-color.js"
19
19
  import { extractText } from "../context.js"
20
20
  import type { AgentRecord } from "../types.js"
21
- import { getLifetimeTotal, getSessionContextPercent } from "../usage.js"
21
+ import {
22
+ getLifetimeCost,
23
+ getLifetimeTotal,
24
+ getSessionContextPercent,
25
+ } from "../usage.js"
22
26
  import type { Theme } from "./agent-widget.js"
23
27
  import {
24
28
  type AgentActivity,
25
29
  buildInvocationTags,
26
30
  describeActivity,
27
31
  fgPreservingNestedStyles,
32
+ formatCost,
28
33
  formatDuration,
29
34
  formatSessionTokens,
30
35
  getPromptModeLabel,
@@ -66,6 +71,12 @@ export class ConversationViewer implements Component {
66
71
  keybindings?: ViewerKeybindings,
67
72
  /** Send a steering message to the agent. Omitted → no compose affordance. */
68
73
  private onSteer?: (message: string) => void,
74
+ /**
75
+ * Whether the header shows an estimated cost after the token count. Read
76
+ * once, at construction: the overlay is opened from a menu, so the setting
77
+ * cannot change while it is on screen.
78
+ */
79
+ private showCost = false,
69
80
  ) {
70
81
  this.keys = createViewerKeys(keybindings)
71
82
  this.unsubscribe = session.subscribe(() => {
@@ -83,7 +94,11 @@ export class ConversationViewer implements Component {
83
94
  return
84
95
  }
85
96
 
86
- if (matchesKey(data, "escape") || matchesKey(data, "q")) {
97
+ if (
98
+ matchesKey(data, "escape") ||
99
+ matchesKey(data, "ctrl+c") ||
100
+ matchesKey(data, "q")
101
+ ) {
87
102
  this.closed = true
88
103
  this.done(undefined)
89
104
  return
@@ -184,13 +199,20 @@ export class ConversationViewer implements Component {
184
199
  const toolUses = this.activity?.toolUses ?? this.record.toolUses
185
200
  if (toolUses > 0)
186
201
  headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`)
187
- const tokens = getLifetimeTotal(this.activity?.lifetimeUsage)
202
+ // Spend from the record, context from the live session: the record is the
203
+ // only total that survives the agent finishing and the only one carrying a
204
+ // nested child's spend.
205
+ const tokens = getLifetimeTotal(this.record.lifetimeUsage)
188
206
  if (tokens > 0) {
189
207
  const percent = getSessionContextPercent(this.activity?.session)
190
208
  headerParts.push(
191
209
  formatSessionTokens(tokens, percent, th, this.record.compactionCount),
192
210
  )
193
211
  }
212
+ const cost = this.showCost
213
+ ? formatCost(getLifetimeCost(this.record.lifetimeUsage))
214
+ : ""
215
+ if (cost) headerParts.push(cost)
194
216
 
195
217
  lines.push(
196
218
  row(
@@ -22,8 +22,8 @@ import {
22
22
  import { hasAgentBadge, renderAgentName } from "../agent-color.js"
23
23
  import type { AgentManager } from "../agent-manager.js"
24
24
  import type { AgentRecord } from "../types.js"
25
- import { getLifetimeTotal } from "../usage.js"
26
- import { type AgentActivity, type Theme } from "./agent-widget.js"
25
+ import { getLifetimeCost, getLifetimeTotal } from "../usage.js"
26
+ import { type AgentActivity, formatCost, type Theme } from "./agent-widget.js"
27
27
  import {
28
28
  ConversationViewer,
29
29
  VIEWPORT_HEIGHT_PCT,
@@ -128,6 +128,12 @@ export class FleetList {
128
128
  constructor(
129
129
  private manager: AgentManager,
130
130
  private agentActivity: Map<string, AgentActivity>,
131
+ /**
132
+ * Read live at render time. Whether each row shows an estimated cost after
133
+ * its token count. Defaults to off — the extension supplies the user's
134
+ * `showCost` setting.
135
+ */
136
+ private showCost: () => boolean = () => false,
131
137
  ) {}
132
138
 
133
139
  // ---- Lifecycle ----
@@ -393,6 +399,7 @@ export class FleetList {
393
399
  },
394
400
  keybindings,
395
401
  (message: string) => this.manager.steer(record.id, message),
402
+ this.showCost(),
396
403
  )
397
404
  },
398
405
  {
@@ -476,6 +483,10 @@ export class FleetList {
476
483
  width: number,
477
484
  theme: Theme,
478
485
  ): string {
486
+ // The selected row renders in the theme's primary text color so it reads as
487
+ // one selection (#230). A configured badge survives — Claude Code's FleetView
488
+ // keeps the agent color on the selected row too and only bolds it — which also
489
+ // keeps the row's width fixed as the selection moves.
479
490
  const selected = rosterIndex === sel
480
491
  const name = renderAgentName(
481
492
  record.type,
@@ -488,11 +499,15 @@ export class FleetList {
488
499
  ? theme.fg("text", record.description)
489
500
  : record.description
490
501
  const left = ` ${this.bullet(rosterIndex, sel, theme)} ${name} ${description}`
491
- const tokens = getLifetimeTotal(
492
- this.agentActivity.get(record.id)?.lifetimeUsage ?? record.lifetimeUsage,
493
- )
502
+ // The record, not the activity tracker — see the note in AgentWidget's
503
+ // running line: only the record carries a nested child's spend, and only it
504
+ // outlives the agent.
505
+ const tokens = getLifetimeTotal(record.lifetimeUsage)
494
506
  const elapsedMs = (record.completedAt ?? Date.now()) - record.startedAt // freezes once finished
495
- const stats = `${formatFleetElapsed(elapsedMs)} · ${formatFleetTokens(tokens)}`
507
+ const cost = this.showCost()
508
+ ? formatCost(getLifetimeCost(record.lifetimeUsage))
509
+ : ""
510
+ const stats = `${formatFleetElapsed(elapsedMs)} · ${formatFleetTokens(tokens)}${cost ? ` · ${cost}` : ""}`
496
511
  const right = selected ? theme.fg("text", stats) : theme.fg("dim", stats)
497
512
  return rightAlign(left, right, width)
498
513
  }