kankaku-claude 0.9.0 → 0.11.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 (53) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +75 -7
  3. package/README.md +109 -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 +78 -0
  14. package/{src/cli.ts → dist/cli.js} +11 -12
  15. package/dist/cost-store.js +121 -0
  16. package/dist/doctor.js +103 -0
  17. package/dist/event-log.js +31 -0
  18. package/dist/events.js +72 -0
  19. package/dist/handle-hook.js +292 -0
  20. package/dist/hook.js +38 -0
  21. package/dist/inflight-recovery.js +71 -0
  22. package/dist/paths.js +35 -0
  23. package/dist/prompts.js +33 -0
  24. package/dist/record.js +56 -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/dist/work-target.js +71 -0
  32. package/hooks/hooks.json +9 -9
  33. package/package.json +7 -6
  34. package/src/auto-sync.ts +0 -32
  35. package/src/claude-pid.ts +0 -68
  36. package/src/cli-core.ts +0 -103
  37. package/src/cost-store.ts +0 -138
  38. package/src/doctor.ts +0 -89
  39. package/src/event-log.ts +0 -33
  40. package/src/events.ts +0 -145
  41. package/src/handle-hook.ts +0 -299
  42. package/src/hook.ts +0 -41
  43. package/src/inflight-recovery.ts +0 -82
  44. package/src/paths.ts +0 -50
  45. package/src/prompts.ts +0 -42
  46. package/src/record.ts +0 -28
  47. package/src/replay.ts +0 -123
  48. package/src/report.ts +0 -132
  49. package/src/session-state.ts +0 -71
  50. package/src/statusline-core.ts +0 -89
  51. package/src/statusline.ts +0 -36
  52. package/src/sync-cli.ts +0 -122
  53. package/tsconfig.json +0 -14
@@ -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": "0.9.0",
4
+ "version": "0.11.0",
5
5
  "author": {
6
6
  "name": "soyunninja"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -4,16 +4,75 @@ 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
- ## 0.9.0 — 2026-09-28
7
+ ## 0.11.0 — 2026-09-29
8
8
 
9
- ### Changed
9
+ ### Added
10
10
 
11
- - Moved into the kankaku monorepo (`github.com/soyunninja/kankaku`,
12
- `packages/claude`); versions are now lockstep with the other client
13
- packages (`kankaku`, `kankaku-tui`). Depends on `kankaku ^0.9.0` (was
14
- `^0.6.0`). No behaviour change.
11
+ - **Records resolve their client and project automatically.** Records
12
+ written at Stop, SessionEnd and crash recovery now carry `clientId`,
13
+ `clientName`, `projectId` and `projectName` (and the legacy `client`
14
+ label) when the project `config.json` ids or the cached catalog's
15
+ `repo_paths` match the session's working directory, so the hub no longer
16
+ files them under the unassigned client. The catalog is read from
17
+ `~/.kankaku/catalog.json` only: no network, no delay, and no target when
18
+ the cache is missing. Records already on disk are not rewritten, and rows
19
+ already uploaded as unassigned stay so until reassigned in the web app.
20
+ - `/kankaku:status` and `/kankaku:doctor` print the resolved target and its
21
+ source, or `target: none (<reason>)`.
22
+
23
+ ## 0.10.2 — 2026-09-29
24
+
25
+ ### Fixed
26
+
27
+ - **Records synced by another tool were attributed to the syncer.** Records
28
+ carried no `agent`/`plugin`, so when the kankaku TUI synced a worklog
29
+ written by Claude Code, the hub row was created as agent `unknown`,
30
+ plugin `kankaku-tui`. Every record (settled, settled at SessionEnd, or
31
+ recovered as `interrupted`) now carries `agent: "claude-code"`,
32
+ `plugin: "kankaku-claude"` and `pluginVersion`. Records already on disk
33
+ are not rewritten.
34
+
35
+ ## 0.10.1 — 2026-09-29
36
+
37
+ ### Fixed
38
+
39
+ - **A prompt interrupted by a hung session was recorded with the time until
40
+ the next session started.** Crash recovery settled the open prompt at
41
+ recovery time, so a session that hung and was recovered the next morning
42
+ produced a record with a 14.4 h wall time. The prompt is now closed at
43
+ the timestamp of the last event recorded for it (its own
44
+ `UserPromptSubmit` when nothing followed); a waiting span still open is
45
+ closed at the same instant, and the record stays `interrupted`.
15
46
 
16
- ## [Unreleased]
47
+ ## 0.10.0 — 2026-09-28
48
+
49
+ ### Fixed
50
+
51
+ - **Installed as a dependency, the plugin could not run at all.** This
52
+ package now compiles to `dist/` (`npm run build`, `tsc -p
53
+ tsconfig.build.json`) and ships `dist/` instead of `src/`/`tsconfig.json`
54
+ in `package.json`'s `files`. Every hook/statusline/CLI command
55
+ (`hooks/hooks.json`, `commands/*.md`, the `statusLine` snippet
56
+ `kankaku setup`/`/kankaku:setup` prints) now points at the compiled
57
+ `dist/*.js`, never `src/*.ts` — Node 24 refuses to type-strip a `.ts`
58
+ file once it sits under a `node_modules` directory
59
+ (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so a `kankaku-tui`
60
+ install of this package (its dependency, not a checkout) could not run
61
+ a single hook, the statusline, or the CLI before this fix. `npm run
62
+ check`/`prepublishOnly` build first; a manual `--plugin-dir` checkout
63
+ now needs `npm install && npm run build` once (see README "Install").
64
+
65
+ ### Changed
66
+
67
+ - README "Install": the recommended path is now `npm i -g kankaku-tui &&
68
+ kankaku setup` — `kankaku-tui` depends on this package directly and its
69
+ setup wizard/`--yes` flow writes both `statusLine` and `hooks` into
70
+ `~/.claude/settings.json` in one step, so no checkout or `--plugin-dir`
71
+ is required for the plugin to measure time or report cost.
72
+ `--plugin-dir <checkout>` remains documented as the manual/dev path
73
+ (also the only way to get the `/kankaku:*` slash commands), with a note
74
+ that combining it with a `kankaku setup`-configured machine double-runs
75
+ the hooks and double-writes worklog records.
17
76
 
18
77
  ### Added
19
78
 
@@ -71,3 +130,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
71
130
  - `SessionEnd` also deletes the session's cost file; `SessionStart`
72
131
  (non-`compact`) sweeps cost files older than 7 days.
73
132
  - `node src/cli.ts status` reads cost from the cost file.
133
+
134
+ ## 0.9.0 — 2026-09-28
135
+
136
+ ### Changed
137
+
138
+ - Moved into the kankaku monorepo (`github.com/soyunninja/kankaku`,
139
+ `packages/claude`); versions are now lockstep with the other client
140
+ packages (`kankaku`, `kankaku-tui`). Depends on `kankaku ^0.9.0` (was
141
+ `^0.6.0`). No behaviour change.
package/README.md CHANGED
@@ -25,25 +25,76 @@ its own `worklog.jsonl`. This means kankaku's existing report and export
25
25
  tooling can read this plugin's worklog unchanged. kankaku-claude also exposes
26
26
  manual and best-effort automatic hub sync through kankaku's public hub adapters (below).
27
27
 
28
+ Every record carries the identity of who measured it: `agent: "claude-code"`,
29
+ `plugin: "kankaku-claude"` and `pluginVersion` (this package's version).
30
+ A worklog synced by another tool, such as the kankaku TUI, therefore keeps
31
+ the right agent on the hub. `agentVersion` is left unset because Claude Code
32
+ does not pass its version to hooks. Records written before this version carry
33
+ no identity and are labelled by whichever tool syncs them first.
34
+
28
35
  ## Requirements
29
36
 
30
37
  - Claude Code with plugin support.
31
- - Node.js >= 24 on `PATH` (this package's `.ts` sources run directly under
32
- Node's built-in type stripping; there is no build step).
38
+ - Node.js >= 24 on `PATH`. This package ships a compiled `dist/` (`npm
39
+ run build`, `tsc -p tsconfig.build.json`) — Node refuses to type-strip
40
+ a `.ts` file once it sits under a `node_modules` directory
41
+ (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so every published
42
+ hook/statusline/CLI command runs the built `dist/*.js` file, never the
43
+ `.ts` source. Installed via `kankaku-tui` (below) this needs no action
44
+ from you; a manual `--plugin-dir` checkout must run `npm install && npm
45
+ run build` once before Claude Code can load it (see "Manual/dev"
46
+ below).
33
47
 
34
48
  ## Install
35
49
 
36
- **From a local path (development).** Clone this repository, then point
37
- Claude Code at it directly:
50
+ **Recommended: `kankaku-tui`'s setup wizard.** This package lives inside
51
+ `kankaku-tui`'s own dependency tree, so installing and running its setup
52
+ wizard configures Claude Code for you — statusline and hooks together, in
53
+ one step, with no checkout and no `--plugin-dir`:
54
+
55
+ ```bash
56
+ npm i -g kankaku-tui
57
+ kankaku setup
58
+ ```
59
+
60
+ Checking Claude Code in the wizard (or confirming it in `kankaku setup
61
+ --yes`) writes `statusLine` and `hooks` into `~/.claude/settings.json`,
62
+ resolved from this package's own bundled files, merged with — never
63
+ clobbering — whatever else is already there. See `kankaku-tui`'s own
64
+ README ("Claude Code") for the full behaviour, the
65
+ `--claude-plugin-dir`/`KANKAKU_CLAUDE_PLUGIN_DIR` override, and why you
66
+ should drop `--plugin-dir` (below) once this has run.
67
+
68
+ **Manual/dev: `--plugin-dir`.** Clone this repository, build it once, then point Claude Code
69
+ at it directly:
38
70
 
39
71
  ```bash
72
+ git clone https://github.com/soyunninja/kankaku.git
73
+ cd kankaku/packages/claude
74
+ npm install && npm run build
40
75
  claude --plugin-dir /path/to/kankaku-claude
41
76
  ```
42
77
 
78
+ `npm run build` compiles `src/` into `dist/`; the hooks, statusline and
79
+ `/kankaku:*` commands all run `dist/*.js`, never `src/*.ts` — a checkout
80
+ that skips this step fails at the hook's very first launch with
81
+ `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` (or, for `--plugin-dir`
82
+ itself, simply nothing recorded). Re-run `npm run build` after pulling
83
+ new commits.
84
+
43
85
  If your Claude Code version does not recognize `--plugin-dir`, or plugin
44
86
  loading has changed since this was written, check your installed version's
45
87
  own plugin documentation (`claude --help`, or `/plugin` inside a session) for
46
- the current local-install flow.
88
+ the current local-install flow. A plugin
89
+ wired only through settings.json hooks does not register its commands, so
90
+ `kankaku setup` installs the `/kankaku:*` commands separately (see
91
+ "Commands").
92
+
93
+ **Do not combine the two.** If `kankaku setup` has already configured this
94
+ machine's `~/.claude/settings.json` hooks, loading the plugin again with
95
+ `--plugin-dir` runs the same hooks twice per event and double-writes
96
+ worklog records. Use `--plugin-dir` only on a machine `kankaku setup` has
97
+ not touched, or drop it once setup has run.
47
98
 
48
99
  **From a marketplace.** Once this plugin is published to a marketplace:
49
100
 
@@ -61,7 +112,8 @@ there never blocks the plugin from loading.
61
112
  Claude Code plugins cannot set `statusLine` for themselves — there is no
62
113
  programmatic way for a plugin to add a `statusLine` entry to your settings.
63
114
  The statusline is also the *only* documented source of per-prompt cost
64
- (`cost.total_cost_usd`); hooks never receive it. So, once installed, run:
115
+ (`cost.total_cost_usd`); hooks never receive it. `kankaku setup` (above)
116
+ writes it for you automatically; without `kankaku-tui`, run:
65
117
 
66
118
  ```
67
119
  /kankaku:setup
@@ -81,23 +133,27 @@ anything behind in whatever project happens to be open.
81
133
 
82
134
  ## Commands
83
135
 
136
+ `kankaku setup` installs these as user commands under
137
+ `~/.claude/commands/kankaku/`, so they work without `--plugin-dir`.
138
+
84
139
  - `/kankaku:report` — a report of recent work, grouped by day (wraps
85
- `node src/cli.ts report`).
140
+ `node dist/cli.js report`).
86
141
  - `/kankaku:status` — the sessions kankaku-claude currently has state for:
87
142
  session id, whether its process is still alive, whether a prompt is open,
88
- and the last cost the statusline reported (wraps `node src/cli.ts status`).
143
+ and the last cost the statusline reported, preceded by the resolved work
144
+ target (wraps `node dist/cli.js status`).
89
145
  - `/kankaku:setup` — prints the `statusLine` snippet described above (wraps
90
- `node src/cli.ts setup`).
146
+ `node dist/cli.js setup`).
91
147
  - `/kankaku:sync` — manually syncs recent local work records to the hub
92
- (wraps `node src/cli.ts sync`; the slash command does not forward arguments).
148
+ (wraps `node dist/cli.js sync`; the slash command does not forward arguments).
93
149
  - `/kankaku:sync-status` — inspects local pending counts and sync state
94
- (wraps `node src/cli.ts sync status`; no hub request or credentials required).
95
- - `/kankaku:sync-all` — requests a full sync (wraps `node src/cli.ts sync all`).
150
+ (wraps `node dist/cli.js sync status`; no hub request or credentials required).
151
+ - `/kankaku:sync-all` — requests a full sync (wraps `node dist/cli.js sync all`).
96
152
  - `/kankaku:doctor` — a read-only local diagnostic of plugin files, session and
97
- cost visibility, and hub sync state (wraps `node src/cli.ts doctor`).
153
+ cost visibility, and hub sync state (wraps `node dist/cli.js doctor`).
98
154
 
99
155
  The report, status, setup, and doctor subcommands are also available directly via
100
- `node src/cli.ts <report|status|setup|doctor>`; `report` accepts `--days N` and
156
+ `node dist/cli.js <report|status|setup|doctor>`; `report` accepts `--days N` and
101
157
  defaults to the last 7 days. Doctor reads only local data; it never contacts the
102
158
  hub or prints credentials. A missing cost file means cost has not been observed
103
159
  under the current `HOME` (it does not prove the statusline is misconfigured).
@@ -114,9 +170,9 @@ request or credentials. The same operations are available directly via CLI:
114
170
 
115
171
  | Command | Purpose |
116
172
  |---------|---------|
117
- | `node src/cli.ts sync` | Manually sync the recent window (24 hours by default). |
118
- | `node src/cli.ts sync all` | Request a full sync. |
119
- | `node src/cli.ts sync status` | Inspect local pending counts and sync state; no hub network request or credentials required. |
173
+ | `node dist/cli.js sync` | Manually sync the recent window (24 hours by default). |
174
+ | `node dist/cli.js sync all` | Request a full sync. |
175
+ | `node dist/cli.js sync status` | Inspect local pending counts and sync state; no hub network request or credentials required. |
120
176
 
121
177
  Optional environment settings:
122
178
 
@@ -140,6 +196,36 @@ window, and record settings above apply to both manual and automatic sync.
140
196
  Automatic runs use kankaku's change detection and per-prompt throttle; session
141
197
  boundaries are not throttled. Set `KANKAKU_SYNC_AUTO=0` to opt out.
142
198
 
199
+ ### Client and project assignment
200
+
201
+ Each record is stamped with a hub client and project when one resolves, so
202
+ the hub files the task under the right client instead of "Sin determinar".
203
+ Sources, in order:
204
+
205
+ 1. the project's `<KANKAKU_DIR>/config.json` ids (`clientId`, optional
206
+ `projectId`);
207
+ 2. the cached catalog's `repo_paths`: the active project whose path equals
208
+ the session's working directory, or contains it.
209
+
210
+ Inactive clients and projects, and the "unassigned" client, are never used.
211
+ The catalog is read from the cache file `~/.kankaku/catalog.json` only; a
212
+ hook never fetches it and never waits on the network. Every real sync
213
+ refreshes that cache, and `kankaku catalog refresh` refreshes it on demand.
214
+ Without a readable cache (or without hub credentials, which name the hub the
215
+ cache belongs to) no target resolves and records stay unassigned, exactly as
216
+ before. The legacy `client` label is the client's code when it is a valid
217
+ label; without a hub target it comes from `KANKAKU_CLIENT`, then the
218
+ `client` in `config.json`.
219
+
220
+ The target is resolved when a record is written (`Stop`, `SessionEnd`,
221
+ crash recovery), never on the per-tool-call hooks. `/kankaku:status` and
222
+ `/kankaku:doctor` print `target: <client> · <project> (source: ...)`, or
223
+ `target: none (<reason>)`.
224
+
225
+ There is nothing to pick inside Claude Code yet. Assignment is create-only
226
+ on the hub: a row already uploaded as unassigned stays that way until it is
227
+ reassigned in the web app; a later sync does not move it.
228
+
143
229
  ## Where the files live
144
230
 
145
231
  - `<KANKAKU_DIR>/worklog.jsonl` — the append-only log of settled records,
@@ -172,6 +258,12 @@ and appended as one `status: "interrupted"` record before its files are
172
258
  deleted; a dead session with no open prompt just has its files deleted. This
173
259
  also runs for the current session's own leftover state at `SessionEnd`.
174
260
 
261
+ An interrupted prompt is closed at its last recorded activity — the
262
+ timestamp of the last event logged for it, or its own start when nothing
263
+ followed — never at the moment of recovery, so a session that hung and was
264
+ recovered hours later does not report those hours as work. A waiting span
265
+ still open is closed at that same instant.
266
+
175
267
  ## Limitations
176
268
 
177
269
  - **`turns` is always 1 per run.** Claude Code hooks give no way to observe
@@ -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,78 @@
1
+ import { homedir } from "node:os";
2
+ import { basename, join } from "node:path";
3
+ import { JsonlWorkLog } from "kankaku/hub";
4
+ import { listStateFiles, resolveKankakuDir } from "./paths.js";
5
+ import { readState } from "./session-state.js";
6
+ import { readCost } from "./cost-store.js";
7
+ import { formatReport } from "./report.js";
8
+ import { runSyncCli } from "./sync-cli.js";
9
+ import { runDoctor } from "./doctor.js";
10
+ import { formatTargetLine, resolveClaudeWorkTarget } from "./work-target.js";
11
+ const USAGE = "usage: node dist/cli.js <report|status|setup|sync|doctor> [--days N]\n";
12
+ /** CLI commands, resolved from `deps.cwd`. */
13
+ export async function runCli(argv, deps) {
14
+ const [command, ...rest] = argv;
15
+ switch (command) {
16
+ case "report":
17
+ return runReport(rest, deps);
18
+ case "status":
19
+ return runStatus(deps);
20
+ case "setup":
21
+ return runSetup(deps);
22
+ case "sync":
23
+ return runSyncCli(rest, deps);
24
+ case "doctor":
25
+ return { stdout: runDoctor(deps), exitCode: 0 };
26
+ default:
27
+ return { stdout: "", exitCode: 1, stderr: USAGE };
28
+ }
29
+ }
30
+ function runReport(args, deps) {
31
+ const days = parseDays(args);
32
+ const kankakuDir = resolveKankakuDir(deps.env.KANKAKU_DIR ?? ".kankaku", deps.cwd);
33
+ const records = new JsonlWorkLog(kankakuDir).readAll();
34
+ const stdout = formatReport(records, { now: deps.now(), ...(days !== undefined ? { days } : {}) });
35
+ return { stdout, exitCode: 0 };
36
+ }
37
+ function parseDays(args) {
38
+ const idx = args.indexOf("--days");
39
+ if (idx === -1)
40
+ return undefined;
41
+ const value = Number(args[idx + 1]);
42
+ return Number.isFinite(value) && value > 0 ? value : undefined;
43
+ }
44
+ function runStatus(deps) {
45
+ const kankakuDir = resolveKankakuDir(deps.env.KANKAKU_DIR ?? ".kankaku", deps.cwd);
46
+ const claudeDir = join(kankakuDir, "claude");
47
+ const files = listStateFiles(claudeDir);
48
+ const targetLine = formatTargetLine(resolveClaudeWorkTarget({ cwd: deps.cwd, kankakuDir, homeDir: deps.env.HOME || homedir(), env: deps.env }));
49
+ if (files.length === 0) {
50
+ return { stdout: `${targetLine}\nNo active sessions.\n`, exitCode: 0 };
51
+ }
52
+ const lines = files
53
+ .sort()
54
+ .map((file) => formatStatusLine(sessionIdFromStateFile(file), file, deps.isAlive, deps.env));
55
+ return { stdout: [targetLine, ...lines].join("\n") + "\n", exitCode: 0 };
56
+ }
57
+ function formatStatusLine(sessionId, stateFile, isAlive, env) {
58
+ const state = readState(stateFile);
59
+ if (!state)
60
+ return `${sessionId} (unreadable state)`;
61
+ const aliveWord = isAlive(state.pid) ? "alive" : "dead";
62
+ const promptWord = state.promptOpen
63
+ ? `prompt open since ${new Date(state.promptOpen.startedAt).toISOString()}`
64
+ : "idle";
65
+ const cost = readCost(env, sessionId);
66
+ const costWord = cost ? `$${cost.totalUsd.toFixed(2)}` : "-";
67
+ return `${sessionId} pid ${state.pid} (${aliveWord}) ${promptWord} cost ${costWord}`;
68
+ }
69
+ function sessionIdFromStateFile(file) {
70
+ return basename(file).replace(/\.state\.json$/, "");
71
+ }
72
+ function runSetup(deps) {
73
+ const statuslinePath = join(deps.pluginRoot, "dist", "statusline.js");
74
+ const command = `node "${statuslinePath}"`;
75
+ const snippet = JSON.stringify({ statusLine: { type: "command", command } }, null, 2);
76
+ 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.";
77
+ return { stdout: `${snippet}\n\n${explanation}\n`, exitCode: 0 };
78
+ }
@@ -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);