@herbertgao/pi-subagents 0.15.4 → 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/src/usage.ts CHANGED
@@ -6,23 +6,151 @@
6
6
  * stats-derived sum). cacheRead is excluded because each turn's cacheRead is
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
+ *
10
+ * That exclusion is about this *display* total, not about what was billed: the
11
+ * prefix really is re-read and re-charged on every call. So `cacheRead` is
12
+ * accumulated here anyway, kept out of `getLifetimeTotal` and used only where
13
+ * billing is the question — reporting to the parent session, whose own messages
14
+ * pi counts the same way (`addUsageToTotals`). Reporting 0 there would make a
15
+ * subagent's rows count differently from every other row in one total.
16
+ *
17
+ * `cost` is a plain sum for the same reason: it is what pi charged for that one
18
+ * message (`usage.cost.total`, priced from the model's rates), not a cumulative
19
+ * figure. Both are optional because a model with no pricing data reports no
20
+ * cost, and because every accumulator predates them; absent reads as 0.
9
21
  */
10
22
  export type LifetimeUsage = {
11
23
  input: number
12
24
  output: number
13
25
  cacheWrite: number
26
+ cacheRead?: number
27
+ cost?: number
14
28
  }
15
29
 
16
- /** Sum of lifetime usage components, or 0 if undefined. */
30
+ /**
31
+ * Sum of lifetime *token* components for DISPLAY, or 0 if undefined.
32
+ * Deliberately excludes `cacheRead` (see above) and `cost` — that is money, not
33
+ * tokens, and lives on the same object only because it accumulates on the same
34
+ * events.
35
+ */
17
36
  export function getLifetimeTotal(u?: LifetimeUsage): number {
18
37
  return u ? u.input + u.output + u.cacheWrite : 0
19
38
  }
20
39
 
40
+ /** Accumulated cost in USD, or 0 when unpriced/undefined. */
41
+ export function getLifetimeCost(u?: LifetimeUsage): number {
42
+ return u?.cost ?? 0
43
+ }
44
+
21
45
  /** Add a usage delta into a target accumulator (mutates target). */
22
46
  export function addUsage(into: LifetimeUsage, delta: LifetimeUsage): void {
23
47
  into.input += delta.input
24
48
  into.output += delta.output
25
49
  into.cacheWrite += delta.cacheWrite
50
+ if (delta.cacheRead) into.cacheRead = (into.cacheRead ?? 0) + delta.cacheRead
51
+ if (delta.cost) into.cost = (into.cost ?? 0) + delta.cost
52
+ }
53
+
54
+ /**
55
+ * A pi `Usage`. Rebuilt here rather than imported so this module stays
56
+ * dependency-free for tests; the fields are pi's, and every one of them must be
57
+ * present: pi's `addUsageToTotals` dereferences `usage.cost.total` with no
58
+ * guard, so a partial object throws inside pi rather than at the call site.
59
+ *
60
+ * This is pi's convention for spend in anything handed to a consumer — every
61
+ * extension-facing payload that carries it takes the whole object
62
+ * (`ToolResultEvent`, `ToolResultEventResult`, `AssistantMessage`, …), never a
63
+ * flattened cost. Pi flattens only in computed read APIs it expects you to
64
+ * render, like `SessionStats`. So both places we hand usage to someone else —
65
+ * `AgentToolResult.usage` and the `subagents:completed` / `subagents:failed`
66
+ * events — carry this, and gain whatever pi adds to `Usage` for free.
67
+ */
68
+ export type ReportedUsage = {
69
+ input: number
70
+ output: number
71
+ cacheRead: number
72
+ cacheWrite: number
73
+ totalTokens: number
74
+ cost: {
75
+ input: number
76
+ output: number
77
+ cacheRead: number
78
+ cacheWrite: number
79
+ total: number
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Render an accumulator as a pi `Usage`, or undefined when nothing was spent —
85
+ * callers attach nothing rather than a zero, so a consumer can tell "spent
86
+ * nothing" from "never ran".
87
+ *
88
+ * `cacheRead` IS included, unlike in `getLifetimeTotal`: pi sums it across a
89
+ * session's own assistant messages, and the prefix genuinely is re-read and
90
+ * re-billed on every call. Only `total` is populated on the cost breakdown; pi
91
+ * reads nothing else from it, and the per-kind split is not tracked.
92
+ */
93
+ export function toReportedUsage(u: LifetimeUsage): ReportedUsage | undefined {
94
+ const { input, output, cacheWrite, cacheRead = 0, cost = 0 } = u
95
+ if (
96
+ input === 0 &&
97
+ output === 0 &&
98
+ cacheWrite === 0 &&
99
+ cacheRead === 0 &&
100
+ cost === 0
101
+ )
102
+ return undefined
103
+ return {
104
+ input,
105
+ output,
106
+ cacheRead,
107
+ cacheWrite,
108
+ totalTokens: input + output + cacheRead + cacheWrite,
109
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: cost },
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Subagent spend that the parent session has not been told about yet.
115
+ *
116
+ * Subagents run in their own pi sessions, so none of what they spend appears in
117
+ * the parent's `getSessionStats()`. Pi does aggregate `toolResult.usage` into
118
+ * those stats, though — so the way back into the parent's footer and `/cost` is
119
+ * to hang the spend on a tool result. Background and scheduled agents finish
120
+ * between tool calls with nothing to hang it on, hence a pool: every assistant
121
+ * message lands here as it happens, and the next tool result we return carries
122
+ * whatever has accumulated.
123
+ *
124
+ * Drain empties it, so each message is reported exactly once no matter how many
125
+ * results are returned or how many agents were running.
126
+ */
127
+ export class PendingUsagePool {
128
+ private pending: LifetimeUsage = {
129
+ input: 0,
130
+ output: 0,
131
+ cacheWrite: 0,
132
+ cacheRead: 0,
133
+ cost: 0,
134
+ }
135
+ private dirty = false
136
+
137
+ add(delta: LifetimeUsage): void {
138
+ addUsage(this.pending, delta)
139
+ this.dirty = true
140
+ }
141
+
142
+ /**
143
+ * Take everything accumulated so far as a pi `Usage`, resetting the pool.
144
+ * Returns undefined when nothing is pending, so callers can leave the tool
145
+ * result untouched rather than attaching a zero.
146
+ */
147
+ drain(): ReportedUsage | undefined {
148
+ if (!this.dirty) return undefined
149
+ const drained = toReportedUsage(this.pending)
150
+ this.pending = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0 }
151
+ this.dirty = false
152
+ return drained
153
+ }
26
154
  }
27
155
 
28
156
  /** Minimal shape we read from upstream `getSessionStats()`. */
package/src/worktree.ts CHANGED
@@ -28,6 +28,26 @@ export interface WorktreeInfo {
28
28
  workPath: string
29
29
  }
30
30
 
31
+ /**
32
+ * Project-wide switch for worktree isolation (`worktreeIsolation` in
33
+ * subagents.json). Default `true` — unchanged behaviour.
34
+ *
35
+ * The `"off"` isolation value gives a model a legal way to decline a worktree,
36
+ * but it still depends on the model choosing it. This is the deterministic half
37
+ * of the same fix: on a large repo where every worktree costs real time and
38
+ * disk (#184), turning it off means no caller can create one, whatever it
39
+ * passes.
40
+ */
41
+ let worktreeIsolationEnabled = true
42
+
43
+ export function setWorktreeIsolationEnabled(enabled: boolean): void {
44
+ worktreeIsolationEnabled = enabled
45
+ }
46
+
47
+ export function isWorktreeIsolationEnabled(): boolean {
48
+ return worktreeIsolationEnabled
49
+ }
50
+
31
51
  export interface WorktreeCleanupResult {
32
52
  /** Whether changes were found in the worktree. */
33
53
  hasChanges: boolean
@@ -134,11 +154,15 @@ export function cleanupWorktree(
134
154
  // Truncate description for commit message (no shell sanitization needed — execFileSync uses argv)
135
155
  const safeDesc = agentDescription.slice(0, 200)
136
156
  const commitMsg = `pi-agent: ${safeDesc}`
137
- execFileSync("git", ["commit", "--no-verify", "-m", commitMsg], {
138
- cwd: worktree.path,
139
- stdio: "pipe",
140
- timeout: 10000,
141
- })
157
+ execFileSync(
158
+ "git",
159
+ ["commit", "--no-verify", "--no-gpg-sign", "-m", commitMsg],
160
+ {
161
+ cwd: worktree.path,
162
+ stdio: "pipe",
163
+ timeout: 10000,
164
+ },
165
+ )
142
166
  } else {
143
167
  const currentSha = execFileSync("git", ["rev-parse", "HEAD"], {
144
168
  cwd: worktree.path,