kankaku-claude 0.2.2 → 0.10.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.
Files changed (52) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/CHANGELOG.md +38 -1
  3. package/README.md +66 -17
  4. package/commands/doctor.md +1 -1
  5. package/commands/report.md +1 -1
  6. package/commands/setup.md +1 -1
  7. package/commands/status.md +1 -1
  8. package/commands/sync-all.md +1 -1
  9. package/commands/sync-status.md +1 -1
  10. package/commands/sync.md +3 -3
  11. package/dist/auto-sync.js +28 -0
  12. package/dist/claude-pid.js +61 -0
  13. package/dist/cli-core.js +75 -0
  14. package/{src/cli.ts → dist/cli.js} +11 -12
  15. package/dist/cost-store.js +121 -0
  16. package/dist/doctor.js +100 -0
  17. package/dist/event-log.js +31 -0
  18. package/dist/events.js +72 -0
  19. package/dist/handle-hook.js +256 -0
  20. package/dist/hook.js +38 -0
  21. package/dist/inflight-recovery.js +66 -0
  22. package/dist/paths.js +35 -0
  23. package/dist/prompts.js +33 -0
  24. package/dist/record.js +20 -0
  25. package/dist/replay.js +101 -0
  26. package/dist/report.js +102 -0
  27. package/dist/session-state.js +47 -0
  28. package/dist/statusline-core.js +71 -0
  29. package/dist/statusline.js +34 -0
  30. package/dist/sync-cli.js +100 -0
  31. package/hooks/hooks.json +9 -9
  32. package/package.json +9 -7
  33. package/src/auto-sync.ts +0 -32
  34. package/src/claude-pid.ts +0 -68
  35. package/src/cli-core.ts +0 -103
  36. package/src/cost-store.ts +0 -138
  37. package/src/doctor.ts +0 -89
  38. package/src/event-log.ts +0 -33
  39. package/src/events.ts +0 -145
  40. package/src/handle-hook.ts +0 -299
  41. package/src/hook.ts +0 -41
  42. package/src/inflight-recovery.ts +0 -82
  43. package/src/paths.ts +0 -50
  44. package/src/prompts.ts +0 -42
  45. package/src/record.ts +0 -28
  46. package/src/replay.ts +0 -123
  47. package/src/report.ts +0 -132
  48. package/src/session-state.ts +0 -71
  49. package/src/statusline-core.ts +0 -89
  50. package/src/statusline.ts +0 -36
  51. package/src/sync-cli.ts +0 -122
  52. package/tsconfig.json +0 -14
@@ -1,11 +1,11 @@
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": "0.1.0",
4
+ "version": "0.10.0",
5
5
  "author": {
6
6
  "name": "soyunninja"
7
7
  },
8
8
  "homepage": "https://kankaku.io",
9
- "repository": "https://github.com/soyunninja/kankaku-claude",
9
+ "repository": "https://github.com/soyunninja/kankaku",
10
10
  "license": "MIT"
11
11
  }
package/CHANGELOG.md CHANGED
@@ -4,7 +4,35 @@ 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
- ## [Unreleased]
7
+ ## 0.10.0 — 2026-09-28
8
+
9
+ ### Fixed
10
+
11
+ - **Installed as a dependency, the plugin could not run at all.** This
12
+ package now compiles to `dist/` (`npm run build`, `tsc -p
13
+ tsconfig.build.json`) and ships `dist/` instead of `src/`/`tsconfig.json`
14
+ in `package.json`'s `files`. Every hook/statusline/CLI command
15
+ (`hooks/hooks.json`, `commands/*.md`, the `statusLine` snippet
16
+ `kankaku setup`/`/kankaku:setup` prints) now points at the compiled
17
+ `dist/*.js`, never `src/*.ts` — Node 24 refuses to type-strip a `.ts`
18
+ file once it sits under a `node_modules` directory
19
+ (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so a `kankaku-tui`
20
+ install of this package (its dependency, not a checkout) could not run
21
+ a single hook, the statusline, or the CLI before this fix. `npm run
22
+ check`/`prepublishOnly` build first; a manual `--plugin-dir` checkout
23
+ now needs `npm install && npm run build` once (see README "Install").
24
+
25
+ ### Changed
26
+
27
+ - README "Install": the recommended path is now `npm i -g kankaku-tui &&
28
+ kankaku setup` — `kankaku-tui` depends on this package directly and its
29
+ setup wizard/`--yes` flow writes both `statusLine` and `hooks` into
30
+ `~/.claude/settings.json` in one step, so no checkout or `--plugin-dir`
31
+ is required for the plugin to measure time or report cost.
32
+ `--plugin-dir <checkout>` remains documented as the manual/dev path
33
+ (also the only way to get the `/kankaku:*` slash commands), with a note
34
+ that combining it with a `kankaku setup`-configured machine double-runs
35
+ the hooks and double-writes worklog records.
8
36
 
9
37
  ### Added
10
38
 
@@ -62,3 +90,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
62
90
  - `SessionEnd` also deletes the session's cost file; `SessionStart`
63
91
  (non-`compact`) sweeps cost files older than 7 days.
64
92
  - `node src/cli.ts status` reads cost from the cost file.
93
+
94
+ ## 0.9.0 — 2026-09-28
95
+
96
+ ### Changed
97
+
98
+ - Moved into the kankaku monorepo (`github.com/soyunninja/kankaku`,
99
+ `packages/claude`); versions are now lockstep with the other client
100
+ packages (`kankaku`, `kankaku-tui`). Depends on `kankaku ^0.9.0` (was
101
+ `^0.6.0`). No behaviour change.
package/README.md CHANGED
@@ -4,6 +4,9 @@ A [Claude Code](https://claude.com/claude-code) plugin that records how long
4
4
  Claude Code works on each of your prompts, in the same worklog format as the
5
5
  [kankaku](https://kankaku.io) pi extension.
6
6
 
7
+ This package lives in the [kankaku monorepo](https://github.com/soyunninja/kankaku),
8
+ under `packages/claude`.
9
+
7
10
  ## What it measures
8
11
 
9
12
  One record per user prompt, appended to `<KANKAKU_DIR>/worklog.jsonl`:
@@ -25,22 +28,67 @@ manual and best-effort automatic hub sync through kankaku's public hub adapters
25
28
  ## Requirements
26
29
 
27
30
  - Claude Code with plugin support.
28
- - Node.js >= 24 on `PATH` (this package's `.ts` sources run directly under
29
- Node's built-in type stripping; there is no build step).
31
+ - Node.js >= 24 on `PATH`. This package ships a compiled `dist/` (`npm
32
+ run build`, `tsc -p tsconfig.build.json`) — Node refuses to type-strip
33
+ a `.ts` file once it sits under a `node_modules` directory
34
+ (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so every published
35
+ hook/statusline/CLI command runs the built `dist/*.js` file, never the
36
+ `.ts` source. Installed via `kankaku-tui` (below) this needs no action
37
+ from you; a manual `--plugin-dir` checkout must run `npm install && npm
38
+ run build` once before Claude Code can load it (see "Manual/dev"
39
+ below).
30
40
 
31
41
  ## Install
32
42
 
33
- **From a local path (development).** Clone this repository, then point
34
- Claude Code at it directly:
43
+ **Recommended: `kankaku-tui`'s setup wizard.** This package lives inside
44
+ `kankaku-tui`'s own dependency tree, so installing and running its setup
45
+ wizard configures Claude Code for you — statusline and hooks together, in
46
+ one step, with no checkout and no `--plugin-dir`:
47
+
48
+ ```bash
49
+ npm i -g kankaku-tui
50
+ kankaku setup
51
+ ```
52
+
53
+ Checking Claude Code in the wizard (or confirming it in `kankaku setup
54
+ --yes`) writes `statusLine` and `hooks` into `~/.claude/settings.json`,
55
+ resolved from this package's own bundled files, merged with — never
56
+ clobbering — whatever else is already there. See `kankaku-tui`'s own
57
+ README ("Claude Code") for the full behaviour, the
58
+ `--claude-plugin-dir`/`KANKAKU_CLAUDE_PLUGIN_DIR` override, and why you
59
+ should drop `--plugin-dir` (below) once this has run.
60
+
61
+ **Manual/dev: `--plugin-dir` (also required for the `/kankaku:*` slash
62
+ commands).** Clone this repository, build it once, then point Claude Code
63
+ at it directly:
35
64
 
36
65
  ```bash
66
+ git clone https://github.com/soyunninja/kankaku.git
67
+ cd kankaku/packages/claude
68
+ npm install && npm run build
37
69
  claude --plugin-dir /path/to/kankaku-claude
38
70
  ```
39
71
 
72
+ `npm run build` compiles `src/` into `dist/`; the hooks, statusline and
73
+ `/kankaku:*` commands all run `dist/*.js`, never `src/*.ts` — a checkout
74
+ that skips this step fails at the hook's very first launch with
75
+ `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` (or, for `--plugin-dir`
76
+ itself, simply nothing recorded). Re-run `npm run build` after pulling
77
+ new commits.
78
+
40
79
  If your Claude Code version does not recognize `--plugin-dir`, or plugin
41
80
  loading has changed since this was written, check your installed version's
42
81
  own plugin documentation (`claude --help`, or `/plugin` inside a session) for
43
- the current local-install flow.
82
+ the current local-install flow. `--plugin-dir` is also the only way to get
83
+ the `/kankaku:*` slash commands (`/kankaku:report`, `/kankaku:setup`, …),
84
+ since a Claude Code plugin loaded only through settings.json hooks — the
85
+ recommended path above — never registers commands.
86
+
87
+ **Do not combine the two.** If `kankaku setup` has already configured this
88
+ machine's `~/.claude/settings.json` hooks, loading the plugin again with
89
+ `--plugin-dir` runs the same hooks twice per event and double-writes
90
+ worklog records. Use `--plugin-dir` only on a machine `kankaku setup` has
91
+ not touched, or drop it once setup has run.
44
92
 
45
93
  **From a marketplace.** Once this plugin is published to a marketplace:
46
94
 
@@ -58,7 +106,8 @@ there never blocks the plugin from loading.
58
106
  Claude Code plugins cannot set `statusLine` for themselves — there is no
59
107
  programmatic way for a plugin to add a `statusLine` entry to your settings.
60
108
  The statusline is also the *only* documented source of per-prompt cost
61
- (`cost.total_cost_usd`); hooks never receive it. So, once installed, run:
109
+ (`cost.total_cost_usd`); hooks never receive it. `kankaku setup` (above)
110
+ writes it for you automatically; without `kankaku-tui`, run:
62
111
 
63
112
  ```
64
113
  /kankaku:setup
@@ -79,22 +128,22 @@ anything behind in whatever project happens to be open.
79
128
  ## Commands
80
129
 
81
130
  - `/kankaku:report` — a report of recent work, grouped by day (wraps
82
- `node src/cli.ts report`).
131
+ `node dist/cli.js report`).
83
132
  - `/kankaku:status` — the sessions kankaku-claude currently has state for:
84
133
  session id, whether its process is still alive, whether a prompt is open,
85
- and the last cost the statusline reported (wraps `node src/cli.ts status`).
134
+ and the last cost the statusline reported (wraps `node dist/cli.js status`).
86
135
  - `/kankaku:setup` — prints the `statusLine` snippet described above (wraps
87
- `node src/cli.ts setup`).
136
+ `node dist/cli.js setup`).
88
137
  - `/kankaku:sync` — manually syncs recent local work records to the hub
89
- (wraps `node src/cli.ts sync`; the slash command does not forward arguments).
138
+ (wraps `node dist/cli.js sync`; the slash command does not forward arguments).
90
139
  - `/kankaku:sync-status` — inspects local pending counts and sync state
91
- (wraps `node src/cli.ts sync status`; no hub request or credentials required).
92
- - `/kankaku:sync-all` — requests a full sync (wraps `node src/cli.ts sync all`).
140
+ (wraps `node dist/cli.js sync status`; no hub request or credentials required).
141
+ - `/kankaku:sync-all` — requests a full sync (wraps `node dist/cli.js sync all`).
93
142
  - `/kankaku:doctor` — a read-only local diagnostic of plugin files, session and
94
- cost visibility, and hub sync state (wraps `node src/cli.ts doctor`).
143
+ cost visibility, and hub sync state (wraps `node dist/cli.js doctor`).
95
144
 
96
145
  The report, status, setup, and doctor subcommands are also available directly via
97
- `node src/cli.ts <report|status|setup|doctor>`; `report` accepts `--days N` and
146
+ `node dist/cli.js <report|status|setup|doctor>`; `report` accepts `--days N` and
98
147
  defaults to the last 7 days. Doctor reads only local data; it never contacts the
99
148
  hub or prints credentials. A missing cost file means cost has not been observed
100
149
  under the current `HOME` (it does not prove the statusline is misconfigured).
@@ -111,9 +160,9 @@ request or credentials. The same operations are available directly via CLI:
111
160
 
112
161
  | Command | Purpose |
113
162
  |---------|---------|
114
- | `node src/cli.ts sync` | Manually sync the recent window (24 hours by default). |
115
- | `node src/cli.ts sync all` | Request a full sync. |
116
- | `node src/cli.ts sync status` | Inspect local pending counts and sync state; no hub network request or credentials required. |
163
+ | `node dist/cli.js sync` | Manually sync the recent window (24 hours by default). |
164
+ | `node dist/cli.js sync all` | Request a full sync. |
165
+ | `node dist/cli.js sync status` | Inspect local pending counts and sync state; no hub network request or credentials required. |
117
166
 
118
167
  Optional environment settings:
119
168
 
@@ -6,4 +6,4 @@ allowed-tools: Bash(node:*)
6
6
  Run the kankaku CLI doctor command and show its output to the user verbatim,
7
7
  inside a code block, with no summarizing or reformatting:
8
8
 
9
- !node "${CLAUDE_PLUGIN_ROOT}/src/cli.ts" doctor
9
+ !node "${CLAUDE_PLUGIN_ROOT}/dist/cli.js" doctor
@@ -6,4 +6,4 @@ allowed-tools: Bash(node:*)
6
6
  Run the kankaku CLI report command and show its output to the user verbatim,
7
7
  inside a code block, with no summarizing or reformatting:
8
8
 
9
- !node "${CLAUDE_PLUGIN_ROOT}/src/cli.ts" report
9
+ !node "${CLAUDE_PLUGIN_ROOT}/dist/cli.js" report
package/commands/setup.md CHANGED
@@ -9,4 +9,4 @@ Then tell the user, briefly: paste the printed `statusLine` block into their
9
9
  because a Claude Code plugin cannot set `statusLine` for itself — the
10
10
  statusline is the only documented source of per-session cost.
11
11
 
12
- !node "${CLAUDE_PLUGIN_ROOT}/src/cli.ts" setup
12
+ !node "${CLAUDE_PLUGIN_ROOT}/dist/cli.js" setup
@@ -6,4 +6,4 @@ allowed-tools: Bash(node:*)
6
6
  Run the kankaku CLI status command and show its output to the user verbatim,
7
7
  inside a code block, with no summarizing or reformatting:
8
8
 
9
- !node "${CLAUDE_PLUGIN_ROOT}/src/cli.ts" status
9
+ !node "${CLAUDE_PLUGIN_ROOT}/dist/cli.js" status
@@ -6,4 +6,4 @@ allowed-tools: Bash(node:*)
6
6
  Run the kankaku CLI sync all command and show its output to the user verbatim,
7
7
  inside a code block, with no summarizing or reformatting:
8
8
 
9
- !node "${CLAUDE_PLUGIN_ROOT}/src/cli.ts" sync all
9
+ !node "${CLAUDE_PLUGIN_ROOT}/dist/cli.js" sync all
@@ -6,4 +6,4 @@ allowed-tools: Bash(node:*)
6
6
  Run the kankaku CLI sync status command and show its output to the user verbatim,
7
7
  inside a code block, with no summarizing or reformatting:
8
8
 
9
- !node "${CLAUDE_PLUGIN_ROOT}/src/cli.ts" sync status
9
+ !node "${CLAUDE_PLUGIN_ROOT}/dist/cli.js" sync status
package/commands/sync.md CHANGED
@@ -6,9 +6,9 @@ allowed-tools: Bash(node:*)
6
6
  Run the kankaku CLI sync command and show its output to the user verbatim,
7
7
  inside a code block, with no summarizing or reformatting:
8
8
 
9
- !node "${CLAUDE_PLUGIN_ROOT}/src/cli.ts" sync
9
+ !node "${CLAUDE_PLUGIN_ROOT}/dist/cli.js" sync
10
10
 
11
11
  For a full sync or local sync status, use the direct CLI instead:
12
- `node "${CLAUDE_PLUGIN_ROOT}/src/cli.ts" sync all` or
13
- `node "${CLAUDE_PLUGIN_ROOT}/src/cli.ts" sync status`.
12
+ `node "${CLAUDE_PLUGIN_ROOT}/dist/cli.js" sync all` or
13
+ `node "${CLAUDE_PLUGIN_ROOT}/dist/cli.js" sync status`.
14
14
  This slash command runs the default sync only; it does not forward arguments.
@@ -0,0 +1,28 @@
1
+ /** Best-effort heavy-hook sync; credential/transport errors never escape into record handling. */
2
+ export async function autoSync(trigger, deps) {
3
+ if (deps.env.KANKAKU_SYNC_AUTO === "0")
4
+ return;
5
+ try {
6
+ const { resolveHubCredentials } = await import("kankaku/hub");
7
+ const { homedir } = await import("node:os");
8
+ const hub = resolveHubCredentials({ env: deps.env, homeDir: deps.homeDir ?? (() => deps.env.HOME || homedir()) });
9
+ if (!hub.credentials || hub.invalidReason)
10
+ return;
11
+ const { syncConfigured } = await import("./sync-cli.js");
12
+ // One deadline across catalog and upload requests; the public client also
13
+ // imposes its own per-request timeout. No timer persists after the hook.
14
+ const deadline = AbortSignal.timeout(8_000);
15
+ const doFetch = deps.fetch ?? fetch;
16
+ const boundedFetch = (input, init) => doFetch(input, {
17
+ ...init,
18
+ signal: init?.signal ? AbortSignal.any([init.signal, deadline]) : deadline,
19
+ });
20
+ const result = await syncConfigured({ ...deps, fetch: boundedFetch }, hub.credentials, { trigger });
21
+ if (result.error || result.failed.length) {
22
+ deps.stderr(`kankaku auto-sync: ${result.error ?? result.failed.map((f) => f.reason).join("; ")}`);
23
+ }
24
+ }
25
+ catch (error) {
26
+ deps.stderr(`kankaku auto-sync: ${error instanceof Error ? error.message : String(error)}`);
27
+ }
28
+ }
@@ -0,0 +1,61 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { basename } from "node:path";
3
+ const SKIP_COMMS = new Set(["sh", "bash", "zsh", "dash", "fish", "node"]);
4
+ const MAX_HOPS = 6;
5
+ /**
6
+ * Walks ancestors from `startPid` (at most `MAX_HOPS` hops), returning the
7
+ * first pid whose `comm` basename is not a shell or `node` — the Claude
8
+ * Code process itself, skipping the shell/node layers a hook can be
9
+ * launched through. Falls back to `startPid` when `ps` fails or nothing
10
+ * qualifies within the hop budget.
11
+ */
12
+ export function resolveClaudePid({ startPid, runPs }) {
13
+ let pid = startPid;
14
+ for (let hop = 0; hop < MAX_HOPS; hop++) {
15
+ const info = runPs(pid);
16
+ if (!info)
17
+ return startPid;
18
+ if (!SKIP_COMMS.has(basename(info.comm)))
19
+ return pid;
20
+ pid = info.ppid;
21
+ }
22
+ return startPid;
23
+ }
24
+ /** Production `runPs`: `ps -o ppid=,comm= -p <pid>`, bounded to 200ms. */
25
+ export function runPsProcess(pid) {
26
+ try {
27
+ const out = execFileSync("ps", ["-o", "ppid=,comm=", "-p", String(pid)], {
28
+ timeout: 200,
29
+ encoding: "utf8",
30
+ });
31
+ const match = out.trim().match(/^(\d+)\s+(.+)$/);
32
+ if (!match)
33
+ return undefined;
34
+ const ppid = Number(match[1]);
35
+ const comm = match[2]?.trim();
36
+ if (!Number.isFinite(ppid) || !comm)
37
+ return undefined;
38
+ return { ppid, comm };
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ }
44
+ /**
45
+ * `true` unless `process.kill(pid, 0)` throws with a code other than
46
+ * `EPERM`. A non-positive or non-integer pid is always `false`, without
47
+ * calling `process.kill` at all: `pid` 0 signals the whole process GROUP
48
+ * (T7 — the defect that let a placeholder state file's `pid: 0` read back
49
+ * as alive forever).
50
+ */
51
+ export function isAlive(pid) {
52
+ if (!Number.isSafeInteger(pid) || pid <= 0)
53
+ return false;
54
+ try {
55
+ process.kill(pid, 0);
56
+ return true;
57
+ }
58
+ catch (error) {
59
+ return error.code === "EPERM";
60
+ }
61
+ }
@@ -0,0 +1,75 @@
1
+ import { basename, join } from "node:path";
2
+ import { JsonlWorkLog } from "kankaku/hub";
3
+ import { listStateFiles, resolveKankakuDir } from "./paths.js";
4
+ import { readState } from "./session-state.js";
5
+ import { readCost } from "./cost-store.js";
6
+ import { formatReport } from "./report.js";
7
+ import { runSyncCli } from "./sync-cli.js";
8
+ import { runDoctor } from "./doctor.js";
9
+ const USAGE = "usage: node dist/cli.js <report|status|setup|sync|doctor> [--days N]\n";
10
+ /** CLI commands, resolved from `deps.cwd`. */
11
+ export async function runCli(argv, deps) {
12
+ const [command, ...rest] = argv;
13
+ switch (command) {
14
+ case "report":
15
+ return runReport(rest, deps);
16
+ case "status":
17
+ return runStatus(deps);
18
+ case "setup":
19
+ return runSetup(deps);
20
+ case "sync":
21
+ return runSyncCli(rest, deps);
22
+ case "doctor":
23
+ return { stdout: runDoctor(deps), exitCode: 0 };
24
+ default:
25
+ return { stdout: "", exitCode: 1, stderr: USAGE };
26
+ }
27
+ }
28
+ function runReport(args, deps) {
29
+ const days = parseDays(args);
30
+ const kankakuDir = resolveKankakuDir(deps.env.KANKAKU_DIR ?? ".kankaku", deps.cwd);
31
+ const records = new JsonlWorkLog(kankakuDir).readAll();
32
+ const stdout = formatReport(records, { now: deps.now(), ...(days !== undefined ? { days } : {}) });
33
+ return { stdout, exitCode: 0 };
34
+ }
35
+ function parseDays(args) {
36
+ const idx = args.indexOf("--days");
37
+ if (idx === -1)
38
+ return undefined;
39
+ const value = Number(args[idx + 1]);
40
+ return Number.isFinite(value) && value > 0 ? value : undefined;
41
+ }
42
+ function runStatus(deps) {
43
+ const kankakuDir = resolveKankakuDir(deps.env.KANKAKU_DIR ?? ".kankaku", deps.cwd);
44
+ const claudeDir = join(kankakuDir, "claude");
45
+ const files = listStateFiles(claudeDir);
46
+ if (files.length === 0) {
47
+ return { stdout: "No active sessions.\n", exitCode: 0 };
48
+ }
49
+ const lines = files
50
+ .sort()
51
+ .map((file) => formatStatusLine(sessionIdFromStateFile(file), file, deps.isAlive, deps.env));
52
+ return { stdout: lines.join("\n") + "\n", exitCode: 0 };
53
+ }
54
+ function formatStatusLine(sessionId, stateFile, isAlive, env) {
55
+ const state = readState(stateFile);
56
+ if (!state)
57
+ return `${sessionId} (unreadable state)`;
58
+ const aliveWord = isAlive(state.pid) ? "alive" : "dead";
59
+ const promptWord = state.promptOpen
60
+ ? `prompt open since ${new Date(state.promptOpen.startedAt).toISOString()}`
61
+ : "idle";
62
+ const cost = readCost(env, sessionId);
63
+ const costWord = cost ? `$${cost.totalUsd.toFixed(2)}` : "-";
64
+ return `${sessionId} pid ${state.pid} (${aliveWord}) ${promptWord} cost ${costWord}`;
65
+ }
66
+ function sessionIdFromStateFile(file) {
67
+ return basename(file).replace(/\.state\.json$/, "");
68
+ }
69
+ function runSetup(deps) {
70
+ const statuslinePath = join(deps.pluginRoot, "dist", "statusline.js");
71
+ const command = `node "${statuslinePath}"`;
72
+ const snippet = JSON.stringify({ statusLine: { type: "command", command } }, null, 2);
73
+ const explanation = "Claude Code plugins cannot set statusLine themselves, so this must be added to ~/.claude/settings.json manually: the statusline is the only documented source of per-session cost.";
74
+ return { stdout: `${snippet}\n\n${explanation}\n`, exitCode: 0 };
75
+ }
@@ -1,19 +1,18 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import { dirname } from "node:path";
3
- import { runCli } from "./cli-core.ts";
4
- import { isAlive } from "./claude-pid.ts";
5
-
3
+ import { runCli } from "./cli-core.js";
4
+ import { isAlive } from "./claude-pid.js";
6
5
  // src/cli.ts -> src -> repo root (the directory that contains .claude-plugin/).
7
6
  const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url)));
8
-
9
7
  const result = await runCli(process.argv.slice(2), {
10
- env: process.env,
11
- cwd: process.cwd(),
12
- now: () => Date.now(),
13
- isAlive,
14
- pluginRoot,
8
+ env: process.env,
9
+ cwd: process.cwd(),
10
+ now: () => Date.now(),
11
+ isAlive,
12
+ pluginRoot,
15
13
  });
16
-
17
- if (result.stdout) process.stdout.write(result.stdout);
18
- if (result.stderr) process.stderr.write(result.stderr);
14
+ if (result.stdout)
15
+ process.stdout.write(result.stdout);
16
+ if (result.stderr)
17
+ process.stderr.write(result.stderr);
19
18
  process.exit(result.exitCode);
@@ -0,0 +1,121 @@
1
+ import { chmodSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
5
+ /**
6
+ * Cost lives under the HOME directory, never a project: the statusline
7
+ * command is wired globally and runs in every session on the machine, so a
8
+ * project-relative path would litter whatever project happens to be open
9
+ * (the defect this module fixes — see `odd/tasks/hook-tracking.md` T7).
10
+ * Imports only node builtins so light hooks that read/write cost stay off
11
+ * the `kankaku` import path.
12
+ */
13
+ export function costDir(env) {
14
+ const home = env.HOME || homedir();
15
+ return join(home, ".kankaku", "claude", "cost");
16
+ }
17
+ export function costFile(env, sessionId) {
18
+ return join(costDir(env), `${sessionId}.json`);
19
+ }
20
+ function isCostState(value) {
21
+ if (typeof value !== "object" || value === null)
22
+ return false;
23
+ const o = value;
24
+ return (typeof o.totalUsd === "number" &&
25
+ typeof o.updatedAt === "number" &&
26
+ (o.model === undefined || typeof o.model === "string"));
27
+ }
28
+ /** `undefined` when the file is absent or its contents are malformed. */
29
+ export function readCost(env, sessionId) {
30
+ let text;
31
+ try {
32
+ text = readFileSync(costFile(env, sessionId), "utf8");
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ let parsed;
38
+ try {
39
+ parsed = JSON.parse(text);
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ return isCostState(parsed) ? parsed : undefined;
45
+ }
46
+ /**
47
+ * Atomic tmp+rename write. Creates `claude/` and `claude/cost/` (both owned
48
+ * exclusively by this plugin) as owner-only (`0700`) when it creates them —
49
+ * `mkdirSync`'s `mode` only ever applies to a directory the call actually
50
+ * creates, so an existing `~/.kankaku` is never chmod'd by this. The written
51
+ * file is chmod'd `0600` best-effort. Never lowers `updatedAt`: a statusline
52
+ * write racing an older one loses.
53
+ */
54
+ export function writeCost(env, sessionId, cost) {
55
+ const dir = costDir(env);
56
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
57
+ const existing = readCost(env, sessionId);
58
+ if (existing && existing.updatedAt > cost.updatedAt)
59
+ return;
60
+ const file = costFile(env, sessionId);
61
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
62
+ writeFileSync(tmp, JSON.stringify(cost));
63
+ renameSync(tmp, file);
64
+ try {
65
+ chmodSync(file, 0o600);
66
+ }
67
+ catch {
68
+ // best-effort
69
+ }
70
+ }
71
+ export function deleteCost(env, sessionId) {
72
+ try {
73
+ unlinkSync(costFile(env, sessionId));
74
+ }
75
+ catch {
76
+ // already gone
77
+ }
78
+ }
79
+ /**
80
+ * Deletes cost files whose `updatedAt` (or mtime, when the file is
81
+ * unparsable) is older than `maxAgeMs` (default 7 days). No-op when the
82
+ * cost directory does not exist yet.
83
+ */
84
+ export function sweepStaleCostFiles(env, opts) {
85
+ const maxAgeMs = opts.maxAgeMs ?? SEVEN_DAYS_MS;
86
+ const dir = costDir(env);
87
+ let entries;
88
+ try {
89
+ entries = readdirSync(dir);
90
+ }
91
+ catch {
92
+ return;
93
+ }
94
+ for (const name of entries) {
95
+ if (!name.endsWith(".json"))
96
+ continue;
97
+ const file = join(dir, name);
98
+ const sessionId = name.slice(0, -".json".length);
99
+ const cost = readCost(env, sessionId);
100
+ let age;
101
+ if (cost) {
102
+ age = opts.now - cost.updatedAt;
103
+ }
104
+ else {
105
+ try {
106
+ age = opts.now - statSync(file).mtimeMs;
107
+ }
108
+ catch {
109
+ continue;
110
+ }
111
+ }
112
+ if (age > maxAgeMs) {
113
+ try {
114
+ unlinkSync(file);
115
+ }
116
+ catch {
117
+ // already gone
118
+ }
119
+ }
120
+ }
121
+ }