kankaku 0.4.5 → 0.4.6

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/README.md CHANGED
@@ -201,9 +201,11 @@ it.
201
201
 
202
202
  ## Crash recovery
203
203
 
204
- While a run is open, each pi process periodically writes a checkpoint of
205
- its current record to `<KANKAKU_DIR>/inflight/<pid>.json` (after every
206
- `turn_end` and `tool_execution_end`), and removes it on a normal
204
+ While a run is open, each pi process writes a checkpoint of its current
205
+ record to `<KANKAKU_DIR>/inflight/<pid>.json` — first as soon as the run
206
+ starts (`before_agent_start`), so even a crash on the very first turn still
207
+ leaves a checkpoint, and then again after every `turn_end` and
208
+ `tool_execution_end` — and removes it on a normal
207
209
  `agent_settled`/`session_shutdown`. If the process is killed outright
208
210
  (`kill -9`, power loss) before it can settle, the checkpoint file survives
209
211
  it. On the next pi start, `session_start` scans `inflight/` for checkpoints
@@ -213,6 +215,13 @@ whose owning pid is no longer alive, appends each one to `worklog.jsonl` as
213
215
  recovered record is the time of its last checkpoint, not the actual crash
214
216
  time, so `wallMs`/`workMs` are a **lower bound** on the real duration.
215
217
 
218
+ The same scan also sweeps `inflight/` for orphaned `.tmp` files: `save`
219
+ writes to a temp file before renaming it into place, and a process killed
220
+ between those two steps leaves the temp file behind. A stray `.tmp` file is
221
+ deleted once its writer pid is no longer alive (or its name cannot be
222
+ parsed); one still owned by a live writer — including this very process's
223
+ own in-progress write — is left alone.
224
+
216
225
  ## Export
217
226
 
218
227
  `/kankaku export [csv|json] [all]` writes one flat row per task (today's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "pi extension that records agent work time per prompt, excluding waits for the user, with subagent linkage and task/session views",
5
5
  "license": "MIT",
6
6
  "author": "soyunninja",
@@ -6,6 +6,9 @@ import type { InflightStore } from "../ports/inflight-store.ts";
6
6
 
7
7
  const INFLIGHT_DIR_NAME = "inflight";
8
8
  const JSON_EXT = ".json";
9
+ const TMP_EXT = ".tmp";
10
+ /** Matches `save`'s tmp filename: `<ownerPid>.json.<writerPid>.<timestamp>.tmp`. */
11
+ const TMP_NAME_PATTERN = /\.json\.(\d+)\.\d+\.tmp$/;
9
12
 
10
13
  function safeUnlink(filePath: string): void {
11
14
  try {
@@ -15,6 +18,14 @@ function safeUnlink(filePath: string): void {
15
18
  }
16
19
  }
17
20
 
21
+ /** The writer pid embedded in a `save` tmp filename, or `undefined` when it cannot be parsed. */
22
+ function parseTmpWriterPid(entry: string): number | undefined {
23
+ const match = TMP_NAME_PATTERN.exec(entry);
24
+ if (!match) return undefined;
25
+ const pid = Number(match[1]);
26
+ return Number.isFinite(pid) ? pid : undefined;
27
+ }
28
+
18
29
  /**
19
30
  * {@link InflightStore} backed by one checkpoint file per process,
20
31
  * `<dir>/inflight/<pid>.json`. `save` writes to a temp file then renames
@@ -56,6 +67,11 @@ export class FileInflightStore implements InflightStore {
56
67
  const ownFileName = `${this.pid}${JSON_EXT}`;
57
68
 
58
69
  for (const entry of readdirSync(this.inflightDir)) {
70
+ if (entry.endsWith(TMP_EXT)) {
71
+ this.sweepTmpEntry(entry, isAlive);
72
+ continue;
73
+ }
74
+
59
75
  if (!entry.endsWith(JSON_EXT) || entry === ownFileName) continue;
60
76
 
61
77
  const filePath = join(this.inflightDir, entry);
@@ -80,4 +96,20 @@ export class FileInflightStore implements InflightStore {
80
96
 
81
97
  return recovered;
82
98
  }
99
+
100
+ /**
101
+ * Delete a stray `save()` tmp file left behind by a writer that crashed
102
+ * between the write and the rename. Deleted when the writer pid is not
103
+ * alive, or when the filename cannot be parsed at all (nothing to check
104
+ * liveness against). A tmp file written by this very process is always
105
+ * left alone regardless of what `isAlive` reports, since a concurrent
106
+ * `save()` in this process may still be renaming it into place.
107
+ */
108
+ private sweepTmpEntry(entry: string, isAlive: (pid: number) => boolean): void {
109
+ const writerPid = parseTmpWriterPid(entry);
110
+ if (writerPid === process.pid) return;
111
+ if (writerPid === undefined || !isAlive(writerPid)) {
112
+ safeUnlink(join(this.inflightDir, entry));
113
+ }
114
+ }
83
115
  }
@@ -1,4 +1,4 @@
1
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { WorkLog } from "../ports/work-log.ts";
4
4
  import type { WorkRecord } from "../domain/work-record.ts";
@@ -27,6 +27,20 @@ export class JsonlWorkLog implements WorkLog {
27
27
  appendFileSync(this.filePath, `${JSON.stringify(record)}\n`);
28
28
  }
29
29
 
30
+ /**
31
+ * Cheap change signal: `mtimeMs:size` of the log file, computed with a
32
+ * single `statSync` rather than reading the file. `"0:0"` when the file
33
+ * does not exist yet (before the first `append`).
34
+ */
35
+ version(): string {
36
+ try {
37
+ const stats = statSync(this.filePath);
38
+ return `${stats.mtimeMs}:${stats.size}`;
39
+ } catch {
40
+ return "0:0";
41
+ }
42
+ }
43
+
30
44
  readAll(): WorkRecord[] {
31
45
  if (!existsSync(this.filePath)) return [];
32
46
  const content = readFileSync(this.filePath, "utf8");
@@ -0,0 +1,226 @@
1
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { Box, Text } from "@earendil-works/pi-tui";
4
+ import { isValidClient } from "../domain/client-label.ts";
5
+ import { exportRows, toCsv, toJson } from "../domain/export.ts";
6
+ import { buildSessions, buildTasks } from "../domain/task-view.ts";
7
+ import type { WorkLog } from "../ports/work-log.ts";
8
+ import { formatClients, formatReport, formatSessions, formatTasks, localDay, summarize, summarizeByClient } from "./report.ts";
9
+ import type { SessionClient } from "./session-client.ts";
10
+
11
+ const REPORT_ENTRY_TYPE = "kankaku-report";
12
+
13
+ /** Durable report rendered inside the chat transcript; never sent to the LLM. */
14
+ export interface KankakuReportData {
15
+ title: string;
16
+ lines: string[];
17
+ }
18
+
19
+ /** Notify the user of an error through the UI, when one is available. */
20
+ export function notifyError(ctx: ExtensionContext, error: unknown): void {
21
+ if (!ctx.hasUI) return;
22
+ const message = error instanceof Error ? error.message : String(error);
23
+ ctx.ui.notify(`kankaku: ${message}`, "error");
24
+ }
25
+
26
+ const COMMAND_TOKENS = ["all", "tasks", "sessions", "client", "clients", "export"];
27
+
28
+ export interface KankakuCommandDeps {
29
+ log: WorkLog;
30
+ sessionClient: SessionClient;
31
+ /** Refresh the idle status line, e.g. after `/kankaku client` changes the session client. */
32
+ refreshIdleStatus: (ctx: ExtensionContext) => void;
33
+ /**
34
+ * Write an export file (name, content) under the kankaku dir and return
35
+ * its absolute path. `/kankaku export` notifies an error when this is not
36
+ * configured.
37
+ */
38
+ writeExportFile?: (name: string, content: string) => string;
39
+ }
40
+
41
+ export interface KankakuCommand {
42
+ /** Drop the cached client-name list so the next completion re-reads the log. */
43
+ invalidateClientNames(): void;
44
+ }
45
+
46
+ /**
47
+ * Registers the `/kankaku` command (report/tasks/sessions/clients/export/
48
+ * client), its argument completions, and the durable report entry renderer.
49
+ */
50
+ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDeps): KankakuCommand {
51
+ const { log, sessionClient } = deps;
52
+
53
+ /**
54
+ * Cached, sorted, de-duplicated client names for `/kankaku client <prefix>`
55
+ * autocomplete, so pressing a key does not re-read the whole worklog.
56
+ * Invalidated whenever this process appends a record, and — when `log`
57
+ * exposes the optional `version()` signal — whenever that signal changes,
58
+ * so a change from another process is picked up too.
59
+ */
60
+ let clientNamesCache: string[] | undefined;
61
+ let clientNamesCacheVersion: string | number | undefined;
62
+
63
+ function invalidateClientNames(): void {
64
+ clientNamesCache = undefined;
65
+ }
66
+
67
+ function clientNames(): string[] {
68
+ const currentVersion = log.version?.();
69
+ const versionUnchanged = log.version === undefined || currentVersion === clientNamesCacheVersion;
70
+ if (clientNamesCache !== undefined && versionUnchanged) {
71
+ return clientNamesCache;
72
+ }
73
+ const names = Array.from(
74
+ new Set(
75
+ log
76
+ .readAll()
77
+ .map((record) => record.client)
78
+ .filter((client): client is string => typeof client === "string"),
79
+ ),
80
+ ).sort();
81
+ clientNamesCache = names;
82
+ clientNamesCacheVersion = currentVersion;
83
+ return names;
84
+ }
85
+
86
+ pi.registerEntryRenderer<KankakuReportData>(REPORT_ENTRY_TYPE, (entry, _options, theme) => {
87
+ const data = entry.data ?? { title: "kankaku", lines: [] };
88
+ const box = new Box(1, 0, (text) => theme.bg("customMessageBg", text));
89
+ box.addChild(new Text(`${theme.fg("accent", "kankaku")} ${data.title}`, 0, 0));
90
+ for (const line of data.lines) {
91
+ box.addChild(new Text(line, 0, 0));
92
+ }
93
+ return box;
94
+ });
95
+
96
+ function showReport(ctx: ExtensionContext, report: KankakuReportData): void {
97
+ if (ctx.hasUI) {
98
+ pi.appendEntry<KankakuReportData>(REPORT_ENTRY_TYPE, report);
99
+ return;
100
+ }
101
+ ctx.ui.notify(`${report.title}\n${report.lines.join("\n")}`);
102
+ }
103
+
104
+ /** Handle `/kankaku client [<name> | --clear]`; `rest` excludes the leading `client` token. */
105
+ function handleClientCommand(rest: string[], ctx: ExtensionContext): void {
106
+ if (rest.length === 1 && rest[0] === "--clear") {
107
+ sessionClient.set(pi, undefined);
108
+ deps.refreshIdleStatus(ctx);
109
+ showReport(ctx, { title: "client", lines: ["client label cleared for this session"] });
110
+ return;
111
+ }
112
+
113
+ if (rest.length === 0) {
114
+ const client = sessionClient.effectiveClient();
115
+ const source = sessionClient.effectiveSource();
116
+ const line = client !== undefined ? `client: ${client} (from ${source})` : "client: none";
117
+ showReport(ctx, { title: "client", lines: [line] });
118
+ return;
119
+ }
120
+
121
+ const name = rest.join(" ");
122
+ if (!isValidClient(name)) {
123
+ notifyError(ctx, new Error(`invalid client name: ${name}`));
124
+ return;
125
+ }
126
+ sessionClient.set(pi, name);
127
+ deps.refreshIdleStatus(ctx);
128
+ showReport(ctx, { title: "client", lines: [`client set to ${name}`] });
129
+ }
130
+
131
+ /** Handle `/kankaku export [csv|json] [all]`; `rest` excludes the leading `export` token. Default format is csv. */
132
+ function handleExportCommand(rest: string[], ctx: ExtensionContext): void {
133
+ if (!deps.writeExportFile) {
134
+ notifyError(ctx, new Error("export is not configured"));
135
+ return;
136
+ }
137
+
138
+ const all = rest.includes("all");
139
+ const format: "csv" | "json" = rest.includes("json") ? "json" : "csv";
140
+ const records = log.readAll();
141
+ const today = localDay(new Date().toISOString());
142
+ const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
143
+ const rows = exportRows(tasks);
144
+ const content = format === "json" ? toJson(rows) : toCsv(rows);
145
+ const name = `tasks-${all ? "all" : today}.${format}`;
146
+ const path = deps.writeExportFile(name, content);
147
+ showReport(ctx, { title: "export", lines: [`wrote ${rows.length} row(s) to ${path}`] });
148
+ }
149
+
150
+ pi.registerCommand("kankaku", {
151
+ description:
152
+ "Show kankaku work-time totals for today. Args (any order): 'all' for every record, " +
153
+ "'tasks' for this session's tasks ('tasks all' for every session), 'sessions' for today's sessions, " +
154
+ "'client <name>' to set the session billing client, 'client' to show the effective one and its source, " +
155
+ "'client --clear' to clear it, 'clients' for per-client totals today ('clients all' for every day), " +
156
+ "'export [csv|json] [all]' to write today's (or every) task as a file.",
157
+ getArgumentCompletions: (argumentPrefix: string): AutocompleteItem[] => {
158
+ const clientMatch = /^client\s+(\S*)$/.exec(argumentPrefix);
159
+ if (clientMatch) {
160
+ const prefix = clientMatch[1] ?? "";
161
+ return clientNames()
162
+ .filter((name) => name.startsWith(prefix))
163
+ .map((name) => ({ value: name, label: name }));
164
+ }
165
+ return COMMAND_TOKENS.filter((value) => value.startsWith(argumentPrefix)).map((value) => ({ value, label: value }));
166
+ },
167
+ handler: async (args, ctx) => {
168
+ try {
169
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
170
+
171
+ if (tokens[0] === "client") {
172
+ handleClientCommand(tokens.slice(1), ctx);
173
+ return;
174
+ }
175
+
176
+ if (tokens[0] === "export") {
177
+ handleExportCommand(tokens.slice(1), ctx);
178
+ return;
179
+ }
180
+
181
+ const all = tokens.includes("all");
182
+ const records = log.readAll();
183
+ const today = localDay(new Date().toISOString());
184
+
185
+ if (tokens.includes("clients")) {
186
+ const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
187
+ showReport(ctx, {
188
+ title: all ? "clients (all days)" : "clients (today)",
189
+ lines: formatClients(summarizeByClient(tasks)).split("\n"),
190
+ });
191
+ return;
192
+ }
193
+
194
+ if (tokens.includes("tasks")) {
195
+ const sessionId = ctx.sessionManager.getSessionId();
196
+ const scoped = all || !sessionId;
197
+ const tasks = buildTasks(records).filter((task) => scoped || task.sessionId === sessionId);
198
+ showReport(ctx, {
199
+ title: scoped ? "tasks (every session)" : "tasks (this session)",
200
+ lines: formatTasks(tasks).split("\n"),
201
+ });
202
+ return;
203
+ }
204
+
205
+ if (tokens.includes("sessions")) {
206
+ const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
207
+ showReport(ctx, {
208
+ title: all ? "sessions (all days)" : "sessions (today)",
209
+ lines: formatSessions(buildSessions(tasks)).split("\n"),
210
+ });
211
+ return;
212
+ }
213
+
214
+ const summary = summarize(records, { all });
215
+ showReport(ctx, {
216
+ title: all ? "summary (all days)" : "summary (today)",
217
+ lines: formatReport(summary).split(" | "),
218
+ });
219
+ } catch (error) {
220
+ notifyError(ctx, error);
221
+ }
222
+ },
223
+ });
224
+
225
+ return { invalidateClientNames };
226
+ }
@@ -32,4 +32,8 @@ export class LazyJsonlWorkLog implements WorkLog {
32
32
  readAll(): WorkRecord[] {
33
33
  return this.resolveFor(this.fallbackCwd()).readAll();
34
34
  }
35
+
36
+ version(): string {
37
+ return this.resolveFor(this.fallbackCwd()).version();
38
+ }
35
39
  }
@@ -1,14 +1,13 @@
1
- import type { AutocompleteItem } from "@earendil-works/pi-tui";
2
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { Box, Text } from "@earendil-works/pi-tui";
4
- import { isValidClient, resolveClient, resolveClientSource } from "../domain/client-label.ts";
5
- import { exportRows, toCsv, toJson } from "../domain/export.ts";
6
- import { WorkTracker } from "../domain/work-tracker.ts";
7
- import { buildSessions, buildTasks } from "../domain/task-view.ts";
8
2
  import type { WorkRecord, WorkRecordCore, WorkRole } from "../domain/work-record.ts";
3
+ import type { WorkTracker } from "../domain/work-tracker.ts";
9
4
  import type { InflightStore } from "../ports/inflight-store.ts";
10
5
  import type { WorkLog } from "../ports/work-log.ts";
11
- import { formatClients, formatReport, formatSessions, formatTasks, localDay, summarize, summarizeByClient } from "./report.ts";
6
+ import { createSessionClient } from "./session-client.ts";
7
+ import { createStatusBar } from "./status-bar.ts";
8
+ import { notifyError, registerKankakuCommand } from "./kankaku-command.ts";
9
+
10
+ export type { KankakuReportData } from "./kankaku-command.ts";
12
11
 
13
12
  export interface PiTrackerDeps {
14
13
  tracker: WorkTracker;
@@ -39,13 +38,6 @@ export interface PiTrackerDeps {
39
38
  writeExportFile?: (name: string, content: string) => string;
40
39
  }
41
40
 
42
- /** Persisted as a `kankaku-client` custom session entry so the session-level client survives a reload. */
43
- interface KankakuClientEntryData {
44
- client: string | undefined;
45
- }
46
-
47
- const CLIENT_ENTRY_TYPE = "kankaku-client";
48
-
49
41
  /** Default `isAlive`: probe with signal 0 — no signal is sent, only existence/permission is checked. */
50
42
  function defaultIsAlive(pid: number): boolean {
51
43
  try {
@@ -57,34 +49,6 @@ function defaultIsAlive(pid: number): boolean {
57
49
  }
58
50
  }
59
51
 
60
- // Footer statuses are sorted alphabetically by key; "zz-" keeps kankaku last.
61
- const STATUS_KEY = "zz-kankaku";
62
- const REPORT_ENTRY_TYPE = "kankaku-report";
63
-
64
- /** Durable report rendered inside the chat transcript; never sent to the LLM. */
65
- export interface KankakuReportData {
66
- title: string;
67
- lines: string[];
68
- }
69
-
70
- // U+FE0F forces emoji presentation so terminals do not fall back to monochrome text glyphs.
71
- const CLOCK_EMOJI = "\u{1F552}\uFE0F";
72
- const CLIENT_EMOJI = "\u{1F4BC}\uFE0F";
73
-
74
- function formatElapsed(ms: number, client?: string): string {
75
- const totalSeconds = Math.max(0, Math.round(ms / 1000));
76
- const minutes = Math.floor(totalSeconds / 60);
77
- const seconds = totalSeconds % 60;
78
- const elapsed = `${CLOCK_EMOJI} ${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
79
- return client ? `${elapsed} · ${client}` : elapsed;
80
- }
81
-
82
- function notifyError(ctx: ExtensionContext, error: unknown): void {
83
- if (!ctx.hasUI) return;
84
- const message = error instanceof Error ? error.message : String(error);
85
- ctx.ui.notify(`kankaku: ${message}`, "error");
86
- }
87
-
88
52
  /** Wraps a handler so it never throws out of the pi event loop. */
89
53
  function guarded<E>(fn: (event: E, ctx: ExtensionContext) => void): (event: E, ctx: ExtensionContext) => void {
90
54
  return (event, ctx) => {
@@ -102,84 +66,38 @@ function guarded<E>(fn: (event: E, ctx: ExtensionContext) => void): (event: E, c
102
66
  */
103
67
  export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
104
68
  const { tracker, log, inflight, role, pid, parentPid } = deps;
105
- const statusIntervalMs = deps.statusIntervalMs ?? 1000;
106
69
  const isAlive = deps.isAlive ?? defaultIsAlive;
107
70
 
108
- let runStartedAt: number | undefined;
109
- let statusTimer: NodeJS.Timeout | undefined;
110
- /** Session-level client override, set with `/kankaku client <name>` and restored on `session_start`. Highest precedence in `resolveClient`. */
111
- let sessionClient: string | undefined;
112
- /** Project client read once per run (first record build) so checkpoints do not hit the filesystem repeatedly. */
113
- let runProjectClient: { value: string | undefined } | undefined;
114
-
115
- function stopStatus(ctx: ExtensionContext): void {
116
- if (statusTimer) {
117
- clearInterval(statusTimer);
118
- statusTimer = undefined;
119
- }
120
- runStartedAt = undefined;
121
- showIdleStatus(ctx);
122
- }
123
-
124
- /** While idle, keep the billing client visible (`💼 <client>`), or clear the status when none resolves. */
125
- function showIdleStatus(ctx: ExtensionContext): void {
126
- if (!ctx.hasUI) return;
127
- // Reuse the project client cached for the run when one is still held, so
128
- // settling does not re-read config.json; otherwise resolve it fresh.
129
- const sources = runProjectClient ? clientSources(runProjectClient.value) : clientSources();
130
- const client = role === "orchestrator" ? resolveClient(sources) : undefined;
131
- ctx.ui.setStatus(STATUS_KEY, client ? `${CLIENT_EMOJI} ${client}` : undefined);
132
- }
133
-
134
- function startStatus(ctx: ExtensionContext): void {
135
- if (!ctx.hasUI) return;
136
- runStartedAt = Date.now();
137
- const client = role === "orchestrator" ? resolveClient(runClientSources()) : undefined;
138
- ctx.ui.setStatus(STATUS_KEY, formatElapsed(0, client));
139
- statusTimer = setInterval(() => {
140
- if (runStartedAt === undefined) return;
141
- ctx.ui.setStatus(STATUS_KEY, formatElapsed(Date.now() - runStartedAt, client));
142
- }, statusIntervalMs);
143
- statusTimer.unref?.();
144
- }
71
+ const sessionClient = createSessionClient({
72
+ role,
73
+ envClient: deps.envClient,
74
+ resolveProjectClient: deps.resolveProjectClient,
75
+ });
145
76
 
146
- /**
147
- * Scan the session's entries for the last `kankaku-client` custom entry
148
- * and return the client it recorded (`undefined` when that entry cleared
149
- * the label, or when no such entry exists yet).
150
- */
151
- function restoreSessionClient(ctx: ExtensionContext): string | undefined {
152
- const entries = ctx.sessionManager.getEntries();
153
- for (let i = entries.length - 1; i >= 0; i--) {
154
- const entry = entries[i] as { type: string; customType?: string; data?: unknown };
155
- if (entry.type === "custom" && entry.customType === CLIENT_ENTRY_TYPE) {
156
- const data = entry.data as KankakuClientEntryData | undefined;
157
- return data?.client;
158
- }
159
- }
160
- return undefined;
161
- }
77
+ const statusBar = createStatusBar({
78
+ intervalMs: deps.statusIntervalMs,
79
+ resolveRunClient: () => sessionClient.runClient(),
80
+ resolveIdleClient: () => sessionClient.idleClient(),
81
+ });
162
82
 
163
- function clientSources(project: string | undefined = deps.resolveProjectClient?.()): { session?: string; env?: string; project?: string } {
164
- return {
165
- session: sessionClient,
166
- env: deps.envClient,
167
- project,
168
- };
169
- }
83
+ const kankakuCommand = registerKankakuCommand(pi, {
84
+ log,
85
+ sessionClient,
86
+ refreshIdleStatus: (ctx) => statusBar.showIdle(ctx),
87
+ writeExportFile: deps.writeExportFile,
88
+ });
170
89
 
171
- function runClientSources(): ReturnType<typeof clientSources> {
172
- if (!runProjectClient) {
173
- runProjectClient = { value: deps.resolveProjectClient?.() };
174
- }
175
- return clientSources(runProjectClient.value);
90
+ /** `log.append` plus cache invalidation, so every append this process makes keeps the completion cache correct. */
91
+ function appendRecord(record: WorkRecord): void {
92
+ log.append(record);
93
+ kankakuCommand.invalidateClientNames();
176
94
  }
177
95
 
178
96
  function buildRecord(core: WorkRecordCore, ctx: ExtensionContext): WorkRecord {
179
97
  const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
180
98
  // Subagent children never carry their own client: they inherit the
181
99
  // orchestrator's label at task level (see task-view.ts).
182
- const client = role === "orchestrator" ? resolveClient(runClientSources()) : undefined;
100
+ const client = sessionClient.runClient();
183
101
  const sessionName = pi.getSessionName();
184
102
  return {
185
103
  ...core,
@@ -212,7 +130,11 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
212
130
  "before_agent_start",
213
131
  guarded((event, ctx) => {
214
132
  tracker.onRunStart(event.prompt);
215
- if (runStartedAt === undefined) startStatus(ctx);
133
+ statusBar.start(ctx);
134
+ // Checkpoint right away so a crash on the very first turn (before any
135
+ // turn_end/tool_execution_end) still leaves a recoverable in-flight
136
+ // record; see the "Crash recovery" README section.
137
+ checkpoint(ctx);
216
138
  }),
217
139
  );
218
140
 
@@ -279,14 +201,14 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
279
201
  const core = tracker.onSettled();
280
202
  try {
281
203
  if (core) {
282
- log.append(buildRecord(core, ctx));
204
+ appendRecord(buildRecord(core, ctx));
283
205
  }
284
206
  } finally {
285
- // Always clean up, even when log.append above threw: an unpersisted
207
+ // Always clean up, even when appendRecord above threw: an unpersisted
286
208
  // checkpoint must not linger, and the status timer must not leak.
287
209
  inflight.clear();
288
- stopStatus(ctx);
289
- runProjectClient = undefined;
210
+ statusBar.stop(ctx);
211
+ sessionClient.endRun();
290
212
  }
291
213
  }),
292
214
  );
@@ -297,12 +219,12 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
297
219
  const core = tracker.onShutdown();
298
220
  try {
299
221
  if (core) {
300
- log.append(buildRecord(core, ctx));
222
+ appendRecord(buildRecord(core, ctx));
301
223
  }
302
224
  } finally {
303
225
  inflight.clear();
304
- stopStatus(ctx);
305
- runProjectClient = undefined;
226
+ statusBar.stop(ctx);
227
+ sessionClient.endRun();
306
228
  }
307
229
  }),
308
230
  );
@@ -310,166 +232,16 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
310
232
  pi.on(
311
233
  "session_start",
312
234
  guarded((_event, ctx) => {
313
- sessionClient = restoreSessionClient(ctx);
314
- showIdleStatus(ctx);
235
+ sessionClient.restore(ctx);
236
+ statusBar.showIdle(ctx);
315
237
 
316
238
  const recovered = inflight.recoverStale(isAlive);
317
239
  for (const record of recovered) {
318
- log.append(record);
240
+ appendRecord(record);
319
241
  }
320
242
  if (recovered.length > 0 && ctx.hasUI) {
321
243
  ctx.ui.notify(`kankaku: recovered ${recovered.length} interrupted record(s)`, "warning");
322
244
  }
323
245
  }),
324
246
  );
325
-
326
- pi.registerEntryRenderer<KankakuReportData>(REPORT_ENTRY_TYPE, (entry, _options, theme) => {
327
- const data = entry.data ?? { title: "kankaku", lines: [] };
328
- const box = new Box(1, 0, (text) => theme.bg("customMessageBg", text));
329
- box.addChild(new Text(`${theme.fg("accent", "kankaku")} ${data.title}`, 0, 0));
330
- for (const line of data.lines) {
331
- box.addChild(new Text(line, 0, 0));
332
- }
333
- return box;
334
- });
335
-
336
- function showReport(ctx: ExtensionContext, report: KankakuReportData): void {
337
- if (ctx.hasUI) {
338
- pi.appendEntry<KankakuReportData>(REPORT_ENTRY_TYPE, report);
339
- return;
340
- }
341
- ctx.ui.notify(`${report.title}\n${report.lines.join("\n")}`);
342
- }
343
-
344
- /** Handle `/kankaku client [<name> | --clear]`; `rest` excludes the leading `client` token. */
345
- function handleClientCommand(rest: string[], ctx: ExtensionContext): void {
346
- if (rest.length === 1 && rest[0] === "--clear") {
347
- sessionClient = undefined;
348
- pi.appendEntry<KankakuClientEntryData>(CLIENT_ENTRY_TYPE, { client: undefined });
349
- showIdleStatus(ctx);
350
- showReport(ctx, { title: "client", lines: ["client label cleared for this session"] });
351
- return;
352
- }
353
-
354
- if (rest.length === 0) {
355
- const sources = clientSources();
356
- const client = resolveClient(sources);
357
- const source = resolveClientSource(sources);
358
- const line = client !== undefined ? `client: ${client} (from ${source})` : "client: none";
359
- showReport(ctx, { title: "client", lines: [line] });
360
- return;
361
- }
362
-
363
- const name = rest.join(" ");
364
- if (!isValidClient(name)) {
365
- notifyError(ctx, new Error(`invalid client name: ${name}`));
366
- return;
367
- }
368
- sessionClient = name;
369
- pi.appendEntry<KankakuClientEntryData>(CLIENT_ENTRY_TYPE, { client: name });
370
- showIdleStatus(ctx);
371
- showReport(ctx, { title: "client", lines: [`client set to ${name}`] });
372
- }
373
-
374
- /** Handle `/kankaku export [csv|json] [all]`; `rest` excludes the leading `export` token. Default format is csv. */
375
- function handleExportCommand(rest: string[], ctx: ExtensionContext): void {
376
- if (!deps.writeExportFile) {
377
- notifyError(ctx, new Error("export is not configured"));
378
- return;
379
- }
380
-
381
- const all = rest.includes("all");
382
- const format: "csv" | "json" = rest.includes("json") ? "json" : "csv";
383
- const records = log.readAll();
384
- const today = localDay(new Date().toISOString());
385
- const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
386
- const rows = exportRows(tasks);
387
- const content = format === "json" ? toJson(rows) : toCsv(rows);
388
- const name = `tasks-${all ? "all" : today}.${format}`;
389
- const path = deps.writeExportFile(name, content);
390
- showReport(ctx, { title: "export", lines: [`wrote ${rows.length} row(s) to ${path}`] });
391
- }
392
-
393
- const COMMAND_TOKENS = ["all", "tasks", "sessions", "client", "clients", "export"];
394
-
395
- pi.registerCommand("kankaku", {
396
- description:
397
- "Show kankaku work-time totals for today. Args (any order): 'all' for every record, " +
398
- "'tasks' for this session's tasks ('tasks all' for every session), 'sessions' for today's sessions, " +
399
- "'client <name>' to set the session billing client, 'client' to show the effective one and its source, " +
400
- "'client --clear' to clear it, 'clients' for per-client totals today ('clients all' for every day), " +
401
- "'export [csv|json] [all]' to write today's (or every) task as a file.",
402
- getArgumentCompletions: (argumentPrefix: string): AutocompleteItem[] => {
403
- const clientMatch = /^client\s+(\S*)$/.exec(argumentPrefix);
404
- if (clientMatch) {
405
- const prefix = clientMatch[1] ?? "";
406
- const names = Array.from(
407
- new Set(
408
- log
409
- .readAll()
410
- .map((record) => record.client)
411
- .filter((client): client is string => typeof client === "string"),
412
- ),
413
- ).sort();
414
- return names.filter((name) => name.startsWith(prefix)).map((name) => ({ value: name, label: name }));
415
- }
416
- return COMMAND_TOKENS.filter((value) => value.startsWith(argumentPrefix)).map((value) => ({ value, label: value }));
417
- },
418
- handler: async (args, ctx) => {
419
- try {
420
- const tokens = args.trim().split(/\s+/).filter(Boolean);
421
-
422
- if (tokens[0] === "client") {
423
- handleClientCommand(tokens.slice(1), ctx);
424
- return;
425
- }
426
-
427
- if (tokens[0] === "export") {
428
- handleExportCommand(tokens.slice(1), ctx);
429
- return;
430
- }
431
-
432
- const all = tokens.includes("all");
433
- const records = log.readAll();
434
- const today = localDay(new Date().toISOString());
435
-
436
- if (tokens.includes("clients")) {
437
- const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
438
- showReport(ctx, {
439
- title: all ? "clients (all days)" : "clients (today)",
440
- lines: formatClients(summarizeByClient(tasks)).split("\n"),
441
- });
442
- return;
443
- }
444
-
445
- if (tokens.includes("tasks")) {
446
- const sessionId = ctx.sessionManager.getSessionId();
447
- const scoped = all || !sessionId;
448
- const tasks = buildTasks(records).filter((task) => scoped || task.sessionId === sessionId);
449
- showReport(ctx, {
450
- title: scoped ? "tasks (every session)" : "tasks (this session)",
451
- lines: formatTasks(tasks).split("\n"),
452
- });
453
- return;
454
- }
455
-
456
- if (tokens.includes("sessions")) {
457
- const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
458
- showReport(ctx, {
459
- title: all ? "sessions (all days)" : "sessions (today)",
460
- lines: formatSessions(buildSessions(tasks)).split("\n"),
461
- });
462
- return;
463
- }
464
-
465
- const summary = summarize(records, { all });
466
- showReport(ctx, {
467
- title: all ? "summary (all days)" : "summary (today)",
468
- lines: formatReport(summary).split(" | "),
469
- });
470
- } catch (error) {
471
- notifyError(ctx, error);
472
- }
473
- },
474
- });
475
247
  }
@@ -131,12 +131,20 @@ export function formatReport(summary: Summary): string {
131
131
  return lines.join(" | ");
132
132
  }
133
133
 
134
- /** Render one line per task: time, client (when present), union-based wall/work, cost, non-zero segment tags, subagent count, and a truncated prompt. */
134
+ /** Maximum visible width of a task prompt before it is truncated with an ellipsis marker. */
135
+ const PROMPT_DISPLAY_LIMIT = 60;
136
+
137
+ /** Truncate `text` to `limit` visible characters, appending `…` (counted within the limit) when it was cut. */
138
+ function truncateWithEllipsis(text: string, limit: number): string {
139
+ return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
140
+ }
141
+
142
+ /** Render one line per task: time, client (when present), union-based wall/work, cost, non-zero segment tags, subagent count, and a truncated prompt (`…` marks a cut). */
135
143
  export function formatTasks(tasks: TaskView[]): string {
136
144
  if (tasks.length === 0) return "no tasks";
137
145
  return tasks
138
146
  .map((task) => {
139
- const prompt = task.prompt.length > 60 ? task.prompt.slice(0, 60) : task.prompt;
147
+ const prompt = truncateWithEllipsis(task.prompt, PROMPT_DISPLAY_LIMIT);
140
148
  const segmentTags = formatSegmentTags(task.segments);
141
149
  const segmentPart = segmentTags !== undefined ? ` ${segmentTags}` : "";
142
150
  const clientPart = task.client !== undefined ? ` client:${task.client}` : "";
@@ -0,0 +1,116 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { resolveClient, resolveClientSource } from "../domain/client-label.ts";
3
+ import type { ClientSourceName, ClientSources } from "../domain/client-label.ts";
4
+ import type { WorkRole } from "../domain/work-record.ts";
5
+
6
+ /** Persisted as a `kankaku-client` custom session entry so the session-level client survives a reload. */
7
+ export interface KankakuClientEntryData {
8
+ client: string | undefined;
9
+ }
10
+
11
+ export const CLIENT_ENTRY_TYPE = "kankaku-client";
12
+
13
+ export interface SessionClientDeps {
14
+ role: WorkRole;
15
+ /** Default billing client for this project, from `KANKAKU_CLIENT` (config.ts). See `domain/client-label.ts`. */
16
+ envClient?: string;
17
+ /**
18
+ * Lazily reads the project's default billing client from
19
+ * `<kankaku dir>/config.json`. Injected from `extension.ts` so this
20
+ * adapter stays free of filesystem code.
21
+ */
22
+ resolveProjectClient?: () => string | undefined;
23
+ }
24
+
25
+ export interface SessionClient {
26
+ /** Restore the session-level client from the last `kankaku-client` custom entry; call on `session_start`. */
27
+ restore(ctx: ExtensionContext): void;
28
+ /** Set (or clear with `undefined`) the session client and persist it as a durable session entry. */
29
+ set(pi: ExtensionAPI, client: string | undefined): void;
30
+ /** Current client sources (session > env > project), reading the project client fresh unless `project` is given. */
31
+ sources(project?: string | undefined): ClientSources;
32
+ /** The effective client from {@link sources}, regardless of role. */
33
+ effectiveClient(): string | undefined;
34
+ /** Which source produced {@link effectiveClient}. */
35
+ effectiveSource(): ClientSourceName | undefined;
36
+ /** Role-gated client for the in-progress run (`undefined` for a subagent); caches the project client for the run. */
37
+ runClient(): string | undefined;
38
+ /** Role-gated client to show while idle, reusing the run's cached project client when still held. */
39
+ idleClient(): string | undefined;
40
+ /** Drop the per-run cached project client; call when a run settles or the session shuts down. */
41
+ endRun(): void;
42
+ }
43
+
44
+ /**
45
+ * Owns the session-level billing client override (`/kankaku client <name>`),
46
+ * its restore/persist round-trip through session entries, and client-source
47
+ * resolution for both the in-progress run and the idle status line. See
48
+ * README "Billing labels" and `domain/client-label.ts#resolveClient`.
49
+ */
50
+ export function createSessionClient(deps: SessionClientDeps): SessionClient {
51
+ /** Session-level client override, set with `/kankaku client <name>` and restored on `session_start`. Highest precedence. */
52
+ let sessionClient: string | undefined;
53
+ /** Project client read once per run (first record build) so checkpoints do not hit the filesystem repeatedly. */
54
+ let runProjectClient: { value: string | undefined } | undefined;
55
+
56
+ function sources(project: string | undefined = deps.resolveProjectClient?.()): ClientSources {
57
+ return {
58
+ session: sessionClient,
59
+ env: deps.envClient,
60
+ project,
61
+ };
62
+ }
63
+
64
+ function runSources(): ClientSources {
65
+ if (!runProjectClient) {
66
+ runProjectClient = { value: deps.resolveProjectClient?.() };
67
+ }
68
+ return sources(runProjectClient.value);
69
+ }
70
+
71
+ /**
72
+ * Scan the session's entries for the last `kankaku-client` custom entry
73
+ * and restore the client it recorded (`undefined` when that entry cleared
74
+ * the label, or when no such entry exists yet).
75
+ */
76
+ function restore(ctx: ExtensionContext): void {
77
+ const entries = ctx.sessionManager.getEntries();
78
+ for (let i = entries.length - 1; i >= 0; i--) {
79
+ const entry = entries[i] as { type: string; customType?: string; data?: unknown };
80
+ if (entry.type === "custom" && entry.customType === CLIENT_ENTRY_TYPE) {
81
+ const data = entry.data as KankakuClientEntryData | undefined;
82
+ sessionClient = data?.client;
83
+ return;
84
+ }
85
+ }
86
+ sessionClient = undefined;
87
+ }
88
+
89
+ function set(pi: ExtensionAPI, client: string | undefined): void {
90
+ sessionClient = client;
91
+ pi.appendEntry<KankakuClientEntryData>(CLIENT_ENTRY_TYPE, { client });
92
+ }
93
+
94
+ function effectiveClient(): string | undefined {
95
+ return resolveClient(sources());
96
+ }
97
+
98
+ function effectiveSource(): ClientSourceName | undefined {
99
+ return resolveClientSource(sources());
100
+ }
101
+
102
+ function runClient(): string | undefined {
103
+ return deps.role === "orchestrator" ? resolveClient(runSources()) : undefined;
104
+ }
105
+
106
+ function idleClient(): string | undefined {
107
+ const idleSources = runProjectClient ? sources(runProjectClient.value) : sources();
108
+ return deps.role === "orchestrator" ? resolveClient(idleSources) : undefined;
109
+ }
110
+
111
+ function endRun(): void {
112
+ runProjectClient = undefined;
113
+ }
114
+
115
+ return { restore, set, sources, effectiveClient, effectiveSource, runClient, idleClient, endRun };
116
+ }
@@ -0,0 +1,86 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ // U+FE0F forces emoji presentation so terminals do not fall back to monochrome text glyphs.
4
+ const CLOCK_EMOJI = "\u{1F552}️";
5
+ const CLIENT_EMOJI = "\u{1F4BC}️";
6
+
7
+ /** Footer status key; footer statuses are sorted alphabetically by key, "zz-" keeps kankaku last. */
8
+ export const STATUS_KEY = "zz-kankaku";
9
+
10
+ export function formatElapsed(ms: number, client?: string): string {
11
+ const totalSeconds = Math.max(0, Math.round(ms / 1000));
12
+ const minutes = Math.floor(totalSeconds / 60);
13
+ const seconds = totalSeconds % 60;
14
+ const elapsed = `${CLOCK_EMOJI} ${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
15
+ return client ? `${elapsed} · ${client}` : elapsed;
16
+ }
17
+
18
+ export interface StatusBarDeps {
19
+ /** Status line refresh interval in ms while a run is active. Defaults to 1000. */
20
+ intervalMs?: number;
21
+ /** Resolve the client label to show for the run that is starting now. */
22
+ resolveRunClient: () => string | undefined;
23
+ /** Resolve the client label to show while idle. */
24
+ resolveIdleClient: () => string | undefined;
25
+ /** Injectable for tests; defaults to the global timer functions. */
26
+ setInterval?: (handler: () => void, ms: number) => NodeJS.Timeout;
27
+ clearInterval?: (timer: NodeJS.Timeout) => void;
28
+ /** Injectable clock for tests; defaults to `Date.now`. */
29
+ now?: () => number;
30
+ }
31
+
32
+ export interface StatusBar {
33
+ /** Start (or, if already running, leave untouched) the elapsed-time status; a no-op without a UI. */
34
+ start(ctx: ExtensionContext): void;
35
+ /** Stop the elapsed-time status and fall back to the idle status. */
36
+ stop(ctx: ExtensionContext): void;
37
+ /** While idle, keep the billing client visible (`💼 <client>`), or clear the status when none resolves. */
38
+ showIdle(ctx: ExtensionContext): void;
39
+ }
40
+
41
+ /**
42
+ * Owns the `/kankaku` footer status: the running elapsed-time clock (with
43
+ * its refresh timer) while a run is active, and the idle billing-client
44
+ * label otherwise.
45
+ */
46
+ export function createStatusBar(deps: StatusBarDeps): StatusBar {
47
+ const intervalMs = deps.intervalMs ?? 1000;
48
+ const scheduleInterval = deps.setInterval ?? setInterval;
49
+ const cancelInterval = deps.clearInterval ?? clearInterval;
50
+ const now = deps.now ?? Date.now;
51
+
52
+ let runStartedAt: number | undefined;
53
+ let statusTimer: NodeJS.Timeout | undefined;
54
+
55
+ function showIdle(ctx: ExtensionContext): void {
56
+ if (!ctx.hasUI) return;
57
+ const client = deps.resolveIdleClient();
58
+ ctx.ui.setStatus(STATUS_KEY, client ? `${CLIENT_EMOJI} ${client}` : undefined);
59
+ }
60
+
61
+ function start(ctx: ExtensionContext): void {
62
+ // Retries within the same run fire before_agent_start again; only the
63
+ // first one starts the timer.
64
+ if (runStartedAt !== undefined) return;
65
+ if (!ctx.hasUI) return;
66
+ runStartedAt = now();
67
+ const client = deps.resolveRunClient();
68
+ ctx.ui.setStatus(STATUS_KEY, formatElapsed(0, client));
69
+ statusTimer = scheduleInterval(() => {
70
+ if (runStartedAt === undefined) return;
71
+ ctx.ui.setStatus(STATUS_KEY, formatElapsed(now() - runStartedAt, client));
72
+ }, intervalMs);
73
+ statusTimer.unref?.();
74
+ }
75
+
76
+ function stop(ctx: ExtensionContext): void {
77
+ if (statusTimer) {
78
+ cancelInterval(statusTimer);
79
+ statusTimer = undefined;
80
+ }
81
+ runStartedAt = undefined;
82
+ showIdle(ctx);
83
+ }
84
+
85
+ return { start, stop, showIdle };
86
+ }
@@ -79,6 +79,11 @@ export function finiteOrZero(value: unknown): number {
79
79
  const ROLES = new Set<WorkRole>(["orchestrator", "subagent"]);
80
80
  const STATUSES = new Set<WorkStatus>(["completed", "aborted", "interrupted"]);
81
81
 
82
+ /** A finite, non-negative number: durations such as `wallMs` can never be negative. */
83
+ function isNonNegativeFinite(value: unknown): boolean {
84
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
85
+ }
86
+
82
87
  /**
83
88
  * Runtime guard for a {@link WorkRecord} read back from disk. `readAll`
84
89
  * skips lines that parse as JSON but fail this check, so a torn write or a
@@ -98,9 +103,9 @@ export function isWorkRecord(value: unknown): value is WorkRecord {
98
103
  typeof record["prompt"] === "string" &&
99
104
  typeof record["startedAt"] === "string" &&
100
105
  typeof record["settledAt"] === "string" &&
101
- Number.isFinite(record["wallMs"]) &&
102
- Number.isFinite(record["waitingMs"]) &&
103
- Number.isFinite(record["workMs"]) &&
106
+ isNonNegativeFinite(record["wallMs"]) &&
107
+ isNonNegativeFinite(record["waitingMs"]) &&
108
+ isNonNegativeFinite(record["workMs"]) &&
104
109
  typeof record["runs"] === "number" &&
105
110
  typeof record["turns"] === "number" &&
106
111
  typeof record["tools"] === "object" &&
@@ -158,7 +158,9 @@ export class WorkTracker {
158
158
  agent: openSubagent.agent,
159
159
  mode: openSubagent.mode,
160
160
  ...(taskId !== undefined ? { taskId } : {}),
161
- ms: this.clock.now() - openSubagent.start,
161
+ // Clamped to >= 0: a backward clock jump while the subagent was
162
+ // running must never produce a negative duration.
163
+ ms: Math.max(0, this.clock.now() - openSubagent.start),
162
164
  });
163
165
  return;
164
166
  }
@@ -228,16 +230,18 @@ export class WorkTracker {
228
230
  if (!state) {
229
231
  throw new Error("buildRecord called without an open run");
230
232
  }
231
- const wallMs = settledAt - state.startedAt;
233
+ // Clamped to >= 0: a backward clock jump (system clock adjustment, NTP
234
+ // correction) must never produce a negative duration.
235
+ const wallMs = Math.max(0, settledAt - state.startedAt);
232
236
 
233
237
  const closedSpans = state.waitingSpans.map((span) => ({ start: span.start, end: span.end ?? settledAt }));
234
- const waitingMs = unionMs(clampIntervals(closedSpans, state.startedAt, settledAt));
235
- const workMs = wallMs - waitingMs;
238
+ const waitingMs = Math.max(0, unionMs(clampIntervals(closedSpans, state.startedAt, settledAt)));
239
+ const workMs = Math.max(0, wallMs - waitingMs);
236
240
 
237
241
  const segmentEntries: Array<[string, number]> = [];
238
242
  for (const [tag, spans] of state.segmentSpans) {
239
243
  const closedTagSpans = spans.map((span) => ({ start: span.start, end: span.end ?? settledAt }));
240
- const tagMs = unionMs(clampIntervals(closedTagSpans, state.startedAt, settledAt));
244
+ const tagMs = Math.max(0, unionMs(clampIntervals(closedTagSpans, state.startedAt, settledAt)));
241
245
  if (tagMs > 0) {
242
246
  segmentEntries.push([tag, tagMs]);
243
247
  }
@@ -3,4 +3,13 @@ import type { WorkRecord } from "../domain/work-record.ts";
3
3
  export interface WorkLog {
4
4
  append(record: WorkRecord): void;
5
5
  readAll(): WorkRecord[];
6
+ /**
7
+ * Optional cheap change signal: a value that changes whenever `append()`
8
+ * would change what `readAll()` returns, computable without reading the
9
+ * whole log (e.g. from file stat metadata). Callers that cache a view
10
+ * derived from `readAll()` (see `pi-tracker.ts`'s client-name completion
11
+ * cache) may use this to invalidate cheaply; a `WorkLog` that omits it
12
+ * simply leaves such callers relying on their own explicit invalidation.
13
+ */
14
+ version?(): string | number;
6
15
  }