@herbertgao/pi-subagents 0.15.5 → 0.16.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/CHANGELOG.md +6 -0
- package/README.md +30 -22
- package/examples/agent-tool-description.md +5 -5
- package/package.json +7 -7
- package/src/agent-file-toggle.ts +7 -1
- package/src/agent-manager.ts +48 -19
- package/src/agent-runner.ts +17 -13
- package/src/custom-agents.ts +26 -2
- package/src/index.ts +219 -40
- package/src/invocation-config.ts +101 -3
- package/src/nested-tools.ts +18 -4
- package/src/settings.ts +99 -0
- package/src/types.ts +22 -3
- package/src/ui/agent-widget.ts +51 -3
- package/src/ui/conversation-viewer.ts +20 -2
- package/src/ui/fleet-list.ts +21 -6
- package/src/usage.ts +129 -1
- package/src/worktree.ts +20 -0
package/src/invocation-config.ts
CHANGED
|
@@ -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
|
-
|
|
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 ??
|
|
136
|
+
agentConfig?.runInBackground ??
|
|
137
|
+
params.run_in_background ??
|
|
138
|
+
opts?.defaultRunInBackground ??
|
|
139
|
+
false,
|
|
42
140
|
isolated: agentConfig?.isolated ?? params.isolated ?? false,
|
|
43
|
-
isolation
|
|
141
|
+
isolation,
|
|
44
142
|
}
|
|
45
143
|
}
|
|
46
144
|
|
package/src/nested-tools.ts
CHANGED
|
@@ -16,7 +16,10 @@ import {
|
|
|
16
16
|
resolveTypeIn,
|
|
17
17
|
} from "./agent-types.js"
|
|
18
18
|
import { loadCustomAgents } from "./custom-agents.js"
|
|
19
|
-
import {
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
25
|
-
|
|
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
|
-
/**
|
|
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
|
package/src/ui/agent-widget.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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(() => {
|
|
@@ -184,13 +195,20 @@ export class ConversationViewer implements Component {
|
|
|
184
195
|
const toolUses = this.activity?.toolUses ?? this.record.toolUses
|
|
185
196
|
if (toolUses > 0)
|
|
186
197
|
headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`)
|
|
187
|
-
|
|
198
|
+
// Spend from the record, context from the live session: the record is the
|
|
199
|
+
// only total that survives the agent finishing and the only one carrying a
|
|
200
|
+
// nested child's spend.
|
|
201
|
+
const tokens = getLifetimeTotal(this.record.lifetimeUsage)
|
|
188
202
|
if (tokens > 0) {
|
|
189
203
|
const percent = getSessionContextPercent(this.activity?.session)
|
|
190
204
|
headerParts.push(
|
|
191
205
|
formatSessionTokens(tokens, percent, th, this.record.compactionCount),
|
|
192
206
|
)
|
|
193
207
|
}
|
|
208
|
+
const cost = this.showCost
|
|
209
|
+
? formatCost(getLifetimeCost(this.record.lifetimeUsage))
|
|
210
|
+
: ""
|
|
211
|
+
if (cost) headerParts.push(cost)
|
|
194
212
|
|
|
195
213
|
lines.push(
|
|
196
214
|
row(
|
package/src/ui/fleet-list.ts
CHANGED
|
@@ -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
|
-
|
|
492
|
-
|
|
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
|
|
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
|
}
|