kankaku-claude 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kankaku",
3
3
  "description": "Records how long Claude Code works on each of your prompts: wall time, waiting time, work time and cost, per prompt, in kankaku's worklog format.",
4
- "version": "1.2.0",
4
+ "version": "1.3.0",
5
5
  "author": {
6
6
  "name": "soyunninja"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -4,6 +4,36 @@ All notable changes to this project are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## 1.3.0 — 2026-09-29
8
+
9
+ ### Added
10
+
11
+ - **Tokens on every record.** The input, output, cache read and cache write
12
+ tokens are read from the session transcript (and from each subagent's
13
+ transcript), so `cache hit` now appears for Claude Code records. Only new
14
+ bytes are read at each settle, at most 16 MiB; `UserPromptSubmit` only
15
+ `stat`s the files and the other hooks read nothing.
16
+ - **`agentVersion`.** Claude Code's version, taken from the transcript.
17
+ - **Cost for headless runs.** A `claude -p` session keeps each settled prompt
18
+ pending and writes its record at `SessionEnd` (or in crash recovery) with
19
+ the transcript's `cost-state` cost, through the same chained baseline as the
20
+ statusline cost. With several prompts the cost is shared in proportion to
21
+ their tokens and each record carries `costAllocated`, kept local and never
22
+ sent to the hub.
23
+ - **Late transcript writes are covered.** Claude Code writes the transcript
24
+ asynchronously, after `Stop` has started. `Stop` polls every 25 ms and
25
+ reads once at least 100 ms have passed since the hook started, the size is
26
+ stable and an assistant line is present, giving up at 300 ms (the statusline
27
+ wait counts), so the last message of a prompt is not shifted into the next
28
+ record, a headless
29
+ `SessionEnd` or recovery reads once more and adds late usage to the last
30
+ pending prompt, and the entry point and version are also read from the
31
+ start of the file when no new line carries them.
32
+ - **`model` from the transcript** (`anthropic/<message.model>`) when the
33
+ statusline gave none.
34
+ - Any surprise in the (undocumented) transcript format leaves the record as it
35
+ was before: written, without tokens, version or cost, and no hook fails.
36
+
7
37
  ## 1.2.0 — 2026-09-29
8
38
 
9
39
  No changes in this package's code; released in lockstep with kankaku 1.2.0.
package/README.md CHANGED
@@ -17,7 +17,9 @@ One record per user prompt, appended to `<KANKAKU_DIR>/worklog.jsonl`:
17
17
  waiting on a permission dialog.
18
18
  - **Work time** — wall time minus waiting time.
19
19
  - **Cost** — the prompt's share of the session's running `total_cost_usd`
20
- (see "Limitations" below for how this is derived).
20
+ (see "How cost is derived" below).
21
+ - **Tokens** — input, output, cache read and cache write tokens, read from
22
+ the session transcript (see "What is read from the transcript").
21
23
 
22
24
  Records are written in kankaku's own `WorkRecord` schema
23
25
  (`WORK_RECORD_SCHEMA = 1`), the exact one the kankaku pi extension writes to
@@ -28,9 +30,10 @@ manual and best-effort automatic hub sync through kankaku's public hub adapters
28
30
  Every record carries the identity of who measured it: `agent: "claude-code"`,
29
31
  `plugin: "kankaku-claude"` and `pluginVersion` (this package's version).
30
32
  A worklog synced by another tool, such as the kankaku TUI, therefore keeps
31
- the right agent on the hub. `agentVersion` is left unset because Claude Code
32
- does not pass its version to hooks. Records written before this version carry
33
- no identity and are labelled by whichever tool syncs them first.
33
+ the right agent on the hub. `agentVersion` is Claude Code's version, read
34
+ from the session transcript; it is left unset when the transcript cannot be
35
+ read, never guessed. Records written before this version carry no identity
36
+ and are labelled by whichever tool syncs them first.
34
37
 
35
38
  ## Requirements
36
39
 
@@ -268,9 +271,9 @@ later sync does not move it.
268
271
 
269
272
  ## How cost is derived
270
273
 
271
- Claude Code hooks carry no cost. The only source is the statusline, whose
272
- `cost.total_cost_usd` is the running total of the session; the statusline
273
- command stores the latest value under your home directory (see "Where the
274
+ Claude Code hooks carry no cost. For interactive sessions the only source is
275
+ the statusline, whose `cost.total_cost_usd` is the running total of the
276
+ session; the statusline command stores the latest value under your home directory (see "Where the
274
277
  files live").
275
278
 
276
279
  - **Per-prompt difference.** A prompt's cost is the session total when the
@@ -291,12 +294,117 @@ files live").
291
294
  - **Counter reset.** If the total is lower than the value the prompt is
292
295
  measured from, the counter was reset: the prompt's cost is the new total
293
296
  and the baseline restarts from it.
294
- - **Headless runs have no cost.** `claude -p` renders no statusline, so
295
- there is no total: the record stays without cost (`costObserved` unset)
296
- and the baseline does not move.
297
+ - **Headless runs take their cost from the transcript.** `claude -p` renders
298
+ no statusline, so the cost comes from the transcript's `cost-state` line
299
+ instead (see "Headless runs" below).
297
300
  - `/kankaku:status` shows `recorded $X of $Y` per live session so a gap is
298
301
  visible.
299
302
 
303
+ ## What is read from the transcript
304
+
305
+ Every hook receives `transcript_path`, the JSON Lines file Claude Code keeps
306
+ for the session. kankaku-claude reads it for three things, and nothing else:
307
+
308
+ - **Tokens.** The `input_tokens`, `output_tokens`, `cache_read_input_tokens`
309
+ and `cache_creation_input_tokens` of the assistant messages become the
310
+ record's `usage.input`, `usage.output`, `usage.cacheRead` and
311
+ `usage.cacheWrite`, which is what makes `cache hit` appear for Claude Code
312
+ records in the `kankaku` CLI. Claude Code writes one message over several
313
+ adjacent lines whose usage grows (the earlier lines are partial snapshots;
314
+ subagent transcripts show it clearly), so each `message.id` is counted once,
315
+ by its last line. When a read ends in the middle of a message, the stored
316
+ position remembers the message id and what was counted for it, and the
317
+ next read adds only the growth.
318
+ - **The version.** The `version` of the first line read becomes the record's
319
+ `agentVersion`.
320
+ - **For headless runs, the cost.** See "Headless runs" below.
321
+
322
+ Only numbers, the version and the entry point (`cli` or `sdk-cli`) are taken
323
+ from a line; prompts, answers, tool inputs and outputs are never kept, copied
324
+ or sent anywhere. Subagents do not appear in the session's transcript: each
325
+ has its own file under `<session id>/subagents/`, and those files are read
326
+ too, so the record of a prompt includes its subagents' tokens.
327
+
328
+ How it is read:
329
+
330
+ - **Only what is new.** The session state keeps a read position per
331
+ transcript file. `UserPromptSubmit` only records the current size of each
332
+ file (a `stat`, no read) so a session that already existed does not read
333
+ its history; `Stop`, `SessionEnd` with an open prompt and crash recovery
334
+ read the bytes after the position, count them and advance it. A subagent
335
+ file that appears later is read from its start, and a partial last line is
336
+ left for the next read.
337
+ - **Tokens between two prompts are not lost.** Positions only advance when a
338
+ record is settled, so tokens spent after `Stop` and before the next
339
+ settle (a background subagent that keeps running) land in the next record
340
+ of the session, the same rule as cost.
341
+ - **The transcript is written asynchronously.** Measured on real runs, the
342
+ last assistant lines reach the disk shortly AFTER the `Stop` hook has
343
+ started (none at 0 ms, present at 50 ms). Before reading at settle,
344
+ `Stop` therefore polls the transcript every 25 ms, measured from when the
345
+ hook process started, and reads once ALL hold: at least 100 ms have
346
+ passed (earlier assistant messages of the same prompt are usually on disk
347
+ long before, and the last one is the late one, so a line being present is
348
+ not enough), the size did not change across two polls, and the new bytes
349
+ hold a complete assistant line. It gives up 300 ms after the hook started
350
+ whatever it sees. Time already spent in the statusline wait counts, so a
351
+ hook that waited 300 ms or more for the statusline does not wait again.
352
+ Usage that still arrives later is counted by the next settle. A headless session reads once more at
353
+ `SessionEnd` (or in recovery, which does not wait: the file is final) and
354
+ adds what it finds to the last pending prompt, since late lines belong to
355
+ the prompt that just settled.
356
+ - **The model.** When the statusline gave no model (headless runs, or no
357
+ statusline), the record's `model` is `anthropic/<message.model>` of the
358
+ last assistant line, the same `anthropic/` prefix the statusline model gets.
359
+ The statusline model wins when there is one.
360
+ - **A bound per settle.** At most 16 MiB of transcript is read per settle,
361
+ across all files. If a settle finds more new content than that, it is
362
+ skipped without counting and the record simply has no tokens; the hook
363
+ never risks its timeout on a huge file.
364
+ - **Hooks that build no record read nothing.** `PreToolUse`, `PostToolUse`,
365
+ `PermissionRequest`, `SubagentStart` and `SubagentStop` never touch the
366
+ transcript, and `UserPromptSubmit` only `stat`s it.
367
+
368
+ The transcript format is internal and undocumented and may change with any
369
+ Claude Code release. kankaku-claude therefore treats every part of it as
370
+ optional: a missing or unreadable file, a line it does not understand, or a
371
+ field of an unexpected type is ignored, and the record is written exactly as
372
+ it was before this existed, without the tokens or the version. It never makes
373
+ a hook fail.
374
+
375
+ ### Headless runs
376
+
377
+ A session whose transcript says `entrypoint: "sdk-cli"` (a `claude -p` run)
378
+ has no statusline. Claude Code appends a `cost-state` line with the session's
379
+ total cost to the transcript only after the last `Stop`, so for these
380
+ sessions:
381
+
382
+ 1. `Stop` does not write the record. The settled prompt (times, waiting and
383
+ tokens are final) is kept in the session state as pending.
384
+ 2. `SessionEnd` reads the last `cost-state`, applies the same chained
385
+ baseline as the statusline cost, and appends the record with the cost and
386
+ `costObserved`. If `SessionEnd` never runs, crash recovery writes the
387
+ pending records the next time another session starts, reading the
388
+ transcript then. Either way each prompt is written exactly once.
389
+ 3. A `cost-state` with a finite `totalCostUSD` is usable whatever its
390
+ `modelUsage` holds (an empty one included); only
391
+ `hasUnknownModelCost: true` disqualifies it. The cost also needs a start
392
+ baseline: a session Claude Code reports as newly started (`SessionStart`
393
+ source `startup`) begins at 0; without one the cost stays unobserved,
394
+ like the statusline cost.
395
+ 4. With no `cost-state`, or one flagged `hasUnknownModelCost` (the total is
396
+ then not reliable), the records are written without cost.
397
+
398
+ **Attribution.** A session with one prompt gets the whole cost. When a
399
+ headless session ran several prompts, the total is shared in proportion to
400
+ each prompt's token total (equal shares when all are zero), in whole
401
+ micro-dollars with the remainder on the last prompt, so the shares add up to
402
+ the session cost exactly. Those records carry `costAllocated: true`: the cost
403
+ is a share, not a measured difference. The marker stays in the local
404
+ worklog and is not sent to the hub. Interactive sessions (entry point `cli`,
405
+ or one that cannot be read) are unchanged: the record is written at `Stop`
406
+ with the statusline cost.
407
+
300
408
  ## Where the files live
301
409
 
302
410
  - `<KANKAKU_DIR>/worklog.jsonl` — the append-only log of settled records,
@@ -326,8 +434,10 @@ If Claude Code's process is killed mid-prompt (or mid-session), the next
326
434
  session's `SessionStart` hook scans for other sessions' state files whose
327
435
  process is no longer alive. A dead session with an open prompt is replayed
328
436
  and appended as one `status: "interrupted"` record before its files are
329
- deleted; a dead session with no open prompt just has its files deleted. This
330
- also runs for the current session's own leftover state at `SessionEnd`.
437
+ deleted; a dead session with no open prompt just has its files deleted. A
438
+ dead headless session that still holds pending prompts gets their records
439
+ written, with the cost from its transcript. This also runs for the current
440
+ session's own leftover state at `SessionEnd`.
331
441
 
332
442
  An interrupted prompt is closed at its last recorded activity — the
333
443
  timestamp of the last event logged for it, or its own start when nothing
@@ -340,15 +450,18 @@ still open is closed at that same instant.
340
450
  - **`turns` is always 1 per run.** Claude Code hooks give no way to observe
341
451
  provider-level retries/turns inside one run; every replayed run reports
342
452
  exactly one turn.
343
- - **No token counts.** Hooks never carry input/output/cache token numbers,
344
- only the statusline's aggregate `total_cost_usd`; per-record `usage` token
345
- fields stay at zero, cost is the only populated figure.
453
+ - **Token counts come from an undocumented file.** Hooks carry no token
454
+ numbers; they are read from the session transcript, whose format can
455
+ change with any Claude Code release. When it cannot be read or understood
456
+ the record has zero tokens, and a settle that finds more than 16 MiB of
457
+ new transcript skips it. See "What is read from the transcript".
346
458
  - **Cost is a per-prompt delta of the session total, from the statusline,
347
459
  and needs the manual setup step.** See "How cost is derived". Spend that
348
460
  lands between two prompts (a late statusline refresh, a background
349
461
  subagent still running) is attributed to the next record of the session,
350
- not lost. Headless `claude -p` runs have no statusline, so their records
351
- carry no cost.
462
+ not lost. Headless `claude -p` runs have no statusline; their cost comes
463
+ from the transcript at `SessionEnd`, and is shared between prompts when
464
+ there are several (see "Headless runs").
352
465
  - **A permission wait ends at the next hook event, not when you actually
353
466
  click.** There is no documented hook that fires the moment you answer a
354
467
  permission dialog, so the waiting span closes at whatever hook fires next
@@ -2,10 +2,13 @@ import { unlinkSync } from "node:fs";
2
2
  import { resolvePaths } from "./paths.js";
3
3
  import { appendEvent, readEventLog, dropSettledPrompts } from "./event-log.js";
4
4
  import { readState, writeState, updateState } from "./session-state.js";
5
+ import { settleTranscripts, trackTranscriptAtSubmit, waitForTranscript } from "./transcript-settle.js";
5
6
  import { resolveClaudePid } from "./claude-pid.js";
6
7
  import { splitPrompts } from "./prompts.js";
7
8
  import { readCost, deleteCost, sweepStaleCostFiles } from "./cost-store.js";
8
9
  import { settleCost } from "./cost-chain.js";
10
+ /** The transcript `entrypoint` of a headless `claude -p` run. */
11
+ const HEADLESS_ENTRYPOINT = "sdk-cli";
9
12
  const STOP_COST_WAIT_POLL_MS = 100;
10
13
  const STOP_COST_WAIT_MAX_MS = 1500;
11
14
  /**
@@ -33,6 +36,7 @@ export async function handleHook(input, deps) {
33
36
  : { ts, event: "UserPromptSubmit", prompt };
34
37
  appendEvent(paths.eventsFile, event);
35
38
  const snapshot = readCost(deps.env, sessionId)?.totalUsd;
39
+ const transcriptPath = readString(raw.transcript_path);
36
40
  updateState(paths.stateFile, (state) => ({
37
41
  pid: state?.pid ?? 0,
38
42
  parentPid: state?.parentPid ?? 0,
@@ -41,7 +45,9 @@ export async function handleHook(input, deps) {
41
45
  // The chained baseline wins over the snapshot: spend since the last settle belongs to this prompt.
42
46
  promptOpen: { id: promptId ?? `${sessionId}:${ts}`, startedAt: ts, costAtStart: state?.costBaseline ?? snapshot },
43
47
  permissionOpen: null,
44
- ...(state?.costBaseline !== undefined ? { costBaseline: state.costBaseline } : {}),
48
+ ...carriedOver(state),
49
+ // Stat only: the transcript's content is read at settle, never here.
50
+ ...withTranscript(trackTranscriptSafely(state, transcriptPath)),
45
51
  }));
46
52
  return;
47
53
  }
@@ -71,7 +77,7 @@ export async function handleHook(input, deps) {
71
77
  startedAt: state?.startedAt ?? ts,
72
78
  promptOpen: state?.promptOpen ?? null,
73
79
  permissionOpen: ts,
74
- ...(state?.costBaseline !== undefined ? { costBaseline: state.costBaseline } : {}),
80
+ ...carriedOver(state),
75
81
  }));
76
82
  return;
77
83
  }
@@ -93,7 +99,7 @@ export async function handleHook(input, deps) {
93
99
  const stopHookActive = raw.stop_hook_active === true;
94
100
  appendEvent(paths.eventsFile, { ts, event: "Stop", stopHookActive });
95
101
  clearPermissionOpen(paths.stateFile);
96
- await handleStop(paths, sessionId, deps);
102
+ await handleStop(paths, sessionId, deps, ts);
97
103
  return;
98
104
  }
99
105
  case "SessionStart": {
@@ -112,32 +118,62 @@ export async function handleHook(input, deps) {
112
118
  return;
113
119
  }
114
120
  }
115
- async function handleStop(paths, sessionId, deps) {
121
+ /** `startedAt` is when the hook process started: the transcript wait is measured from it. */
122
+ async function handleStop(paths, sessionId, deps, startedAt) {
116
123
  const { replayPrompt } = await import("./replay.js");
117
- const { buildClaudeRecord } = await import("./record.js");
124
+ const { buildClaudeRecord, stampTokens } = await import("./record.js");
118
125
  const { JsonlWorkLog } = await import("kankaku-pi/hub");
119
126
  const events = readEventLog(paths.eventsFile);
120
127
  const beforeStopTs = events.length >= 2 ? events[events.length - 2].ts : events[events.length - 1]?.ts ?? deps.now();
128
+ // A headless session (`claude -p`) has no statusline: its cost comes from the transcript at SessionEnd,
129
+ // so the statusline wait is pointless. The entry point is known after the first settle; before that, peek.
130
+ const opened = readState(paths.stateFile);
131
+ const entrypoint = opened?.transcript?.entrypoint ?? (opened?.promptOpen ? settleSafely(opened, deps)?.transcript.entrypoint : undefined);
132
+ const skipCostWait = entrypoint === HEADLESS_ENTRYPOINT;
121
133
  const deadline = deps.now() + STOP_COST_WAIT_MAX_MS;
122
134
  let cost = readCost(deps.env, sessionId);
123
- while ((!cost || cost.updatedAt < beforeStopTs) && deps.now() < deadline) {
135
+ while (!skipCostWait && (!cost || cost.updatedAt < beforeStopTs) && deps.now() < deadline) {
124
136
  await deps.sleep(STOP_COST_WAIT_POLL_MS);
125
137
  cost = readCost(deps.env, sessionId);
126
138
  }
139
+ // Claude Code writes the transcript asynchronously: give the last assistant lines a bounded moment to land.
140
+ if (opened?.promptOpen)
141
+ await waitForTranscript(opened.transcript, deps, startedAt).catch(() => { });
142
+ const state = readState(paths.stateFile);
143
+ const transcript = state?.promptOpen ? settleSafely(state, deps) : undefined;
144
+ const headless = transcript?.transcript.entrypoint === HEADLESS_ENTRYPOINT;
127
145
  const prompts = splitPrompts(events);
128
146
  const last = prompts[prompts.length - 1];
129
- const state = readState(paths.stateFile);
130
147
  if (!last || !state) {
131
148
  if (!state)
132
149
  deps.stderr(`kankaku: no session state at Stop for ${sessionId}`);
133
150
  return;
134
151
  }
152
+ if (headless && state.promptOpen) {
153
+ // Times, waiting and tokens are final; the cost is not known until SessionEnd. Keep the prompt and write nothing.
154
+ const core = replayPrompt(last, {});
155
+ const pending = [...(state.pending ?? [])];
156
+ if (core)
157
+ pending.push({ core: stampTokens(core, transcript?.tokens), costAtStart: state.promptOpen.costAtStart });
158
+ writeState(paths.stateFile, {
159
+ ...state,
160
+ promptOpen: null,
161
+ permissionOpen: null,
162
+ ...withTranscript(transcript?.transcript),
163
+ pending,
164
+ });
165
+ dropSettledPrompts(paths.eventsFile, events.slice(0, events.length - last.events.length));
166
+ return;
167
+ }
135
168
  // A Stop with no open prompt settles nothing: no cost, no baseline move.
136
169
  const settled = state.promptOpen ? settleCost(cost?.totalUsd, state.promptOpen.costAtStart) : {};
137
170
  const core = replayPrompt(last, { cost: settled.cost });
138
171
  if (core) {
139
172
  const assignment = await assignmentResolver(paths, deps);
140
- const record = buildClaudeRecord(core, state, sessionId, cost?.model, assignment(state.cwd, sessionId));
173
+ const version = agentVersionOf(transcript, state);
174
+ const record = buildClaudeRecord(stampTokens(core, transcript?.tokens), state, sessionId, cost?.model ?? modelOf(transcript, state), assignment(state.cwd, sessionId), {
175
+ ...(version !== undefined ? { agentVersion: version } : {}),
176
+ });
141
177
  const log = deps.log ?? new JsonlWorkLog(paths.kankakuDir);
142
178
  log.append(record);
143
179
  }
@@ -146,6 +182,7 @@ async function handleStop(paths, sessionId, deps) {
146
182
  promptOpen: null,
147
183
  permissionOpen: null,
148
184
  ...(settled.baseline !== undefined ? { costBaseline: settled.baseline } : {}),
185
+ ...withTranscript(transcript?.transcript),
149
186
  });
150
187
  const keep = events.slice(0, events.length - last.events.length);
151
188
  dropSettledPrompts(paths.eventsFile, keep);
@@ -194,25 +231,58 @@ async function handleSessionStart(paths, sessionId, cwd, source, isStartup, ts,
194
231
  }
195
232
  async function handleSessionEnd(paths, sessionId, cwd, ts, deps) {
196
233
  const state = readState(paths.stateFile);
197
- if (state?.promptOpen) {
234
+ if (state && (state.promptOpen || (state.pending?.length ?? 0) > 0)) {
198
235
  const { replayPrompt } = await import("./replay.js");
199
- const { buildClaudeRecord } = await import("./record.js");
236
+ const { buildClaudeRecord, stampTokens } = await import("./record.js");
200
237
  const { JsonlWorkLog } = await import("kankaku-pi/hub");
201
- const events = readEventLog(paths.eventsFile);
202
- const prompts = splitPrompts(events);
203
- const last = prompts[prompts.length - 1];
204
- if (last) {
205
- const costNow = readCost(deps.env, sessionId);
206
- const settled = settleCost(costNow?.totalUsd, state.promptOpen.costAtStart);
207
- const core = replayPrompt(last, { settledAt: ts, cost: settled.cost });
208
- if (core) {
209
- const model = costNow?.model;
210
- const assignment = await assignmentResolver(paths, deps);
211
- const record = buildClaudeRecord(core, state, sessionId, model, assignment(state.cwd, sessionId));
212
- const log = deps.log ?? new JsonlWorkLog(paths.kankakuDir);
213
- log.append(record);
238
+ const log = deps.log ?? new JsonlWorkLog(paths.kankakuDir);
239
+ const assignment = await assignmentResolver(paths, deps);
240
+ // Also the only place the cost-state of a headless run is visible: it is written after the last Stop.
241
+ const transcript = settleSafely(state, deps);
242
+ const version = agentVersionOf(transcript, state);
243
+ const headless = (state.pending?.length ?? 0) > 0 || transcript?.transcript.entrypoint === HEADLESS_ENTRYPOINT;
244
+ let pending = [...(state.pending ?? [])];
245
+ let attached = false;
246
+ if (state.promptOpen) {
247
+ const events = readEventLog(paths.eventsFile);
248
+ const last = splitPrompts(events).at(-1);
249
+ if (last) {
250
+ const costNow = readCost(deps.env, sessionId);
251
+ const settled = headless ? {} : settleCost(costNow?.totalUsd, state.promptOpen.costAtStart);
252
+ const core = replayPrompt(last, { settledAt: ts, cost: settled.cost });
253
+ if (core && headless) {
254
+ pending.push({ core: stampTokens(core, transcript?.tokens), costAtStart: state.promptOpen.costAtStart });
255
+ attached = true;
256
+ }
257
+ else if (core) {
258
+ const record = buildClaudeRecord(stampTokens(core, transcript?.tokens), state, sessionId, costNow?.model ?? modelOf(transcript, state), assignment(state.cwd, sessionId), {
259
+ ...(version !== undefined ? { agentVersion: version } : {}),
260
+ });
261
+ log.append(record);
262
+ }
214
263
  }
215
264
  }
265
+ if (headless && pending.length > 0) {
266
+ const { buildHeadlessRecords, withLateTokens } = await import("./headless.js");
267
+ // Lines written after the prompt settled belong to the prompt that just settled: the last one.
268
+ if (!attached)
269
+ pending = withLateTokens(pending, transcript?.tokens);
270
+ const model = modelOf(transcript, state);
271
+ const records = buildHeadlessRecords({
272
+ pending,
273
+ ...(model !== undefined ? { model } : {}),
274
+ state,
275
+ sessionId,
276
+ ...(transcript?.costState !== undefined ? { costState: transcript.costState } : {}),
277
+ assignment: assignment(state.cwd, sessionId),
278
+ ...(version !== undefined ? { agentVersion: version } : {}),
279
+ });
280
+ for (const record of records)
281
+ log.append(record);
282
+ // Written once, whichever path gets there first: drop the pending list before anything slow runs.
283
+ const { pending: _written, ...withoutPending } = state;
284
+ writeState(paths.stateFile, { ...withoutPending, promptOpen: null, permissionOpen: null });
285
+ }
216
286
  }
217
287
  try {
218
288
  await syncHeavy("session_shutdown", state?.cwd ?? cwd, deps);
@@ -275,6 +345,42 @@ async function syncHeavy(trigger, cwd, deps) {
275
345
  deps.stderr(`kankaku auto-sync: ${error instanceof Error ? error.message : String(error)}`);
276
346
  }
277
347
  }
348
+ /** Everything that survives a state rewrite besides the fields the handler sets itself. */
349
+ function carriedOver(state) {
350
+ return {
351
+ ...(state?.costBaseline !== undefined ? { costBaseline: state.costBaseline } : {}),
352
+ ...(state?.transcript !== undefined ? { transcript: state.transcript } : {}),
353
+ ...(state?.pending !== undefined ? { pending: state.pending } : {}),
354
+ };
355
+ }
356
+ function withTranscript(transcript) {
357
+ return transcript !== undefined ? { transcript } : {};
358
+ }
359
+ /** The transcript bookkeeping never fails a hook: on any error the state keeps what it had. */
360
+ function trackTranscriptSafely(state, path) {
361
+ try {
362
+ return trackTranscriptAtSubmit(state?.transcript, path);
363
+ }
364
+ catch {
365
+ return state?.transcript;
366
+ }
367
+ }
368
+ /** Reads what the transcript gained since the stored positions; any failure means "no tokens", as before this feature. */
369
+ function settleSafely(state, deps) {
370
+ try {
371
+ return settleTranscripts(state.transcript);
372
+ }
373
+ catch (error) {
374
+ deps.stderr(`kankaku: transcript: ${error instanceof Error ? error.message : String(error)}`);
375
+ return undefined;
376
+ }
377
+ }
378
+ function modelOf(settled, state) {
379
+ return settled?.transcript.model ?? state.transcript?.model;
380
+ }
381
+ function agentVersionOf(settled, state) {
382
+ return settled?.transcript.agentVersion ?? state.transcript?.agentVersion;
383
+ }
278
384
  function deleteSessionFiles(paths) {
279
385
  for (const file of [paths.stateFile, paths.eventsFile, paths.targetFile]) {
280
386
  try {
@@ -0,0 +1,88 @@
1
+ import { settleCost } from "./cost-chain.js";
2
+ import { buildClaudeRecord } from "./record.js";
3
+ /**
4
+ * Headless (`claude -p`, entry point `sdk-cli`) sessions have no statusline,
5
+ * so no per-prompt cost. Their transcript gains a `cost-state` line only
6
+ * after the last `Stop`, so the prompts are held as pending at `Stop` and
7
+ * turned into records here, at `SessionEnd` or in crash recovery. Heavy
8
+ * path only (imports `record.ts`).
9
+ */
10
+ const MICRO = 1e6;
11
+ /**
12
+ * Splits `totalUsd` over prompts in proportion to `weights`, in whole
13
+ * micro-dollars; the rounding remainder goes to the last share so the shares
14
+ * add up to the total exactly. All weights zero: equal shares. Exact for any
15
+ * weight size (integer arithmetic).
16
+ */
17
+ export function allocateCost(totalUsd, weights) {
18
+ if (weights.length === 0)
19
+ return [];
20
+ const total = BigInt(Math.round(Math.max(0, totalUsd) * MICRO));
21
+ let parts = weights.map((weight) => BigInt(Number.isFinite(weight) && weight > 0 ? Math.round(weight) : 0));
22
+ if (parts.every((part) => part === 0n))
23
+ parts = parts.map(() => 1n);
24
+ const sum = parts.reduce((a, b) => a + b, 0n);
25
+ const shares = [];
26
+ let given = 0n;
27
+ for (let i = 0; i < parts.length - 1; i++) {
28
+ const share = (total * parts[i]) / sum;
29
+ shares.push(share);
30
+ given += share;
31
+ }
32
+ shares.push(total - given);
33
+ return shares.map((share) => Number(share) / MICRO);
34
+ }
35
+ /**
36
+ * Adds tokens read after a prompt settled to the LAST pending prompt: the
37
+ * transcript is written asynchronously, so late lines belong to the prompt
38
+ * that just settled. No pending prompt or no tokens: unchanged.
39
+ */
40
+ export function withLateTokens(pending, tokens) {
41
+ if (!tokens || pending.length === 0)
42
+ return pending;
43
+ const last = pending[pending.length - 1];
44
+ const usage = last.core.usage;
45
+ const merged = {
46
+ ...last,
47
+ core: {
48
+ ...last.core,
49
+ usage: {
50
+ ...usage,
51
+ input: usage.input + tokens.input,
52
+ output: usage.output + tokens.output,
53
+ cacheRead: usage.cacheRead + tokens.cacheRead,
54
+ cacheWrite: usage.cacheWrite + tokens.cacheWrite,
55
+ },
56
+ },
57
+ };
58
+ return [...pending.slice(0, -1), merged];
59
+ }
60
+ function totalTokens(prompt) {
61
+ const { input, output, cacheRead, cacheWrite } = prompt.core.usage;
62
+ return [input, output, cacheRead, cacheWrite].reduce((sum, n) => sum + (Number.isFinite(n) ? n : 0), 0);
63
+ }
64
+ /**
65
+ * The records of a finished headless session. The session cost is the
66
+ * `cost-state` total put through the chained baseline (`settleCost`, from the
67
+ * first pending prompt's start); with several prompts it is shared by their
68
+ * token totals and every record is marked `costAllocated`. Without a usable
69
+ * `cost-state` (none, or one that flags a model with unknown pricing) the
70
+ * records carry no cost, exactly as a session without a statusline does.
71
+ */
72
+ export function buildHeadlessRecords(input) {
73
+ const { pending, state, sessionId } = input;
74
+ if (pending.length === 0)
75
+ return [];
76
+ const usable = input.costState !== undefined && !input.costState.hasUnknownModelCost;
77
+ const sessionCost = usable ? settleCost(input.costState.totalUsd, pending[0].costAtStart).cost : undefined;
78
+ const allocated = pending.length > 1;
79
+ const costs = sessionCost === undefined ? undefined : allocated ? allocateCost(sessionCost, pending.map(totalTokens)) : [sessionCost];
80
+ return pending.map((prompt, index) => {
81
+ const cost = costs?.[index];
82
+ const core = cost === undefined ? prompt.core : { ...prompt.core, usage: { ...prompt.core.usage, cost }, costObserved: true };
83
+ return buildClaudeRecord(core, state, sessionId, input.model, input.assignment, {
84
+ ...(input.agentVersion !== undefined ? { agentVersion: input.agentVersion } : {}),
85
+ ...(cost !== undefined && allocated ? { costAllocated: true } : {}),
86
+ });
87
+ });
88
+ }
@@ -4,7 +4,9 @@ import { listStateFiles, resolveTargetFile } from "./paths.js";
4
4
  import { readState } from "./session-state.js";
5
5
  import { readEventLog } from "./event-log.js";
6
6
  import { splitPrompts, replayPrompt } from "./replay.js";
7
- import { buildClaudeRecord } from "./record.js";
7
+ import { buildClaudeRecord, stampTokens } from "./record.js";
8
+ import { settleTranscripts } from "./transcript-settle.js";
9
+ import { buildHeadlessRecords, withLateTokens } from "./headless.js";
8
10
  import { readCost, deleteCost } from "./cost-store.js";
9
11
  import { settleCost } from "./cost-chain.js";
10
12
  /**
@@ -37,23 +39,52 @@ export function recoverStaleSessions(input) {
37
39
  const dead = state.pid <= 0 || !input.isAlive(state.pid);
38
40
  if (!dead)
39
41
  continue;
40
- if (state.promptOpen && state.cwd !== "") {
41
- const events = readEventLog(eventsFile);
42
- const prompts = splitPrompts(events);
43
- const last = prompts[prompts.length - 1];
44
- if (last && last.open) {
45
- // Close at the prompt's last recorded activity, never at recovery
46
- // time (which is the next session start, possibly hours later).
47
- // `now` is only a fallback when no usable timestamp exists.
48
- const lastTs = last.events[last.events.length - 1]?.ts;
49
- const settledAt = typeof lastTs === "number" && Number.isFinite(lastTs) ? lastTs : input.now;
50
- const costNow = readCost(input.env, sessionId);
51
- const core = replayPrompt(last, { settledAt, cost: settleCost(costNow?.totalUsd, state.promptOpen.costAtStart).cost });
52
- if (core) {
53
- const model = costNow?.model;
54
- records.push(buildClaudeRecord(core, state, sessionId, model, input.resolveAssignment?.(state.cwd, sessionId)));
42
+ const held = [...(state.pending ?? [])];
43
+ const openPrompt = state.promptOpen && state.cwd !== "" ? state.promptOpen : undefined;
44
+ if (openPrompt || held.length > 0) {
45
+ // One read of the dead session's transcript: tokens of the open prompt,
46
+ // the entry point, and the cost-state a headless run wrote after its last Stop.
47
+ const transcript = settleSafely(state);
48
+ const version = transcript?.transcript.agentVersion ?? state.transcript?.agentVersion;
49
+ const headless = held.length > 0 || transcript?.transcript.entrypoint === "sdk-cli";
50
+ const costNow = readCost(input.env, sessionId);
51
+ const model = costNow?.model ?? transcript?.transcript.model ?? state.transcript?.model;
52
+ let attached = false;
53
+ const headlessModel = transcript?.transcript.model ?? state.transcript?.model;
54
+ const assignment = input.resolveAssignment?.(state.cwd, sessionId);
55
+ if (openPrompt) {
56
+ const events = readEventLog(eventsFile);
57
+ const prompts = splitPrompts(events);
58
+ const last = prompts[prompts.length - 1];
59
+ if (last && last.open) {
60
+ // Close at the prompt's last recorded activity, never at recovery
61
+ // time (which is the next session start, possibly hours later).
62
+ // `now` is only a fallback when no usable timestamp exists.
63
+ const lastTs = last.events[last.events.length - 1]?.ts;
64
+ const settledAt = typeof lastTs === "number" && Number.isFinite(lastTs) ? lastTs : input.now;
65
+ const cost = headless ? undefined : settleCost(costNow?.totalUsd, openPrompt.costAtStart).cost;
66
+ const core = replayPrompt(last, { settledAt, cost });
67
+ if (core && headless) {
68
+ held.push({ core: stampTokens(core, transcript?.tokens), costAtStart: openPrompt.costAtStart });
69
+ attached = true;
70
+ }
71
+ else if (core) {
72
+ records.push(buildClaudeRecord(stampTokens(core, transcript?.tokens), state, sessionId, model, assignment, {
73
+ ...(version !== undefined ? { agentVersion: version } : {}),
74
+ }));
75
+ }
55
76
  }
56
77
  }
78
+ records.push(...buildHeadlessRecords({
79
+ // The file is final: lines written after the last prompt settled belong to the last pending one.
80
+ pending: attached ? held : withLateTokens(held, transcript?.tokens),
81
+ state,
82
+ sessionId,
83
+ ...(headlessModel !== undefined ? { model: headlessModel } : {}),
84
+ ...(transcript?.costState !== undefined ? { costState: transcript.costState } : {}),
85
+ ...(assignment !== undefined ? { assignment } : {}),
86
+ ...(version !== undefined ? { agentVersion: version } : {}),
87
+ }));
57
88
  }
58
89
  safeUnlink(stateFile);
59
90
  safeUnlink(eventsFile);
@@ -62,6 +93,15 @@ export function recoverStaleSessions(input) {
62
93
  }
63
94
  return records;
64
95
  }
96
+ /** A dead session's transcript is best-effort: any failure means a record without tokens, as before. */
97
+ function settleSafely(state) {
98
+ try {
99
+ return settleTranscripts(state.transcript);
100
+ }
101
+ catch {
102
+ return undefined;
103
+ }
104
+ }
65
105
  function sessionIdFromStateFile(file) {
66
106
  const match = basename(file).match(/^(.*)\.state\.json$/);
67
107
  return match?.[1];
package/dist/record.js CHANGED
@@ -13,6 +13,16 @@ export function readPackageVersion(file) {
13
13
  }
14
14
  // Resolved once per hook process; `src/` and `dist/` share the same depth.
15
15
  const PLUGIN_VERSION = readPackageVersion(join(dirname(dirname(fileURLToPath(import.meta.url))), "package.json"));
16
+ /**
17
+ * Stamps the four token counts read from the transcript onto a replayed
18
+ * record. The cost stays as the replay set it; without tokens the record is
19
+ * returned as it is.
20
+ */
21
+ export function stampTokens(core, tokens) {
22
+ if (!tokens)
23
+ return core;
24
+ return { ...core, usage: { ...core.usage, input: tokens.input, output: tokens.output, cacheRead: tokens.cacheRead, cacheWrite: tokens.cacheWrite } };
25
+ }
16
26
  /**
17
27
  * Attaches the orchestrator metadata a replayed {@link WorkRecordCore}
18
28
  * needs to become a persistable {@link WorkRecord}. `assignment` carries the
@@ -25,11 +35,11 @@ const PLUGIN_VERSION = readPackageVersion(join(dirname(dirname(fileURLToPath(imp
25
35
  *
26
36
  * Also stamps who MEASURED the record (`agent`, `plugin`, `pluginVersion`),
27
37
  * so a worklog later synced by another tool (the `kankaku` CLI) keeps the
28
- * right identity. `agentVersion` is omitted: Claude Code passes its version
29
- * to no hook payload, and spawning `claude --version` from a hook is not
30
- * acceptable.
38
+ * right identity. `agentVersion` is stamped only when `extras` carries one,
39
+ * read from the session transcript (no hook payload carries it, and
40
+ * spawning `claude --version` from a hook is not acceptable); never guessed.
31
41
  */
32
- export function buildClaudeRecord(core, state, sessionId, model, assignment = {}) {
42
+ export function buildClaudeRecord(core, state, sessionId, model, assignment = {}, extras = {}) {
33
43
  const { target, legacyClient } = assignment;
34
44
  return {
35
45
  ...core,
@@ -42,6 +52,8 @@ export function buildClaudeRecord(core, state, sessionId, model, assignment = {}
42
52
  agent: "claude-code",
43
53
  plugin: "kankaku-claude",
44
54
  ...(PLUGIN_VERSION !== undefined ? { pluginVersion: PLUGIN_VERSION } : {}),
55
+ ...(extras.agentVersion !== undefined ? { agentVersion: extras.agentVersion } : {}),
56
+ ...(extras.costAllocated === true ? { costAllocated: true } : {}),
45
57
  ...(model ? { model: `anthropic/${model}` } : {}),
46
58
  ...(legacyClient !== undefined ? { client: legacyClient } : {}),
47
59
  ...(target !== undefined
@@ -32,7 +32,54 @@ export function readState(file) {
32
32
  catch {
33
33
  return undefined;
34
34
  }
35
- return isSessionState(parsed) ? parsed : undefined;
35
+ return isSessionState(parsed) ? withoutInvalidOptionals(parsed) : undefined;
36
+ }
37
+ /** The optional transcript fields are advisory: a malformed one is dropped, never the whole state. */
38
+ function withoutInvalidOptionals(state) {
39
+ const { transcript, pending, ...rest } = state;
40
+ return {
41
+ ...rest,
42
+ ...(isTranscriptState(transcript) ? { transcript } : {}),
43
+ ...(Array.isArray(pending) && pending.every(isPendingPrompt) ? { pending } : {}),
44
+ };
45
+ }
46
+ function isTranscriptState(value) {
47
+ if (typeof value !== "object" || value === null)
48
+ return false;
49
+ const o = value;
50
+ if (typeof o.path !== "string" || typeof o.offsets !== "object" || o.offsets === null)
51
+ return false;
52
+ if (o.agentVersion !== undefined && typeof o.agentVersion !== "string")
53
+ return false;
54
+ if (o.entrypoint !== undefined && typeof o.entrypoint !== "string")
55
+ return false;
56
+ if (o.model !== undefined && typeof o.model !== "string")
57
+ return false;
58
+ return Object.values(o.offsets).every((position) => {
59
+ if (typeof position !== "object" || position === null)
60
+ return false;
61
+ const p = position;
62
+ return (typeof p.bytes === "number" &&
63
+ Number.isFinite(p.bytes) &&
64
+ p.bytes >= 0 &&
65
+ (p.lastMessageId === undefined || typeof p.lastMessageId === "string") &&
66
+ (p.lastMessageUsage === undefined || isTokenUsage(p.lastMessageUsage)));
67
+ });
68
+ }
69
+ function isTokenUsage(value) {
70
+ if (typeof value !== "object" || value === null)
71
+ return false;
72
+ const u = value;
73
+ return ["input", "output", "cacheRead", "cacheWrite"].every((key) => typeof u[key] === "number" && Number.isFinite(u[key]) && u[key] >= 0);
74
+ }
75
+ function isPendingPrompt(value) {
76
+ if (typeof value !== "object" || value === null)
77
+ return false;
78
+ const o = value;
79
+ return (typeof o.core === "object" &&
80
+ o.core !== null &&
81
+ typeof o.core.id === "string" &&
82
+ (o.costAtStart === undefined || o.costAtStart === null || typeof o.costAtStart === "number"));
36
83
  }
37
84
  /** Atomic tmp+rename write, creating parent directories as needed. */
38
85
  export function writeState(file, state) {
@@ -0,0 +1,147 @@
1
+ import { MAX_SETTLE_BYTES, listSubagentTranscripts, readTranscriptHead, readTranscriptSince, statTranscriptSize, } from "./transcript.js";
2
+ /**
3
+ * Session-level use of the transcript reader: which files belong to a
4
+ * session, where each one is read from, and what a settle learns from them.
5
+ * Node builtins only (see `transcript.ts`).
6
+ */
7
+ /**
8
+ * `UserPromptSubmit`: remember the transcript path and, when no position is
9
+ * known yet, the CURRENT size of the main file and of every subagent file,
10
+ * so a session that existed before the plugin (or was resumed) does not
11
+ * read its history at the first settle. Metadata only: no file is read. A
12
+ * new path (the session moved to another file) restarts the positions.
13
+ */
14
+ export function trackTranscriptAtSubmit(existing, path) {
15
+ if (path === undefined || path === "")
16
+ return existing;
17
+ if (existing && existing.path === path && Object.keys(existing.offsets).length > 0)
18
+ return existing;
19
+ const offsets = {};
20
+ for (const file of [path, ...listSubagentTranscripts(path)]) {
21
+ const size = statTranscriptSize(file);
22
+ if (size !== undefined)
23
+ offsets[file] = { bytes: size };
24
+ }
25
+ return {
26
+ path,
27
+ offsets,
28
+ ...(existing?.agentVersion !== undefined ? { agentVersion: existing.agentVersion } : {}),
29
+ ...(existing?.entrypoint !== undefined ? { entrypoint: existing.entrypoint } : {}),
30
+ ...(existing?.model !== undefined ? { model: existing.model } : {}),
31
+ };
32
+ }
33
+ /** Poll every 25 ms; not before 100 ms and not after 300 ms since the hook started (see {@link waitForTranscript}). */
34
+ export const TRANSCRIPT_POLL_MS = 25;
35
+ export const TRANSCRIPT_WAIT_MIN_MS = 100;
36
+ export const TRANSCRIPT_WAIT_MAX_MS = 300;
37
+ /**
38
+ * Claude Code writes the transcript asynchronously: the last assistant lines
39
+ * reach the disk shortly AFTER the Stop hook has started, while earlier
40
+ * assistant messages of the same prompt are usually on disk long before. So
41
+ * a line being there is not enough. Before a settle reads, poll the main
42
+ * transcript every {@link TRANSCRIPT_POLL_MS} until ALL hold: at least
43
+ * {@link TRANSCRIPT_WAIT_MIN_MS} have passed since `startedAt` (when the hook
44
+ * process started), its size did not change across two consecutive polls,
45
+ * and its new bytes hold a complete assistant line. It gives up
46
+ * {@link TRANSCRIPT_WAIT_MAX_MS} after `startedAt` regardless; time already
47
+ * spent (the statusline wait) counts, so a hook that waited 300 ms or more
48
+ * does not sleep again. The caller then reads whatever is there. Clock and
49
+ * sleep are injected. Never throws.
50
+ */
51
+ export async function waitForTranscript(transcript, clock, startedAt = clock.now()) {
52
+ if (!transcript)
53
+ return;
54
+ const position = transcript.offsets[transcript.path] ?? { bytes: 0 };
55
+ let previousSize;
56
+ for (;;) {
57
+ const elapsed = clock.now() - startedAt;
58
+ if (elapsed >= TRANSCRIPT_WAIT_MAX_MS)
59
+ return;
60
+ let ready = false;
61
+ try {
62
+ const size = statTranscriptSize(transcript.path);
63
+ const read = readTranscriptSince(transcript.path, position);
64
+ const sawLine = read.lastMessageId !== undefined &&
65
+ (read.lastMessageId !== position.lastMessageId || read.usage.input + read.usage.output + read.usage.cacheRead + read.usage.cacheWrite > 0);
66
+ ready = elapsed >= TRANSCRIPT_WAIT_MIN_MS && sawLine && size !== undefined && size === previousSize;
67
+ previousSize = size;
68
+ }
69
+ catch {
70
+ return;
71
+ }
72
+ if (ready)
73
+ return;
74
+ await clock.sleep(Math.min(TRANSCRIPT_POLL_MS, TRANSCRIPT_WAIT_MAX_MS - elapsed));
75
+ }
76
+ }
77
+ /**
78
+ * Settle: read everything appended since the stored positions, in the main
79
+ * transcript and in every subagent file (a file without a position starts
80
+ * at 0), sum it, and return the advanced positions. At most
81
+ * `maxBytes` (default {@link MAX_SETTLE_BYTES}) are read across all files;
82
+ * a file whose new content does not fit is skipped to its end without
83
+ * counting and the whole result carries no tokens.
84
+ */
85
+ export function settleTranscripts(transcript, options = {}) {
86
+ if (!transcript)
87
+ return undefined;
88
+ let remaining = options.maxBytes ?? MAX_SETTLE_BYTES;
89
+ const offsets = { ...transcript.offsets };
90
+ const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
91
+ let truncated = false;
92
+ let version;
93
+ let entrypoint;
94
+ let mainModel;
95
+ let subagentModel;
96
+ let costState;
97
+ const files = [transcript.path, ...listSubagentTranscripts(transcript.path)];
98
+ for (const file of files) {
99
+ const before = offsets[file] ?? { bytes: 0 };
100
+ const result = readTranscriptSince(file, before, { maxBytes: remaining });
101
+ if (result.truncated) {
102
+ truncated = true;
103
+ remaining = 0;
104
+ }
105
+ else {
106
+ const start = result.position.bytes >= before.bytes ? before.bytes : 0;
107
+ remaining = Math.max(0, remaining - (result.position.bytes - start));
108
+ }
109
+ if (file in offsets || result.position.bytes > 0)
110
+ offsets[file] = result.position;
111
+ total.input += result.usage.input;
112
+ total.output += result.usage.output;
113
+ total.cacheRead += result.usage.cacheRead;
114
+ total.cacheWrite += result.usage.cacheWrite;
115
+ if (version === undefined)
116
+ version = result.version;
117
+ if (entrypoint === undefined)
118
+ entrypoint = result.entrypoint;
119
+ if (file === transcript.path) {
120
+ costState = result.costState;
121
+ mainModel = result.model;
122
+ }
123
+ else if (result.model !== undefined) {
124
+ subagentModel = result.model;
125
+ }
126
+ }
127
+ if ((version ?? transcript.agentVersion) === undefined || (entrypoint ?? transcript.entrypoint) === undefined) {
128
+ const head = readTranscriptHead(transcript.path);
129
+ version ??= head.version;
130
+ entrypoint ??= head.entrypoint;
131
+ }
132
+ const agentVersion = version ?? transcript.agentVersion;
133
+ const knownEntrypoint = entrypoint ?? transcript.entrypoint;
134
+ const model = mainModel ?? transcript.model ?? subagentModel;
135
+ return {
136
+ tokens: truncated ? undefined : total,
137
+ transcript: {
138
+ path: transcript.path,
139
+ offsets,
140
+ ...(agentVersion !== undefined ? { agentVersion } : {}),
141
+ ...(knownEntrypoint !== undefined ? { entrypoint: knownEntrypoint } : {}),
142
+ ...(model !== undefined ? { model } : {}),
143
+ },
144
+ ...(costState !== undefined ? { costState } : {}),
145
+ truncated,
146
+ };
147
+ }
@@ -0,0 +1,286 @@
1
+ import { closeSync, openSync, readSync, readdirSync, fstatSync, statSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ /**
4
+ * Reader for Claude Code's session transcripts (`~/.claude/projects/...jsonl`).
5
+ *
6
+ * The format is internal and undocumented and may change with any Claude
7
+ * Code release, so everything here is tolerant: a missing file, an
8
+ * unparseable line or an unexpected field yields zero / `undefined`, never
9
+ * an error. Only numbers, the Claude Code version, the entry point and the
10
+ * session cost total are ever taken from a line; no message content is
11
+ * kept. Node builtins only, so it may sit on the light hook path (which
12
+ * only ever uses {@link statTranscriptSize} and {@link listSubagentTranscripts}).
13
+ */
14
+ /** Most bytes read across all transcript files of one settle (16 MiB). */
15
+ export const MAX_SETTLE_BYTES = 16 * 1024 * 1024;
16
+ export function zeroUsage() {
17
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
18
+ }
19
+ /** Lines that can matter besides the first few (which are tried for the version and entry point). */
20
+ const RELEVANT_LINE = /"type"\s*:\s*"(?:assistant|cost-state)"/;
21
+ const METADATA_LINES = 20;
22
+ function counted(value) {
23
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
24
+ }
25
+ function isObject(value) {
26
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27
+ }
28
+ function parseUsage(raw) {
29
+ return {
30
+ input: counted(raw.input_tokens),
31
+ output: counted(raw.output_tokens),
32
+ cacheRead: counted(raw.cache_read_input_tokens),
33
+ cacheWrite: counted(raw.cache_creation_input_tokens),
34
+ };
35
+ }
36
+ /**
37
+ * Pure: parses the text of complete transcript lines.
38
+ *
39
+ * - Claude Code writes one message over several adjacent lines whose usage
40
+ * grows (earlier lines are partial snapshots), so an `assistant` message is
41
+ * counted once per `message.id` by its LAST line in the chunk. A line
42
+ * without an id cannot be de-duplicated and is not counted.
43
+ * - A message can straddle two reads. `previous` carries the last id and the
44
+ * usage counted for it: when this chunk holds more lines of that id, only
45
+ * the growth is added, per field and never negative. A `previous` with an
46
+ * id but no usage (a legacy position) skips that id's lines instead.
47
+ * - A non-finite, negative or non-numeric usage field counts as 0.
48
+ * - `version` and `entrypoint` come from the first line that carries each.
49
+ * - `costState` is the last valid `cost-state` line.
50
+ */
51
+ export function parseTranscriptChunk(text, previous) {
52
+ const finals = new Map();
53
+ let lastMessageId = previous.lastMessageId;
54
+ let version;
55
+ let entrypoint;
56
+ let model;
57
+ let costState;
58
+ let index = 0;
59
+ for (const line of text.split("\n")) {
60
+ const lineNumber = index++;
61
+ if (line.trim() === "")
62
+ continue;
63
+ const needsMetadata = (version === undefined || entrypoint === undefined) && lineNumber < METADATA_LINES;
64
+ if (!needsMetadata && !RELEVANT_LINE.test(line))
65
+ continue;
66
+ let parsed;
67
+ try {
68
+ parsed = JSON.parse(line);
69
+ }
70
+ catch {
71
+ continue;
72
+ }
73
+ if (!isObject(parsed))
74
+ continue;
75
+ if (version === undefined && typeof parsed.version === "string" && parsed.version !== "")
76
+ version = parsed.version;
77
+ if (entrypoint === undefined && typeof parsed.entrypoint === "string" && parsed.entrypoint !== "")
78
+ entrypoint = parsed.entrypoint;
79
+ if (parsed.type === "assistant") {
80
+ const message = parsed.message;
81
+ if (isObject(message) && typeof message.model === "string" && message.model !== "")
82
+ model = message.model;
83
+ if (!isObject(message) || typeof message.id !== "string" || message.id === "")
84
+ continue;
85
+ lastMessageId = message.id;
86
+ if (isObject(message.usage))
87
+ finals.set(message.id, parseUsage(message.usage));
88
+ else if (!finals.has(message.id))
89
+ finals.set(message.id, zeroUsage());
90
+ }
91
+ else if (parsed.type === "cost-state") {
92
+ const total = parsed.totalCostUSD;
93
+ if (typeof total !== "number" || !Number.isFinite(total) || total < 0)
94
+ continue;
95
+ costState = { totalUsd: total, hasUnknownModelCost: parsed.hasUnknownModelCost === true };
96
+ }
97
+ }
98
+ const usage = zeroUsage();
99
+ for (const [id, final] of finals) {
100
+ let added = final;
101
+ if (id === previous.lastMessageId) {
102
+ const before = previous.lastMessageUsage;
103
+ if (before === undefined)
104
+ continue; // legacy: this message's total so far is unknown, skip it
105
+ added = {
106
+ input: Math.max(0, final.input - before.input),
107
+ output: Math.max(0, final.output - before.output),
108
+ cacheRead: Math.max(0, final.cacheRead - before.cacheRead),
109
+ cacheWrite: Math.max(0, final.cacheWrite - before.cacheWrite),
110
+ };
111
+ }
112
+ usage.input += added.input;
113
+ usage.output += added.output;
114
+ usage.cacheRead += added.cacheRead;
115
+ usage.cacheWrite += added.cacheWrite;
116
+ }
117
+ let lastMessageUsage = previous.lastMessageUsage;
118
+ if (lastMessageId !== undefined && finals.has(lastMessageId)) {
119
+ const final = finals.get(lastMessageId);
120
+ if (lastMessageId !== previous.lastMessageId)
121
+ lastMessageUsage = final;
122
+ else if (previous.lastMessageUsage !== undefined) {
123
+ const before = previous.lastMessageUsage;
124
+ lastMessageUsage = {
125
+ input: Math.max(before.input, final.input),
126
+ output: Math.max(before.output, final.output),
127
+ cacheRead: Math.max(before.cacheRead, final.cacheRead),
128
+ cacheWrite: Math.max(before.cacheWrite, final.cacheWrite),
129
+ };
130
+ }
131
+ }
132
+ return {
133
+ usage,
134
+ ...(lastMessageId !== undefined ? { lastMessageId } : {}),
135
+ ...(lastMessageUsage !== undefined ? { lastMessageUsage } : {}),
136
+ ...(version !== undefined ? { version } : {}),
137
+ ...(entrypoint !== undefined ? { entrypoint } : {}),
138
+ ...(model !== undefined ? { model } : {}),
139
+ ...(costState !== undefined ? { costState } : {}),
140
+ };
141
+ }
142
+ /**
143
+ * Reads the complete lines after `position.bytes` and parses them.
144
+ *
145
+ * - A trailing partial line (no newline yet) is not consumed: the returned
146
+ * position stops before it.
147
+ * - A file shorter than the stored position was rotated or replaced: it is
148
+ * read from 0 and the stale `lastMessageId` is dropped.
149
+ * - More than `maxBytes` of new content (default {@link MAX_SETTLE_BYTES}) is
150
+ * not read at all: the position jumps to the end of the file, nothing is
151
+ * counted and `truncated` is set, so a hook never times out on a huge file.
152
+ * - A missing or unreadable file returns zero usage and the same position.
153
+ */
154
+ export function readTranscriptSince(file, position, options = {}) {
155
+ const unchanged = { usage: zeroUsage(), position, truncated: false };
156
+ const maxBytes = options.maxBytes ?? MAX_SETTLE_BYTES;
157
+ let fd;
158
+ try {
159
+ fd = openSync(file, "r");
160
+ }
161
+ catch {
162
+ return unchanged;
163
+ }
164
+ try {
165
+ const stats = fstatSync(fd);
166
+ if (!stats.isFile())
167
+ return unchanged;
168
+ const size = stats.size;
169
+ const replaced = size < position.bytes;
170
+ const start = replaced ? 0 : position.bytes;
171
+ const lastMessageId = replaced ? undefined : position.lastMessageId;
172
+ const lastMessageUsage = replaced ? undefined : position.lastMessageUsage;
173
+ const carried = { ...(lastMessageId !== undefined ? { lastMessageId } : {}), ...(lastMessageUsage !== undefined ? { lastMessageUsage } : {}) };
174
+ const length = size - start;
175
+ if (length === 0) {
176
+ return { usage: zeroUsage(), position: { bytes: start, ...carried }, truncated: false };
177
+ }
178
+ if (length > maxBytes) {
179
+ return {
180
+ usage: zeroUsage(),
181
+ position: { bytes: size, ...carried },
182
+ truncated: true,
183
+ };
184
+ }
185
+ const buffer = Buffer.alloc(length);
186
+ let filled = 0;
187
+ while (filled < length) {
188
+ const n = readSync(fd, buffer, filled, length - filled, start + filled);
189
+ if (n === 0)
190
+ break;
191
+ filled += n;
192
+ }
193
+ const lastNewline = buffer.subarray(0, filled).lastIndexOf(0x0a);
194
+ if (lastNewline < 0) {
195
+ return { usage: zeroUsage(), position: { bytes: start, ...carried }, truncated: false };
196
+ }
197
+ const consumed = lastNewline + 1;
198
+ const parsed = parseTranscriptChunk(buffer.subarray(0, consumed).toString("utf8"), carried);
199
+ return {
200
+ ...parsed,
201
+ position: {
202
+ bytes: start + consumed,
203
+ ...(parsed.lastMessageId !== undefined ? { lastMessageId: parsed.lastMessageId } : {}),
204
+ ...(parsed.lastMessageUsage !== undefined ? { lastMessageUsage: parsed.lastMessageUsage } : {}),
205
+ },
206
+ truncated: false,
207
+ };
208
+ }
209
+ catch {
210
+ return unchanged;
211
+ }
212
+ finally {
213
+ try {
214
+ closeSync(fd);
215
+ }
216
+ catch {
217
+ // nothing to do
218
+ }
219
+ }
220
+ }
221
+ /** How much of the start of a transcript is looked at for the version and entry point (64 KiB). */
222
+ const HEAD_BYTES = 64 * 1024;
223
+ /**
224
+ * The version and entry point from the first complete lines of a transcript.
225
+ * A settle needs them even when no new line carries them yet (the transcript
226
+ * is written asynchronously and the read position is already past the first
227
+ * lines). Bounded; empty on any problem.
228
+ */
229
+ export function readTranscriptHead(file) {
230
+ let fd;
231
+ try {
232
+ fd = openSync(file, "r");
233
+ }
234
+ catch {
235
+ return {};
236
+ }
237
+ try {
238
+ const buffer = Buffer.alloc(HEAD_BYTES);
239
+ const n = readSync(fd, buffer, 0, HEAD_BYTES, 0);
240
+ const lastNewline = buffer.subarray(0, n).lastIndexOf(0x0a);
241
+ if (lastNewline < 0)
242
+ return {};
243
+ const { version, entrypoint } = parseTranscriptChunk(buffer.subarray(0, lastNewline + 1).toString("utf8"), {});
244
+ return { ...(version !== undefined ? { version } : {}), ...(entrypoint !== undefined ? { entrypoint } : {}) };
245
+ }
246
+ catch {
247
+ return {};
248
+ }
249
+ finally {
250
+ try {
251
+ closeSync(fd);
252
+ }
253
+ catch {
254
+ // nothing to do
255
+ }
256
+ }
257
+ }
258
+ /** Current size of a file in bytes (metadata only, no read), or `undefined` when it cannot be statted. */
259
+ export function statTranscriptSize(file) {
260
+ try {
261
+ const stats = statSync(file);
262
+ return stats.isFile() ? stats.size : undefined;
263
+ }
264
+ catch {
265
+ return undefined;
266
+ }
267
+ }
268
+ /**
269
+ * The subagent transcripts of a session: `*.jsonl` files in
270
+ * `<dirname>/<session id>/subagents/`, sorted, everything else ignored.
271
+ * Empty when the directory does not exist.
272
+ */
273
+ export function listSubagentTranscripts(sessionTranscriptPath) {
274
+ const dir = join(dirname(sessionTranscriptPath), basename(sessionTranscriptPath, ".jsonl"), "subagents");
275
+ let entries;
276
+ try {
277
+ entries = readdirSync(dir, { withFileTypes: true });
278
+ }
279
+ catch {
280
+ return [];
281
+ }
282
+ return entries
283
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
284
+ .map((entry) => join(dir, entry.name))
285
+ .sort();
286
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku-claude",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Claude Code plugin that records how long the agent works on each user prompt, in kankaku's worklog format.",
5
5
  "type": "module",
6
6
  "files": [
@@ -27,7 +27,7 @@
27
27
  "prepublishOnly": "npm run check"
28
28
  },
29
29
  "dependencies": {
30
- "kankaku-pi": "^1.2.0"
30
+ "kankaku-pi": "^1.3.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^24.13.4",