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 +12 -3
- package/package.json +1 -1
- package/src/adapters/file-inflight-store.ts +32 -0
- package/src/adapters/jsonl-work-log.ts +15 -1
- package/src/adapters/kankaku-command.ts +226 -0
- package/src/adapters/lazy-jsonl-work-log.ts +4 -0
- package/src/adapters/pi-tracker.ts +42 -270
- package/src/adapters/report.ts +10 -2
- package/src/adapters/session-client.ts +116 -0
- package/src/adapters/status-bar.ts +86 -0
- package/src/domain/work-record.ts +8 -3
- package/src/domain/work-tracker.ts +9 -5
- package/src/ports/work-log.ts +9 -0
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
|
|
205
|
-
|
|
206
|
-
|
|
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
|
@@ -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
|
+
}
|
|
@@ -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 {
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
204
|
+
appendRecord(buildRecord(core, ctx));
|
|
283
205
|
}
|
|
284
206
|
} finally {
|
|
285
|
-
// Always clean up, even when
|
|
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
|
-
|
|
289
|
-
|
|
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
|
-
|
|
222
|
+
appendRecord(buildRecord(core, ctx));
|
|
301
223
|
}
|
|
302
224
|
} finally {
|
|
303
225
|
inflight.clear();
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
314
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/adapters/report.ts
CHANGED
|
@@ -131,12 +131,20 @@ export function formatReport(summary: Summary): string {
|
|
|
131
131
|
return lines.join(" | ");
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
/**
|
|
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
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/ports/work-log.ts
CHANGED
|
@@ -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
|
}
|