kankaku 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/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,156 @@
1
+ # kankaku
2
+
3
+ A [pi](https://pi.dev) extension that measures how long an agent actually
4
+ spends working on each prompt, so the time can later be accounted for
5
+ (billing, reporting).
6
+
7
+ ## What it measures
8
+
9
+ For every prompt, kankaku tracks the span from `before_agent_start` to
10
+ `agent_settled` (or to `session_shutdown` if pi exits mid-run) and splits it
11
+ into:
12
+
13
+ - **`waitingMs`**: time pi spent blocked on the user — the union of
14
+ `ui_prompt_start/end` spans and the execution spans of configured
15
+ interactive tools (default `ask_user_question`, `ask_user_choice`). Union
16
+ avoids double-counting when a tool internally triggers a UI prompt.
17
+ - **`workMs`**: `wallMs - waitingMs`, the actual work time.
18
+
19
+ Every pi process — the orchestrator and any subagent child spawned by
20
+ `subagent_run` — records its own prompt-to-idle spans, tagged with a `role`
21
+ (`orchestrator` or `subagent`) and its `pid`/`parentPid`, so records can be
22
+ joined later.
23
+
24
+ ## Install
25
+
26
+ kankaku is a pi package. Pick one source:
27
+
28
+ ```
29
+ pi install npm:kankaku # from npm
30
+ pi install git:github.com/soyunninja/kankaku # from git (add @v0.1.0 to pin)
31
+ pi install /absolute/path/to/kankaku # local checkout, no copy
32
+ ```
33
+
34
+ `pi install` writes to your global `~/.pi/agent/settings.json`, so the
35
+ extension loads in every pi process, including the subagent children that
36
+ `subagent_run` spawns. Use `-l` to install into a project's `.pi/settings.json`
37
+ instead; note that project-local resources load only after the project is
38
+ trusted, which a subagent child may not inherit.
39
+
40
+ To try it without installing: `pi -e /absolute/path/to/kankaku`.
41
+
42
+ ## Record schema
43
+
44
+ Each line in `worklog.jsonl` is one JSON object:
45
+
46
+ ```json
47
+ {
48
+ "schema": 1,
49
+ "id": "uuid",
50
+ "role": "orchestrator",
51
+ "pid": 4242,
52
+ "parentPid": 4000,
53
+ "project": "/abs/project/path",
54
+ "sessionId": "…",
55
+ "sessionFile": "…",
56
+ "mode": "tui",
57
+ "model": "anthropic/claude-opus",
58
+ "prompt": "first 200 chars of the first prompt",
59
+ "startedAt": "2026-09-10T16:00:00.000Z",
60
+ "settledAt": "2026-09-10T16:04:10.000Z",
61
+ "wallMs": 250000,
62
+ "waitingMs": 30000,
63
+ "workMs": 220000,
64
+ "runs": 2,
65
+ "turns": 9,
66
+ "tools": { "bash": 4, "read": 3, "subagent_run": 1, "ask_user_question": 1 },
67
+ "subagents": [{ "toolCallId": "…", "agent": "sdd-explore", "mode": "task", "taskId": "t1", "ms": 90000 }],
68
+ "usage": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "cost": 0 },
69
+ "status": "completed"
70
+ }
71
+ ```
72
+
73
+ `status` is one of `completed`, `aborted` (the last assistant message had
74
+ `stopReason: "aborted"`), or `interrupted` (pi shut down while still
75
+ running).
76
+
77
+ ## Task and session views
78
+
79
+ Each `WorkRecord` still measures one pi process's own prompt-to-idle span.
80
+ But a `subagent_run` in `background` mode returns immediately while its
81
+ child process keeps working, so the orchestrator's own `wallMs` can
82
+ under-report how long the task actually took. Two derived, read-only views
83
+ correct for that, built purely from `pid`/`parentPid`/`startedAt`/`settledAt`
84
+ already present on every record — no new fields are persisted to
85
+ `worklog.jsonl`.
86
+
87
+ - **Task**: one orchestrator record plus every subagent record matched to
88
+ it — same `project`, `parentPid === orchestrator.pid`, and the child's
89
+ `startedAt` falling inside the orchestrator's `[startedAt, settledAt]`
90
+ window. (If a pid is reused across runs and several orchestrator records
91
+ match, the child attaches to the latest-starting one.) A task's `wallMs`
92
+ is the **union** of the orchestrator's interval and every matched child's
93
+ interval — never their sum — so parallel background children are not
94
+ double-counted, and a child that outlives the orchestrator's own settle
95
+ time correctly extends the task's span. `waitingMs` is the orchestrator's
96
+ own waiting time, `workMs = wallMs - waitingMs`, and `usage` is the sum of
97
+ the orchestrator's and every child's token/cost totals.
98
+ - **Session**: tasks grouped by `sessionId` (tasks with no `sessionId` are
99
+ grouped under `"unknown"`). A session's `wallMs` is the union of every
100
+ interval — orchestrator and subagent alike — across all of its tasks;
101
+ `waitingMs` is the sum of each task's `waitingMs`, and `workMs = wallMs -
102
+ waitingMs`.
103
+ - **Orphan subagents**: a subagent record with no matching orchestrator
104
+ record (for example, its parent's record was lost, or it belongs to a
105
+ different project) is excluded from every task but is not silently
106
+ dropped — it stays visible so gaps in the log are noticeable rather than
107
+ hidden.
108
+
109
+ ## The `/kankaku` command
110
+
111
+ Run `/kankaku` inside pi to see today's totals (work, waiting, record count)
112
+ per role, plus a union-based tasks segment. In the interactive TUI the report
113
+ is appended to the chat transcript as a durable card that is never sent to
114
+ the LLM; without a UI (print or RPC mode) it falls back to a notification.
115
+ Arguments are whitespace-separated and order-insensitive:
116
+
117
+ - `/kankaku` — today's role totals and tasks segment, each with its estimated cost.
118
+ - `/kankaku all` — same, but across every record.
119
+ - `/kankaku tasks` — one line per task (time, union wall/work, cost,
120
+ subagent count, truncated prompt) for the **current pi session**. Add `all` for
121
+ every session. If the current session has no `sessionId`, tasks from every
122
+ session are shown instead.
123
+ - `/kankaku sessions` — one line per session (id, time range, union
124
+ wall/work, cost, task count) for today. Add `all` for every day.
125
+
126
+ Cost figures are the sum of `usage.cost` as priced by pi's model table
127
+ (per-million-token rates in `models.json`, adjustable with `modelOverrides`).
128
+ For subscription-based providers this is an estimate at API list prices, not
129
+ an invoice.
130
+
131
+ While an agent is running, pi's status bar shows a `⏱ mm:ss` indicator with
132
+ the elapsed time for the current run.
133
+
134
+ ## Environment variables
135
+
136
+ - `KANKAKU_DIR`: directory for the work log, relative to the project cwd
137
+ unless given as an absolute path. Defaults to `.kankaku`.
138
+ - `KANKAKU_INTERACTIVE_TOOLS`: comma-separated list of tool names whose
139
+ execution span counts as waiting time. Defaults to
140
+ `ask_user_question,ask_user_choice`.
141
+
142
+ ## Limitations
143
+
144
+ - A prompt shown by a tool that does not go through `ctx.ui` and is not
145
+ listed in `KANKAKU_INTERACTIVE_TOOLS` counts as work, not waiting time.
146
+ - Subagent totals are reported separately in the per-role summary and are
147
+ **not** summed into the orchestrator's `wallMs` there: task-mode subagents
148
+ run inside the parent's wall clock, and background subagents can outlive
149
+ the parent's idle moment, so naively adding them would double-count or
150
+ misrepresent billable time. Use the task/session views above (union-based,
151
+ never a sum) for a correct combined figure.
152
+
153
+ ## Roadmap
154
+
155
+ - Remote sync service: the `id` and `schema` fields are already in place for
156
+ a future `synced` cursor that uploads records to a remote store.
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "kankaku",
3
+ "version": "0.1.0",
4
+ "description": "pi extension that records agent work time per prompt, excluding waits for the user, with subagent linkage and task/session views",
5
+ "license": "MIT",
6
+ "author": "soyunninja",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/soyunninja/kankaku.git"
10
+ },
11
+ "homepage": "https://github.com/soyunninja/kankaku#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/soyunninja/kankaku/issues"
14
+ },
15
+ "type": "module",
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi",
19
+ "pi-extension",
20
+ "time-tracking",
21
+ "agents"
22
+ ],
23
+ "files": [
24
+ "src/",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "pi": {
29
+ "extensions": [
30
+ "./src/extension.ts"
31
+ ]
32
+ },
33
+ "scripts": {
34
+ "test": "node --test tests/*.test.ts",
35
+ "typecheck": "tsc --noEmit",
36
+ "check": "npm run typecheck && npm test",
37
+ "prepublishOnly": "npm run check"
38
+ },
39
+ "devDependencies": {
40
+ "@earendil-works/pi-coding-agent": "0.85.1",
41
+ "@earendil-works/pi-tui": "^0.85.1",
42
+ "@types/node": "^24.13.4",
43
+ "typescript": "^5.7.0"
44
+ }
45
+ }
@@ -0,0 +1,44 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { WorkLog } from "../ports/work-log.ts";
4
+ import type { WorkRecord } from "../domain/work-record.ts";
5
+
6
+ const LOG_FILE_NAME = "worklog.jsonl";
7
+
8
+ /**
9
+ * Append-only JSONL {@link WorkLog} backed by `<dir>/worklog.jsonl`.
10
+ * A record is written with a single `appendFileSync` call so a parent and
11
+ * its subagent children can write concurrently without interleaving lines.
12
+ */
13
+ export class JsonlWorkLog implements WorkLog {
14
+ private readonly dir: string;
15
+
16
+ constructor(dir: string) {
17
+ this.dir = dir;
18
+ }
19
+
20
+ private get filePath(): string {
21
+ return join(this.dir, LOG_FILE_NAME);
22
+ }
23
+
24
+ append(record: WorkRecord): void {
25
+ mkdirSync(this.dir, { recursive: true });
26
+ appendFileSync(this.filePath, `${JSON.stringify(record)}\n`);
27
+ }
28
+
29
+ readAll(): WorkRecord[] {
30
+ if (!existsSync(this.filePath)) return [];
31
+ const content = readFileSync(this.filePath, "utf8");
32
+ const records: WorkRecord[] = [];
33
+ for (const line of content.split("\n")) {
34
+ const trimmed = line.trim();
35
+ if (!trimmed) continue;
36
+ try {
37
+ records.push(JSON.parse(trimmed) as WorkRecord);
38
+ } catch {
39
+ // Tolerate malformed lines (e.g. a torn write); skip them.
40
+ }
41
+ }
42
+ return records;
43
+ }
44
+ }
@@ -0,0 +1,36 @@
1
+ import { isAbsolute, join } from "node:path";
2
+ import type { WorkRecord } from "../domain/work-record.ts";
3
+ import type { WorkLog } from "../ports/work-log.ts";
4
+ import { JsonlWorkLog } from "./jsonl-work-log.ts";
5
+
6
+ /**
7
+ * {@link WorkLog} that resolves a relative log directory lazily: against the
8
+ * project of the first appended record, or against `fallbackCwd()` when a
9
+ * read happens before any record was written in this process.
10
+ */
11
+ export class LazyJsonlWorkLog implements WorkLog {
12
+ private readonly dirOrRelative: string;
13
+ private readonly fallbackCwd: () => string;
14
+ private resolved: JsonlWorkLog | undefined;
15
+
16
+ constructor(dirOrRelative: string, fallbackCwd: () => string = () => process.cwd()) {
17
+ this.dirOrRelative = dirOrRelative;
18
+ this.fallbackCwd = fallbackCwd;
19
+ }
20
+
21
+ private resolveFor(cwd: string): JsonlWorkLog {
22
+ if (!this.resolved) {
23
+ const dir = isAbsolute(this.dirOrRelative) ? this.dirOrRelative : join(cwd, this.dirOrRelative);
24
+ this.resolved = new JsonlWorkLog(dir);
25
+ }
26
+ return this.resolved;
27
+ }
28
+
29
+ append(record: WorkRecord): void {
30
+ this.resolveFor(record.project).append(record);
31
+ }
32
+
33
+ readAll(): WorkRecord[] {
34
+ return this.resolveFor(this.fallbackCwd()).readAll();
35
+ }
36
+ }
@@ -0,0 +1,243 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Box, Text } from "@earendil-works/pi-tui";
3
+ import { WorkTracker } from "../domain/work-tracker.ts";
4
+ import { buildSessions, buildTasks } from "../domain/task-view.ts";
5
+ import type { WorkRecord, WorkRole } from "../domain/work-record.ts";
6
+ import type { WorkLog } from "../ports/work-log.ts";
7
+ import { formatReport, formatSessions, formatTasks, localDay, summarize } from "./report.ts";
8
+
9
+ export interface PiTrackerDeps {
10
+ tracker: WorkTracker;
11
+ log: WorkLog;
12
+ role: WorkRole;
13
+ pid: number;
14
+ parentPid: number;
15
+ /** Status line refresh interval in ms. Defaults to 1000. */
16
+ statusIntervalMs?: number;
17
+ }
18
+
19
+ const STATUS_KEY = "kankaku";
20
+ const REPORT_ENTRY_TYPE = "kankaku-report";
21
+
22
+ /** Durable report rendered inside the chat transcript; never sent to the LLM. */
23
+ export interface KankakuReportData {
24
+ title: string;
25
+ lines: string[];
26
+ }
27
+
28
+ function formatElapsed(ms: number): string {
29
+ const totalSeconds = Math.max(0, Math.round(ms / 1000));
30
+ const minutes = Math.floor(totalSeconds / 60);
31
+ const seconds = totalSeconds % 60;
32
+ return `⏱ ${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
33
+ }
34
+
35
+ function notifyError(ctx: ExtensionContext, error: unknown): void {
36
+ if (!ctx.hasUI) return;
37
+ const message = error instanceof Error ? error.message : String(error);
38
+ ctx.ui.notify(`kankaku: ${message}`, "error");
39
+ }
40
+
41
+ /** Wraps a handler so it never throws out of the pi event loop. */
42
+ function guarded<E>(fn: (event: E, ctx: ExtensionContext) => void): (event: E, ctx: ExtensionContext) => void {
43
+ return (event, ctx) => {
44
+ try {
45
+ fn(event, ctx);
46
+ } catch (error) {
47
+ notifyError(ctx, error);
48
+ }
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Wires pi lifecycle events to a {@link WorkTracker}, persisting finished
54
+ * records to a {@link WorkLog} and exposing the `/kankaku` report command.
55
+ */
56
+ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
57
+ const { tracker, log, role, pid, parentPid } = deps;
58
+ const statusIntervalMs = deps.statusIntervalMs ?? 1000;
59
+
60
+ let runStartedAt: number | undefined;
61
+ let statusTimer: NodeJS.Timeout | undefined;
62
+
63
+ function stopStatus(ctx: ExtensionContext): void {
64
+ if (statusTimer) {
65
+ clearInterval(statusTimer);
66
+ statusTimer = undefined;
67
+ }
68
+ if (ctx.hasUI) {
69
+ ctx.ui.setStatus(STATUS_KEY, undefined);
70
+ }
71
+ runStartedAt = undefined;
72
+ }
73
+
74
+ function startStatus(ctx: ExtensionContext): void {
75
+ if (!ctx.hasUI) return;
76
+ runStartedAt = Date.now();
77
+ ctx.ui.setStatus(STATUS_KEY, formatElapsed(0));
78
+ statusTimer = setInterval(() => {
79
+ if (runStartedAt === undefined) return;
80
+ ctx.ui.setStatus(STATUS_KEY, formatElapsed(Date.now() - runStartedAt));
81
+ }, statusIntervalMs);
82
+ statusTimer.unref?.();
83
+ }
84
+
85
+ function buildRecord(core: NonNullable<ReturnType<WorkTracker["onSettled"]>>, ctx: ExtensionContext): WorkRecord {
86
+ const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
87
+ return {
88
+ ...core,
89
+ role,
90
+ pid,
91
+ parentPid,
92
+ project: ctx.cwd,
93
+ sessionId: ctx.sessionManager.getSessionId(),
94
+ sessionFile: ctx.sessionManager.getSessionFile(),
95
+ mode: ctx.mode,
96
+ ...(model !== undefined ? { model } : {}),
97
+ };
98
+ }
99
+
100
+ pi.on(
101
+ "before_agent_start",
102
+ guarded((event, ctx) => {
103
+ tracker.onRunStart(event.prompt);
104
+ if (runStartedAt === undefined) startStatus(ctx);
105
+ }),
106
+ );
107
+
108
+ pi.on(
109
+ "agent_end",
110
+ guarded((event) => {
111
+ tracker.onRunEnd(event.messages);
112
+ }),
113
+ );
114
+
115
+ pi.on(
116
+ "turn_end",
117
+ guarded((event) => {
118
+ const message = event.message;
119
+ const usage = message && "usage" in message ? message.usage : undefined;
120
+ tracker.onTurnEnd(
121
+ usage
122
+ ? {
123
+ input: usage.input,
124
+ output: usage.output,
125
+ cacheRead: usage.cacheRead,
126
+ cacheWrite: usage.cacheWrite,
127
+ cost: usage.cost.total,
128
+ }
129
+ : undefined,
130
+ );
131
+ }),
132
+ );
133
+
134
+ pi.on(
135
+ "tool_execution_start",
136
+ guarded((event) => {
137
+ tracker.onToolStart(event.toolCallId, event.toolName, event.args);
138
+ }),
139
+ );
140
+
141
+ pi.on(
142
+ "tool_execution_end",
143
+ guarded((event) => {
144
+ tracker.onToolEnd(event.toolCallId, event.result);
145
+ }),
146
+ );
147
+
148
+ pi.on(
149
+ "ui_prompt_start",
150
+ guarded((event) => {
151
+ tracker.onUiPromptStart(event.kind);
152
+ }),
153
+ );
154
+
155
+ pi.on(
156
+ "ui_prompt_end",
157
+ guarded((event) => {
158
+ tracker.onUiPromptEnd(event.kind);
159
+ }),
160
+ );
161
+
162
+ pi.on(
163
+ "agent_settled",
164
+ guarded((_event, ctx) => {
165
+ const core = tracker.onSettled();
166
+ if (core) {
167
+ log.append(buildRecord(core, ctx));
168
+ }
169
+ stopStatus(ctx);
170
+ }),
171
+ );
172
+
173
+ pi.on(
174
+ "session_shutdown",
175
+ guarded((_event, ctx) => {
176
+ const core = tracker.onShutdown();
177
+ if (core) {
178
+ log.append(buildRecord(core, ctx));
179
+ }
180
+ stopStatus(ctx);
181
+ }),
182
+ );
183
+
184
+ pi.registerEntryRenderer<KankakuReportData>(REPORT_ENTRY_TYPE, (entry, _options, theme) => {
185
+ const data = entry.data ?? { title: "kankaku", lines: [] };
186
+ const box = new Box(1, 0, (text) => theme.bg("customMessageBg", text));
187
+ box.addChild(new Text(`${theme.fg("accent", "kankaku")} ${data.title}`, 0, 0));
188
+ for (const line of data.lines) {
189
+ box.addChild(new Text(line, 0, 0));
190
+ }
191
+ return box;
192
+ });
193
+
194
+ function showReport(ctx: ExtensionContext, report: KankakuReportData): void {
195
+ if (ctx.hasUI) {
196
+ pi.appendEntry<KankakuReportData>(REPORT_ENTRY_TYPE, report);
197
+ return;
198
+ }
199
+ ctx.ui.notify(`${report.title}\n${report.lines.join("\n")}`);
200
+ }
201
+
202
+ pi.registerCommand("kankaku", {
203
+ description:
204
+ "Show kankaku work-time totals for today. Args (any order): 'all' for every record, " +
205
+ "'tasks' for this session's tasks ('tasks all' for every session), 'sessions' for today's sessions.",
206
+ handler: async (args, ctx) => {
207
+ try {
208
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
209
+ const all = tokens.includes("all");
210
+ const records = log.readAll();
211
+ const today = localDay(new Date().toISOString());
212
+
213
+ if (tokens.includes("tasks")) {
214
+ const sessionId = ctx.sessionManager.getSessionId();
215
+ const scoped = all || !sessionId;
216
+ const tasks = buildTasks(records).filter((task) => scoped || task.sessionId === sessionId);
217
+ showReport(ctx, {
218
+ title: scoped ? "tasks (every session)" : "tasks (this session)",
219
+ lines: formatTasks(tasks).split("\n"),
220
+ });
221
+ return;
222
+ }
223
+
224
+ if (tokens.includes("sessions")) {
225
+ const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
226
+ showReport(ctx, {
227
+ title: all ? "sessions (all days)" : "sessions (today)",
228
+ lines: formatSessions(buildSessions(tasks)).split("\n"),
229
+ });
230
+ return;
231
+ }
232
+
233
+ const summary = summarize(records, { all });
234
+ showReport(ctx, {
235
+ title: all ? "summary (all days)" : "summary (today)",
236
+ lines: formatReport(summary).split(" | "),
237
+ });
238
+ } catch (error) {
239
+ notifyError(ctx, error);
240
+ }
241
+ },
242
+ });
243
+ }
@@ -0,0 +1,133 @@
1
+ import { buildTasks } from "../domain/task-view.ts";
2
+ import type { SessionView, TaskView } from "../domain/task-view.ts";
3
+ import type { WorkRecord, WorkRole } from "../domain/work-record.ts";
4
+
5
+ export interface RoleTotals {
6
+ workMs: number;
7
+ waitingMs: number;
8
+ wallMs: number;
9
+ count: number;
10
+ /** Estimated cost in USD, as priced by pi's model table. */
11
+ cost: number;
12
+ }
13
+
14
+ export interface TaskTotals {
15
+ count: number;
16
+ wallMs: number;
17
+ workMs: number;
18
+ /** Estimated cost in USD, orchestrator and subagents combined. */
19
+ cost: number;
20
+ }
21
+
22
+ export type Summary = Record<WorkRole, RoleTotals> & { tasks: TaskTotals };
23
+
24
+ export interface SummarizeOptions {
25
+ /** Local day in `YYYY-MM-DD` format. Defaults to today when `all` is not set. */
26
+ day?: string;
27
+ /** Include every record regardless of day. */
28
+ all?: boolean;
29
+ }
30
+
31
+ const ROLES: WorkRole[] = ["orchestrator", "subagent"];
32
+
33
+ /** Local (not UTC) calendar day of an ISO timestamp, as `YYYY-MM-DD`. */
34
+ export function localDay(iso: string): string {
35
+ const date = new Date(iso);
36
+ const year = date.getFullYear();
37
+ const month = String(date.getMonth() + 1).padStart(2, "0");
38
+ const day = String(date.getDate()).padStart(2, "0");
39
+ return `${year}-${month}-${day}`;
40
+ }
41
+
42
+ function emptyTotals(): RoleTotals {
43
+ return { workMs: 0, waitingMs: 0, wallMs: 0, count: 0, cost: 0 };
44
+ }
45
+
46
+ /**
47
+ * Aggregate work records by role, restricted to one local day unless `all`
48
+ * is set. Also computes a `tasks` segment (union-based `wallMs`/`workMs`
49
+ * over one orchestrator run and its subagents) for tasks whose `startedAt`
50
+ * falls on the same day.
51
+ */
52
+ export function summarize(records: WorkRecord[], options: SummarizeOptions): Summary {
53
+ const targetDay = options.all ? undefined : (options.day ?? localDay(new Date().toISOString()));
54
+
55
+ const summary: Summary = {
56
+ orchestrator: emptyTotals(),
57
+ subagent: emptyTotals(),
58
+ tasks: { count: 0, wallMs: 0, workMs: 0, cost: 0 },
59
+ };
60
+
61
+ for (const record of records) {
62
+ if (targetDay !== undefined && localDay(record.startedAt) !== targetDay) continue;
63
+ const totals = summary[record.role];
64
+ totals.workMs += record.workMs;
65
+ totals.waitingMs += record.waitingMs;
66
+ totals.wallMs += record.wallMs;
67
+ totals.count += 1;
68
+ totals.cost += record.usage.cost;
69
+ }
70
+
71
+ const tasks = buildTasks(records).filter((task) => targetDay === undefined || localDay(task.startedAt) === targetDay);
72
+ for (const task of tasks) {
73
+ summary.tasks.count += 1;
74
+ summary.tasks.wallMs += task.wallMs;
75
+ summary.tasks.workMs += task.workMs;
76
+ summary.tasks.cost += task.usage.cost;
77
+ }
78
+
79
+ return summary;
80
+ }
81
+
82
+ function formatMinutes(ms: number): string {
83
+ const totalSeconds = Math.round(ms / 1000);
84
+ const minutes = Math.floor(totalSeconds / 60);
85
+ const seconds = totalSeconds % 60;
86
+ return `${minutes}m${String(seconds).padStart(2, "0")}s`;
87
+ }
88
+
89
+ /** Estimated USD cost with two decimals, e.g. `$1.23`. */
90
+ function formatCost(cost: number): string {
91
+ return `$${cost.toFixed(2)}`;
92
+ }
93
+
94
+ function formatTime(iso: string): string {
95
+ const date = new Date(iso);
96
+ const hours = String(date.getHours()).padStart(2, "0");
97
+ const minutes = String(date.getMinutes()).padStart(2, "0");
98
+ return `${hours}:${minutes}`;
99
+ }
100
+
101
+ /** Render a short, human-readable summary for the `/kankaku` command. */
102
+ export function formatReport(summary: Summary): string {
103
+ const lines = ROLES.map((role) => {
104
+ const totals = summary[role];
105
+ return `${role}: work ${formatMinutes(totals.workMs)}, waiting ${formatMinutes(totals.waitingMs)}, ${totals.count} record(s), ${formatCost(totals.cost)}`;
106
+ });
107
+ lines.push(
108
+ `tasks: ${summary.tasks.count}, wall ${formatMinutes(summary.tasks.wallMs)}, work ${formatMinutes(summary.tasks.workMs)}, ${formatCost(summary.tasks.cost)}`,
109
+ );
110
+ return lines.join(" | ");
111
+ }
112
+
113
+ /** Render one line per task: time, union-based wall/work, cost, subagent count, and a truncated prompt. */
114
+ export function formatTasks(tasks: TaskView[]): string {
115
+ if (tasks.length === 0) return "no tasks";
116
+ return tasks
117
+ .map((task) => {
118
+ const prompt = task.prompt.length > 60 ? task.prompt.slice(0, 60) : task.prompt;
119
+ return `${formatTime(task.startedAt)} wall ${formatMinutes(task.wallMs)} work ${formatMinutes(task.workMs)} ${formatCost(task.usage.cost)} subagents ${task.subagents.length} ${prompt}`;
120
+ })
121
+ .join("\n");
122
+ }
123
+
124
+ /** Render one line per session: truncated id, time range, union-based wall/work, cost, and task count. */
125
+ export function formatSessions(sessions: SessionView[]): string {
126
+ if (sessions.length === 0) return "no sessions";
127
+ return sessions
128
+ .map(
129
+ (session) =>
130
+ `${session.sessionId.slice(0, 8)} ${formatTime(session.startedAt)}–${formatTime(session.endedAt)} wall ${formatMinutes(session.wallMs)} work ${formatMinutes(session.workMs)} ${formatCost(session.usage.cost)} tasks ${session.tasks.length}`,
131
+ )
132
+ .join("\n");
133
+ }
package/src/config.ts ADDED
@@ -0,0 +1,33 @@
1
+ export interface KankakuConfig {
2
+ /** Directory for the work log, relative to the project cwd unless absolute. */
3
+ dir: string;
4
+ /** Tool names whose execution span counts as waiting time. */
5
+ interactiveTools: string[];
6
+ /** Tool name used to run subagents. */
7
+ subagentTool: string;
8
+ }
9
+
10
+ const DEFAULT_DIR = ".kankaku";
11
+ const DEFAULT_INTERACTIVE_TOOLS = ["ask_user_question", "ask_user_choice"];
12
+ const SUBAGENT_TOOL = "subagent_run";
13
+
14
+ export function loadConfig(env: NodeJS.ProcessEnv = process.env): KankakuConfig {
15
+ const dir = env["KANKAKU_DIR"]?.trim() || DEFAULT_DIR;
16
+ const interactiveToolsRaw = env["KANKAKU_INTERACTIVE_TOOLS"]?.trim();
17
+ const interactiveTools = interactiveToolsRaw
18
+ ? interactiveToolsRaw
19
+ .split(",")
20
+ .map((tool) => tool.trim())
21
+ .filter((tool) => tool.length > 0)
22
+ : DEFAULT_INTERACTIVE_TOOLS;
23
+
24
+ return {
25
+ dir,
26
+ interactiveTools,
27
+ subagentTool: SUBAGENT_TOOL,
28
+ };
29
+ }
30
+
31
+ export function detectRole(env: NodeJS.ProcessEnv = process.env): "orchestrator" | "subagent" {
32
+ return env["GENTLE_PI_AGENTS_CHILD"] === "1" ? "subagent" : "orchestrator";
33
+ }
@@ -0,0 +1,53 @@
1
+ export interface Interval {
2
+ start: number;
3
+ end: number;
4
+ }
5
+
6
+ /**
7
+ * Restrict every interval to `[windowStart, windowEnd]` and drop any that
8
+ * become empty (or invalid) after clamping.
9
+ */
10
+ export function clampIntervals(intervals: Interval[], windowStart: number, windowEnd: number): Interval[] {
11
+ return intervals
12
+ .map((interval) => ({
13
+ start: Math.max(interval.start, windowStart),
14
+ end: Math.min(interval.end, windowEnd),
15
+ }))
16
+ .filter((interval) => interval.end > interval.start);
17
+ }
18
+
19
+ /**
20
+ * Total duration covered by the union of the given intervals — never the
21
+ * sum, so overlapping (e.g. parallel subagent) spans are not double-counted.
22
+ */
23
+ export function unionMs(intervals: Array<{ start: number; end: number }>): number {
24
+ const sorted = [...intervals].sort((a, b) => a.start - b.start);
25
+
26
+ let total = 0;
27
+ let currentStart: number | undefined;
28
+ let currentEnd: number | undefined;
29
+
30
+ for (const interval of sorted) {
31
+ if (interval.end <= interval.start) continue;
32
+
33
+ if (currentStart === undefined || currentEnd === undefined) {
34
+ currentStart = interval.start;
35
+ currentEnd = interval.end;
36
+ continue;
37
+ }
38
+
39
+ if (interval.start <= currentEnd) {
40
+ currentEnd = Math.max(currentEnd, interval.end);
41
+ } else {
42
+ total += currentEnd - currentStart;
43
+ currentStart = interval.start;
44
+ currentEnd = interval.end;
45
+ }
46
+ }
47
+
48
+ if (currentStart !== undefined && currentEnd !== undefined) {
49
+ total += currentEnd - currentStart;
50
+ }
51
+
52
+ return total;
53
+ }
@@ -0,0 +1,200 @@
1
+ import { unionMs } from "./intervals.ts";
2
+ import { emptyUsage } from "./work-record.ts";
3
+ import type { UsageTotals, WorkRecord, WorkStatus } from "./work-record.ts";
4
+
5
+ /**
6
+ * One orchestrator run plus every subagent it spawned, with `wallMs`
7
+ * recomputed as the union of the orchestrator's span and each child's span
8
+ * — never their sum — because background children keep running in parallel
9
+ * after the orchestrator settles.
10
+ */
11
+ export interface TaskView {
12
+ id: string;
13
+ sessionId?: string;
14
+ project: string;
15
+ prompt: string;
16
+ startedAt: string;
17
+ endedAt: string;
18
+ wallMs: number;
19
+ waitingMs: number;
20
+ workMs: number;
21
+ status: WorkStatus;
22
+ orchestrator: WorkRecord;
23
+ subagents: WorkRecord[];
24
+ usage: UsageTotals;
25
+ }
26
+
27
+ /** One or more tasks grouped by their pi session, with the same union rule. */
28
+ export interface SessionView {
29
+ sessionId: string;
30
+ project: string;
31
+ startedAt: string;
32
+ endedAt: string;
33
+ wallMs: number;
34
+ waitingMs: number;
35
+ workMs: number;
36
+ tasks: TaskView[];
37
+ usage: UsageTotals;
38
+ }
39
+
40
+ function toMs(iso: string): number {
41
+ return Date.parse(iso);
42
+ }
43
+
44
+ function sumUsage(totals: UsageTotals[]): UsageTotals {
45
+ const usage = emptyUsage();
46
+ for (const total of totals) {
47
+ usage.input += total.input;
48
+ usage.output += total.output;
49
+ usage.cacheRead += total.cacheRead;
50
+ usage.cacheWrite += total.cacheWrite;
51
+ usage.cost += total.cost;
52
+ }
53
+ return usage;
54
+ }
55
+
56
+ /**
57
+ * Match every subagent record to the orchestrator record it belongs to: same
58
+ * project, `parentPid === orchestrator.pid`, and the child's `startedAt`
59
+ * falls inside the orchestrator's `[startedAt, settledAt]` window. When
60
+ * several orchestrator records match (a reused pid), the latest-starting one
61
+ * wins. Each child is assigned at most once; unmatched children are orphans.
62
+ */
63
+ function matchChildren(records: WorkRecord[]): {
64
+ childrenByOrchestratorId: Map<string, WorkRecord[]>;
65
+ orphans: WorkRecord[];
66
+ } {
67
+ const orchestrators = records.filter((record) => record.role === "orchestrator");
68
+ const subagents = records.filter((record) => record.role === "subagent");
69
+
70
+ const childrenByOrchestratorId = new Map<string, WorkRecord[]>();
71
+ for (const orchestrator of orchestrators) {
72
+ childrenByOrchestratorId.set(orchestrator.id, []);
73
+ }
74
+
75
+ const orphans: WorkRecord[] = [];
76
+
77
+ for (const child of subagents) {
78
+ const childStart = toMs(child.startedAt);
79
+ let best: WorkRecord | undefined;
80
+ let bestStart = Number.NEGATIVE_INFINITY;
81
+
82
+ for (const orchestrator of orchestrators) {
83
+ if (orchestrator.project !== child.project) continue;
84
+ if (orchestrator.pid !== child.parentPid) continue;
85
+
86
+ const parentStart = toMs(orchestrator.startedAt);
87
+ const parentEnd = toMs(orchestrator.settledAt);
88
+ if (childStart < parentStart || childStart > parentEnd) continue;
89
+
90
+ if (parentStart > bestStart) {
91
+ bestStart = parentStart;
92
+ best = orchestrator;
93
+ }
94
+ }
95
+
96
+ if (best) {
97
+ childrenByOrchestratorId.get(best.id)!.push(child);
98
+ } else {
99
+ orphans.push(child);
100
+ }
101
+ }
102
+
103
+ return { childrenByOrchestratorId, orphans };
104
+ }
105
+
106
+ function buildTaskView(orchestrator: WorkRecord, subagents: WorkRecord[]): TaskView {
107
+ const parentStart = toMs(orchestrator.startedAt);
108
+ const parentEnd = toMs(orchestrator.settledAt);
109
+
110
+ const intervals = [
111
+ { start: parentStart, end: parentEnd },
112
+ ...subagents.map((child) => ({ start: toMs(child.startedAt), end: toMs(child.settledAt) })),
113
+ ];
114
+ const wallMs = unionMs(intervals);
115
+
116
+ const endedAtMs = Math.max(parentEnd, ...subagents.map((child) => toMs(child.settledAt)));
117
+ const waitingMs = orchestrator.waitingMs;
118
+ const workMs = wallMs - waitingMs;
119
+ const usage = sumUsage([orchestrator.usage, ...subagents.map((child) => child.usage)]);
120
+
121
+ return {
122
+ id: orchestrator.id,
123
+ ...(orchestrator.sessionId !== undefined ? { sessionId: orchestrator.sessionId } : {}),
124
+ project: orchestrator.project,
125
+ prompt: orchestrator.prompt,
126
+ startedAt: orchestrator.startedAt,
127
+ endedAt: new Date(endedAtMs).toISOString(),
128
+ wallMs,
129
+ waitingMs,
130
+ workMs,
131
+ status: orchestrator.status,
132
+ orchestrator,
133
+ subagents,
134
+ usage,
135
+ };
136
+ }
137
+
138
+ /** Build one {@link TaskView} per orchestrator record, sorted by `startedAt`. */
139
+ export function buildTasks(records: WorkRecord[]): TaskView[] {
140
+ const orchestrators = records
141
+ .filter((record) => record.role === "orchestrator")
142
+ .sort((a, b) => toMs(a.startedAt) - toMs(b.startedAt));
143
+
144
+ const { childrenByOrchestratorId } = matchChildren(records);
145
+
146
+ return orchestrators.map((orchestrator) => buildTaskView(orchestrator, childrenByOrchestratorId.get(orchestrator.id) ?? []));
147
+ }
148
+
149
+ /** Subagent records that could not be matched to any orchestrator record. */
150
+ export function orphanSubagents(records: WorkRecord[]): WorkRecord[] {
151
+ return matchChildren(records).orphans;
152
+ }
153
+
154
+ /**
155
+ * Group tasks by `sessionId` (tasks without one fall under `"unknown"`).
156
+ * `wallMs` is the union of every interval — orchestrator and subagent alike
157
+ * — across all of the session's tasks, not a sum of per-task `wallMs`.
158
+ */
159
+ export function buildSessions(tasks: TaskView[]): SessionView[] {
160
+ const groups = new Map<string, TaskView[]>();
161
+ for (const task of tasks) {
162
+ const key = task.sessionId ?? "unknown";
163
+ const list = groups.get(key);
164
+ if (list) {
165
+ list.push(task);
166
+ } else {
167
+ groups.set(key, [task]);
168
+ }
169
+ }
170
+
171
+ const sessions: SessionView[] = [];
172
+
173
+ for (const [sessionId, sessionTasks] of groups) {
174
+ const intervals = sessionTasks.flatMap((task) => [
175
+ { start: toMs(task.orchestrator.startedAt), end: toMs(task.orchestrator.settledAt) },
176
+ ...task.subagents.map((child) => ({ start: toMs(child.startedAt), end: toMs(child.settledAt) })),
177
+ ]);
178
+
179
+ const wallMs = unionMs(intervals);
180
+ const waitingMs = sessionTasks.reduce((sum, task) => sum + task.waitingMs, 0);
181
+ const workMs = wallMs - waitingMs;
182
+ const startedAtMs = Math.min(...sessionTasks.map((task) => toMs(task.startedAt)));
183
+ const endedAtMs = Math.max(...sessionTasks.map((task) => toMs(task.endedAt)));
184
+ const usage = sumUsage(sessionTasks.map((task) => task.usage));
185
+
186
+ sessions.push({
187
+ sessionId,
188
+ project: sessionTasks[0]!.project,
189
+ startedAt: new Date(startedAtMs).toISOString(),
190
+ endedAt: new Date(endedAtMs).toISOString(),
191
+ wallMs,
192
+ waitingMs,
193
+ workMs,
194
+ tasks: sessionTasks,
195
+ usage,
196
+ });
197
+ }
198
+
199
+ return sessions.sort((a, b) => toMs(a.startedAt) - toMs(b.startedAt));
200
+ }
@@ -0,0 +1,61 @@
1
+ /** Current schema version for {@link WorkRecord}. */
2
+ export const WORK_RECORD_SCHEMA = 1;
3
+
4
+ export type WorkRole = "orchestrator" | "subagent";
5
+
6
+ export type WorkStatus = "completed" | "aborted" | "interrupted";
7
+
8
+ export interface UsageTotals {
9
+ input: number;
10
+ output: number;
11
+ cacheRead: number;
12
+ cacheWrite: number;
13
+ cost: number;
14
+ }
15
+
16
+ export interface SubagentSpan {
17
+ toolCallId: string;
18
+ agent: string;
19
+ mode: string;
20
+ taskId?: string;
21
+ ms: number;
22
+ }
23
+
24
+ /**
25
+ * Fields the pure {@link WorkTracker} state machine can compute on its own,
26
+ * with no knowledge of the pi process or session it runs in.
27
+ */
28
+ export interface WorkRecordCore {
29
+ schema: number;
30
+ id: string;
31
+ prompt: string;
32
+ startedAt: string;
33
+ settledAt: string;
34
+ wallMs: number;
35
+ waitingMs: number;
36
+ workMs: number;
37
+ runs: number;
38
+ turns: number;
39
+ tools: Record<string, number>;
40
+ subagents: SubagentSpan[];
41
+ usage: UsageTotals;
42
+ status: WorkStatus;
43
+ }
44
+
45
+ /** Process/session metadata the adapter layer attaches before persisting a record. */
46
+ export interface WorkRecordMetadata {
47
+ role: WorkRole;
48
+ pid: number;
49
+ parentPid: number;
50
+ project: string;
51
+ sessionId?: string;
52
+ sessionFile?: string;
53
+ mode?: string;
54
+ model?: string;
55
+ }
56
+
57
+ export type WorkRecord = WorkRecordCore & WorkRecordMetadata;
58
+
59
+ export function emptyUsage(): UsageTotals {
60
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
61
+ }
@@ -0,0 +1,224 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { Clock } from "../ports/clock.ts";
3
+ import { clampIntervals, unionMs } from "./intervals.ts";
4
+ import { emptyUsage, WORK_RECORD_SCHEMA } from "./work-record.ts";
5
+ import type { SubagentSpan, UsageTotals, WorkRecordCore, WorkStatus } from "./work-record.ts";
6
+
7
+ export interface WorkTrackerOptions {
8
+ clock: Clock;
9
+ interactiveTools: string[];
10
+ subagentTool: string;
11
+ }
12
+
13
+ interface Interval {
14
+ start: number;
15
+ end: number | undefined;
16
+ }
17
+
18
+ interface OpenSubagentSpan {
19
+ toolCallId: string;
20
+ agent: string;
21
+ mode: string;
22
+ start: number;
23
+ }
24
+
25
+ interface RunState {
26
+ startedAt: number;
27
+ prompt: string;
28
+ runs: number;
29
+ turns: number;
30
+ tools: Record<string, number>;
31
+ usage: UsageTotals;
32
+ status: WorkStatus;
33
+ waitingSpans: Interval[];
34
+ /** Waiting spans opened by interactive tools, keyed by tool call id. */
35
+ openToolWaits: Map<string, Interval>;
36
+ subagents: SubagentSpan[];
37
+ openSubagents: Map<string, OpenSubagentSpan>;
38
+ }
39
+
40
+ interface RunEndMessage {
41
+ role: string;
42
+ stopReason?: string;
43
+ }
44
+
45
+ /**
46
+ * Pure domain state machine that turns pi lifecycle events into finished
47
+ * {@link WorkRecord} entries. Holds no I/O; timestamps come from the
48
+ * injected {@link Clock} so behaviour is deterministic under test.
49
+ */
50
+ export class WorkTracker {
51
+ private readonly clock: Clock;
52
+ private readonly interactiveTools: Set<string>;
53
+ private readonly subagentTool: string;
54
+ private state: RunState | undefined;
55
+
56
+ constructor(options: WorkTrackerOptions) {
57
+ this.clock = options.clock;
58
+ this.interactiveTools = new Set(options.interactiveTools);
59
+ this.subagentTool = options.subagentTool;
60
+ }
61
+
62
+ onRunStart(prompt: string): void {
63
+ if (!this.state) {
64
+ this.state = {
65
+ startedAt: this.clock.now(),
66
+ prompt,
67
+ runs: 1,
68
+ turns: 0,
69
+ tools: {},
70
+ usage: emptyUsage(),
71
+ status: "completed",
72
+ waitingSpans: [],
73
+ openToolWaits: new Map(),
74
+ subagents: [],
75
+ openSubagents: new Map(),
76
+ };
77
+ return;
78
+ }
79
+ this.state.runs++;
80
+ }
81
+
82
+ onTurnEnd(usage: Partial<UsageTotals> | undefined): void {
83
+ if (!this.state) return;
84
+ this.state.turns++;
85
+ if (!usage) return;
86
+ this.state.usage.input += usage.input ?? 0;
87
+ this.state.usage.output += usage.output ?? 0;
88
+ this.state.usage.cacheRead += usage.cacheRead ?? 0;
89
+ this.state.usage.cacheWrite += usage.cacheWrite ?? 0;
90
+ this.state.usage.cost += usage.cost ?? 0;
91
+ }
92
+
93
+ onToolStart(toolCallId: string, toolName: string, args: Record<string, unknown> | undefined): void {
94
+ if (!this.state) return;
95
+ this.state.tools[toolName] = (this.state.tools[toolName] ?? 0) + 1;
96
+
97
+ if (this.interactiveTools.has(toolName)) {
98
+ const span: Interval = { start: this.clock.now(), end: undefined };
99
+ this.state.waitingSpans.push(span);
100
+ this.state.openToolWaits.set(toolCallId, span);
101
+ return;
102
+ }
103
+
104
+ if (toolName === this.subagentTool) {
105
+ const agent = typeof args?.["agent"] === "string" ? (args["agent"] as string) : "unknown";
106
+ const mode = typeof args?.["mode"] === "string" ? (args["mode"] as string) : "task";
107
+ this.state.openSubagents.set(toolCallId, {
108
+ toolCallId,
109
+ agent,
110
+ mode,
111
+ start: this.clock.now(),
112
+ });
113
+ }
114
+ }
115
+
116
+ onToolEnd(toolCallId: string, result: unknown): void {
117
+ if (!this.state) return;
118
+
119
+ const openSubagent = this.state.openSubagents.get(toolCallId);
120
+ if (openSubagent) {
121
+ this.state.openSubagents.delete(toolCallId);
122
+ const taskId = extractTaskId(result);
123
+ this.state.subagents.push({
124
+ toolCallId: openSubagent.toolCallId,
125
+ agent: openSubagent.agent,
126
+ mode: openSubagent.mode,
127
+ ...(taskId !== undefined ? { taskId } : {}),
128
+ ms: this.clock.now() - openSubagent.start,
129
+ });
130
+ return;
131
+ }
132
+
133
+ const openWaitingSpan = this.state.openToolWaits.get(toolCallId);
134
+ if (openWaitingSpan) {
135
+ this.state.openToolWaits.delete(toolCallId);
136
+ openWaitingSpan.end = this.clock.now();
137
+ }
138
+ }
139
+
140
+ onUiPromptStart(_kind: string): void {
141
+ if (!this.state) return;
142
+ this.state.waitingSpans.push({ start: this.clock.now(), end: undefined });
143
+ }
144
+
145
+ onUiPromptEnd(_kind: string): void {
146
+ if (!this.state) return;
147
+ // Prompts are sequential; close the most recently opened span (LIFO).
148
+ for (let i = this.state.waitingSpans.length - 1; i >= 0; i--) {
149
+ const span = this.state.waitingSpans[i];
150
+ if (span && span.end === undefined) {
151
+ span.end = this.clock.now();
152
+ return;
153
+ }
154
+ }
155
+ }
156
+
157
+ onRunEnd(messages: RunEndMessage[]): void {
158
+ if (!this.state) return;
159
+ const lastAssistant = [...messages].reverse().find((message) => message.role === "assistant");
160
+ if (lastAssistant?.stopReason === "aborted") {
161
+ this.state.status = "aborted";
162
+ }
163
+ }
164
+
165
+ onSettled(): WorkRecordCore | undefined {
166
+ if (!this.state) return undefined;
167
+ const record = this.finalize(this.state.status);
168
+ this.state = undefined;
169
+ return record;
170
+ }
171
+
172
+ onShutdown(): WorkRecordCore | undefined {
173
+ if (!this.state) return undefined;
174
+ const record = this.finalize("interrupted");
175
+ this.state = undefined;
176
+ return record;
177
+ }
178
+
179
+ private finalize(status: WorkStatus): WorkRecordCore {
180
+ const state = this.state;
181
+ if (!state) {
182
+ throw new Error("finalize called without an open run");
183
+ }
184
+ const settledAt = this.clock.now();
185
+ const wallMs = settledAt - state.startedAt;
186
+
187
+ for (const span of state.waitingSpans) {
188
+ if (span.end === undefined) {
189
+ span.end = settledAt;
190
+ }
191
+ }
192
+
193
+ const closedSpans = state.waitingSpans.map((span) => ({ start: span.start, end: span.end as number }));
194
+ const waitingMs = unionMs(clampIntervals(closedSpans, state.startedAt, settledAt));
195
+ const workMs = wallMs - waitingMs;
196
+
197
+ return {
198
+ schema: WORK_RECORD_SCHEMA,
199
+ id: randomUUID(),
200
+ prompt: state.prompt,
201
+ startedAt: new Date(state.startedAt).toISOString(),
202
+ settledAt: new Date(settledAt).toISOString(),
203
+ wallMs,
204
+ waitingMs,
205
+ workMs,
206
+ runs: state.runs,
207
+ turns: state.turns,
208
+ tools: state.tools,
209
+ subagents: state.subagents,
210
+ usage: state.usage,
211
+ status,
212
+ };
213
+ }
214
+ }
215
+
216
+ function extractTaskId(result: unknown): string | undefined {
217
+ if (!result || typeof result !== "object") return undefined;
218
+ const details = (result as { details?: unknown }).details;
219
+ if (!details || typeof details !== "object") return undefined;
220
+ const gentleAgents = (details as { gentleAgents?: unknown }).gentleAgents;
221
+ if (!gentleAgents || typeof gentleAgents !== "object") return undefined;
222
+ const taskId = (gentleAgents as { taskId?: unknown }).taskId;
223
+ return typeof taskId === "string" ? taskId : undefined;
224
+ }
@@ -0,0 +1,26 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { detectRole, loadConfig } from "./config.ts";
3
+ import { WorkTracker } from "./domain/work-tracker.ts";
4
+ import { LazyJsonlWorkLog } from "./adapters/lazy-jsonl-work-log.ts";
5
+ import { createPiTracker } from "./adapters/pi-tracker.ts";
6
+
7
+ export default function kankaku(pi: ExtensionAPI): void {
8
+ const config = loadConfig();
9
+ const role = detectRole();
10
+
11
+ const tracker = new WorkTracker({
12
+ clock: { now: () => Date.now() },
13
+ interactiveTools: config.interactiveTools,
14
+ subagentTool: config.subagentTool,
15
+ });
16
+
17
+ const log = new LazyJsonlWorkLog(config.dir);
18
+
19
+ createPiTracker(pi, {
20
+ tracker,
21
+ log,
22
+ role,
23
+ pid: process.pid,
24
+ parentPid: process.ppid,
25
+ });
26
+ }
@@ -0,0 +1,3 @@
1
+ export interface Clock {
2
+ now(): number;
3
+ }
@@ -0,0 +1,6 @@
1
+ import type { WorkRecord } from "../domain/work-record.ts";
2
+
3
+ export interface WorkLog {
4
+ append(record: WorkRecord): void;
5
+ readAll(): WorkRecord[];
6
+ }