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,138 @@
|
|
|
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
|
+
|
|
5
|
+
/** Only the field this module actually needs from `process.env`. */
|
|
6
|
+
export interface CostEnv {
|
|
7
|
+
HOME?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CostState {
|
|
11
|
+
totalUsd: number;
|
|
12
|
+
updatedAt: number;
|
|
13
|
+
model?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Cost lives under the HOME directory, never a project: the statusline
|
|
20
|
+
* command is wired globally and runs in every session on the machine, so a
|
|
21
|
+
* project-relative path would litter whatever project happens to be open
|
|
22
|
+
* (the defect this module fixes — see `odd/tasks/hook-tracking.md` T7).
|
|
23
|
+
* Imports only node builtins so light hooks that read/write cost stay off
|
|
24
|
+
* the `kankaku` import path.
|
|
25
|
+
*/
|
|
26
|
+
export function costDir(env: CostEnv): string {
|
|
27
|
+
const home = env.HOME || homedir();
|
|
28
|
+
return join(home, ".kankaku", "claude", "cost");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function costFile(env: CostEnv, sessionId: string): string {
|
|
32
|
+
return join(costDir(env), `${sessionId}.json`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isCostState(value: unknown): value is CostState {
|
|
36
|
+
if (typeof value !== "object" || value === null) return false;
|
|
37
|
+
const o = value as Record<string, unknown>;
|
|
38
|
+
return (
|
|
39
|
+
typeof o.totalUsd === "number" &&
|
|
40
|
+
typeof o.updatedAt === "number" &&
|
|
41
|
+
(o.model === undefined || typeof o.model === "string")
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `undefined` when the file is absent or its contents are malformed. */
|
|
46
|
+
export function readCost(env: CostEnv, sessionId: string): CostState | undefined {
|
|
47
|
+
let text: string;
|
|
48
|
+
try {
|
|
49
|
+
text = readFileSync(costFile(env, sessionId), "utf8");
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
let parsed: unknown;
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(text);
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
return isCostState(parsed) ? parsed : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Atomic tmp+rename write. Creates `claude/` and `claude/cost/` (both owned
|
|
64
|
+
* exclusively by this plugin) as owner-only (`0700`) when it creates them —
|
|
65
|
+
* `mkdirSync`'s `mode` only ever applies to a directory the call actually
|
|
66
|
+
* creates, so an existing `~/.kankaku` is never chmod'd by this. The written
|
|
67
|
+
* file is chmod'd `0600` best-effort. Never lowers `updatedAt`: a statusline
|
|
68
|
+
* write racing an older one loses.
|
|
69
|
+
*/
|
|
70
|
+
export function writeCost(env: CostEnv, sessionId: string, cost: CostState): void {
|
|
71
|
+
const dir = costDir(env);
|
|
72
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
73
|
+
|
|
74
|
+
const existing = readCost(env, sessionId);
|
|
75
|
+
if (existing && existing.updatedAt > cost.updatedAt) return;
|
|
76
|
+
|
|
77
|
+
const file = costFile(env, sessionId);
|
|
78
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
79
|
+
writeFileSync(tmp, JSON.stringify(cost));
|
|
80
|
+
renameSync(tmp, file);
|
|
81
|
+
try {
|
|
82
|
+
chmodSync(file, 0o600);
|
|
83
|
+
} catch {
|
|
84
|
+
// best-effort
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function deleteCost(env: CostEnv, sessionId: string): void {
|
|
89
|
+
try {
|
|
90
|
+
unlinkSync(costFile(env, sessionId));
|
|
91
|
+
} catch {
|
|
92
|
+
// already gone
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface SweepStaleCostFilesOptions {
|
|
97
|
+
now: number;
|
|
98
|
+
maxAgeMs?: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Deletes cost files whose `updatedAt` (or mtime, when the file is
|
|
103
|
+
* unparsable) is older than `maxAgeMs` (default 7 days). No-op when the
|
|
104
|
+
* cost directory does not exist yet.
|
|
105
|
+
*/
|
|
106
|
+
export function sweepStaleCostFiles(env: CostEnv, opts: SweepStaleCostFilesOptions): void {
|
|
107
|
+
const maxAgeMs = opts.maxAgeMs ?? SEVEN_DAYS_MS;
|
|
108
|
+
const dir = costDir(env);
|
|
109
|
+
let entries: string[];
|
|
110
|
+
try {
|
|
111
|
+
entries = readdirSync(dir);
|
|
112
|
+
} catch {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
for (const name of entries) {
|
|
116
|
+
if (!name.endsWith(".json")) continue;
|
|
117
|
+
const file = join(dir, name);
|
|
118
|
+
const sessionId = name.slice(0, -".json".length);
|
|
119
|
+
const cost = readCost(env, sessionId);
|
|
120
|
+
let age: number;
|
|
121
|
+
if (cost) {
|
|
122
|
+
age = opts.now - cost.updatedAt;
|
|
123
|
+
} else {
|
|
124
|
+
try {
|
|
125
|
+
age = opts.now - statSync(file).mtimeMs;
|
|
126
|
+
} catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (age > maxAgeMs) {
|
|
131
|
+
try {
|
|
132
|
+
unlinkSync(file);
|
|
133
|
+
} catch {
|
|
134
|
+
// already gone
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/event-log.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { readEvents, serializeEvent, type Event } from "./events.ts";
|
|
4
|
+
|
|
5
|
+
/** Appends one event line, creating parent directories as needed. */
|
|
6
|
+
export function appendEvent(file: string, event: Event): void {
|
|
7
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
8
|
+
appendFileSync(file, serializeEvent(event) + "\n");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Empty array when the file does not exist. Tolerant of malformed/truncated lines (see `readEvents`). */
|
|
12
|
+
export function readEventLog(file: string): Event[] {
|
|
13
|
+
let text: string;
|
|
14
|
+
try {
|
|
15
|
+
text = readFileSync(file, "utf8");
|
|
16
|
+
} catch {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
return readEvents(text);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Rewrites the events file atomically (tmp+rename) so it holds only `keep`
|
|
24
|
+
* — used to drop a prompt's events once it has settled, keeping only the
|
|
25
|
+
* still-open prompt's events, if any.
|
|
26
|
+
*/
|
|
27
|
+
export function dropSettledPrompts(file: string, keep: Event[]): void {
|
|
28
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
29
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
30
|
+
const text = keep.length === 0 ? "" : keep.map(serializeEvent).join("\n") + "\n";
|
|
31
|
+
writeFileSync(tmp, text);
|
|
32
|
+
renameSync(tmp, file);
|
|
33
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The append-only event line format written to
|
|
3
|
+
* `<KANKAKU_DIR>/claude/<session_id>.events.jsonl`. One line per hook
|
|
4
|
+
* invocation, minimal fields only — see AGENTS.md / odd/tasks/hook-tracking.md
|
|
5
|
+
* "Event line".
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface UserPromptSubmitEvent {
|
|
9
|
+
ts: number;
|
|
10
|
+
event: "UserPromptSubmit";
|
|
11
|
+
prompt: string;
|
|
12
|
+
promptId?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PreToolUseEvent {
|
|
16
|
+
ts: number;
|
|
17
|
+
event: "PreToolUse";
|
|
18
|
+
toolUseId: string;
|
|
19
|
+
toolName: string;
|
|
20
|
+
toolInput: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PostToolUseEvent {
|
|
24
|
+
ts: number;
|
|
25
|
+
event: "PostToolUse";
|
|
26
|
+
toolUseId: string;
|
|
27
|
+
toolName: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PermissionRequestEvent {
|
|
31
|
+
ts: number;
|
|
32
|
+
event: "PermissionRequest";
|
|
33
|
+
toolUseId: string;
|
|
34
|
+
toolName: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SubagentStartEvent {
|
|
38
|
+
ts: number;
|
|
39
|
+
event: "SubagentStart";
|
|
40
|
+
agentId: string;
|
|
41
|
+
agentType: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SubagentStopEvent {
|
|
45
|
+
ts: number;
|
|
46
|
+
event: "SubagentStop";
|
|
47
|
+
agentId: string;
|
|
48
|
+
agentType: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface StopEvent {
|
|
52
|
+
ts: number;
|
|
53
|
+
event: "Stop";
|
|
54
|
+
stopHookActive: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SessionStartEvent {
|
|
58
|
+
ts: number;
|
|
59
|
+
event: "SessionStart";
|
|
60
|
+
source: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SessionEndEvent {
|
|
64
|
+
ts: number;
|
|
65
|
+
event: "SessionEnd";
|
|
66
|
+
reason: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type Event =
|
|
70
|
+
| UserPromptSubmitEvent
|
|
71
|
+
| PreToolUseEvent
|
|
72
|
+
| PostToolUseEvent
|
|
73
|
+
| PermissionRequestEvent
|
|
74
|
+
| SubagentStartEvent
|
|
75
|
+
| SubagentStopEvent
|
|
76
|
+
| StopEvent
|
|
77
|
+
| SessionStartEvent
|
|
78
|
+
| SessionEndEvent;
|
|
79
|
+
|
|
80
|
+
function isFiniteNumber(value: unknown): value is number {
|
|
81
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isString(value: unknown): value is string {
|
|
85
|
+
return typeof value === "string";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
89
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const validators: Record<string, (o: Record<string, unknown>) => boolean> = {
|
|
93
|
+
UserPromptSubmit: (o) =>
|
|
94
|
+
isString(o.prompt) && (o.promptId === undefined || isString(o.promptId)),
|
|
95
|
+
PreToolUse: (o) =>
|
|
96
|
+
isString(o.toolUseId) && isString(o.toolName) && isPlainObject(o.toolInput),
|
|
97
|
+
PostToolUse: (o) => isString(o.toolUseId) && isString(o.toolName),
|
|
98
|
+
PermissionRequest: (o) => isString(o.toolUseId) && isString(o.toolName),
|
|
99
|
+
SubagentStart: (o) => isString(o.agentId) && isString(o.agentType),
|
|
100
|
+
SubagentStop: (o) => isString(o.agentId) && isString(o.agentType),
|
|
101
|
+
Stop: (o) => typeof o.stopHookActive === "boolean",
|
|
102
|
+
SessionStart: (o) => isString(o.source),
|
|
103
|
+
SessionEnd: (o) => isString(o.reason),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** One JSON line, no trailing newline. */
|
|
107
|
+
export function serializeEvent(event: Event): string {
|
|
108
|
+
return JSON.stringify(event);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Parses one event line. Never throws: malformed JSON, a non-object value,
|
|
113
|
+
* an unknown `event` name, or a known event missing/mistyping a required
|
|
114
|
+
* field all return `undefined` instead.
|
|
115
|
+
*/
|
|
116
|
+
export function parseEventLine(line: string): Event | undefined {
|
|
117
|
+
let parsed: unknown;
|
|
118
|
+
try {
|
|
119
|
+
parsed = JSON.parse(line);
|
|
120
|
+
} catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
if (!isPlainObject(parsed)) return undefined;
|
|
124
|
+
if (!isFiniteNumber(parsed.ts)) return undefined;
|
|
125
|
+
if (!isString(parsed.event)) return undefined;
|
|
126
|
+
const validate = validators[parsed.event];
|
|
127
|
+
if (!validate) return undefined;
|
|
128
|
+
if (!validate(parsed)) return undefined;
|
|
129
|
+
return parsed as unknown as Event;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Parses every line of an events file. Tolerant of blank lines and of a
|
|
134
|
+
* truncated last line (a partial write left by a crash): any line that
|
|
135
|
+
* fails to parse is simply skipped, never thrown.
|
|
136
|
+
*/
|
|
137
|
+
export function readEvents(text: string): Event[] {
|
|
138
|
+
const events: Event[] = [];
|
|
139
|
+
for (const line of text.split("\n")) {
|
|
140
|
+
if (line.trim() === "") continue;
|
|
141
|
+
const event = parseEventLine(line);
|
|
142
|
+
if (event) events.push(event);
|
|
143
|
+
}
|
|
144
|
+
return events;
|
|
145
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { unlinkSync } from "node:fs";
|
|
2
|
+
import { resolvePaths, type ResolvedPaths } from "./paths.ts";
|
|
3
|
+
import { appendEvent, readEventLog, dropSettledPrompts } from "./event-log.ts";
|
|
4
|
+
import { readState, writeState, updateState, type SessionState } from "./session-state.ts";
|
|
5
|
+
import { resolveClaudePid, type PsInfo } from "./claude-pid.ts";
|
|
6
|
+
import { splitPrompts, type PromptEvents } from "./prompts.ts";
|
|
7
|
+
import { readCost, deleteCost, sweepStaleCostFiles } from "./cost-store.ts";
|
|
8
|
+
import type { Event } from "./events.ts";
|
|
9
|
+
import type { WorkLog } from "kankaku/ports";
|
|
10
|
+
|
|
11
|
+
export interface HandleHookDeps {
|
|
12
|
+
env: NodeJS.ProcessEnv;
|
|
13
|
+
/** Epoch ms; called once per event to stamp it and drive the Stop cost wait. */
|
|
14
|
+
now: () => number;
|
|
15
|
+
sleep: (ms: number) => Promise<void>;
|
|
16
|
+
/** Injected for tests; production defaults to `new JsonlWorkLog(kankakuDir)`. */
|
|
17
|
+
log?: WorkLog;
|
|
18
|
+
isAlive: (pid: number) => boolean;
|
|
19
|
+
runPs: (pid: number) => PsInfo | undefined;
|
|
20
|
+
stderr: (message: string) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const STOP_COST_WAIT_POLL_MS = 100;
|
|
24
|
+
const STOP_COST_WAIT_MAX_MS = 1500;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Dispatches one Claude Code hook invocation. Light events (everything but
|
|
28
|
+
* Stop/SessionStart/SessionEnd) only append one event line plus a tiny
|
|
29
|
+
* state update, and never import `kankaku` at runtime — the heavier replay/
|
|
30
|
+
* storage layer is loaded with dynamic `import()` only inside the three
|
|
31
|
+
* branches that actually settle or recover a record.
|
|
32
|
+
*/
|
|
33
|
+
export async function handleHook(input: unknown, deps: HandleHookDeps): Promise<void> {
|
|
34
|
+
const common = parseCommon(input);
|
|
35
|
+
if (!common) {
|
|
36
|
+
deps.stderr("kankaku: malformed hook input, ignoring");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const { sessionId, cwd, hookEventName, raw } = common;
|
|
40
|
+
const paths = resolvePaths({ env: deps.env, cwd, sessionId });
|
|
41
|
+
const ts = deps.now();
|
|
42
|
+
|
|
43
|
+
switch (hookEventName) {
|
|
44
|
+
case "UserPromptSubmit": {
|
|
45
|
+
const prompt = readString(raw.prompt) ?? "";
|
|
46
|
+
const promptId = readString(raw.prompt_id);
|
|
47
|
+
const event: Event = promptId
|
|
48
|
+
? { ts, event: "UserPromptSubmit", prompt, promptId }
|
|
49
|
+
: { ts, event: "UserPromptSubmit", prompt };
|
|
50
|
+
appendEvent(paths.eventsFile, event);
|
|
51
|
+
const costAtStart = readCost(deps.env, sessionId)?.totalUsd;
|
|
52
|
+
updateState(paths.stateFile, (state) => ({
|
|
53
|
+
pid: state?.pid ?? 0,
|
|
54
|
+
parentPid: state?.parentPid ?? 0,
|
|
55
|
+
cwd: state?.cwd || cwd,
|
|
56
|
+
startedAt: state?.startedAt ?? ts,
|
|
57
|
+
promptOpen: { id: promptId ?? `${sessionId}:${ts}`, startedAt: ts, costAtStart },
|
|
58
|
+
permissionOpen: null,
|
|
59
|
+
}));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
case "PreToolUse": {
|
|
63
|
+
const toolUseId = readString(raw.tool_use_id) ?? "";
|
|
64
|
+
const toolName = readString(raw.tool_name) ?? "";
|
|
65
|
+
const toolInput = isPlainObject(raw.tool_input) ? raw.tool_input : {};
|
|
66
|
+
appendEvent(paths.eventsFile, { ts, event: "PreToolUse", toolUseId, toolName, toolInput });
|
|
67
|
+
clearPermissionOpen(paths.stateFile);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
case "PostToolUse": {
|
|
71
|
+
const toolUseId = readString(raw.tool_use_id) ?? "";
|
|
72
|
+
const toolName = readString(raw.tool_name) ?? "";
|
|
73
|
+
appendEvent(paths.eventsFile, { ts, event: "PostToolUse", toolUseId, toolName });
|
|
74
|
+
clearPermissionOpen(paths.stateFile);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
case "PermissionRequest": {
|
|
78
|
+
const toolUseId = readString(raw.tool_use_id) ?? "";
|
|
79
|
+
const toolName = readString(raw.tool_name) ?? "";
|
|
80
|
+
appendEvent(paths.eventsFile, { ts, event: "PermissionRequest", toolUseId, toolName });
|
|
81
|
+
updateState(paths.stateFile, (state) => ({
|
|
82
|
+
pid: state?.pid ?? 0,
|
|
83
|
+
parentPid: state?.parentPid ?? 0,
|
|
84
|
+
cwd: state?.cwd || cwd,
|
|
85
|
+
startedAt: state?.startedAt ?? ts,
|
|
86
|
+
promptOpen: state?.promptOpen ?? null,
|
|
87
|
+
permissionOpen: ts,
|
|
88
|
+
}));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
case "SubagentStart": {
|
|
92
|
+
const agentId = readString(raw.agent_id) ?? "";
|
|
93
|
+
const agentType = readString(raw.agent_type) ?? "";
|
|
94
|
+
appendEvent(paths.eventsFile, { ts, event: "SubagentStart", agentId, agentType });
|
|
95
|
+
clearPermissionOpen(paths.stateFile);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
case "SubagentStop": {
|
|
99
|
+
const agentId = readString(raw.agent_id) ?? "";
|
|
100
|
+
const agentType = readString(raw.agent_type) ?? "";
|
|
101
|
+
appendEvent(paths.eventsFile, { ts, event: "SubagentStop", agentId, agentType });
|
|
102
|
+
clearPermissionOpen(paths.stateFile);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
case "Stop": {
|
|
106
|
+
const stopHookActive = raw.stop_hook_active === true;
|
|
107
|
+
appendEvent(paths.eventsFile, { ts, event: "Stop", stopHookActive });
|
|
108
|
+
clearPermissionOpen(paths.stateFile);
|
|
109
|
+
await handleStop(paths, sessionId, deps);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
case "SessionStart": {
|
|
113
|
+
const source = readString(raw.source) ?? "startup";
|
|
114
|
+
appendEvent(paths.eventsFile, { ts, event: "SessionStart", source });
|
|
115
|
+
await handleSessionStart(paths, sessionId, cwd, source, ts, deps);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
case "SessionEnd": {
|
|
119
|
+
const reason = readString(raw.reason) ?? "other";
|
|
120
|
+
appendEvent(paths.eventsFile, { ts, event: "SessionEnd", reason });
|
|
121
|
+
await handleSessionEnd(paths, sessionId, ts, deps);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
default:
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function handleStop(paths: ResolvedPaths, sessionId: string, deps: HandleHookDeps): Promise<void> {
|
|
130
|
+
const { replayPrompt } = await import("./replay.ts");
|
|
131
|
+
const { buildClaudeRecord } = await import("./record.ts");
|
|
132
|
+
const { JsonlWorkLog } = await import("kankaku/hub");
|
|
133
|
+
|
|
134
|
+
const events = readEventLog(paths.eventsFile);
|
|
135
|
+
const beforeStopTs = events.length >= 2 ? events[events.length - 2]!.ts : events[events.length - 1]?.ts ?? deps.now();
|
|
136
|
+
|
|
137
|
+
const deadline = deps.now() + STOP_COST_WAIT_MAX_MS;
|
|
138
|
+
let cost = readCost(deps.env, sessionId);
|
|
139
|
+
while ((!cost || cost.updatedAt < beforeStopTs) && deps.now() < deadline) {
|
|
140
|
+
await deps.sleep(STOP_COST_WAIT_POLL_MS);
|
|
141
|
+
cost = readCost(deps.env, sessionId);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const prompts = splitPrompts(events);
|
|
145
|
+
const last: PromptEvents | undefined = prompts[prompts.length - 1];
|
|
146
|
+
const state = readState(paths.stateFile);
|
|
147
|
+
if (!last || !state) {
|
|
148
|
+
if (!state) deps.stderr(`kankaku: no session state at Stop for ${sessionId}`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const costAtStart = state.promptOpen?.costAtStart;
|
|
153
|
+
const totalUsd = cost?.totalUsd;
|
|
154
|
+
const costDelta =
|
|
155
|
+
typeof costAtStart === "number" && typeof totalUsd === "number"
|
|
156
|
+
? Math.max(0, Math.round((totalUsd - costAtStart) * 1e6) / 1e6) // micro-dollars: no binary float noise in the record
|
|
157
|
+
: undefined;
|
|
158
|
+
|
|
159
|
+
const core = replayPrompt(last, { cost: costDelta });
|
|
160
|
+
if (core) {
|
|
161
|
+
const record = buildClaudeRecord(core, state, sessionId, cost?.model);
|
|
162
|
+
const log = deps.log ?? new JsonlWorkLog(paths.kankakuDir);
|
|
163
|
+
log.append(record);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
writeState(paths.stateFile, { ...state, promptOpen: null, permissionOpen: null });
|
|
167
|
+
const keep = events.slice(0, events.length - last.events.length);
|
|
168
|
+
dropSettledPrompts(paths.eventsFile, keep);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function handleSessionStart(
|
|
172
|
+
paths: ResolvedPaths,
|
|
173
|
+
sessionId: string,
|
|
174
|
+
cwd: string,
|
|
175
|
+
source: string,
|
|
176
|
+
ts: number,
|
|
177
|
+
deps: HandleHookDeps,
|
|
178
|
+
): Promise<void> {
|
|
179
|
+
if (source !== "compact") {
|
|
180
|
+
const { recoverStaleSessions } = await import("./inflight-recovery.ts");
|
|
181
|
+
const { JsonlWorkLog } = await import("kankaku/hub");
|
|
182
|
+
const recovered = recoverStaleSessions({
|
|
183
|
+
claudeDir: paths.claudeDir,
|
|
184
|
+
currentSessionId: sessionId,
|
|
185
|
+
isAlive: deps.isAlive,
|
|
186
|
+
now: deps.now(),
|
|
187
|
+
env: deps.env,
|
|
188
|
+
});
|
|
189
|
+
if (recovered.length > 0) {
|
|
190
|
+
const log = deps.log ?? new JsonlWorkLog(paths.kankakuDir);
|
|
191
|
+
for (const record of recovered) log.append(record);
|
|
192
|
+
}
|
|
193
|
+
sweepStaleCostFiles(deps.env, { now: deps.now() });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const existing = readState(paths.stateFile);
|
|
197
|
+
if (existing) return; // resume/fork on an existing state: keep it as-is
|
|
198
|
+
|
|
199
|
+
const pid = resolveClaudePid({ startPid: process.ppid, runPs: deps.runPs });
|
|
200
|
+
const resolvedInfo = deps.runPs(pid);
|
|
201
|
+
const parentPid = resolvedInfo?.ppid ?? process.ppid;
|
|
202
|
+
|
|
203
|
+
const state: SessionState = {
|
|
204
|
+
pid,
|
|
205
|
+
parentPid,
|
|
206
|
+
cwd,
|
|
207
|
+
startedAt: ts,
|
|
208
|
+
promptOpen: null,
|
|
209
|
+
permissionOpen: null,
|
|
210
|
+
};
|
|
211
|
+
writeState(paths.stateFile, state);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function handleSessionEnd(paths: ResolvedPaths, sessionId: string, ts: number, deps: HandleHookDeps): Promise<void> {
|
|
215
|
+
const state = readState(paths.stateFile);
|
|
216
|
+
if (state?.promptOpen) {
|
|
217
|
+
const { replayPrompt } = await import("./replay.ts");
|
|
218
|
+
const { buildClaudeRecord } = await import("./record.ts");
|
|
219
|
+
const { JsonlWorkLog } = await import("kankaku/hub");
|
|
220
|
+
const events = readEventLog(paths.eventsFile);
|
|
221
|
+
const prompts = splitPrompts(events);
|
|
222
|
+
const last = prompts[prompts.length - 1];
|
|
223
|
+
if (last) {
|
|
224
|
+
const core = replayPrompt(last, { settledAt: ts });
|
|
225
|
+
if (core) {
|
|
226
|
+
const model = readCost(deps.env, sessionId)?.model;
|
|
227
|
+
const record = buildClaudeRecord(core, state, sessionId, model);
|
|
228
|
+
const log = deps.log ?? new JsonlWorkLog(paths.kankakuDir);
|
|
229
|
+
log.append(record);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
deleteSessionFiles(paths);
|
|
234
|
+
deleteCost(deps.env, sessionId);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function deleteSessionFiles(paths: ResolvedPaths): void {
|
|
238
|
+
for (const file of [paths.stateFile, paths.eventsFile]) {
|
|
239
|
+
try {
|
|
240
|
+
unlinkSync(file);
|
|
241
|
+
} catch {
|
|
242
|
+
// already gone
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function clearPermissionOpen(stateFile: string): void {
|
|
248
|
+
const state = readState(stateFile);
|
|
249
|
+
if (!state || state.permissionOpen === null) return;
|
|
250
|
+
writeState(stateFile, { ...state, permissionOpen: null });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
interface CommonHookFields {
|
|
254
|
+
sessionId: string;
|
|
255
|
+
cwd: string;
|
|
256
|
+
hookEventName: string;
|
|
257
|
+
raw: Record<string, unknown>;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function parseCommon(input: unknown): CommonHookFields | undefined {
|
|
261
|
+
if (!isPlainObject(input)) return undefined;
|
|
262
|
+
const sessionId = readString(input.session_id);
|
|
263
|
+
const cwd = readString(input.cwd);
|
|
264
|
+
const hookEventName = readString(input.hook_event_name);
|
|
265
|
+
if (!sessionId || !cwd || !hookEventName) return undefined;
|
|
266
|
+
return { sessionId, cwd, hookEventName, raw: input };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
270
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function readString(value: unknown): string | undefined {
|
|
274
|
+
return typeof value === "string" ? value : undefined;
|
|
275
|
+
}
|
package/src/hook.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { handleHook } from "./handle-hook.ts";
|
|
2
|
+
import { isAlive, runPsProcess } from "./claude-pid.ts";
|
|
3
|
+
|
|
4
|
+
async function readStdin(): Promise<string> {
|
|
5
|
+
const chunks: Buffer[] = [];
|
|
6
|
+
for await (const chunk of process.stdin) {
|
|
7
|
+
chunks.push(chunk as Buffer);
|
|
8
|
+
}
|
|
9
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function sleep(ms: number): Promise<void> {
|
|
13
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function main(): Promise<void> {
|
|
17
|
+
const text = await readStdin();
|
|
18
|
+
let input: unknown;
|
|
19
|
+
try {
|
|
20
|
+
input = JSON.parse(text);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
process.stderr.write(`kankaku: could not parse hook input: ${String(error)}\n`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
await handleHook(input, {
|
|
26
|
+
env: process.env,
|
|
27
|
+
now: () => Date.now(),
|
|
28
|
+
sleep,
|
|
29
|
+
isAlive,
|
|
30
|
+
runPs: runPsProcess,
|
|
31
|
+
stderr: (message) => process.stderr.write(`${message}\n`),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
main()
|
|
36
|
+
.catch((error) => {
|
|
37
|
+
process.stderr.write(`kankaku: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
|
38
|
+
})
|
|
39
|
+
.finally(() => {
|
|
40
|
+
process.exit(0);
|
|
41
|
+
});
|