@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +30 -0
- package/README.md +19 -9
- package/hooks/cap.ts +5 -1
- package/hooks/panes.tsx +62 -0
- package/hooks/register.tsx +125 -128
- package/hooks/usage.ts +2 -1
- package/hooks/write.ts +44 -0
- package/package.json +4 -2
- package/subagent_spend.py +2 -2
- package/types/index.d.ts +4 -1
|
@@ -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.
|
|
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
|
+
"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`
|
|
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
|

|
|
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
|
|
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
|
|
332
|
-
|
|
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 .`,
|
|
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
|
|
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
|
|
package/hooks/panes.tsx
ADDED
|
@@ -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
|
+
}
|
package/hooks/register.tsx
CHANGED
|
@@ -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,
|
|
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 (
|
|
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
|
|
42
|
-
//
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
251
|
-
cap.spent
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
const
|
|
271
|
-
if (
|
|
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
|
-
$.
|
|
263
|
+
left = ofLeft(session, await $.session.usage())
|
|
274
264
|
} catch {
|
|
275
|
-
//
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
}
|