@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.
@@ -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
+ })
@@ -0,0 +1,8 @@
1
+ {
2
+ "hooks": {
3
+ "SubagentStop": [
4
+ { "hooks": [ { "type": "command", "command": "python3 -I /path/to/subagent_spend.py hook" } ] }
5
+ ]
6
+ },
7
+ "statusLine": { "type": "command", "command": "python3 -I /path/to/subagent_spend.py statusline" }
8
+ }
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "@nightlab-agent/subagent-spend",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }