@nightlab-agent/subagent-spend 0.0.0-stage → 0.7.3
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/icon.png +0 -0
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +57 -0
- package/CHANGELOG.md +190 -0
- package/LICENSE +9 -0
- package/README.md +363 -2
- package/hooks/hooks.json +1 -0
- package/hooks/register.tsx +329 -0
- package/hooks/spend.ts +101 -0
- package/hooks/turns.ts +45 -0
- package/hooks/usage.ts +86 -0
- package/kit/settings-snippet.json +8 -0
- package/package.json +32 -4
- package/subagent_spend.py +96 -0
- package/types/index.d.ts +29 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { atom, read, update } from 'claude-code'
|
|
2
|
+
import type { EngineInterface, Register } from 'claude-code'
|
|
3
|
+
|
|
4
|
+
import type { Row, Shown } from '../types'
|
|
5
|
+
import { addStep, count, fmt, latest, nextStep, rowsText, paneLines, rows, spendStatus, types } from './spend'
|
|
6
|
+
import { countCall, localDay, tally, tallyText, turnsStatus } from './turns'
|
|
7
|
+
import { OPEN_AT, bar, fileText, shownOf, shownStatus, usageLines, usageStatus } from './usage'
|
|
8
|
+
|
|
9
|
+
// subagent-spend, the hooks module: a session's meters in one mod.
|
|
10
|
+
//
|
|
11
|
+
// Three parts, each with its own file folder:
|
|
12
|
+
// - spend (always on): on every model step of a subagent's loop (`turn.step` with an
|
|
13
|
+
// `agentId`) the engine hands back the response's usage; it is summed per agent, the
|
|
14
|
+
// session's rows file is rewritten whole under the `dir` option, and a side pane lists
|
|
15
|
+
// every agent, opened at the first subagent of a session or by /subagent-spend.
|
|
16
|
+
// - usage (option `usageFile`, was the usage-file mod): on `session.measure` the rate-limit
|
|
17
|
+
// windows, context fill and cost go to `<usageFileDir>/latest.json` and a second pane,
|
|
18
|
+
// opened by /usage-file or once when a window reaches OPEN_AT percent.
|
|
19
|
+
// - turns (option `turnTally`, was the turn-tally mod): on each main-loop step the steps,
|
|
20
|
+
// tool calls, tool errors and error streak go to `<turnTallyDir>/<day>.txt`.
|
|
21
|
+
// A part whose option is off passes its events to `next` and returns the result untouched.
|
|
22
|
+
//
|
|
23
|
+
// One status line: three mods each setting the whole line would overwrite each other, so
|
|
24
|
+
// each part keeps its own piece and the line shows the pieces that have a reading, in the
|
|
25
|
+
// order spend | usage | turns.
|
|
26
|
+
//
|
|
27
|
+
// DESIGN NOTE, deliberately narrow: it never denies, never rewrites a request or an event,
|
|
28
|
+
// reads only the events the engine hands it and sends nothing off the machine. A failed
|
|
29
|
+
// write is swallowed (the spend part names it once in the status line): the files are
|
|
30
|
+
// advisory, and a gauge that stops the engine would be worse than no gauge.
|
|
31
|
+
|
|
32
|
+
const SPEND_PANE = 'subagent-spend'
|
|
33
|
+
const SPEND_TITLE = 'Subagent spend'
|
|
34
|
+
const USAGE_PANE = 'usage-file'
|
|
35
|
+
const USAGE_TITLE = 'Usage'
|
|
36
|
+
|
|
37
|
+
// What the panes draw; the module's own Map stays the rows file's source. Change a tag
|
|
38
|
+
// when its type's fields change: it declines what an earlier version stored.
|
|
39
|
+
const shownRows = atom({ plugin: 'subagent-spend', key: 'rows' } as const, [] as Row[], { shape: 'rows-2' })
|
|
40
|
+
const shownUsage = atom({ plugin: 'subagent-spend', key: 'latest' } as const, null as Shown | null, { shape: 'latest-1' })
|
|
41
|
+
|
|
42
|
+
let spendOpened = false
|
|
43
|
+
let usageOpened = false
|
|
44
|
+
// The session the sums belong to. A /clear goes on under a new session id with no new session.start;
|
|
45
|
+
// session.end fires for it (declarations, SessionEndInput.reason), and there the sums and the panes'
|
|
46
|
+
// opening start over. The first event that sees another id does the same, for a host that sends no end.
|
|
47
|
+
let rowsSession: string | null = null
|
|
48
|
+
// Asks for the id are numbered. An answer asked before the latest switch that names the session just left
|
|
49
|
+
// came back late (asks wait at once); it never switches back. A later ask may name it again (a resume).
|
|
50
|
+
let asks = 0
|
|
51
|
+
let switchedAt = 0
|
|
52
|
+
let leftSession: string | null = null
|
|
53
|
+
|
|
54
|
+
function startOver(left: string | null) {
|
|
55
|
+
leftSession = left
|
|
56
|
+
switchedAt = asks
|
|
57
|
+
rowsSession = null
|
|
58
|
+
rows.clear()
|
|
59
|
+
latest.clear()
|
|
60
|
+
spendOpened = false
|
|
61
|
+
usageOpened = false
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** This session's id ('' on a host with none), or null for a late answer naming the session just left. */
|
|
65
|
+
async function sessionNow($: EngineInterface) {
|
|
66
|
+
const mine = ++asks
|
|
67
|
+
let id = ''
|
|
68
|
+
try {
|
|
69
|
+
id = await $.session.id()
|
|
70
|
+
} catch {
|
|
71
|
+
// no session id on this host
|
|
72
|
+
}
|
|
73
|
+
if (mine <= switchedAt && id === leftSession) return null
|
|
74
|
+
if (rowsSession !== null && id !== rowsSession) startOver(rowsSession)
|
|
75
|
+
rowsSession = id
|
|
76
|
+
return id
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One file of one part in this session: whether a write is in flight, whether one is owed, whether
|
|
80
|
+
* `<dir>/.gitignore` is left alone (a folder not of the mod's own name) or was written, and the newest content to write. */
|
|
81
|
+
type Out = { writing: boolean; owed: boolean; ignore: boolean; ignored: boolean; newest: { name: string; text: () => string } | null }
|
|
82
|
+
const out = (own: boolean): Out => ({ writing: false, owed: false, ignore: !own, ignored: false, newest: null })
|
|
83
|
+
|
|
84
|
+
/** One write at a time per file, always from the newest content. Two events can wait at once (on
|
|
85
|
+
* `$.session.id()` or on a write); if the earlier one's write landed last, its older figures would sit on
|
|
86
|
+
* top of the newer ones. An event that finds a write in flight marks the file as owed and goes on; the
|
|
87
|
+
* writing one writes again, from the newest content, until nothing is owed. A failed write is made up
|
|
88
|
+
* by the next event. Before each write, when it is missing: `<dir>/.gitignore` holding `*`, so git
|
|
89
|
+
* ignores the folder in every project with no step from the user. */
|
|
90
|
+
async function writeLatest($: EngineInterface, dir: string, f: Out) {
|
|
91
|
+
if (f.writing) {
|
|
92
|
+
f.owed = true
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
f.writing = true
|
|
96
|
+
try {
|
|
97
|
+
do {
|
|
98
|
+
f.owed = false
|
|
99
|
+
if (!f.ignore) {
|
|
100
|
+
// checked before every write, not once: a user who deletes the folder mid-session gets it back ignored
|
|
101
|
+
let has = f.ignored
|
|
102
|
+
try {
|
|
103
|
+
has = await $.fs.exists(`${dir}/.gitignore`)
|
|
104
|
+
} catch {
|
|
105
|
+
// a host that cannot answer: once per session, as before
|
|
106
|
+
}
|
|
107
|
+
if (!has) await $.fs.write(`${dir}/.gitignore`, '*\n')
|
|
108
|
+
f.ignored = true
|
|
109
|
+
}
|
|
110
|
+
const n = f.newest
|
|
111
|
+
if (n) await $.fs.write(`${dir}/${n.name}`, n.text())
|
|
112
|
+
} while (f.owed)
|
|
113
|
+
} finally {
|
|
114
|
+
f.writing = false
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Each part's piece of the status line; undefined until that part has a reading. */
|
|
119
|
+
type Parts = { usage?: string; turns?: string; spend?: string }
|
|
120
|
+
|
|
121
|
+
/** The one status line: the pieces that have a reading, spend | usage | turns (spend first: a narrow window cuts the end). */
|
|
122
|
+
function showStatus($: EngineInterface, parts: Parts) {
|
|
123
|
+
const line = [parts.spend, parts.usage, parts.turns].filter(Boolean).join(' | ')
|
|
124
|
+
try {
|
|
125
|
+
$.ui.status(line || undefined)
|
|
126
|
+
} catch {
|
|
127
|
+
// a surface with no status line; the files still get written
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const register: Register = (on, options) => {
|
|
132
|
+
const usageOn = options.usageFile !== false
|
|
133
|
+
const turnsOn = options.turnTally !== false
|
|
134
|
+
const spendDir = String(options.dir || '.subagent-spend')
|
|
135
|
+
const usageDir = String(options.usageFileDir || '.usage-file')
|
|
136
|
+
const turnsDir = String(options.turnTallyDir || '.turn-tally')
|
|
137
|
+
// Only a folder of the mod's own name is its own; any other may hold the user's files (even be the
|
|
138
|
+
// project itself, `.`), so no .gitignore is written there.
|
|
139
|
+
const own = (dir: string, name: string) => dir.replace(/[\\/]+$/, '').split(/[\\/]/).at(-1) === name
|
|
140
|
+
const files = { spend: out(own(spendDir, '.subagent-spend')), usage: out(own(usageDir, '.usage-file')), turns: out(own(turnsDir, '.turn-tally')) }
|
|
141
|
+
const parts: Parts = {}
|
|
142
|
+
let spendWarned = false
|
|
143
|
+
// Numbers each measurement in the order the engine raised them; latest.json is written from the newest.
|
|
144
|
+
let seq = 0
|
|
145
|
+
|
|
146
|
+
on('session.start', async ($, e, next) => {
|
|
147
|
+
try {
|
|
148
|
+
await $.command.register({ name: 'subagent-spend', description: 'Show what each subagent of this session costs, in a pane' })
|
|
149
|
+
if (usageOn) await $.command.register({ name: 'usage-file', description: 'Show the rate-limit windows, context and cost of this session, in a pane' })
|
|
150
|
+
} catch {
|
|
151
|
+
// a surface with no commands; the panes still open by themselves
|
|
152
|
+
}
|
|
153
|
+
return next(e)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
on('command.run', { command: 'subagent-spend' }, async $ => {
|
|
157
|
+
await $.ui.open({ id: SPEND_PANE, title: SPEND_TITLE })
|
|
158
|
+
return { text: `Subagent spend pane opened. ${spendStatus()}` }
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
on('command.run', { command: 'usage-file' }, async ($, e, next) => {
|
|
162
|
+
if (!usageOn) return next(e)
|
|
163
|
+
await $.ui.open({ id: USAGE_PANE, title: USAGE_TITLE })
|
|
164
|
+
const s = await read($, shownUsage)
|
|
165
|
+
return { text: `Usage pane opened. ${s ? shownStatus(s) : 'No measurement yet in this session.'}` }
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
on('ui.render', { component: 'Pane', requestId: SPEND_PANE }, async ($, e) => {
|
|
169
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
170
|
+
const list = await read($, shownRows)
|
|
171
|
+
const room = Math.max(1, (e.viewport?.rows ?? 24) - 4)
|
|
172
|
+
let tok = 0
|
|
173
|
+
for (const r of list) tok += r.total
|
|
174
|
+
return (
|
|
175
|
+
<Box flexDirection="column">
|
|
176
|
+
<Text bold>
|
|
177
|
+
{count(list.length, 'subagent')}, {fmt(tok)} tokens
|
|
178
|
+
</Text>
|
|
179
|
+
{list.length === 0 && <Text dimColor>No subagent has run in this session yet.</Text>}
|
|
180
|
+
{paneLines(list)
|
|
181
|
+
.slice(0, room)
|
|
182
|
+
.map(line => (
|
|
183
|
+
<Text>{line}</Text>
|
|
184
|
+
))}
|
|
185
|
+
</Box>
|
|
186
|
+
)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
on('ui.render', { component: 'Pane', requestId: USAGE_PANE }, async ($, e, next) => {
|
|
190
|
+
if (!usageOn) return next(e)
|
|
191
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
192
|
+
const s = await read($, shownUsage)
|
|
193
|
+
if (!s) {
|
|
194
|
+
return (
|
|
195
|
+
<Box flexDirection="column">
|
|
196
|
+
<Text dimColor>No measurement yet in this session.</Text>
|
|
197
|
+
</Box>
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
const now = new Date()
|
|
201
|
+
return (
|
|
202
|
+
<Box flexDirection="column">
|
|
203
|
+
{s.windows.length === 0 && <Text dimColor>No rate-limit windows (not on a subscription).</Text>}
|
|
204
|
+
{usageLines(s, now).map((line, i) => (
|
|
205
|
+
<Text color={s.windows[i]!.percentUsed >= OPEN_AT ? 'red' : undefined}>{line}</Text>
|
|
206
|
+
))}
|
|
207
|
+
{s.context !== null && <Text>{`Context ${bar(s.context)} ${s.context}%`}</Text>}
|
|
208
|
+
{s.usd !== null && <Text>{`Cost $${s.usd.toFixed(2)}`}</Text>}
|
|
209
|
+
<Text dimColor>{`measured ${s.at.slice(11, 19)} UTC`}</Text>
|
|
210
|
+
</Box>
|
|
211
|
+
)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
on('agent.spawn', async ($, e, next) => {
|
|
215
|
+
const started = await next(e)
|
|
216
|
+
if (started.agentId) types.set(started.agentId, e.subagentType)
|
|
217
|
+
await sessionNow($)
|
|
218
|
+
if (!spendOpened) {
|
|
219
|
+
spendOpened = true
|
|
220
|
+
// a surface that seats no panes refuses; the status line and rows file go on
|
|
221
|
+
$.ui.open({ id: SPEND_PANE, title: SPEND_TITLE }).catch(() => undefined)
|
|
222
|
+
}
|
|
223
|
+
return started
|
|
224
|
+
}).catch(($, e, next) => next(e)) // an observer: whatever breaks here, the spawn goes on
|
|
225
|
+
|
|
226
|
+
on('tool.call', async ($, e, next) => {
|
|
227
|
+
const ran = await next(e)
|
|
228
|
+
if (turnsOn) countCall(e.agentId, ran.deny !== undefined, ran.isError === true)
|
|
229
|
+
return ran
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
on('session.end', async ($, e, next) => {
|
|
233
|
+
startOver(e.sessionId)
|
|
234
|
+
parts.spend = undefined
|
|
235
|
+
// cost and context belong to the session left: the panes and the line wait for the new one's first reading
|
|
236
|
+
parts.usage = undefined
|
|
237
|
+
showStatus($, parts)
|
|
238
|
+
try {
|
|
239
|
+
await update($, shownRows, () => [])
|
|
240
|
+
await update($, shownUsage, () => null)
|
|
241
|
+
} catch {
|
|
242
|
+
// the panes miss one redraw; the next step or measurement draws them
|
|
243
|
+
}
|
|
244
|
+
return next(e)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
on('session.measure', async ($, e, next) => {
|
|
248
|
+
if (!usageOn) return next(e)
|
|
249
|
+
const mine = ++seq
|
|
250
|
+
parts.usage = usageStatus(e)
|
|
251
|
+
showStatus($, parts)
|
|
252
|
+
try {
|
|
253
|
+
// a newer measurement already shown keeps the pane; this one's figures are behind it
|
|
254
|
+
await update($, shownUsage, s => (mine !== seq ? s : shownOf(e, new Date())))
|
|
255
|
+
} catch {
|
|
256
|
+
// the pane misses one redraw; the file is the record
|
|
257
|
+
}
|
|
258
|
+
if (!usageOpened && e.rateLimits.some(w => w.percentUsed >= OPEN_AT)) {
|
|
259
|
+
usageOpened = true
|
|
260
|
+
// a surface that seats no panes refuses; the status line and file go on
|
|
261
|
+
$.ui.open({ id: USAGE_PANE, title: USAGE_TITLE }).catch(() => undefined)
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
let session = ''
|
|
265
|
+
try {
|
|
266
|
+
session = await $.session.id()
|
|
267
|
+
} catch {
|
|
268
|
+
// no session id on this host: the file says null
|
|
269
|
+
}
|
|
270
|
+
// a newer measurement already came while this one waited; its figures are the ones to write
|
|
271
|
+
if (mine === seq) files.usage.newest = { name: 'latest.json', text: () => fileText(e, session, new Date()) }
|
|
272
|
+
await writeLatest($, usageDir, files.usage)
|
|
273
|
+
} catch {
|
|
274
|
+
// unwritable folder: the file is advisory, the session goes on
|
|
275
|
+
}
|
|
276
|
+
return next(e)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
on('turn.step', async function* ($, e, next) {
|
|
280
|
+
if (!e.agentId) {
|
|
281
|
+
if (!turnsOn) return yield* next(e)
|
|
282
|
+
// A main-loop step: counted as it starts, its file named and filled before any wait, so an
|
|
283
|
+
// older step can never land on top of a newer one.
|
|
284
|
+
tally.steps++
|
|
285
|
+
const now = new Date()
|
|
286
|
+
parts.turns = turnsStatus()
|
|
287
|
+
showStatus($, parts)
|
|
288
|
+
files.turns.newest = { name: `${localDay(now)}.txt`, text: () => tallyText(e.model, e.messageCount, now) }
|
|
289
|
+
try {
|
|
290
|
+
await writeLatest($, turnsDir, files.turns)
|
|
291
|
+
} catch {
|
|
292
|
+
// unwritable folder: the tally is advisory, the session goes on
|
|
293
|
+
}
|
|
294
|
+
return yield* next(e)
|
|
295
|
+
}
|
|
296
|
+
const response = yield* next(e)
|
|
297
|
+
if (!response.usage) return response
|
|
298
|
+
const agentId = e.agentId
|
|
299
|
+
const order = nextStep()
|
|
300
|
+
const sessionId = await sessionNow($)
|
|
301
|
+
// a step whose id came back after its session ended: its rows file is closed
|
|
302
|
+
if (sessionId === null) return response
|
|
303
|
+
addStep(agentId, sessionId, response.usage, e.model, new Date(), order)
|
|
304
|
+
try {
|
|
305
|
+
await update($, shownRows, () => [...rows.values()])
|
|
306
|
+
} catch {
|
|
307
|
+
// the pane misses one redraw; the rows file is the record
|
|
308
|
+
}
|
|
309
|
+
parts.spend = spendStatus()
|
|
310
|
+
showStatus($, parts)
|
|
311
|
+
try {
|
|
312
|
+
// the text is taken now, not at the write: a /clear while a write waits empties the Map it reads
|
|
313
|
+
const text = rowsText()
|
|
314
|
+
files.spend.newest = { name: `${sessionId || 'no-session'}.jsonl`, text: () => text }
|
|
315
|
+
await writeLatest($, spendDir, files.spend)
|
|
316
|
+
} catch (err) {
|
|
317
|
+
// unwritable folder: say so once, then the session goes on
|
|
318
|
+
if (!spendWarned) {
|
|
319
|
+
spendWarned = true
|
|
320
|
+
try {
|
|
321
|
+
$.ui.status(`subagent-spend: rows file not written (${err instanceof Error ? err.message : String(err)})`)
|
|
322
|
+
} catch {
|
|
323
|
+
// nothing left to tell it on
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return response
|
|
328
|
+
})
|
|
329
|
+
}
|
package/hooks/spend.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Row } from '../types'
|
|
2
|
+
|
|
3
|
+
// The per-subagent sums: what each subagent of the session cost, as the API counts it.
|
|
4
|
+
// Keyed by the exact agent id the engine hands over; two ids that share a prefix or a
|
|
5
|
+
// suffix stay two rows, and a type is carried only from a spawn with that exact id.
|
|
6
|
+
// Sums live in module variables, so a hot reload starts them over; the row's `ts`
|
|
7
|
+
// says when it was last written.
|
|
8
|
+
|
|
9
|
+
export const rows = new Map<string, Row>()
|
|
10
|
+
export const types = new Map<string, string>()
|
|
11
|
+
/** The order number of the step that last set each row's context, ts and model. */
|
|
12
|
+
export const latest = new Map<string, number>()
|
|
13
|
+
let stepCount = 0
|
|
14
|
+
/** Takes a step's order number when its response arrives, before any wait. */
|
|
15
|
+
export const nextStep = () => ++stepCount
|
|
16
|
+
|
|
17
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
18
|
+
|
|
19
|
+
/** Local time with offset, the shape the command hook writes (`2026-10-27T16:40:02-04:00`). */
|
|
20
|
+
export const localIso = (d: Date) => {
|
|
21
|
+
const off = -d.getTimezoneOffset()
|
|
22
|
+
const sign = off >= 0 ? '+' : '-'
|
|
23
|
+
const abs = Math.abs(off)
|
|
24
|
+
return (
|
|
25
|
+
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
|
|
26
|
+
`T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +
|
|
27
|
+
`${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const fmt = (n: number) => (n >= 10000 ? `${Math.round(n / 1000)}k` : String(n))
|
|
32
|
+
|
|
33
|
+
/** The four counts of one step's usage, as the engine hands them over. */
|
|
34
|
+
export type StepUsage = {
|
|
35
|
+
input_tokens: number
|
|
36
|
+
output_tokens: number
|
|
37
|
+
cache_read_input_tokens: number
|
|
38
|
+
cache_creation_input_tokens: number
|
|
39
|
+
model?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Adds one step to the agent's row. Reads the prior row at the moment it adds, never before a wait. A step
|
|
43
|
+
* that lands after a newer one of the same agent adds its tokens but keeps the newer context, ts and model. */
|
|
44
|
+
export const addStep = (agentId: string, sessionId: string, u: StepUsage, model: string, now: Date, order = nextStep()): Row => {
|
|
45
|
+
const prior = rows.get(agentId)
|
|
46
|
+
const older = prior !== undefined && (latest.get(agentId) ?? 0) > order
|
|
47
|
+
const row: Row = {
|
|
48
|
+
ts: older ? prior.ts : localIso(now),
|
|
49
|
+
session_id: sessionId,
|
|
50
|
+
agent_id: agentId,
|
|
51
|
+
agent_type: types.get(agentId) ?? prior?.agent_type ?? '',
|
|
52
|
+
input: (prior?.input ?? 0) + u.input_tokens,
|
|
53
|
+
output: (prior?.output ?? 0) + u.output_tokens,
|
|
54
|
+
cache_read: (prior?.cache_read ?? 0) + u.cache_read_input_tokens,
|
|
55
|
+
cache_create: (prior?.cache_create ?? 0) + u.cache_creation_input_tokens,
|
|
56
|
+
turns: (prior?.turns ?? 0) + 1,
|
|
57
|
+
total: 0,
|
|
58
|
+
context: older ? prior.context : u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens,
|
|
59
|
+
model: older ? prior.model : u.model || prior?.model || model,
|
|
60
|
+
}
|
|
61
|
+
if (!older) latest.set(agentId, order)
|
|
62
|
+
row.total = row.input + row.output + row.cache_read + row.cache_create
|
|
63
|
+
rows.set(agentId, row)
|
|
64
|
+
return row
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The rows file's whole text: one row per agent, one JSON object per line. */
|
|
68
|
+
export const rowsText = () =>
|
|
69
|
+
[...rows.values()].map(r => JSON.stringify(r)).join('\n') + (rows.size ? '\n' : '')
|
|
70
|
+
|
|
71
|
+
/** The status line part: `subagents 2 | tok 7340 (out 120)`. */
|
|
72
|
+
export const spendStatus = () => {
|
|
73
|
+
let tok = 0
|
|
74
|
+
let out = 0
|
|
75
|
+
for (const r of rows.values()) {
|
|
76
|
+
tok += r.total
|
|
77
|
+
out += r.output
|
|
78
|
+
}
|
|
79
|
+
return `subagents ${rows.size} | tok ${fmt(tok)} (out ${fmt(out)})`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The shortest start of `id`, six characters or more, that no other listed id starts with; the whole id
|
|
83
|
+
* when none is (one id is the start of another). Two agents never share a label. */
|
|
84
|
+
export const label = (id: string, all: readonly string[]) => {
|
|
85
|
+
for (let n = Math.min(6, id.length); n < id.length; n++) {
|
|
86
|
+
const head = id.slice(0, n)
|
|
87
|
+
if (!all.some(o => o !== id && o.startsWith(head))) return head
|
|
88
|
+
}
|
|
89
|
+
return id
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** `1 turn`, `2 turns`: a count with its word, singular for one. */
|
|
93
|
+
export const count = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
|
|
94
|
+
|
|
95
|
+
/** The pane's lines, biggest spender first: `Explore a1b2c3 8 turns 41k (out 2104, ctx 24k) claude-opus-5-5`. */
|
|
96
|
+
export const paneLines = (list: Row[]) => {
|
|
97
|
+
const ids = list.map(r => r.agent_id)
|
|
98
|
+
return [...list]
|
|
99
|
+
.sort((a, b) => b.total - a.total)
|
|
100
|
+
.map(r => `${r.agent_type || 'agent'} ${label(r.agent_id, ids)} ${count(r.turns, 'turn')} ${fmt(r.total)} (out ${fmt(r.output)}, ctx ${fmt(r.context ?? 0)}) ${r.model}`)
|
|
101
|
+
}
|
package/hooks/turns.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The turns part (option `turnTally`): the main conversation's model steps, tool calls,
|
|
2
|
+
// tool errors and error streak. This file holds the counts and the text; register.tsx
|
|
3
|
+
// holds the hooks. The day file's format is the one turn-tally 0.4.0 wrote, line for line.
|
|
4
|
+
//
|
|
5
|
+
// Counts live in module variables, so a hot reload of the module starts them over; the
|
|
6
|
+
// file says `updated` so a reader can tell.
|
|
7
|
+
|
|
8
|
+
export const tally = { steps: 0, toolCalls: 0, toolErrors: 0, streak: 0, longestStreak: 0, subagentToolCalls: 0 }
|
|
9
|
+
|
|
10
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
11
|
+
|
|
12
|
+
export const localDay = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
13
|
+
|
|
14
|
+
const localTime = (d: Date) => `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
|
15
|
+
|
|
16
|
+
/** Counts one finished tool call. A subagent's call is counted apart. A denied call never ran, so it is
|
|
17
|
+
* not a tool error; an errored result is. */
|
|
18
|
+
export const countCall = (agentId: string | undefined, denied: boolean, isError: boolean) => {
|
|
19
|
+
if (agentId) {
|
|
20
|
+
tally.subagentToolCalls++
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
tally.toolCalls++
|
|
24
|
+
if (!denied && isError) {
|
|
25
|
+
tally.toolErrors++
|
|
26
|
+
tally.streak++
|
|
27
|
+
if (tally.streak > tally.longestStreak) tally.longestStreak = tally.streak
|
|
28
|
+
} else {
|
|
29
|
+
tally.streak = 0
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The day file's whole text: one fact per line, rewritten whole every step. */
|
|
34
|
+
export const tallyText = (model: string, messageCount: number, now: Date) =>
|
|
35
|
+
[
|
|
36
|
+
`steps ${tally.steps}`,
|
|
37
|
+
`tool calls ${tally.toolCalls} (${tally.subagentToolCalls} by subagents)`,
|
|
38
|
+
`tool errors ${tally.toolErrors}, current streak ${tally.streak}, longest streak ${tally.longestStreak}`,
|
|
39
|
+
`model ${model}, messages ${messageCount}`,
|
|
40
|
+
`updated ${localDay(now)} ${localTime(now)}`,
|
|
41
|
+
'',
|
|
42
|
+
].join('\n')
|
|
43
|
+
|
|
44
|
+
/** The status line part: `steps 12 | tools 11 | errors 1 (streak 0)`. */
|
|
45
|
+
export const turnsStatus = () => `steps ${tally.steps} | tools ${tally.toolCalls} | errors ${tally.toolErrors} (streak ${tally.streak})`
|
package/hooks/usage.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { SessionMeasureInput } from 'claude-code'
|
|
2
|
+
|
|
3
|
+
import type { Shown } from '../types'
|
|
4
|
+
|
|
5
|
+
// The usage part (option `usageFile`): the session's rate-limit windows, context fill and
|
|
6
|
+
// cost, as the engine's `session.measure` event carries them. This file holds the text;
|
|
7
|
+
// register.tsx holds the hooks. The file format is the one usage-file 0.4.1 wrote, field
|
|
8
|
+
// for field, so a reader of `.usage-file/latest.json` keeps working.
|
|
9
|
+
|
|
10
|
+
const LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
|
|
11
|
+
const NAME: Record<string, string> = { five_hour: '5-hour', seven_day: 'Weekly', spend_limit: 'Spend limit' }
|
|
12
|
+
|
|
13
|
+
/** The usage pane opens itself once, the first time any window reaches this percent. */
|
|
14
|
+
export const OPEN_AT = 80
|
|
15
|
+
|
|
16
|
+
/** A ten-cell bar: `██████░░░░` for 60. */
|
|
17
|
+
export const bar = (pct: number) => {
|
|
18
|
+
const full = Math.max(0, Math.min(10, Math.round(pct / 10)))
|
|
19
|
+
return '█'.repeat(full) + '░'.repeat(10 - full)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Time left to a reset: `2h 05m`, `3d 4h`, `now`; empty when the engine gave no reset time. */
|
|
23
|
+
export const untilText = (resetsAt: string | null, now: Date) => {
|
|
24
|
+
if (!resetsAt) return ''
|
|
25
|
+
const ms = Date.parse(resetsAt) - now.getTime()
|
|
26
|
+
if (Number.isNaN(ms)) return ''
|
|
27
|
+
if (ms <= 0) return 'now'
|
|
28
|
+
const min = Math.floor(ms / 60000)
|
|
29
|
+
if (min >= 2880) return `${Math.floor(min / 1440)}d ${Math.floor((min % 1440) / 60)}h`
|
|
30
|
+
return `${Math.floor(min / 60)}h ${String(min % 60).padStart(2, '0')}m`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One pane line per window: `5-hour ██░░░░░░░░ 23.5% resets in 2h 05m`. */
|
|
34
|
+
export const usageLines = (s: Shown, now: Date) =>
|
|
35
|
+
s.windows.map(w => {
|
|
36
|
+
const left = untilText(w.resetsAt, now)
|
|
37
|
+
return `${(NAME[w.kind] ?? w.kind).padEnd(11)} ${bar(w.percentUsed)} ${w.percentUsed}%${left ? ` resets in ${left}` : ''}`
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
/** latest.json's whole text: one JSON object, rewritten whole on every measurement. */
|
|
41
|
+
export const fileText = (e: SessionMeasureInput, session: string, now: Date) =>
|
|
42
|
+
JSON.stringify(
|
|
43
|
+
{
|
|
44
|
+
at: now.toISOString(),
|
|
45
|
+
session: session || null,
|
|
46
|
+
changed: e.changed,
|
|
47
|
+
context: {
|
|
48
|
+
tokens: e.context.tokens ?? null,
|
|
49
|
+
window: e.context.window,
|
|
50
|
+
percent: e.context.percent ?? null,
|
|
51
|
+
},
|
|
52
|
+
rateLimits: e.rateLimits.map(w => ({
|
|
53
|
+
kind: w.kind,
|
|
54
|
+
percentUsed: w.percentUsed,
|
|
55
|
+
resetsAt: w.resetsAt ?? null,
|
|
56
|
+
})),
|
|
57
|
+
cost: e.cost ? { usd: e.cost.usd } : null,
|
|
58
|
+
},
|
|
59
|
+
null,
|
|
60
|
+
2,
|
|
61
|
+
) + '\n'
|
|
62
|
+
|
|
63
|
+
/** The status line part: only the figures that have a reading; undefined when none has. */
|
|
64
|
+
export const usageStatus = (e: SessionMeasureInput) => {
|
|
65
|
+
const parts = e.rateLimits.map(w => `${LABEL[w.kind] ?? w.kind} ${w.percentUsed}%`)
|
|
66
|
+
if (e.context.percent !== undefined) parts.push(`ctx ${e.context.percent}%`)
|
|
67
|
+
if (e.cost) parts.push(`$${e.cost.usd.toFixed(2)}`)
|
|
68
|
+
return parts.length ? parts.join(' | ') : undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The usage part again, from what the pane holds (for the command's answer). */
|
|
72
|
+
export const shownStatus = (s: Shown) =>
|
|
73
|
+
usageStatus({
|
|
74
|
+
rateLimits: s.windows.map(w => ({ kind: w.kind, percentUsed: w.percentUsed })),
|
|
75
|
+
context: { window: 0, percent: s.context ?? undefined },
|
|
76
|
+
cost: s.usd === null ? undefined : { usd: s.usd },
|
|
77
|
+
changed: [],
|
|
78
|
+
} as SessionMeasureInput) ?? 'No figures yet.'
|
|
79
|
+
|
|
80
|
+
/** What the usage pane stores from one measurement. */
|
|
81
|
+
export const shownOf = (e: SessionMeasureInput, now: Date): Shown => ({
|
|
82
|
+
at: now.toISOString(),
|
|
83
|
+
windows: e.rateLimits.map(w => ({ kind: w.kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt ?? null })),
|
|
84
|
+
context: e.context.percent ?? null,
|
|
85
|
+
usd: e.cost ? e.cost.usd : null,
|
|
86
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nightlab-agent/subagent-spend",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.7.3",
|
|
4
|
+
"description": "Claude Code mod: 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.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude-code",
|
|
7
|
+
"claude-code-mod",
|
|
8
|
+
"claude-code-plugin",
|
|
9
|
+
"subagents",
|
|
10
|
+
"token-usage",
|
|
11
|
+
"rate-limits",
|
|
12
|
+
"statusline"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/nightlab-agent/subagent-spend#readme",
|
|
15
|
+
"bugs": "https://github.com/nightlab-agent/subagent-spend/issues",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/nightlab-agent/subagent-spend"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"author": "Nightlab",
|
|
22
|
+
"files": [
|
|
23
|
+
".claude-plugin/",
|
|
24
|
+
"hooks/hooks.json",
|
|
25
|
+
"hooks/register.tsx",
|
|
26
|
+
"hooks/spend.ts",
|
|
27
|
+
"hooks/turns.ts",
|
|
28
|
+
"hooks/usage.ts",
|
|
29
|
+
"types/",
|
|
30
|
+
"kit/",
|
|
31
|
+
"subagent_spend.py",
|
|
32
|
+
"CHANGELOG.md"
|
|
33
|
+
]
|
|
34
|
+
}
|