kankaku-claude 1.2.0 → 1.3.1
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/plugin.json +1 -1
- package/CHANGELOG.md +35 -0
- package/README.md +130 -17
- package/dist/handle-hook.js +129 -23
- package/dist/headless.js +88 -0
- package/dist/inflight-recovery.js +56 -16
- package/dist/record.js +16 -4
- package/dist/session-state.js +48 -1
- package/dist/transcript-settle.js +147 -0
- package/dist/transcript.js +286 -0
- package/package.json +2 -2
|
@@ -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.
|
|
4
|
+
"version": "1.3.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "soyunninja"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,41 @@ 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.1 — 2026-09-30
|
|
8
|
+
|
|
9
|
+
No changes in this package; released in lockstep with kankaku-pi and
|
|
10
|
+
kankaku 1.3.1.
|
|
11
|
+
|
|
12
|
+
## 1.3.0 — 2026-09-29
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **Tokens on every record.** The input, output, cache read and cache write
|
|
17
|
+
tokens are read from the session transcript (and from each subagent's
|
|
18
|
+
transcript), so `cache hit` now appears for Claude Code records. Only new
|
|
19
|
+
bytes are read at each settle, at most 16 MiB; `UserPromptSubmit` only
|
|
20
|
+
`stat`s the files and the other hooks read nothing.
|
|
21
|
+
- **`agentVersion`.** Claude Code's version, taken from the transcript.
|
|
22
|
+
- **Cost for headless runs.** A `claude -p` session keeps each settled prompt
|
|
23
|
+
pending and writes its record at `SessionEnd` (or in crash recovery) with
|
|
24
|
+
the transcript's `cost-state` cost, through the same chained baseline as the
|
|
25
|
+
statusline cost. With several prompts the cost is shared in proportion to
|
|
26
|
+
their tokens and each record carries `costAllocated`, kept local and never
|
|
27
|
+
sent to the hub.
|
|
28
|
+
- **Late transcript writes are covered.** Claude Code writes the transcript
|
|
29
|
+
asynchronously, after `Stop` has started. `Stop` polls every 25 ms and
|
|
30
|
+
reads once at least 100 ms have passed since the hook started, the size is
|
|
31
|
+
stable and an assistant line is present, giving up at 300 ms (the statusline
|
|
32
|
+
wait counts), so the last message of a prompt is not shifted into the next
|
|
33
|
+
record, a headless
|
|
34
|
+
`SessionEnd` or recovery reads once more and adds late usage to the last
|
|
35
|
+
pending prompt, and the entry point and version are also read from the
|
|
36
|
+
start of the file when no new line carries them.
|
|
37
|
+
- **`model` from the transcript** (`anthropic/<message.model>`) when the
|
|
38
|
+
statusline gave none.
|
|
39
|
+
- Any surprise in the (undocumented) transcript format leaves the record as it
|
|
40
|
+
was before: written, without tokens, version or cost, and no hook fails.
|
|
41
|
+
|
|
7
42
|
## 1.2.0 — 2026-09-29
|
|
8
43
|
|
|
9
44
|
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 "
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
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.
|
|
272
|
-
`cost.total_cost_usd` is the running total of the
|
|
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
|
|
295
|
-
|
|
296
|
-
|
|
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.
|
|
330
|
-
|
|
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
|
-
- **
|
|
344
|
-
|
|
345
|
-
|
|
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
|
|
351
|
-
|
|
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
|
package/dist/handle-hook.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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?.
|
|
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
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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 {
|
package/dist/headless.js
ADDED
|
@@ -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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
29
|
-
*
|
|
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
|
package/dist/session-state.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "1.3.1",
|
|
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.
|
|
30
|
+
"kankaku-pi": "^1.3.1"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/node": "^24.13.4",
|