@nightlab-agent/subagent-spend 0.8.3 → 0.8.4

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.
@@ -8,7 +8,7 @@
8
8
  "name": "subagent-spend",
9
9
  "source": "./",
10
10
  "description": "A spend cap for interactive sessions, and per-subagent tokens, rate-limit windows, context fill, cost, steps, tool calls and tool errors of a Claude Code session, in a notice under the prompt, local files and panes (the usage pane not yet seen on a screen).",
11
- "version": "0.8.3",
11
+ "version": "0.8.4",
12
12
  "category": "productivity"
13
13
  }
14
14
  ],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "subagent-spend",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "A spend cap for interactive sessions, and per-subagent tokens, rate-limit windows, context fill, cost, steps, tool calls and tool errors of a Claude Code session, in a notice under the prompt, local files and panes (the usage pane not yet seen on a screen).",
5
5
  "author": {
6
6
  "name": "Nightlab"
package/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  The form is Keep a Changelog; versions follow SemVer.
4
4
 
5
+ ## 0.8.4 - 2026-11-04
6
+ - Lowering or changing `capUsd` now ends a cap set with `/spend-cap` for good. Before, changing the option away and
7
+ back brought the old `/spend-cap` figure back.
8
+ - A measurement from before /clear no longer sets the new session's cost when its usage read fails. Before, the new
9
+ session could stop at the cap on the old session's cost. One gap stays: a measurement raised just before the engine's
10
+ reset whose read answers just after it still counts; `/spend-cap off` clears that.
11
+ - Two overlapping measurements keep the newer one in the status line; the older could overwrite it.
12
+ - A subagent step in the moment after /clear, while the engine still names the session left, no longer rewrites that
13
+ session's rows file with that one step.
14
+ - The npm description fits the registry's 255 characters; it was cut in the middle of a word.
15
+ - The 80 percent spend notice now also comes from the cost read before each step. A tool loop that crossed 80 percent
16
+ and reached the cap inside one turn got no notice: only a measurement could show it, and by the next one the cap was
17
+ already reached.
18
+ - The "rows file not written" notice now stays in the status line after the subagent and usage figures until a write
19
+ lands or the session ends. It was replaced by the next main-loop step and never came back, and it took the place of
20
+ the subagent figures and pushed the cost toward the end of the line.
21
+ - The by-hand script's status line rounds as the mod does: 12500 tokens read 12k there and 13k in the mod.
22
+ - The Usage pane reads "resets in <1m" in a window's last minute; it read "resets in 0h 00m".
23
+ - `/spend-cap` takes plain dollars only. `0x10` set a $16 cap and `1e2` a $100 cap; both are now refused, and so are
24
+ a leading `+` or a space after the `$`.
25
+ - A rows write that fails during or after a `/clear` no longer puts the "rows file not written" notice on the new session, where
26
+ nothing would take it off.
27
+ - A `/clear` while a subagent step redraws the pane no longer empties the rows file of the session it left.
28
+ - A step that reads the cost while a session is ending no longer holds the new session at the old cost.
29
+ - A cap set or lifted with `/spend-cap` now outlives a reload of the plugin; it went back to `capUsd`. A new `capUsd`
30
+ in `/config` still wins.
31
+ - A late measurement no longer moves the status line's cost back below the figure the cap holds on.
32
+ - A cost or measurement from the moment between a `/clear` and Claude Code's reset of its cost no longer lands on the
33
+ new session. It could hold that session at the old cost, or use up its 80 percent notice.
34
+
5
35
  ## 0.8.3 - 2026-11-03
6
36
 
7
37
  ### Fixed
package/README.md CHANGED
@@ -141,7 +141,9 @@ For an interactive session, set `capUsd` in `/config` to a number of dollars.
141
141
  At 80 percent of it a notice says so, once. At the cap, the next model step,
142
142
  the main conversation's or a subagent's, is answered by the mod and no request
143
143
  goes out, until you raise it (`/spend-cap 5`) or lift it (`/spend-cap off`)
144
- for the session. `/spend-cap` alone shows the cap and the cost so far.
144
+ for the session. What `/spend-cap` sets outlives a reload of the plugin;
145
+ changing `capUsd` replaces it. `/spend-cap` alone shows the cap and the cost
146
+ so far.
145
147
 
146
148
  ![Claude Code with a $0.10 cap: asked to read every file and describe each, Claude reads four files; the next step is answered by the mod's line saying the session cost $0.11 of the $0.10 cap and no request was sent](https://raw.githubusercontent.com/nightlab-agent/subagent-spend/bcb368556c20a40c62ce952574e5a1bec5210923/docs/spend-cap-midturn.png)
147
149
 
@@ -186,7 +188,8 @@ of consecutive main-conversation tool errors.
186
188
 
187
189
  **The status line** is one line for all three. Each part keeps its own
188
190
  piece, and the line shows the pieces that have a reading, in the order
189
- subagents, usage, turns. A part that is off, or has nothing yet, adds
191
+ subagents, usage, turns, with the "rows file not written" notice between
192
+ usage and turns when it is up. A part that is off, or has nothing yet, adds
190
193
  nothing. One line rather than three because each call a mod makes sets its
191
194
  whole status line: three parts each setting it would replace each other.
192
195
 
@@ -309,14 +312,14 @@ environment variable. Everything stays on your machine.
309
312
 
310
313
  | Hook | What it does | What it changes |
311
314
  |---|---|---|
312
- | `session.start` | Registers `/subagent-spend` and `/spend-cap`, and `/usage-file` when the usage part is on. | Nothing. |
315
+ | `session.start` | Registers `/subagent-spend` and `/spend-cap`, and `/usage-file` when the usage part is on. After a reload, puts back what `/spend-cap` set. | Nothing. |
313
316
  | `command.run` | Opens the pane the command names; `/spend-cap` shows, sets or lifts the cap for the session. | Nothing. |
314
317
  | `ui.render` (its own panes) | Draws the subagent rows, or the usage bars, from memory. | Nothing. |
315
318
  | `agent.spawn` | Notes the subagent's type; opens the spend pane at the first one. | Nothing: the spawn goes on as asked, even if the mod fails. |
316
319
  | `tool.call` | Counts the call, and whether it came back as an error (turn counts). | Nothing: the call runs as asked and its result passes through. |
317
320
  | `session.measure` | Updates the usage figures, the status line and `latest.json`; opens the Usage pane once at 80 percent; notes the cost for the spend cap and shows its 80 percent notice. | Nothing: the event passes through untouched. |
318
- | `session.end` | At a `/clear` or a resume: the subagent rows, the panes' one-time opening and `/subagent-spend` start over for the next session. | Nothing: the event passes through untouched. |
319
- | `turn.step` | Main conversation: counts the step and rewrites the day file. Subagent: adds the step's tokens to its row and rewrites the rows file. | Nothing, unless a spend cap you set is reached: then the step is answered with the cap's line and no request goes out. |
321
+ | `session.end` | At a `/clear` or a resume: the subagent rows, the panes' one-time opening, the cost the cap counts and `/subagent-spend` start over for the next session. | Nothing: the event passes through untouched. |
322
+ | `turn.step` | With a spend cap: reads the cost before the step and shows the 80 percent notice if that read crossed it. Main conversation: counts the step and rewrites the day file. Subagent: adds the step's tokens to its row and rewrites the rows file. | Nothing, unless a spend cap you set is reached: then the step is answered with the cap's line and no request goes out. |
320
323
 
321
324
  ## What it does not do
322
325
 
@@ -328,11 +331,18 @@ history beyond the files it leaves, and it does not say which model a usage
328
331
  window is for; Claude Code reports the windows as the account's.
329
332
 
330
333
  The sums and counts live in memory: reloading the plugin in a running
331
- session starts them over. A folder that cannot be written is named once in
332
- the status line for the subagent rows, and passed over in silence for the
333
- other two; the session goes on. A subagent step whose response carried no
334
+ session starts them over. A folder that cannot be written is named in the
335
+ status line for the subagent rows, until a write lands or the session ends,
336
+ and passed over in silence for the other two; the session goes on. A subagent step whose response carried no
334
337
  usage (a failed or aborted request) adds nothing.
335
338
 
339
+ Around a `/clear`, Claude Code resets its cost a moment after the session
340
+ ends. A cost read in that moment is left out, so the spend cap does not stop a
341
+ step that starts then, even in a session already at the cap. A resume has the
342
+ same moment and no such check: a cost read then counts for the session you
343
+ resumed until a measurement or a later step passes it, and it can hold that
344
+ session until you raise the cap.
345
+
336
346
  ## Remove it
337
347
 
338
348
  1. Uninstall the mod: `/plugin uninstall subagent-spend` at the Claude Code
@@ -376,7 +386,7 @@ session with `claude --plugin-dir .` from the clone, or set `CC_TYPES` to
376
386
  that file). A missing compiler or missing declarations fails the check with
377
387
  what to install; it never skips.
378
388
 
379
- That runs `claude plugin validate .`; `claude plugin test .`, 78 tests
389
+ That runs `claude plugin validate .`; `claude plugin test .`, 142 tests
380
390
  through the test runner Claude Code ships for plugins, with a mocked file
381
391
  system and mocked Claude Code events (the tests of the three former mods, one per
382
392
  option turned off, and tests where events finish out of order, arrive two at
package/hooks/cap.ts CHANGED
@@ -14,9 +14,13 @@ export const cap = {
14
14
  warned: false,
15
15
  }
16
16
 
17
+ /** Dollars as a person writes them: `5`, `2.50`, `$4`, `.5`. Number() alone also reads `0x10` as 16 and `1e2` as 100. */
18
+ const DOLLARS = /^\$?(\d+(\.\d*)?|\.\d+)$/
19
+
17
20
  /** The option's value as a cap: a positive number of dollars, anything else off. */
18
21
  export function capOf(v: unknown): number | null {
19
- const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v.trim().replace(/^\$/, '')) : NaN
22
+ const text = typeof v === 'string' ? v.trim() : ''
23
+ const n = typeof v === 'number' ? v : DOLLARS.test(text) ? Number(text.replace(/^\$/, '')) : NaN
20
24
  return Number.isFinite(n) && n > 0 ? n : null
21
25
  }
22
26
 
@@ -0,0 +1,62 @@
1
+ import { atom, read } from 'claude-code'
2
+ import type { Register } from 'claude-code'
3
+
4
+ import type { Row, Shown } from '../types'
5
+ import { count, fmt, paneLines } from './spend'
6
+ import { OPEN_AT, bar, usageLines } from './usage'
7
+
8
+ export const SPEND_PANE = 'subagent-spend'
9
+ export const USAGE_PANE = 'usage-file'
10
+
11
+ // What the panes draw: the same atoms as hooks/register.tsx. Change a tag in both files.
12
+ const shownRows = atom({ plugin: 'subagent-spend', key: 'rows' } as const, [] as Row[], { shape: 'rows-2' })
13
+ const shownUsage = atom({ plugin: 'subagent-spend', key: 'latest' } as const, null as Shown | null, { shape: 'latest-1' })
14
+
15
+ /** The two panes. With the usage part off, its pane passes on. */
16
+ export const registerPanes = (on: Parameters<Register>[0], usageOn: boolean) => {
17
+ on('ui.render', { component: 'Pane', requestId: SPEND_PANE }, async ($, e) => {
18
+ const { Box, Text } = $.ui.resolve(e)
19
+ const list = await read($, shownRows)
20
+ const room = Math.max(1, (e.viewport?.rows ?? 24) - 4)
21
+ let tok = 0
22
+ for (const r of list) tok += r.total
23
+ return (
24
+ <Box flexDirection="column">
25
+ <Text bold>
26
+ {count(list.length, 'subagent')}, {fmt(tok)} tokens
27
+ </Text>
28
+ {list.length === 0 && <Text dimColor>No subagent has run in this session yet.</Text>}
29
+ {paneLines(list)
30
+ .slice(0, room)
31
+ .map(line => (
32
+ <Text>{line}</Text>
33
+ ))}
34
+ </Box>
35
+ )
36
+ })
37
+
38
+ on('ui.render', { component: 'Pane', requestId: USAGE_PANE }, async ($, e, next) => {
39
+ if (!usageOn) return next(e)
40
+ const { Box, Text } = $.ui.resolve(e)
41
+ const s = await read($, shownUsage)
42
+ if (!s) {
43
+ return (
44
+ <Box flexDirection="column">
45
+ <Text dimColor>No measurement yet in this session.</Text>
46
+ </Box>
47
+ )
48
+ }
49
+ const now = new Date()
50
+ return (
51
+ <Box flexDirection="column">
52
+ {s.windows.length === 0 && <Text dimColor>No rate-limit windows (not on a subscription).</Text>}
53
+ {usageLines(s, now).map((line, i) => (
54
+ <Text color={s.windows[i]!.percentUsed >= OPEN_AT ? 'red' : undefined}>{line}</Text>
55
+ ))}
56
+ {s.context !== null && <Text>{`Context ${bar(s.context)} ${s.context}%`}</Text>}
57
+ {s.usd !== null && <Text>{`Cost $${s.usd.toFixed(2)}`}</Text>}
58
+ <Text dimColor>{`measured ${s.at.slice(11, 19)} UTC`}</Text>
59
+ </Box>
60
+ )
61
+ })
62
+ }
@@ -1,11 +1,14 @@
1
1
  import { atom, read, update } from 'claude-code'
2
- import type { EngineInterface, Register, SessionMeasureInput } from 'claude-code'
2
+ import type { EngineInterface, Register, SessionMeasureInput, SessionUsage } from 'claude-code'
3
3
 
4
- import type { Row, Shown } from '../types'
4
+ import type { HeldCap, Row, Shown } from '../types'
5
5
  import { addStep, count, fmt, latest, nextStep, rowsText, paneLines, rows, spendStatus, types } from './spend'
6
6
  import { countCall, localDay, tally, tallyText, turnsStatus } from './turns'
7
7
  import { cap, capCommand, capOf, heldText, reached, warnNow } from './cap'
8
- import { OPEN_AT, bar, fileText, shownOf, shownStatus, usageLines, usageStatus } from './usage'
8
+ import { OPEN_AT, fileText, shownOf, shownStatus, usageStatus } from './usage'
9
+ import { SPEND_PANE, USAGE_PANE, registerPanes } from './panes'
10
+ import { out, writeLatest } from './write'
11
+ import type { Out } from './write'
9
12
 
10
13
  // subagent-spend, the hooks module: a session's meters in one mod.
11
14
  //
@@ -23,25 +26,26 @@ import { OPEN_AT, bar, fileText, shownOf, shownStatus, usageLines, usageStatus }
23
26
  //
24
27
  // One status line: three mods each setting the whole line would overwrite each other, so
25
28
  // each part keeps its own piece and the line shows the pieces that have a reading, in the
26
- // order spend | usage | turns.
29
+ // order spend | usage | notice | turns.
27
30
  //
28
31
  // - cap (option `capUsd`, off by default): at the cap the next model step is answered by the
29
32
  // mod with no request made (./cap.ts).
30
33
  //
31
34
  // DESIGN NOTE, deliberately narrow: apart from the cap the user sets, it never denies, never rewrites a
32
35
  // request or an event, reads only the events the engine hands it and sends nothing off the machine. A failed
33
- // write is swallowed (the spend part names it once in the status line): the files are
36
+ // write is swallowed (a failed rows write is named in the status line until a write lands): the files are
34
37
  // advisory, and a gauge that stops the engine would be worse than no gauge.
35
38
 
36
- const SPEND_PANE = 'subagent-spend'
37
39
  const SPEND_TITLE = 'Subagent spend'
38
- const USAGE_PANE = 'usage-file'
39
40
  const USAGE_TITLE = 'Usage'
40
41
 
41
- // What the panes draw; the module's own Map stays the rows file's source. Change a tag
42
- // when its type's fields change: it declines what an earlier version stored.
42
+ // What the panes draw (the same plugin, key and shape in hooks/panes.tsx: the engine's scan reads each file's own
43
+ // atoms); the module's own Map stays the rows file's source. Change a tag, in both files, when its type's fields change:
44
+ // it declines what an earlier version stored.
43
45
  const shownRows = atom({ plugin: 'subagent-spend', key: 'rows' } as const, [] as Row[], { shape: 'rows-2' })
44
46
  const shownUsage = atom({ plugin: 'subagent-spend', key: 'latest' } as const, null as Shown | null, { shape: 'latest-1' })
47
+ // `/spend-cap`'s last word, held by the host: a reload starts these modules over and runs `register` from the options alone.
48
+ const heldCap = atom({ plugin: 'subagent-spend', key: 'cap' } as const, null as HeldCap | null, { shape: 'cap-1' })
45
49
 
46
50
  let spendOpened = false
47
51
  let usageOpened = false
@@ -54,6 +58,9 @@ let rowsSession: string | null = null
54
58
  let asks = 0
55
59
  let switchedAt = 0
56
60
  let leftSession: string | null = null
61
+ // The session left at a /clear, until the next end says otherwise: the engine may go on naming it for a moment after
62
+ // session.end, and a step in that moment would rewrite its closed rows file with that step alone. Only a resume comes back to it.
63
+ let cleared: string | null = null
57
64
 
58
65
  function startOver(left: string | null) {
59
66
  leftSession = left
@@ -74,57 +81,18 @@ async function sessionNow($: EngineInterface) {
74
81
  } catch {
75
82
  // no session id on this host
76
83
  }
77
- if (mine <= switchedAt && id === leftSession) return null
84
+ if (id === cleared || (mine <= switchedAt && id === leftSession)) return null
78
85
  if (rowsSession !== null && id !== rowsSession) startOver(rowsSession)
79
86
  rowsSession = id
80
87
  return id
81
88
  }
82
89
 
83
- /** One file of one part in this session: whether a write is in flight, whether one is owed, whether
84
- * `<dir>/.gitignore` is left alone (a folder not of the mod's own name) or was written, and the newest content to write. */
85
- type Out = { writing: boolean; owed: boolean; ignore: boolean; ignored: boolean; newest: { name: string; text: () => string } | null }
86
- const out = (own: boolean): Out => ({ writing: false, owed: false, ignore: !own, ignored: false, newest: null })
87
-
88
- /** One write at a time per file, always from the newest content. Two events can wait at once (on
89
- * `$.session.id()` or on a write); if the earlier one's write landed last, its older figures would sit on
90
- * top of the newer ones. An event that finds a write in flight marks the file as owed and goes on; the
91
- * writing one writes again, from the newest content, until nothing is owed. A failed write is made up
92
- * by the next event. Before each write, when it is missing: `<dir>/.gitignore` holding `*`, so git
93
- * ignores the folder in every project with no step from the user. */
94
- async function writeLatest($: EngineInterface, dir: string, f: Out) {
95
- if (f.writing) {
96
- f.owed = true
97
- return
98
- }
99
- f.writing = true
100
- try {
101
- do {
102
- f.owed = false
103
- if (!f.ignore) {
104
- // checked before every write, not once: a user who deletes the folder mid-session gets it back ignored
105
- let has = f.ignored
106
- try {
107
- has = await $.fs.exists(`${dir}/.gitignore`)
108
- } catch {
109
- // a host that cannot answer: once per session, as before
110
- }
111
- if (!has) await $.fs.write(`${dir}/.gitignore`, '*\n')
112
- f.ignored = true
113
- }
114
- const n = f.newest
115
- if (n) await $.fs.write(`${dir}/${n.name}`, n.text())
116
- } while (f.owed)
117
- } finally {
118
- f.writing = false
119
- }
120
- }
121
-
122
90
  /** Each part's piece of the status line; undefined until that part has a reading. */
123
- type Parts = { usage?: string; turns?: string; spend?: string }
91
+ type Parts = { usage?: string; turns?: string; spend?: string; notice?: string }
124
92
 
125
- /** The one status line: the pieces that have a reading, spend | usage | turns (spend first: a narrow window cuts the end). */
93
+ /** The one status line: the pieces that have a reading, spend | usage | notice | turns (the figures first: a narrow window cuts the end). */
126
94
  function showStatus($: EngineInterface, parts: Parts) {
127
- const line = [parts.spend, parts.usage, parts.turns].filter(Boolean).join(' | ')
95
+ const line = [parts.spend, parts.usage, parts.notice, parts.turns].filter(Boolean).join(' | ')
128
96
  try {
129
97
  $.ui.status(line || undefined)
130
98
  } catch {
@@ -132,9 +100,21 @@ function showStatus($: EngineInterface, parts: Parts) {
132
100
  }
133
101
  }
134
102
 
103
+ /** The cap's 80 percent notice, when one is due. */
104
+ function showWarning($: EngineInterface) {
105
+ const warning = warnNow()
106
+ if (!warning) return
107
+ try {
108
+ $.ui.toast(warning)
109
+ } catch {
110
+ // a surface with no toasts: the cap still holds
111
+ }
112
+ }
113
+
135
114
  export const register: Register = (on, options) => {
136
115
  const usageOn = options.usageFile !== false
137
- cap.usd = capOf(options.capUsd)
116
+ const capOption = capOf(options.capUsd)
117
+ cap.usd = capOption
138
118
  cap.spent = null
139
119
  cap.warned = false
140
120
  const turnsOn = options.turnTally !== false
@@ -146,7 +126,6 @@ export const register: Register = (on, options) => {
146
126
  const own = (dir: string, name: string) => dir.replace(/[\\/]+$/, '').split(/[\\/]/).at(-1) === name
147
127
  const files = { spend: out(own(spendDir, '.subagent-spend')), usage: out(own(usageDir, '.usage-file')), turns: out(own(turnsDir, '.turn-tally')) }
148
128
  const parts: Parts = {}
149
- let spendWarned = false
150
129
  // Numbers each measurement in the order the engine raised them; latest.json is written from the newest.
151
130
  let seq = 0
152
131
  /** The last measurement, whose windows and context the status line keeps between measurements. */
@@ -155,8 +134,29 @@ export const register: Register = (on, options) => {
155
134
  // reading is the newest: a read that answers late with a lower figure never brings it back. Each
156
135
  // session end starts a new count, and a read still out for the session left is dropped.
157
136
  let costSession = 0
137
+ // A /clear can reset the cost ledger after session.end is over (seen in the 2.1.312 bundle; the declarations name no
138
+ // order), and a step or measurement can land between. Cost and start reset together (SessionUsage.startedAt: what the figures count from), so a reading
139
+ // with the left session's start is the left session's. A fork or resume may keep that start: only a /clear sets this.
140
+ let leftStart: number | null = null
141
+ /** Whether a usage read made while `costSession` stood at `session` belongs to a session already left. */
142
+ const ofLeft = (session: number, usage: SessionUsage) => session !== costSession || usage.startedAt === leftStart
143
+ /** The next cost reading is the new session's; the cap the user set stays. */
144
+ const startCost = () => {
145
+ cap.spent = null
146
+ cap.warned = false
147
+ costSession++
148
+ }
158
149
 
159
150
  on('session.start', async ($, e, next) => {
151
+ try {
152
+ // fires again at a reload; a cap set over another option gives way to the new one
153
+ const held = await read($, heldCap)
154
+ if (held?.option === capOption) cap.usd = held.usd
155
+ // the new option replaced it for good: changing back brings the option, not this
156
+ else if (held) await update($, heldCap, () => null)
157
+ } catch {
158
+ // nothing held on this surface: the option stands
159
+ }
160
160
  try {
161
161
  await $.command.register({ name: 'subagent-spend', description: 'Show what each subagent of this session costs, in a pane' })
162
162
  await $.command.register({ name: 'spend-cap', description: 'Show, set (/spend-cap 5) or lift (/spend-cap off) the spend cap of this session' })
@@ -167,7 +167,15 @@ export const register: Register = (on, options) => {
167
167
  return next(e)
168
168
  })
169
169
 
170
- on('command.run', { command: 'spend-cap' }, async (_, e) => ({ text: capCommand(e.args ?? '') }))
170
+ on('command.run', { command: 'spend-cap' }, async ($, e) => {
171
+ const text = capCommand(e.args ?? '')
172
+ try {
173
+ await update($, heldCap, () => ({ usd: cap.usd, option: capOption }))
174
+ } catch {
175
+ // the cap holds in memory; only a reload would lose it
176
+ }
177
+ return { text }
178
+ })
171
179
 
172
180
  on('command.run', { command: 'subagent-spend' }, async $ => {
173
181
  await $.ui.open({ id: SPEND_PANE, title: SPEND_TITLE })
@@ -181,51 +189,7 @@ export const register: Register = (on, options) => {
181
189
  return { text: `Usage pane opened. ${s ? shownStatus(s) : 'No measurement yet in this session.'}` }
182
190
  })
183
191
 
184
- on('ui.render', { component: 'Pane', requestId: SPEND_PANE }, async ($, e) => {
185
- const { Box, Text } = $.ui.resolve(e)
186
- const list = await read($, shownRows)
187
- const room = Math.max(1, (e.viewport?.rows ?? 24) - 4)
188
- let tok = 0
189
- for (const r of list) tok += r.total
190
- return (
191
- <Box flexDirection="column">
192
- <Text bold>
193
- {count(list.length, 'subagent')}, {fmt(tok)} tokens
194
- </Text>
195
- {list.length === 0 && <Text dimColor>No subagent has run in this session yet.</Text>}
196
- {paneLines(list)
197
- .slice(0, room)
198
- .map(line => (
199
- <Text>{line}</Text>
200
- ))}
201
- </Box>
202
- )
203
- })
204
-
205
- on('ui.render', { component: 'Pane', requestId: USAGE_PANE }, async ($, e, next) => {
206
- if (!usageOn) return next(e)
207
- const { Box, Text } = $.ui.resolve(e)
208
- const s = await read($, shownUsage)
209
- if (!s) {
210
- return (
211
- <Box flexDirection="column">
212
- <Text dimColor>No measurement yet in this session.</Text>
213
- </Box>
214
- )
215
- }
216
- const now = new Date()
217
- return (
218
- <Box flexDirection="column">
219
- {s.windows.length === 0 && <Text dimColor>No rate-limit windows (not on a subscription).</Text>}
220
- {usageLines(s, now).map((line, i) => (
221
- <Text color={s.windows[i]!.percentUsed >= OPEN_AT ? 'red' : undefined}>{line}</Text>
222
- ))}
223
- {s.context !== null && <Text>{`Context ${bar(s.context)} ${s.context}%`}</Text>}
224
- {s.usd !== null && <Text>{`Cost $${s.usd.toFixed(2)}`}</Text>}
225
- <Text dimColor>{`measured ${s.at.slice(11, 19)} UTC`}</Text>
226
- </Box>
227
- )
228
- })
192
+ registerPanes(on, usageOn)
229
193
 
230
194
  on('agent.spawn', async ($, e, next) => {
231
195
  const started = await next(e)
@@ -247,11 +211,27 @@ export const register: Register = (on, options) => {
247
211
 
248
212
  on('session.end', async ($, e, next) => {
249
213
  startOver(e.sessionId)
250
- // the next session's first measurement brings its own cost; the cap the user set stays
251
- cap.spent = null
252
- cap.warned = false
253
- costSession++
214
+ cleared = e.reason === 'clear' && e.sessionId ? e.sessionId : null
215
+ const held = cap.spent
216
+ startCost()
217
+ // a key outlives no end: a resume may come back to the session it names
218
+ leftStart = null
219
+ if (e.reason === 'clear') {
220
+ // asked before the end step, not waited for: this chain shares one short bound. No answer, no key: a cost read
221
+ // before the ledger's reset then counts for the new session
222
+ const asked = costSession
223
+ try {
224
+ $.session.usage().then(u => {
225
+ const usd = u.cost?.usd ?? 0
226
+ // an answer after the end step, or a figure below the one held, may be of a ledger already reset
227
+ if (asked === costSession && usd > 0 && usd >= (held ?? 0)) leftStart = u.startedAt ?? null
228
+ }, () => undefined)
229
+ } catch {
230
+ // no answer, no key
231
+ }
232
+ }
254
233
  parts.spend = undefined
234
+ parts.notice = undefined
255
235
  // cost and context belong to the session left: the panes and the line wait for the new one's first reading
256
236
  parts.usage = undefined
257
237
  measured = undefined
@@ -262,24 +242,40 @@ export const register: Register = (on, options) => {
262
242
  } catch {
263
243
  // the panes miss one redraw; the next step or measurement draws them
264
244
  }
265
- return next(e)
245
+ try {
246
+ return await next(e)
247
+ } finally {
248
+ // a step can read the old cost, or fail a write, while the end step runs; both go with the session too
249
+ startCost()
250
+ parts.notice = undefined
251
+ showStatus($, parts)
252
+ }
266
253
  })
267
254
 
268
255
  on('session.measure', async ($, e, next) => {
269
- if (e.cost) cap.spent = Math.max(cap.spent ?? 0, e.cost.usd)
270
- const warning = warnNow()
271
- if (warning) {
256
+ // numbered before any wait: two at once keep the order the engine raised them in
257
+ const mine = ++seq
258
+ if (leftStart !== null) {
259
+ // a measurement carries no start, so it asks
260
+ const session = costSession
261
+ let left = false
272
262
  try {
273
- $.ui.toast(warning)
263
+ left = ofLeft(session, await $.session.usage())
274
264
  } catch {
275
- // a surface with no toasts: the cap still holds
265
+ // no answer: the measurement stands, unless /clear came during the read
266
+ left = session !== costSession
276
267
  }
268
+ if (left) return next(e)
277
269
  }
270
+ if (e.cost) cap.spent = Math.max(cap.spent ?? 0, e.cost.usd)
271
+ showWarning($)
278
272
  if (!usageOn) return next(e)
279
- const mine = ++seq
280
- measured = e
281
- parts.usage = usageStatus(e)
282
- showStatus($, parts)
273
+ if (mine === seq) {
274
+ measured = e
275
+ // the highest reading, as the cap: a measurement can land after a read that went past it
276
+ parts.usage = usageStatus(e, e.cost ? (cap.spent ?? undefined) : undefined)
277
+ showStatus($, parts)
278
+ }
283
279
  try {
284
280
  // a newer measurement already shown keeps the pane; this one's figures are behind it
285
281
  await update($, shownUsage, s => (mine !== seq ? s : shownOf(e, new Date())))
@@ -314,7 +310,7 @@ export const register: Register = (on, options) => {
314
310
  const session = costSession
315
311
  try {
316
312
  const usage = await $.session.usage()
317
- const usd = session === costSession ? usage.cost?.usd : undefined
313
+ const usd = ofLeft(session, usage) ? undefined : usage.cost?.usd
318
314
  if (usd !== undefined && usd >= (cap.spent ?? 0)) cap.spent = usd
319
315
  if (usd !== undefined && usd === cap.spent && usageOn && measured) {
320
316
  // the line's dollar figure is the one the cap holds on, not the last turn's
@@ -324,6 +320,7 @@ export const register: Register = (on, options) => {
324
320
  } catch {
325
321
  // no cost here: the last measurement stands
326
322
  }
323
+ showWarning($)
327
324
  }
328
325
  if (reached()) {
329
326
  // answered here, no request made: the turn (or the subagent) ends on this text
@@ -356,6 +353,10 @@ export const register: Register = (on, options) => {
356
353
  // a step whose id came back after its session ended: its rows file is closed
357
354
  if (sessionId === null) return response
358
355
  addStep(agentId, sessionId, response.usage, e.model, new Date(), order)
356
+ // the file's content and session are taken now, before any wait: a /clear during one empties the Map
357
+ const writing = costSession
358
+ const text = rowsText()
359
+ files.spend.newest = { name: `${sessionId || 'no-session'}.jsonl`, text: () => text }
359
360
  try {
360
361
  await update($, shownRows, () => [...rows.values()])
361
362
  } catch {
@@ -364,20 +365,16 @@ export const register: Register = (on, options) => {
364
365
  parts.spend = spendStatus()
365
366
  showStatus($, parts)
366
367
  try {
367
- // the text is taken now, not at the write: a /clear while a write waits empties the Map it reads
368
- const text = rowsText()
369
- files.spend.newest = { name: `${sessionId || 'no-session'}.jsonl`, text: () => text }
370
- await writeLatest($, spendDir, files.spend)
371
- } catch (err) {
372
- // unwritable folder: say so once, then the session goes on
373
- if (!spendWarned) {
374
- spendWarned = true
375
- try {
376
- $.ui.status(`subagent-spend: rows file not written (${err instanceof Error ? err.message : String(err)})`)
377
- } catch {
378
- // nothing left to tell it on
379
- }
368
+ if ((await writeLatest($, spendDir, files.spend)) && parts.notice) {
369
+ parts.notice = undefined
370
+ showStatus($, parts)
380
371
  }
372
+ } catch (err) {
373
+ // a write of the session left: on the new session nothing would take its notice off
374
+ if (writing !== costSession) return response
375
+ // unwritable folder: the line says so until a write lands, and the session goes on
376
+ parts.notice = `subagent-spend: rows file not written (${err instanceof Error ? err.message : String(err)})`
377
+ showStatus($, parts)
381
378
  }
382
379
  return response
383
380
  })
package/hooks/usage.ts CHANGED
@@ -19,13 +19,14 @@ export const bar = (pct: number) => {
19
19
  return '█'.repeat(full) + '░'.repeat(10 - full)
20
20
  }
21
21
 
22
- /** Time left to a reset: `2h 05m`, `3d 4h`, `now`; empty when the engine gave no reset time. */
22
+ /** Time left to a reset: `2h 05m`, `3d 4h`, `<1m`, `now`; empty when the engine gave no reset time. */
23
23
  export const untilText = (resetsAt: string | null, now: Date) => {
24
24
  if (!resetsAt) return ''
25
25
  const ms = Date.parse(resetsAt) - now.getTime()
26
26
  if (Number.isNaN(ms)) return ''
27
27
  if (ms <= 0) return 'now'
28
28
  const min = Math.floor(ms / 60000)
29
+ if (min === 0) return '<1m'
29
30
  if (min >= 2880) return `${Math.floor(min / 1440)}d ${Math.floor((min % 1440) / 60)}h`
30
31
  return `${Math.floor(min / 60)}h ${String(min % 60).padStart(2, '0')}m`
31
32
  }
package/hooks/write.ts ADDED
@@ -0,0 +1,44 @@
1
+ import type { EngineInterface } from 'claude-code'
2
+
3
+ // The files the mod keeps: one writer per file, from the newest content.
4
+
5
+ /** One file of one part in this session: whether a write is in flight, whether one is owed, whether
6
+ * `<dir>/.gitignore` is left alone (a folder not of the mod's own name) or was written, and the newest content to write. */
7
+ export type Out = { writing: boolean; owed: boolean; ignore: boolean; ignored: boolean; newest: { name: string; text: () => string } | null }
8
+ export const out = (own: boolean): Out => ({ writing: false, owed: false, ignore: !own, ignored: false, newest: null })
9
+
10
+ /** One write at a time per file, always from the newest content. Two events can wait at once (on
11
+ * `$.session.id()` or on a write); if the earlier one's write landed last, its older figures would sit on
12
+ * top of the newer ones. An event that finds a write in flight marks the file as owed and goes on; the
13
+ * writing one writes again, from the newest content, until nothing is owed. A failed write is made up
14
+ * by the next event. Before each write, when it is missing: `<dir>/.gitignore` holding `*`, so git
15
+ * ignores the folder in every project with no step from the user. True when this call wrote; false when
16
+ * it left the file to the write in flight, whose result is not known yet. */
17
+ export async function writeLatest($: EngineInterface, dir: string, f: Out) {
18
+ if (f.writing) {
19
+ f.owed = true
20
+ return false
21
+ }
22
+ f.writing = true
23
+ try {
24
+ do {
25
+ f.owed = false
26
+ if (!f.ignore) {
27
+ // checked before every write, not once: a user who deletes the folder mid-session gets it back ignored
28
+ let has = f.ignored
29
+ try {
30
+ has = await $.fs.exists(`${dir}/.gitignore`)
31
+ } catch {
32
+ // a host that cannot answer: once per session, as before
33
+ }
34
+ if (!has) await $.fs.write(`${dir}/.gitignore`, '*\n')
35
+ f.ignored = true
36
+ }
37
+ const n = f.newest
38
+ if (n) await $.fs.write(`${dir}/${n.name}`, n.text())
39
+ } while (f.owed)
40
+ return true
41
+ } finally {
42
+ f.writing = false
43
+ }
44
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nightlab-agent/subagent-spend",
3
- "version": "0.8.3",
4
- "description": "Claude Code plugin: a spend cap for interactive sessions, and per-subagent tokens, rate-limit windows, context fill, cost, steps, tool calls and tool errors of a session, in a notice under the prompt, local files and panes (the usage pane not yet seen on a screen).",
3
+ "version": "0.8.4",
4
+ "description": "Claude Code plugin: a spend cap for interactive sessions, and per-subagent tokens, rate-limit windows, context fill, cost, steps, tool calls and tool errors, in a notice under the prompt, local files and panes (the usage pane not yet seen on a screen).",
5
5
  "keywords": [
6
6
  "claude-code",
7
7
  "claude-code-mod",
@@ -30,6 +30,8 @@
30
30
  "hooks/spend.ts",
31
31
  "hooks/turns.ts",
32
32
  "hooks/usage.ts",
33
+ "hooks/panes.tsx",
34
+ "hooks/write.ts",
33
35
  "types/",
34
36
  "kit/",
35
37
  "subagent_spend.py",
package/subagent_spend.py CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """subagent-spend 0.8.3: attribute Claude Code token usage to each subagent.
2
+ """subagent-spend 0.8.4: attribute Claude Code token usage to each subagent.
3
3
 
4
4
  subagent_spend.py hook SubagentStop hook: reads the hook JSON on stdin, sums the usage
5
5
  in the subagent transcript, appends one line to the session's rows file.
@@ -65,7 +65,7 @@ def rows_for(session_id):
65
65
  if isinstance(j, dict) and "total" in j: byid[j.get("agent_id") or f"row{n}"] = j
66
66
  return list(byid.values())
67
67
 
68
- def fmt(n): return f"{n/1000:.0f}k" if n >= 10000 else str(n)
68
+ def fmt(n): return f"{(n + 500) // 1000}k" if n >= 10000 else str(n) # a half goes up, as Math.round does in hooks/spend.ts
69
69
 
70
70
  def statusline():
71
71
  try:
package/types/index.d.ts CHANGED
@@ -22,8 +22,11 @@ export type Shown = {
22
22
  usd: number | null
23
23
  }
24
24
 
25
+ /** What /spend-cap last left, and the `capUsd` option in force then. */
26
+ export type HeldCap = { usd: number | null; option: number | null }
27
+
25
28
  declare module 'claude-code' {
26
29
  interface PluginState {
27
- 'subagent-spend': { rows: Shaped<Row[]>; latest: Shaped<Shown | null> }
30
+ 'subagent-spend': { rows: Shaped<Row[]>; latest: Shaped<Shown | null>; cap: Shaped<HeldCap | null> }
28
31
  }
29
32
  }