kankaku-claude 0.1.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.
- package/.claude-plugin/plugin.json +11 -0
- package/CHANGELOG.md +55 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/commands/report.md +9 -0
- package/commands/setup.md +12 -0
- package/commands/status.md +9 -0
- package/commands/sync.md +14 -0
- package/hooks/hooks.json +103 -0
- package/package.json +34 -0
- package/src/claude-pid.ts +68 -0
- package/src/cli-core.ts +100 -0
- package/src/cli.ts +19 -0
- package/src/cost-store.ts +138 -0
- package/src/event-log.ts +33 -0
- package/src/events.ts +145 -0
- package/src/handle-hook.ts +275 -0
- package/src/hook.ts +41 -0
- package/src/inflight-recovery.ts +82 -0
- package/src/paths.ts +50 -0
- package/src/prompts.ts +42 -0
- package/src/record.ts +28 -0
- package/src/replay.ts +123 -0
- package/src/report.ts +132 -0
- package/src/session-state.ts +71 -0
- package/src/statusline-core.ts +89 -0
- package/src/statusline.ts +36 -0
- package/src/sync-cli.ts +99 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kankaku",
|
|
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",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "soyunninja"
|
|
7
|
+
},
|
|
8
|
+
"homepage": "https://kankaku.io",
|
|
9
|
+
"repository": "https://github.com/soyunninja/kankaku-claude",
|
|
10
|
+
"license": "MIT"
|
|
11
|
+
}
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Phase 1: per-prompt work records from Claude Code hooks. Writes one
|
|
12
|
+
`WorkRecord` per user prompt to `<KANKAKU_DIR>/worklog.jsonl`, in the same
|
|
13
|
+
schema the kankaku pi extension writes, so kankaku's existing
|
|
14
|
+
report/export/hub tooling can consume it unchanged.
|
|
15
|
+
- Event sourcing per session (`<KANKAKU_DIR>/claude/<session_id>.events.jsonl`)
|
|
16
|
+
plus pure replay into a fresh `WorkTracker`, since Claude Code hooks are
|
|
17
|
+
fresh short-lived processes with no persistent in-memory state.
|
|
18
|
+
- All 9 documented hook events wired: `SessionStart`, `UserPromptSubmit`,
|
|
19
|
+
`PreToolUse`, `PostToolUse`, `PermissionRequest`, `SubagentStart`,
|
|
20
|
+
`SubagentStop`, `Stop`, `SessionEnd`.
|
|
21
|
+
- Per-prompt cost from the statusline's `cost.total_cost_usd`, as a delta
|
|
22
|
+
across the prompt.
|
|
23
|
+
- Crash recovery: a dead session with an open prompt is recovered as an
|
|
24
|
+
`interrupted` record on the next `SessionStart`.
|
|
25
|
+
- `src/statusline.ts` (a manually wired `statusLine` command) reports the
|
|
26
|
+
open prompt's elapsed clock and the current cost.
|
|
27
|
+
- `node src/cli.ts report|status|setup` and the `/kankaku:report`,
|
|
28
|
+
`/kankaku:status`, `/kankaku:setup` slash commands.
|
|
29
|
+
|
|
30
|
+
- Manual hub sync via `node src/cli.ts sync [all|status]` and the
|
|
31
|
+
`/kankaku:sync` slash command (default sync only). No automatic sync hooks;
|
|
32
|
+
prompts are omitted by default.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- T7: the statusline command is wired globally in `~/.claude/settings.json`,
|
|
37
|
+
so it runs in every Claude Code session on the machine, plugin loaded or
|
|
38
|
+
not. It used to write cost into the open project's own
|
|
39
|
+
`.kankaku/claude/<session_id>.state.json`, creating a placeholder file
|
|
40
|
+
(`pid: 0`, `cwd: ""`) in whatever project happened to be open and
|
|
41
|
+
recreating it after `SessionEnd` deleted it — and `isAlive(0)`
|
|
42
|
+
(`process.kill(0, 0)` signals the whole process GROUP) reported that
|
|
43
|
+
placeholder alive forever, so crash recovery never swept it.
|
|
44
|
+
- Cost now lives only under `~/.kankaku/claude/cost/<session_id>.json`
|
|
45
|
+
(`src/cost-store.ts`, new; node builtins only), never under a project.
|
|
46
|
+
The statusline only reads a project's state file, read-only, for the
|
|
47
|
+
open-prompt clock; a missing state file renders `kankaku idle` without
|
|
48
|
+
creating anything.
|
|
49
|
+
- `SessionState` no longer has a `cost` field; `mergeCost` is removed.
|
|
50
|
+
- `isAlive` returns `false` for any non-positive or non-integer pid
|
|
51
|
+
without calling `process.kill`; `recoverStaleSessions` treats
|
|
52
|
+
`pid <= 0` as dead independently of `isAlive`'s answer.
|
|
53
|
+
- `SessionEnd` also deletes the session's cost file; `SessionStart`
|
|
54
|
+
(non-`compact`) sweeps cost files older than 7 days.
|
|
55
|
+
- `node src/cli.ts status` reads cost from the cost file.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 soyunninja
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# kankaku-claude
|
|
2
|
+
|
|
3
|
+
A [Claude Code](https://claude.com/claude-code) plugin that records how long
|
|
4
|
+
Claude Code works on each of your prompts, in the same worklog format as the
|
|
5
|
+
[kankaku](https://kankaku.io) pi extension.
|
|
6
|
+
|
|
7
|
+
## What it measures
|
|
8
|
+
|
|
9
|
+
One record per user prompt, appended to `<KANKAKU_DIR>/worklog.jsonl`:
|
|
10
|
+
|
|
11
|
+
- **Wall time** — from the moment you submit a prompt to the moment Claude
|
|
12
|
+
Code settles it.
|
|
13
|
+
- **Waiting time** — time spent inside an `AskUserQuestion` tool call or
|
|
14
|
+
waiting on a permission dialog.
|
|
15
|
+
- **Work time** — wall time minus waiting time.
|
|
16
|
+
- **Cost** — the prompt's share of the session's running `total_cost_usd`
|
|
17
|
+
(see "Limitations" below for how this is derived).
|
|
18
|
+
|
|
19
|
+
Records are written in kankaku's own `WorkRecord` schema
|
|
20
|
+
(`WORK_RECORD_SCHEMA = 1`), the exact one the kankaku pi extension writes to
|
|
21
|
+
its own `worklog.jsonl`. This means kankaku's existing report and export
|
|
22
|
+
tooling can read this plugin's worklog unchanged. kankaku-claude also exposes
|
|
23
|
+
manual hub sync through kankaku's public hub adapters (below).
|
|
24
|
+
|
|
25
|
+
## Requirements
|
|
26
|
+
|
|
27
|
+
- 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).
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
**From a local path (development).** Clone this repository, then point
|
|
34
|
+
Claude Code at it directly:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
claude --plugin-dir /path/to/kankaku-claude
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
If your Claude Code version does not recognize `--plugin-dir`, or plugin
|
|
41
|
+
loading has changed since this was written, check your installed version's
|
|
42
|
+
own plugin documentation (`claude --help`, or `/plugin` inside a session) for
|
|
43
|
+
the current local-install flow.
|
|
44
|
+
|
|
45
|
+
**From a marketplace.** Once this plugin is published to a marketplace:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
/plugin marketplace add <marketplace-source>
|
|
49
|
+
/plugin install kankaku@<marketplace-name>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
On install, if `package.json` and `package-lock.json` are both present (as
|
|
53
|
+
they are here), Claude Code runs `npm ci --ignore-scripts` for you; a failure
|
|
54
|
+
there never blocks the plugin from loading.
|
|
55
|
+
|
|
56
|
+
## The manual step you cannot skip
|
|
57
|
+
|
|
58
|
+
Claude Code plugins cannot set `statusLine` for themselves — there is no
|
|
59
|
+
programmatic way for a plugin to add a `statusLine` entry to your settings.
|
|
60
|
+
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:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
/kankaku:setup
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
and paste the printed `statusLine` block into `~/.claude/settings.json`
|
|
68
|
+
(merge it with whatever is already there rather than overwriting the file).
|
|
69
|
+
Without this step, kankaku-claude still records wall/waiting/work time, but
|
|
70
|
+
every record's cost stays unset.
|
|
71
|
+
|
|
72
|
+
Because this settings.json entry is global, the statusline command runs in
|
|
73
|
+
every Claude Code session on the machine, plugin loaded or not — including
|
|
74
|
+
projects that never installed kankaku-claude. It writes only to
|
|
75
|
+
`~/.kankaku/claude/cost/<session_id>.json` (never under any project) and
|
|
76
|
+
only reads a project's own state file, so an unrelated session never leaves
|
|
77
|
+
anything behind in whatever project happens to be open.
|
|
78
|
+
|
|
79
|
+
## Commands
|
|
80
|
+
|
|
81
|
+
- `/kankaku:report` — a report of recent work, grouped by day (wraps
|
|
82
|
+
`node src/cli.ts report`).
|
|
83
|
+
- `/kankaku:status` — the sessions kankaku-claude currently has state for:
|
|
84
|
+
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`).
|
|
86
|
+
- `/kankaku:setup` — prints the `statusLine` snippet described above (wraps
|
|
87
|
+
`node src/cli.ts setup`).
|
|
88
|
+
- `/kankaku:sync` — manually syncs local work records to the hub (wraps
|
|
89
|
+
`node src/cli.ts sync`; the slash command does not forward arguments).
|
|
90
|
+
|
|
91
|
+
The report, status, and setup subcommands are also available directly via
|
|
92
|
+
`node src/cli.ts <report|status|setup>`; `report` accepts `--days N` and
|
|
93
|
+
defaults to the last 7 days.
|
|
94
|
+
|
|
95
|
+
### Manual hub sync
|
|
96
|
+
|
|
97
|
+
Configure hub credentials with `KANKAKU_PB_URL`, `KANKAKU_PB_EMAIL`, and
|
|
98
|
+
`KANKAKU_PB_PASSWORD`, or use `~/.kankaku/credentials.json` (the shared
|
|
99
|
+
kankaku hub credential file). Then run `/kankaku:sync` or
|
|
100
|
+
`node src/cli.ts sync` in the project whose worklog you want to upload.
|
|
101
|
+
For options not forwarded by the slash command, use the CLI directly:
|
|
102
|
+
|
|
103
|
+
| Command | Purpose |
|
|
104
|
+
|---------|---------|
|
|
105
|
+
| `node src/cli.ts sync` | Manually sync the recent window (24 hours by default). |
|
|
106
|
+
| `node src/cli.ts sync all` | Request a full sync. |
|
|
107
|
+
| `node src/cli.ts sync status` | Inspect local pending counts and sync state; no hub network request or credentials required. |
|
|
108
|
+
|
|
109
|
+
Optional environment settings:
|
|
110
|
+
|
|
111
|
+
| Variable | Effect |
|
|
112
|
+
|----------|--------|
|
|
113
|
+
| `KANKAKU_MACHINE` | Machine label for uploaded records (defaults to the host name). |
|
|
114
|
+
| `KANKAKU_SYNC_PROMPT` | Prompt privacy: omitted by default; set `truncated` or `full` to include prompts. |
|
|
115
|
+
| `KANKAKU_SYNC_WINDOW_HOURS` | Recent sync window in hours (defaults to 24). |
|
|
116
|
+
| `KANKAKU_SYNC_RECORDS` | Set to `0` to disable uploading raw `work_records` children; consolidated `task_entries` still sync. |
|
|
117
|
+
|
|
118
|
+
This first slice is **manual-only**: no automatic sync hooks run yet.
|
|
119
|
+
|
|
120
|
+
## Where the files live
|
|
121
|
+
|
|
122
|
+
- `<KANKAKU_DIR>/worklog.jsonl` — the append-only log of settled records,
|
|
123
|
+
shared with (and readable by) kankaku's own tooling.
|
|
124
|
+
- `<KANKAKU_DIR>/claude/<session_id>.events.jsonl` and
|
|
125
|
+
`<KANKAKU_DIR>/claude/<session_id>.state.json` — kankaku-claude's own
|
|
126
|
+
per-session working files (event log and small state file). These are
|
|
127
|
+
implementation detail, not part of the shared worklog format, and are
|
|
128
|
+
cleaned up once a session ends. Only the hooks ever write this state file;
|
|
129
|
+
the statusline command only reads it (see below).
|
|
130
|
+
- `~/.kankaku/claude/cost/<session_id>.json` — the per-session cost the
|
|
131
|
+
statusline last reported, **always under your home directory, never under
|
|
132
|
+
a project.** The statusline command is wired into `~/.claude/settings.json`
|
|
133
|
+
globally, so it runs on every Claude Code session on the machine; writing
|
|
134
|
+
cost next to a project's own state file would litter whatever project
|
|
135
|
+
happens to be open with another session's data. This directory is
|
|
136
|
+
owner-only (`0700`), each cost file is `0600`, and a file older than 7
|
|
137
|
+
days is swept on the next `SessionStart`.
|
|
138
|
+
|
|
139
|
+
`KANKAKU_DIR` defaults to `.kankaku` (relative to the session's working
|
|
140
|
+
directory); set the `KANKAKU_DIR` environment variable to use an absolute
|
|
141
|
+
path or a different relative one, exactly as the pi extension does.
|
|
142
|
+
|
|
143
|
+
## Crash recovery
|
|
144
|
+
|
|
145
|
+
If Claude Code's process is killed mid-prompt (or mid-session), the next
|
|
146
|
+
session's `SessionStart` hook scans for other sessions' state files whose
|
|
147
|
+
process is no longer alive. A dead session with an open prompt is replayed
|
|
148
|
+
and appended as one `status: "interrupted"` record before its files are
|
|
149
|
+
deleted; a dead session with no open prompt just has its files deleted. This
|
|
150
|
+
also runs for the current session's own leftover state at `SessionEnd`.
|
|
151
|
+
|
|
152
|
+
## Limitations
|
|
153
|
+
|
|
154
|
+
- **`turns` is always 1 per run.** Claude Code hooks give no way to observe
|
|
155
|
+
provider-level retries/turns inside one run; every replayed run reports
|
|
156
|
+
exactly one turn.
|
|
157
|
+
- **No token counts.** Hooks never carry input/output/cache token numbers,
|
|
158
|
+
only the statusline's aggregate `total_cost_usd`; per-record `usage` token
|
|
159
|
+
fields stay at zero, cost is the only populated figure.
|
|
160
|
+
- **Cost is a per-prompt delta of the session total, from the statusline,
|
|
161
|
+
and needs the manual setup step.** A tiny race is possible: the statusline
|
|
162
|
+
can render after `Stop` has already computed the delta, in which case a
|
|
163
|
+
sliver of one prompt's cost is attributed to the next prompt instead.
|
|
164
|
+
- **A permission wait ends at the next hook event, not when you actually
|
|
165
|
+
click.** There is no documented hook that fires the moment you answer a
|
|
166
|
+
permission dialog, so the waiting span closes at whatever hook fires next
|
|
167
|
+
(typically the tool's own `PreToolUse`), not at the click itself.
|
|
168
|
+
- **A background `Agent`/`Task` subagent span ends when the tool call
|
|
169
|
+
returns, not when the subagent actually finishes.** Claude Code does not
|
|
170
|
+
document a link between a `SubagentStart`/`SubagentStop` pair and the
|
|
171
|
+
`tool_use_id` that launched it, so kankaku-claude cannot join them; the
|
|
172
|
+
subagent's own time is not separately measured here.
|
|
173
|
+
- **Hub sync is manual-only.** Use `/kankaku:sync` or the direct CLI as
|
|
174
|
+
described above; no automatic sync hooks run yet. Prompts are omitted
|
|
175
|
+
from uploads unless `KANKAKU_SYNC_PROMPT` is `truncated` or `full`.
|
|
176
|
+
|
|
177
|
+
See [kankaku.io](https://kankaku.io) for the pi extension this plugin shares
|
|
178
|
+
its worklog format with.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Show a kankaku report of recent Claude Code work (wall, waiting, work time and cost, per prompt)
|
|
3
|
+
allowed-tools: Bash(node:*)
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Run the kankaku CLI report command and show its output to the user verbatim,
|
|
7
|
+
inside a code block, with no summarizing or reformatting:
|
|
8
|
+
|
|
9
|
+
!node "${CLAUDE_PLUGIN_ROOT}/src/cli.ts" report
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Print the statusLine snippet to add to ~/.claude/settings.json so kankaku can read per-prompt cost
|
|
3
|
+
allowed-tools: Bash(node:*)
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Run the kankaku CLI setup command and show its output to the user verbatim.
|
|
7
|
+
Then tell the user, briefly: paste the printed `statusLine` block into their
|
|
8
|
+
`~/.claude/settings.json` (merging it if that file already has other keys),
|
|
9
|
+
because a Claude Code plugin cannot set `statusLine` for itself — the
|
|
10
|
+
statusline is the only documented source of per-session cost.
|
|
11
|
+
|
|
12
|
+
!node "${CLAUDE_PLUGIN_ROOT}/src/cli.ts" setup
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Show the current kankaku session states tracked for this project (pid liveness, open prompt, last cost)
|
|
3
|
+
allowed-tools: Bash(node:*)
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Run the kankaku CLI status command and show its output to the user verbatim,
|
|
7
|
+
inside a code block, with no summarizing or reformatting:
|
|
8
|
+
|
|
9
|
+
!node "${CLAUDE_PLUGIN_ROOT}/src/cli.ts" status
|
package/commands/sync.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Manually sync local kankaku work records to the hub
|
|
3
|
+
allowed-tools: Bash(node:*)
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Run the kankaku CLI sync command and show its output to the user verbatim,
|
|
7
|
+
inside a code block, with no summarizing or reformatting:
|
|
8
|
+
|
|
9
|
+
!node "${CLAUDE_PLUGIN_ROOT}/src/cli.ts" sync
|
|
10
|
+
|
|
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`.
|
|
14
|
+
This slash command runs the default sync only; it does not forward arguments.
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{
|
|
5
|
+
"hooks": [
|
|
6
|
+
{
|
|
7
|
+
"type": "command",
|
|
8
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/src/hook.ts\"",
|
|
9
|
+
"timeout": 15
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"UserPromptSubmit": [
|
|
15
|
+
{
|
|
16
|
+
"hooks": [
|
|
17
|
+
{
|
|
18
|
+
"type": "command",
|
|
19
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/src/hook.ts\"",
|
|
20
|
+
"timeout": 5
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"PreToolUse": [
|
|
26
|
+
{
|
|
27
|
+
"hooks": [
|
|
28
|
+
{
|
|
29
|
+
"type": "command",
|
|
30
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/src/hook.ts\"",
|
|
31
|
+
"timeout": 5
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"PostToolUse": [
|
|
37
|
+
{
|
|
38
|
+
"hooks": [
|
|
39
|
+
{
|
|
40
|
+
"type": "command",
|
|
41
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/src/hook.ts\"",
|
|
42
|
+
"timeout": 5
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
"PermissionRequest": [
|
|
48
|
+
{
|
|
49
|
+
"hooks": [
|
|
50
|
+
{
|
|
51
|
+
"type": "command",
|
|
52
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/src/hook.ts\"",
|
|
53
|
+
"timeout": 5
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
],
|
|
58
|
+
"SubagentStart": [
|
|
59
|
+
{
|
|
60
|
+
"hooks": [
|
|
61
|
+
{
|
|
62
|
+
"type": "command",
|
|
63
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/src/hook.ts\"",
|
|
64
|
+
"timeout": 5
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
],
|
|
69
|
+
"SubagentStop": [
|
|
70
|
+
{
|
|
71
|
+
"hooks": [
|
|
72
|
+
{
|
|
73
|
+
"type": "command",
|
|
74
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/src/hook.ts\"",
|
|
75
|
+
"timeout": 5
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
],
|
|
80
|
+
"Stop": [
|
|
81
|
+
{
|
|
82
|
+
"hooks": [
|
|
83
|
+
{
|
|
84
|
+
"type": "command",
|
|
85
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/src/hook.ts\"",
|
|
86
|
+
"timeout": 15
|
|
87
|
+
}
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
],
|
|
91
|
+
"SessionEnd": [
|
|
92
|
+
{
|
|
93
|
+
"hooks": [
|
|
94
|
+
{
|
|
95
|
+
"type": "command",
|
|
96
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/src/hook.ts\"",
|
|
97
|
+
"timeout": 15
|
|
98
|
+
}
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
}
|
|
103
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kankaku-claude",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code plugin that records how long the agent works on each user prompt, in kankaku's worklog format.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
".claude-plugin/",
|
|
8
|
+
"commands/",
|
|
9
|
+
"hooks/",
|
|
10
|
+
"src/",
|
|
11
|
+
"README.md",
|
|
12
|
+
"CHANGELOG.md",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"tsconfig.json"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "soyunninja",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/soyunninja/kankaku-claude.git"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test tests/*.test.ts",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"check": "npm run typecheck && npm test"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"kankaku": "^0.6.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^24.13.4",
|
|
32
|
+
"typescript": "^5.7.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface PsInfo {
|
|
5
|
+
ppid: number;
|
|
6
|
+
comm: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const SKIP_COMMS = new Set(["sh", "bash", "zsh", "dash", "fish", "node"]);
|
|
10
|
+
const MAX_HOPS = 6;
|
|
11
|
+
|
|
12
|
+
export interface ResolveClaudePidInput {
|
|
13
|
+
startPid: number;
|
|
14
|
+
runPs: (pid: number) => PsInfo | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Walks ancestors from `startPid` (at most `MAX_HOPS` hops), returning the
|
|
19
|
+
* first pid whose `comm` basename is not a shell or `node` — the Claude
|
|
20
|
+
* Code process itself, skipping the shell/node layers a hook can be
|
|
21
|
+
* launched through. Falls back to `startPid` when `ps` fails or nothing
|
|
22
|
+
* qualifies within the hop budget.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveClaudePid({ startPid, runPs }: ResolveClaudePidInput): number {
|
|
25
|
+
let pid = startPid;
|
|
26
|
+
for (let hop = 0; hop < MAX_HOPS; hop++) {
|
|
27
|
+
const info = runPs(pid);
|
|
28
|
+
if (!info) return startPid;
|
|
29
|
+
if (!SKIP_COMMS.has(basename(info.comm))) return pid;
|
|
30
|
+
pid = info.ppid;
|
|
31
|
+
}
|
|
32
|
+
return startPid;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Production `runPs`: `ps -o ppid=,comm= -p <pid>`, bounded to 200ms. */
|
|
36
|
+
export function runPsProcess(pid: number): PsInfo | undefined {
|
|
37
|
+
try {
|
|
38
|
+
const out = execFileSync("ps", ["-o", "ppid=,comm=", "-p", String(pid)], {
|
|
39
|
+
timeout: 200,
|
|
40
|
+
encoding: "utf8",
|
|
41
|
+
});
|
|
42
|
+
const match = out.trim().match(/^(\d+)\s+(.+)$/);
|
|
43
|
+
if (!match) return undefined;
|
|
44
|
+
const ppid = Number(match[1]);
|
|
45
|
+
const comm = match[2]?.trim();
|
|
46
|
+
if (!Number.isFinite(ppid) || !comm) return undefined;
|
|
47
|
+
return { ppid, comm };
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `true` unless `process.kill(pid, 0)` throws with a code other than
|
|
55
|
+
* `EPERM`. A non-positive or non-integer pid is always `false`, without
|
|
56
|
+
* calling `process.kill` at all: `pid` 0 signals the whole process GROUP
|
|
57
|
+
* (T7 — the defect that let a placeholder state file's `pid: 0` read back
|
|
58
|
+
* as alive forever).
|
|
59
|
+
*/
|
|
60
|
+
export function isAlive(pid: number): boolean {
|
|
61
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
62
|
+
try {
|
|
63
|
+
process.kill(pid, 0);
|
|
64
|
+
return true;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/cli-core.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { basename, join } from "node:path";
|
|
2
|
+
import { JsonlWorkLog } from "kankaku/hub";
|
|
3
|
+
import { listStateFiles, resolveKankakuDir } from "./paths.ts";
|
|
4
|
+
import { readState } from "./session-state.ts";
|
|
5
|
+
import { readCost } from "./cost-store.ts";
|
|
6
|
+
import { formatReport } from "./report.ts";
|
|
7
|
+
import { runSyncCli } from "./sync-cli.ts";
|
|
8
|
+
|
|
9
|
+
export interface CliDeps {
|
|
10
|
+
env: NodeJS.ProcessEnv;
|
|
11
|
+
cwd: string;
|
|
12
|
+
now: () => number;
|
|
13
|
+
isAlive: (pid: number) => boolean;
|
|
14
|
+
/** Absolute path to the plugin/repo root (the directory containing `.claude-plugin/`). */
|
|
15
|
+
pluginRoot: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CliResult {
|
|
19
|
+
stdout: string;
|
|
20
|
+
exitCode: number;
|
|
21
|
+
/** Usage or sync error output. */
|
|
22
|
+
stderr?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const USAGE = "usage: node src/cli.ts <report|status|setup|sync> [--days N]\n";
|
|
26
|
+
|
|
27
|
+
/** CLI commands, resolved from `deps.cwd`. */
|
|
28
|
+
export async function runCli(argv: string[], deps: CliDeps): Promise<CliResult> {
|
|
29
|
+
const [command, ...rest] = argv;
|
|
30
|
+
switch (command) {
|
|
31
|
+
case "report":
|
|
32
|
+
return runReport(rest, deps);
|
|
33
|
+
case "status":
|
|
34
|
+
return runStatus(deps);
|
|
35
|
+
case "setup":
|
|
36
|
+
return runSetup(deps);
|
|
37
|
+
case "sync":
|
|
38
|
+
return runSyncCli(rest, deps);
|
|
39
|
+
default:
|
|
40
|
+
return { stdout: "", exitCode: 1, stderr: USAGE };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function runReport(args: string[], deps: CliDeps): CliResult {
|
|
45
|
+
const days = parseDays(args);
|
|
46
|
+
const kankakuDir = resolveKankakuDir(deps.env.KANKAKU_DIR ?? ".kankaku", deps.cwd);
|
|
47
|
+
const records = new JsonlWorkLog(kankakuDir).readAll();
|
|
48
|
+
const stdout = formatReport(records, { now: deps.now(), ...(days !== undefined ? { days } : {}) });
|
|
49
|
+
return { stdout, exitCode: 0 };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseDays(args: string[]): number | undefined {
|
|
53
|
+
const idx = args.indexOf("--days");
|
|
54
|
+
if (idx === -1) return undefined;
|
|
55
|
+
const value = Number(args[idx + 1]);
|
|
56
|
+
return Number.isFinite(value) && value > 0 ? value : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function runStatus(deps: CliDeps): CliResult {
|
|
60
|
+
const kankakuDir = resolveKankakuDir(deps.env.KANKAKU_DIR ?? ".kankaku", deps.cwd);
|
|
61
|
+
const claudeDir = join(kankakuDir, "claude");
|
|
62
|
+
const files = listStateFiles(claudeDir);
|
|
63
|
+
if (files.length === 0) {
|
|
64
|
+
return { stdout: "No active sessions.\n", exitCode: 0 };
|
|
65
|
+
}
|
|
66
|
+
const lines = files
|
|
67
|
+
.sort()
|
|
68
|
+
.map((file) => formatStatusLine(sessionIdFromStateFile(file), file, deps.isAlive, deps.env));
|
|
69
|
+
return { stdout: lines.join("\n") + "\n", exitCode: 0 };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatStatusLine(
|
|
73
|
+
sessionId: string,
|
|
74
|
+
stateFile: string,
|
|
75
|
+
isAlive: (pid: number) => boolean,
|
|
76
|
+
env: NodeJS.ProcessEnv,
|
|
77
|
+
): string {
|
|
78
|
+
const state = readState(stateFile);
|
|
79
|
+
if (!state) return `${sessionId} (unreadable state)`;
|
|
80
|
+
const aliveWord = isAlive(state.pid) ? "alive" : "dead";
|
|
81
|
+
const promptWord = state.promptOpen
|
|
82
|
+
? `prompt open since ${new Date(state.promptOpen.startedAt).toISOString()}`
|
|
83
|
+
: "idle";
|
|
84
|
+
const cost = readCost(env, sessionId);
|
|
85
|
+
const costWord = cost ? `$${cost.totalUsd.toFixed(2)}` : "-";
|
|
86
|
+
return `${sessionId} pid ${state.pid} (${aliveWord}) ${promptWord} cost ${costWord}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sessionIdFromStateFile(file: string): string {
|
|
90
|
+
return basename(file).replace(/\.state\.json$/, "");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function runSetup(deps: CliDeps): CliResult {
|
|
94
|
+
const statuslinePath = join(deps.pluginRoot, "src", "statusline.ts");
|
|
95
|
+
const command = `node "${statuslinePath}"`;
|
|
96
|
+
const snippet = JSON.stringify({ statusLine: { type: "command", command } }, null, 2);
|
|
97
|
+
const explanation =
|
|
98
|
+
"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.";
|
|
99
|
+
return { stdout: `${snippet}\n\n${explanation}\n`, exitCode: 0 };
|
|
100
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { runCli } from "./cli-core.ts";
|
|
4
|
+
import { isAlive } from "./claude-pid.ts";
|
|
5
|
+
|
|
6
|
+
// src/cli.ts -> src -> repo root (the directory that contains .claude-plugin/).
|
|
7
|
+
const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
8
|
+
|
|
9
|
+
const result = await runCli(process.argv.slice(2), {
|
|
10
|
+
env: process.env,
|
|
11
|
+
cwd: process.cwd(),
|
|
12
|
+
now: () => Date.now(),
|
|
13
|
+
isAlive,
|
|
14
|
+
pluginRoot,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
18
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
19
|
+
process.exit(result.exitCode);
|