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,82 @@
|
|
|
1
|
+
import { unlinkSync } from "node:fs";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import type { WorkRecord } from "kankaku/domain";
|
|
4
|
+
import { listStateFiles } from "./paths.ts";
|
|
5
|
+
import { readState } from "./session-state.ts";
|
|
6
|
+
import { readEventLog } from "./event-log.ts";
|
|
7
|
+
import { splitPrompts, replayPrompt } from "./replay.ts";
|
|
8
|
+
import { buildClaudeRecord } from "./record.ts";
|
|
9
|
+
import { readCost, deleteCost, type CostEnv } from "./cost-store.ts";
|
|
10
|
+
|
|
11
|
+
export interface RecoverStaleSessionsInput {
|
|
12
|
+
claudeDir: string;
|
|
13
|
+
currentSessionId: string;
|
|
14
|
+
isAlive: (pid: number) => boolean;
|
|
15
|
+
now: number;
|
|
16
|
+
env: CostEnv;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Crash recovery: for every other session's state file whose pid is dead,
|
|
21
|
+
* replay its still-open prompt (if any) into an `interrupted` record, then
|
|
22
|
+
* delete that session's files (state, events, cost). A live session is left
|
|
23
|
+
* untouched.
|
|
24
|
+
*
|
|
25
|
+
* A non-positive `pid` counts as dead regardless of what `isAlive` reports
|
|
26
|
+
* (T7: the pre-fix `mergeCost` placeholder carried `pid: 0`, and the old
|
|
27
|
+
* `isAlive(0)` — `process.kill(0, 0)` signals the whole process GROUP —
|
|
28
|
+
* reported it alive forever). Such a placeholder also carries `cwd: ""`; an
|
|
29
|
+
* open prompt with no cwd to attribute it to is discarded, not replayed.
|
|
30
|
+
*/
|
|
31
|
+
export function recoverStaleSessions(input: RecoverStaleSessionsInput): WorkRecord[] {
|
|
32
|
+
const records: WorkRecord[] = [];
|
|
33
|
+
|
|
34
|
+
for (const stateFile of listStateFiles(input.claudeDir)) {
|
|
35
|
+
const sessionId = sessionIdFromStateFile(stateFile);
|
|
36
|
+
if (!sessionId || sessionId === input.currentSessionId) continue;
|
|
37
|
+
|
|
38
|
+
const state = readState(stateFile);
|
|
39
|
+
const eventsFile = stateFile.replace(/\.state\.json$/, ".events.jsonl");
|
|
40
|
+
|
|
41
|
+
if (!state) {
|
|
42
|
+
safeUnlink(stateFile);
|
|
43
|
+
safeUnlink(eventsFile);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const dead = state.pid <= 0 || !input.isAlive(state.pid);
|
|
48
|
+
if (!dead) continue;
|
|
49
|
+
|
|
50
|
+
if (state.promptOpen && state.cwd !== "") {
|
|
51
|
+
const events = readEventLog(eventsFile);
|
|
52
|
+
const prompts = splitPrompts(events);
|
|
53
|
+
const last = prompts[prompts.length - 1];
|
|
54
|
+
if (last && last.open) {
|
|
55
|
+
const core = replayPrompt(last, { settledAt: input.now });
|
|
56
|
+
if (core) {
|
|
57
|
+
const model = readCost(input.env, sessionId)?.model;
|
|
58
|
+
records.push(buildClaudeRecord(core, state, sessionId, model));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
safeUnlink(stateFile);
|
|
64
|
+
safeUnlink(eventsFile);
|
|
65
|
+
deleteCost(input.env, sessionId);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return records;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sessionIdFromStateFile(file: string): string | undefined {
|
|
72
|
+
const match = basename(file).match(/^(.*)\.state\.json$/);
|
|
73
|
+
return match?.[1];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function safeUnlink(file: string): void {
|
|
77
|
+
try {
|
|
78
|
+
unlinkSync(file);
|
|
79
|
+
} catch {
|
|
80
|
+
// already gone
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readdirSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface ResolvePathsInput {
|
|
5
|
+
env: NodeJS.ProcessEnv;
|
|
6
|
+
cwd: string;
|
|
7
|
+
sessionId: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ResolvedPaths {
|
|
11
|
+
kankakuDir: string;
|
|
12
|
+
claudeDir: string;
|
|
13
|
+
eventsFile: string;
|
|
14
|
+
stateFile: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Same rule as kankaku's `resolveKankakuDir` (kankaku/hub): an absolute
|
|
19
|
+
* `KANKAKU_DIR` is used as-is, a relative one is joined against the session's
|
|
20
|
+
* cwd. Inlined rather than imported so the light hooks (one per tool call)
|
|
21
|
+
* never load the hub barrel for a two-line helper.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveKankakuDir(dirOrRelative: string, cwd: string): string {
|
|
24
|
+
return isAbsolute(dirOrRelative) ? dirOrRelative : join(cwd, dirOrRelative);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** `<KANKAKU_DIR or .kankaku>/claude/<sessionId>.{events.jsonl,state.json}`. */
|
|
28
|
+
export function resolvePaths(input: ResolvePathsInput): ResolvedPaths {
|
|
29
|
+
const kankakuDir = resolveKankakuDir(input.env.KANKAKU_DIR ?? ".kankaku", input.cwd);
|
|
30
|
+
const claudeDir = join(kankakuDir, "claude");
|
|
31
|
+
return {
|
|
32
|
+
kankakuDir,
|
|
33
|
+
claudeDir,
|
|
34
|
+
eventsFile: join(claudeDir, `${input.sessionId}.events.jsonl`),
|
|
35
|
+
stateFile: join(claudeDir, `${input.sessionId}.state.json`),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Every `*.state.json` path in `claudeDir`, absolute. Empty array when the directory does not exist. */
|
|
40
|
+
export function listStateFiles(claudeDir: string): string[] {
|
|
41
|
+
let entries: string[];
|
|
42
|
+
try {
|
|
43
|
+
entries = readdirSync(claudeDir);
|
|
44
|
+
} catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
return entries
|
|
48
|
+
.filter((name) => name.endsWith(".state.json"))
|
|
49
|
+
.map((name) => join(claudeDir, name));
|
|
50
|
+
}
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Event } from "./events.ts";
|
|
2
|
+
|
|
3
|
+
export interface PromptEvents {
|
|
4
|
+
events: Event[];
|
|
5
|
+
/** `false` once the prompt's terminal Stop has been seen; `true` while it is still in progress or was interrupted. */
|
|
6
|
+
open: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Groups a session's event stream into prompts: each group starts at a
|
|
11
|
+
* `UserPromptSubmit` and collects every following event up to (and
|
|
12
|
+
* including) the next `UserPromptSubmit`. A group is closed (`open: false`)
|
|
13
|
+
* only when its last event is a `Stop` — a Stop followed by more events (a
|
|
14
|
+
* Stop-hook-continued turn) leaves the group open until a later Stop
|
|
15
|
+
* actually terminates it, or forever if it never does (interrupted).
|
|
16
|
+
* Leading events before any `UserPromptSubmit` are ignored.
|
|
17
|
+
*/
|
|
18
|
+
export function splitPrompts(events: Event[]): PromptEvents[] {
|
|
19
|
+
const prompts: PromptEvents[] = [];
|
|
20
|
+
let current: Event[] | undefined;
|
|
21
|
+
|
|
22
|
+
for (const event of events) {
|
|
23
|
+
if (event.event === "UserPromptSubmit") {
|
|
24
|
+
if (current) {
|
|
25
|
+
prompts.push({ events: current, open: isOpen(current) });
|
|
26
|
+
}
|
|
27
|
+
current = [event];
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (!current) continue; // orphan event before any UserPromptSubmit
|
|
31
|
+
current.push(event);
|
|
32
|
+
}
|
|
33
|
+
if (current) {
|
|
34
|
+
prompts.push({ events: current, open: isOpen(current) });
|
|
35
|
+
}
|
|
36
|
+
return prompts;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isOpen(events: Event[]): boolean {
|
|
40
|
+
const last = events[events.length - 1];
|
|
41
|
+
return last === undefined || last.event !== "Stop";
|
|
42
|
+
}
|
package/src/record.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { WorkRecord, WorkRecordCore } from "kankaku/domain";
|
|
2
|
+
import type { SessionState } from "./session-state.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Attaches the orchestrator metadata a replayed {@link WorkRecordCore}
|
|
6
|
+
* needs to become a persistable {@link WorkRecord}. Phase 1: no
|
|
7
|
+
* `client`/`clientId` (hub sync is phase 2). `model` is the statusline
|
|
8
|
+
* model id (from `src/cost-store.ts#readCost`, since T7 no longer a field
|
|
9
|
+
* of `SessionState`) — passed in explicitly rather than read here, so the
|
|
10
|
+
* caller decides which cost snapshot's model applies.
|
|
11
|
+
*/
|
|
12
|
+
export function buildClaudeRecord(
|
|
13
|
+
core: WorkRecordCore,
|
|
14
|
+
state: SessionState,
|
|
15
|
+
sessionId: string,
|
|
16
|
+
model: string | undefined,
|
|
17
|
+
): WorkRecord {
|
|
18
|
+
return {
|
|
19
|
+
...core,
|
|
20
|
+
role: "orchestrator",
|
|
21
|
+
pid: state.pid,
|
|
22
|
+
parentPid: state.parentPid,
|
|
23
|
+
project: state.cwd,
|
|
24
|
+
sessionId,
|
|
25
|
+
mode: "claude-code",
|
|
26
|
+
...(model ? { model: `anthropic/${model}` } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
package/src/replay.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { WorkTracker } from "kankaku/domain";
|
|
2
|
+
import type { WorkRecordCore } from "kankaku/domain";
|
|
3
|
+
import type { SubagentProfile } from "kankaku/domain";
|
|
4
|
+
import type { Clock } from "kankaku/ports";
|
|
5
|
+
import type { Event } from "./events.ts";
|
|
6
|
+
|
|
7
|
+
/** Tool names whose span counts as waiting rather than working. */
|
|
8
|
+
const INTERACTIVE_TOOLS = ["AskUserQuestion"];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The plugin's own subagent profile: Claude Code's `Agent`/`Task` tool
|
|
12
|
+
* calls open a subagent span, read from `subagent_type`. No join key is
|
|
13
|
+
* offered (`SubagentStart`/`SubagentStop` are recorded but not linked — see
|
|
14
|
+
* odd/tasks/hook-tracking.md "Replay (pure)").
|
|
15
|
+
*/
|
|
16
|
+
const CLAUDE_CODE_SUBAGENT_PROFILE: SubagentProfile = {
|
|
17
|
+
id: "claude-code",
|
|
18
|
+
toolNames: ["Agent", "Task"],
|
|
19
|
+
childEnvMarkers: [],
|
|
20
|
+
joinKeyConfidence: "none",
|
|
21
|
+
readLaunchArgs: (args) => ({
|
|
22
|
+
agent: typeof args?.subagent_type === "string" ? args.subagent_type : "unknown",
|
|
23
|
+
mode: "task",
|
|
24
|
+
}),
|
|
25
|
+
readResult: () => ({}),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export { splitPrompts, type PromptEvents } from "./prompts.ts";
|
|
29
|
+
import type { PromptEvents } from "./prompts.ts";
|
|
30
|
+
|
|
31
|
+
export interface ReplayOptions {
|
|
32
|
+
/** Epoch ms to settle an open prompt at. Defaults to the last event's `ts`. */
|
|
33
|
+
settledAt?: number;
|
|
34
|
+
/** The prompt's cost delta (from the statusline), when known. */
|
|
35
|
+
cost?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Feeds one prompt's events into a fresh {@link WorkTracker} and returns the
|
|
40
|
+
* resulting {@link WorkRecordCore}. Pure: all timestamps come from the
|
|
41
|
+
* events themselves (or `opts.settledAt`), never from the wall clock.
|
|
42
|
+
*/
|
|
43
|
+
export function replayPrompt(
|
|
44
|
+
prompt: PromptEvents,
|
|
45
|
+
opts: ReplayOptions = {},
|
|
46
|
+
): WorkRecordCore | undefined {
|
|
47
|
+
if (prompt.events.length === 0) return undefined;
|
|
48
|
+
|
|
49
|
+
let current = prompt.events[0]!.ts;
|
|
50
|
+
const clock: Clock = { now: () => current };
|
|
51
|
+
const tracker = new WorkTracker({
|
|
52
|
+
clock,
|
|
53
|
+
interactiveTools: INTERACTIVE_TOOLS,
|
|
54
|
+
subagentProfiles: [CLAUDE_CODE_SUBAGENT_PROFILE],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const terminalStopIndex = prompt.open ? -1 : prompt.events.length - 1;
|
|
58
|
+
let permissionOpen = false;
|
|
59
|
+
let pendingAgentStart = false;
|
|
60
|
+
let settled: WorkRecordCore | undefined;
|
|
61
|
+
|
|
62
|
+
for (let i = 0; i < prompt.events.length; i++) {
|
|
63
|
+
const event = prompt.events[i]!;
|
|
64
|
+
current = event.ts;
|
|
65
|
+
|
|
66
|
+
if (permissionOpen) {
|
|
67
|
+
tracker.onUiPromptEnd("permission");
|
|
68
|
+
permissionOpen = false;
|
|
69
|
+
}
|
|
70
|
+
if (pendingAgentStart) {
|
|
71
|
+
tracker.onAgentStart();
|
|
72
|
+
pendingAgentStart = false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
switch (event.event) {
|
|
76
|
+
case "UserPromptSubmit":
|
|
77
|
+
tracker.onRunStart(event.prompt);
|
|
78
|
+
tracker.onAgentStart();
|
|
79
|
+
break;
|
|
80
|
+
case "PreToolUse":
|
|
81
|
+
tracker.onToolStart(event.toolUseId, event.toolName, event.toolInput);
|
|
82
|
+
break;
|
|
83
|
+
case "PostToolUse":
|
|
84
|
+
tracker.onToolEnd(event.toolUseId, undefined);
|
|
85
|
+
break;
|
|
86
|
+
case "PermissionRequest":
|
|
87
|
+
tracker.onUiPromptStart("permission");
|
|
88
|
+
permissionOpen = true;
|
|
89
|
+
break;
|
|
90
|
+
case "SubagentStart":
|
|
91
|
+
case "SubagentStop":
|
|
92
|
+
// Recorded on disk but not joined to any span — see the profile's
|
|
93
|
+
// doc comment: no documented link between agent_id and tool_use_id.
|
|
94
|
+
break;
|
|
95
|
+
case "Stop":
|
|
96
|
+
if (i === terminalStopIndex) {
|
|
97
|
+
if (typeof opts.cost === "number" && Number.isFinite(opts.cost)) {
|
|
98
|
+
tracker.onTurnEnd({ cost: opts.cost });
|
|
99
|
+
} else {
|
|
100
|
+
tracker.onTurnEnd(undefined);
|
|
101
|
+
}
|
|
102
|
+
tracker.onRunEnd([]);
|
|
103
|
+
settled = tracker.onSettled();
|
|
104
|
+
} else {
|
|
105
|
+
// A Stop-hook-continued turn: end this run's loop now so the next
|
|
106
|
+
// event's onAgentStart() opens a genuinely new run that gets
|
|
107
|
+
// folded back into the same record at the terminal settle,
|
|
108
|
+
// bumping `runs`.
|
|
109
|
+
tracker.onRunEnd([]);
|
|
110
|
+
pendingAgentStart = true;
|
|
111
|
+
}
|
|
112
|
+
break;
|
|
113
|
+
case "SessionStart":
|
|
114
|
+
case "SessionEnd":
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (terminalStopIndex >= 0) return settled;
|
|
120
|
+
|
|
121
|
+
current = opts.settledAt ?? prompt.events[prompt.events.length - 1]!.ts;
|
|
122
|
+
return tracker.onShutdown();
|
|
123
|
+
}
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { buildTasks } from "kankaku/domain";
|
|
2
|
+
import type { TaskView, WorkRecord, WorkStatus } from "kankaku/domain";
|
|
3
|
+
|
|
4
|
+
export interface FormatReportOptions {
|
|
5
|
+
/** Epoch ms "now" the window is measured back from. */
|
|
6
|
+
now: number;
|
|
7
|
+
/** How many trailing local days to include. Defaults to 7. */
|
|
8
|
+
days?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const STATUS_WORD: Record<WorkStatus, string> = {
|
|
12
|
+
completed: "done",
|
|
13
|
+
interrupted: "interrupted",
|
|
14
|
+
aborted: "aborted",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const PROMPT_MAX_LENGTH = 60;
|
|
18
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Renders a per-day, per-task report from raw {@link WorkRecord}s: builds
|
|
22
|
+
* {@link TaskView}s via `buildTasks`, keeps the ones started within the
|
|
23
|
+
* last `opts.days` local days (default 7), groups by local calendar day,
|
|
24
|
+
* and lists one line per task plus a per-day and a grand total.
|
|
25
|
+
*/
|
|
26
|
+
export function formatReport(records: WorkRecord[], opts: FormatReportOptions): string {
|
|
27
|
+
const days = opts.days ?? 7;
|
|
28
|
+
const cutoff = opts.now - days * MS_PER_DAY;
|
|
29
|
+
|
|
30
|
+
const tasks = buildTasks(records).filter((task) => new Date(task.startedAt).getTime() >= cutoff);
|
|
31
|
+
|
|
32
|
+
if (tasks.length === 0) {
|
|
33
|
+
return `No tasks recorded in the last ${days} days.\n`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const byDay = new Map<string, TaskView[]>();
|
|
37
|
+
for (const task of tasks) {
|
|
38
|
+
const day = localDay(task.startedAt);
|
|
39
|
+
const group = byDay.get(day);
|
|
40
|
+
if (group) group.push(task);
|
|
41
|
+
else byDay.set(day, [task]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const lines: string[] = [];
|
|
45
|
+
let grand = emptyTotals();
|
|
46
|
+
|
|
47
|
+
for (const day of [...byDay.keys()].sort()) {
|
|
48
|
+
const dayTasks = byDay.get(day)!.sort((a, b) => a.startedAt.localeCompare(b.startedAt));
|
|
49
|
+
lines.push(day);
|
|
50
|
+
let dayTotals = emptyTotals();
|
|
51
|
+
for (const task of dayTasks) {
|
|
52
|
+
lines.push(` ${formatTaskLine(task)}`);
|
|
53
|
+
dayTotals = addTotals(dayTotals, totalsOf(task));
|
|
54
|
+
}
|
|
55
|
+
lines.push(` ${formatTotalsLine(dayTasks.length, dayTotals)}`);
|
|
56
|
+
grand = addTotals(grand, dayTotals);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const grandTaskCount = tasks.length;
|
|
60
|
+
lines.push("");
|
|
61
|
+
lines.push(`Grand ${formatTotalsLine(grandTaskCount, grand)}`);
|
|
62
|
+
|
|
63
|
+
return lines.join("\n") + "\n";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface Totals {
|
|
67
|
+
wallMs: number;
|
|
68
|
+
waitingMs: number;
|
|
69
|
+
workMs: number;
|
|
70
|
+
cost: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function emptyTotals(): Totals {
|
|
74
|
+
return { wallMs: 0, waitingMs: 0, workMs: 0, cost: 0 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function totalsOf(task: TaskView): Totals {
|
|
78
|
+
return { wallMs: task.wallMs, waitingMs: task.waitingMs, workMs: task.workMs, cost: task.usage.cost };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function addTotals(a: Totals, b: Totals): Totals {
|
|
82
|
+
return {
|
|
83
|
+
wallMs: a.wallMs + b.wallMs,
|
|
84
|
+
waitingMs: a.waitingMs + b.waitingMs,
|
|
85
|
+
workMs: a.workMs + b.workMs,
|
|
86
|
+
cost: a.cost + b.cost,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formatTaskLine(task: TaskView): string {
|
|
91
|
+
const time = localTime(task.startedAt);
|
|
92
|
+
const status = STATUS_WORD[task.status] ?? task.status;
|
|
93
|
+
const cost = task.usage.cost > 0 ? `$${task.usage.cost.toFixed(2)}` : "-";
|
|
94
|
+
const prompt = truncatePrompt(task.prompt);
|
|
95
|
+
return `${time} ${status} wall ${formatDuration(task.wallMs)} wait ${formatDuration(task.waitingMs)} work ${formatDuration(task.workMs)} ${cost} ${prompt}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function formatTotalsLine(taskCount: number, totals: Totals): string {
|
|
99
|
+
const label = taskCount === 1 ? "task" : "tasks";
|
|
100
|
+
return `total: ${taskCount} ${label} wall ${formatDuration(totals.wallMs)} wait ${formatDuration(totals.waitingMs)} work ${formatDuration(totals.workMs)} $${totals.cost.toFixed(2)}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** `1h 02m`, `12m 05s`, or `8s` — matches the spec's examples exactly. */
|
|
104
|
+
export function formatDuration(ms: number): string {
|
|
105
|
+
const totalSeconds = Math.max(0, Math.round(ms / 1000));
|
|
106
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
107
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
108
|
+
const seconds = totalSeconds % 60;
|
|
109
|
+
if (hours > 0) return `${hours}h ${pad2(minutes)}m`;
|
|
110
|
+
if (minutes > 0) return `${minutes}m ${pad2(seconds)}s`;
|
|
111
|
+
return `${seconds}s`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function pad2(n: number): string {
|
|
115
|
+
return String(n).padStart(2, "0");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function truncatePrompt(prompt: string): string {
|
|
119
|
+
const singleLine = prompt.replace(/\s+/g, " ").trim();
|
|
120
|
+
if (singleLine.length <= PROMPT_MAX_LENGTH) return singleLine;
|
|
121
|
+
return `${singleLine.slice(0, PROMPT_MAX_LENGTH - 1)}…`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function localDay(iso: string): string {
|
|
125
|
+
const d = new Date(iso);
|
|
126
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function localTime(iso: string): string {
|
|
130
|
+
const d = new Date(iso);
|
|
131
|
+
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
132
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface PromptOpenState {
|
|
5
|
+
id: string;
|
|
6
|
+
startedAt: number;
|
|
7
|
+
costAtStart: number | undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `<claude/>/<session_id>.state.json` — small, rewritten atomically. Cost is
|
|
12
|
+
* NOT a field here (since T7, `odd/tasks/hook-tracking.md`): it lives under
|
|
13
|
+
* `~/.kankaku/claude/cost/` (`src/cost-store.ts`), never in a project's
|
|
14
|
+
* state file — only the hooks write this file.
|
|
15
|
+
*/
|
|
16
|
+
export interface SessionState {
|
|
17
|
+
pid: number;
|
|
18
|
+
parentPid: number;
|
|
19
|
+
cwd: string;
|
|
20
|
+
startedAt: number;
|
|
21
|
+
promptOpen: PromptOpenState | null;
|
|
22
|
+
permissionOpen: number | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A legacy state file written before T7 may still carry a `cost` field; an
|
|
27
|
+
* extra field is harmless (never required, never round-tripped by
|
|
28
|
+
* `writeState`), so it is not rejected here.
|
|
29
|
+
*/
|
|
30
|
+
function isSessionState(value: unknown): value is SessionState {
|
|
31
|
+
if (typeof value !== "object" || value === null) return false;
|
|
32
|
+
const o = value as Record<string, unknown>;
|
|
33
|
+
return (
|
|
34
|
+
typeof o.pid === "number" &&
|
|
35
|
+
typeof o.parentPid === "number" &&
|
|
36
|
+
typeof o.cwd === "string" &&
|
|
37
|
+
typeof o.startedAt === "number" &&
|
|
38
|
+
"promptOpen" in o &&
|
|
39
|
+
"permissionOpen" in o
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `undefined` when the file is absent or its contents are malformed. */
|
|
44
|
+
export function readState(file: string): SessionState | undefined {
|
|
45
|
+
let text: string;
|
|
46
|
+
try {
|
|
47
|
+
text = readFileSync(file, "utf8");
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
let parsed: unknown;
|
|
52
|
+
try {
|
|
53
|
+
parsed = JSON.parse(text);
|
|
54
|
+
} catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
return isSessionState(parsed) ? parsed : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Atomic tmp+rename write, creating parent directories as needed. */
|
|
61
|
+
export function writeState(file: string, state: SessionState): void {
|
|
62
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
63
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
64
|
+
writeFileSync(tmp, JSON.stringify(state));
|
|
65
|
+
renameSync(tmp, file);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Read-modify-write: `fn` receives the current state (`undefined` if absent) and returns the next one. */
|
|
69
|
+
export function updateState(file: string, fn: (state: SessionState | undefined) => SessionState): void {
|
|
70
|
+
writeState(file, fn(readState(file)));
|
|
71
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { resolvePaths } from "./paths.ts";
|
|
2
|
+
import type { readState, PromptOpenState } from "./session-state.ts";
|
|
3
|
+
import type { writeCost } from "./cost-store.ts";
|
|
4
|
+
|
|
5
|
+
export interface StatuslineDeps {
|
|
6
|
+
env: NodeJS.ProcessEnv;
|
|
7
|
+
/** Epoch ms; used to stamp a written cost and to compute the open prompt's elapsed clock. */
|
|
8
|
+
now: () => number;
|
|
9
|
+
readState: typeof readState;
|
|
10
|
+
writeCost: typeof writeCost;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Renders the Claude Code statusline line. Never throws: any parsing or
|
|
15
|
+
* I/O failure falls back to the bare `"kankaku"` string.
|
|
16
|
+
*
|
|
17
|
+
* Side effect: when `input.cost.total_cost_usd` is a finite number, writes
|
|
18
|
+
* it (and the model id, when present) to `~/.kankaku/claude/cost/<session_id>.json`
|
|
19
|
+
* via `deps.writeCost` — NEVER under the project (T7,
|
|
20
|
+
* `odd/tasks/hook-tracking.md`: the statusline command is wired globally in
|
|
21
|
+
* `~/.claude/settings.json`, so it runs in every session on the machine).
|
|
22
|
+
* The project's own state file (`<KANKAKU_DIR>/claude/<session_id>.state.json`)
|
|
23
|
+
* is only ever READ here, for the open-prompt clock, and is never created:
|
|
24
|
+
* a missing state file renders as `idle` rather than creating anything.
|
|
25
|
+
*/
|
|
26
|
+
export function renderStatusline(input: unknown, deps: StatuslineDeps): string {
|
|
27
|
+
try {
|
|
28
|
+
if (!isPlainObject(input)) return "kankaku";
|
|
29
|
+
|
|
30
|
+
const sessionId = readStringField(input.session_id);
|
|
31
|
+
const workspace = isPlainObject(input.workspace) ? input.workspace : undefined;
|
|
32
|
+
const cwd = readStringField(workspace?.current_dir) ?? readStringField(input.cwd);
|
|
33
|
+
if (!sessionId || !cwd) return "kankaku";
|
|
34
|
+
|
|
35
|
+
const cost = isPlainObject(input.cost) ? input.cost : undefined;
|
|
36
|
+
const totalUsd = readFiniteNumberField(cost?.total_cost_usd);
|
|
37
|
+
const model = isPlainObject(input.model) ? input.model : undefined;
|
|
38
|
+
const modelId = readStringField(model?.id);
|
|
39
|
+
|
|
40
|
+
if (totalUsd !== undefined) {
|
|
41
|
+
deps.writeCost(deps.env, sessionId, {
|
|
42
|
+
totalUsd,
|
|
43
|
+
updatedAt: deps.now(),
|
|
44
|
+
...(modelId ? { model: modelId } : {}),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const paths = resolvePaths({ env: deps.env, cwd, sessionId });
|
|
49
|
+
const state = deps.readState(paths.stateFile);
|
|
50
|
+
const clock = formatClock(state?.promptOpen ?? null, deps.now());
|
|
51
|
+
|
|
52
|
+
return totalUsd !== undefined ? `kankaku ${clock} · $${totalUsd.toFixed(2)}` : `kankaku ${clock}`;
|
|
53
|
+
} catch {
|
|
54
|
+
return "kankaku";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function formatClock(promptOpen: PromptOpenState | null, now: number): string {
|
|
59
|
+
if (!promptOpen) return "idle";
|
|
60
|
+
return formatElapsed(Math.max(0, now - promptOpen.startedAt));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `m:ss` under an hour, `h:mm:ss` from an hour onward. */
|
|
64
|
+
function formatElapsed(ms: number): string {
|
|
65
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
66
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
67
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
68
|
+
const seconds = totalSeconds % 60;
|
|
69
|
+
if (hours > 0) {
|
|
70
|
+
return `${hours}:${pad2(minutes)}:${pad2(seconds)}`;
|
|
71
|
+
}
|
|
72
|
+
return `${minutes}:${pad2(seconds)}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function pad2(n: number): string {
|
|
76
|
+
return String(n).padStart(2, "0");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
80
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readStringField(value: unknown): string | undefined {
|
|
84
|
+
return typeof value === "string" ? value : undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readFiniteNumberField(value: unknown): number | undefined {
|
|
88
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
89
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { renderStatusline } from "./statusline-core.ts";
|
|
2
|
+
import { readState } from "./session-state.ts";
|
|
3
|
+
import { writeCost } from "./cost-store.ts";
|
|
4
|
+
|
|
5
|
+
async function readStdin(): Promise<string> {
|
|
6
|
+
const chunks: Buffer[] = [];
|
|
7
|
+
for await (const chunk of process.stdin) {
|
|
8
|
+
chunks.push(chunk as Buffer);
|
|
9
|
+
}
|
|
10
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function main(): Promise<void> {
|
|
14
|
+
let input: unknown;
|
|
15
|
+
try {
|
|
16
|
+
const text = await readStdin();
|
|
17
|
+
input = JSON.parse(text);
|
|
18
|
+
} catch {
|
|
19
|
+
input = undefined;
|
|
20
|
+
}
|
|
21
|
+
const line = renderStatusline(input, {
|
|
22
|
+
env: process.env,
|
|
23
|
+
now: () => Date.now(),
|
|
24
|
+
readState,
|
|
25
|
+
writeCost,
|
|
26
|
+
});
|
|
27
|
+
process.stdout.write(`${line}\n`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
main()
|
|
31
|
+
.catch(() => {
|
|
32
|
+
process.stdout.write("kankaku\n");
|
|
33
|
+
})
|
|
34
|
+
.finally(() => {
|
|
35
|
+
process.exit(0);
|
|
36
|
+
});
|