kankaku 0.1.0 → 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 +129 -3
- package/package.json +1 -1
- package/src/adapters/export-writer.ts +33 -0
- package/src/adapters/file-inflight-store.ts +115 -0
- package/src/adapters/jsonl-work-log.ts +22 -2
- package/src/adapters/kankaku-command.ts +226 -0
- package/src/adapters/kankaku-dir.ts +10 -0
- package/src/adapters/lazy-file-inflight-store.ts +43 -0
- package/src/adapters/lazy-jsonl-work-log.ts +6 -3
- package/src/adapters/pi-tracker.ts +120 -116
- package/src/adapters/project-config.ts +44 -0
- package/src/adapters/report.ts +98 -19
- package/src/adapters/session-client.ts +116 -0
- package/src/adapters/status-bar.ts +86 -0
- package/src/config.ts +65 -0
- package/src/domain/client-label.ts +56 -0
- package/src/domain/day.ts +8 -0
- package/src/domain/export.ts +107 -0
- package/src/domain/segment-rule.ts +10 -0
- package/src/domain/task-view.ts +43 -7
- package/src/domain/work-record.ts +59 -0
- package/src/domain/work-tracker.ts +85 -21
- package/src/extension.ts +12 -0
- package/src/ports/inflight-store.ts +16 -0
- package/src/ports/work-log.ts +9 -0
|
@@ -1,41 +1,52 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
import { WorkTracker } from "../domain/work-tracker.ts";
|
|
4
|
-
import {
|
|
5
|
-
import type { WorkRecord, WorkRole } from "../domain/work-record.ts";
|
|
2
|
+
import type { WorkRecord, WorkRecordCore, WorkRole } from "../domain/work-record.ts";
|
|
3
|
+
import type { WorkTracker } from "../domain/work-tracker.ts";
|
|
4
|
+
import type { InflightStore } from "../ports/inflight-store.ts";
|
|
6
5
|
import type { WorkLog } from "../ports/work-log.ts";
|
|
7
|
-
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";
|
|
8
11
|
|
|
9
12
|
export interface PiTrackerDeps {
|
|
10
13
|
tracker: WorkTracker;
|
|
11
14
|
log: WorkLog;
|
|
15
|
+
/** Crash-recovery checkpoint store; see the "Crash recovery" README section. */
|
|
16
|
+
inflight: InflightStore;
|
|
12
17
|
role: WorkRole;
|
|
13
18
|
pid: number;
|
|
14
19
|
parentPid: number;
|
|
15
20
|
/** Status line refresh interval in ms. Defaults to 1000. */
|
|
16
21
|
statusIntervalMs?: number;
|
|
22
|
+
/** Whether a pid is still alive. Defaults to signal-probing with `process.kill(pid, 0)`. */
|
|
23
|
+
isAlive?: (pid: number) => boolean;
|
|
24
|
+
/** Default billing client for this project, from `KANKAKU_CLIENT` (config.ts). See `domain/client-label.ts`. */
|
|
25
|
+
envClient?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Lazily reads the project's default billing client from
|
|
28
|
+
* `<kankaku dir>/config.json`. Injected from `extension.ts` so this
|
|
29
|
+
* adapter stays free of filesystem code.
|
|
30
|
+
*/
|
|
31
|
+
resolveProjectClient?: () => string | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Write an export file (name, content) under the kankaku dir and return
|
|
34
|
+
* its absolute path. Injected from `extension.ts` to keep this adapter
|
|
35
|
+
* free of filesystem code. `/kankaku export` notifies an error when this
|
|
36
|
+
* is not configured.
|
|
37
|
+
*/
|
|
38
|
+
writeExportFile?: (name: string, content: string) => string;
|
|
17
39
|
}
|
|
18
40
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
function formatElapsed(ms: number): string {
|
|
29
|
-
const totalSeconds = Math.max(0, Math.round(ms / 1000));
|
|
30
|
-
const minutes = Math.floor(totalSeconds / 60);
|
|
31
|
-
const seconds = totalSeconds % 60;
|
|
32
|
-
return `⏱ ${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function notifyError(ctx: ExtensionContext, error: unknown): void {
|
|
36
|
-
if (!ctx.hasUI) return;
|
|
37
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
38
|
-
ctx.ui.notify(`kankaku: ${message}`, "error");
|
|
41
|
+
/** Default `isAlive`: probe with signal 0 — no signal is sent, only existence/permission is checked. */
|
|
42
|
+
function defaultIsAlive(pid: number): boolean {
|
|
43
|
+
try {
|
|
44
|
+
process.kill(pid, 0);
|
|
45
|
+
return true;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
// EPERM means the process exists but we lack permission to signal it — still alive.
|
|
48
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
49
|
+
}
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
/** Wraps a handler so it never throws out of the pi event loop. */
|
|
@@ -54,36 +65,40 @@ function guarded<E>(fn: (event: E, ctx: ExtensionContext) => void): (event: E, c
|
|
|
54
65
|
* records to a {@link WorkLog} and exposing the `/kankaku` report command.
|
|
55
66
|
*/
|
|
56
67
|
export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
|
|
57
|
-
const { tracker, log, role, pid, parentPid } = deps;
|
|
58
|
-
const
|
|
68
|
+
const { tracker, log, inflight, role, pid, parentPid } = deps;
|
|
69
|
+
const isAlive = deps.isAlive ?? defaultIsAlive;
|
|
59
70
|
|
|
60
|
-
|
|
61
|
-
|
|
71
|
+
const sessionClient = createSessionClient({
|
|
72
|
+
role,
|
|
73
|
+
envClient: deps.envClient,
|
|
74
|
+
resolveProjectClient: deps.resolveProjectClient,
|
|
75
|
+
});
|
|
62
76
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
77
|
+
const statusBar = createStatusBar({
|
|
78
|
+
intervalMs: deps.statusIntervalMs,
|
|
79
|
+
resolveRunClient: () => sessionClient.runClient(),
|
|
80
|
+
resolveIdleClient: () => sessionClient.idleClient(),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const kankakuCommand = registerKankakuCommand(pi, {
|
|
84
|
+
log,
|
|
85
|
+
sessionClient,
|
|
86
|
+
refreshIdleStatus: (ctx) => statusBar.showIdle(ctx),
|
|
87
|
+
writeExportFile: deps.writeExportFile,
|
|
88
|
+
});
|
|
73
89
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
statusTimer = setInterval(() => {
|
|
79
|
-
if (runStartedAt === undefined) return;
|
|
80
|
-
ctx.ui.setStatus(STATUS_KEY, formatElapsed(Date.now() - runStartedAt));
|
|
81
|
-
}, statusIntervalMs);
|
|
82
|
-
statusTimer.unref?.();
|
|
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();
|
|
83
94
|
}
|
|
84
95
|
|
|
85
|
-
function buildRecord(core:
|
|
96
|
+
function buildRecord(core: WorkRecordCore, ctx: ExtensionContext): WorkRecord {
|
|
86
97
|
const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
|
|
98
|
+
// Subagent children never carry their own client: they inherit the
|
|
99
|
+
// orchestrator's label at task level (see task-view.ts).
|
|
100
|
+
const client = sessionClient.runClient();
|
|
101
|
+
const sessionName = pi.getSessionName();
|
|
87
102
|
return {
|
|
88
103
|
...core,
|
|
89
104
|
role,
|
|
@@ -94,14 +109,32 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
|
|
|
94
109
|
sessionFile: ctx.sessionManager.getSessionFile(),
|
|
95
110
|
mode: ctx.mode,
|
|
96
111
|
...(model !== undefined ? { model } : {}),
|
|
112
|
+
...(client !== undefined ? { client } : {}),
|
|
113
|
+
...(sessionName !== undefined ? { sessionName } : {}),
|
|
97
114
|
};
|
|
98
115
|
}
|
|
99
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Save an in-flight checkpoint of the run's current state, so a hard
|
|
119
|
+
* crash before the next one (or the final settle) still leaves a
|
|
120
|
+
* recoverable `interrupted` record. A no-op while idle.
|
|
121
|
+
*/
|
|
122
|
+
function checkpoint(ctx: ExtensionContext): void {
|
|
123
|
+
const core = tracker.peek("interrupted");
|
|
124
|
+
if (core) {
|
|
125
|
+
inflight.save(buildRecord(core, ctx));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
100
129
|
pi.on(
|
|
101
130
|
"before_agent_start",
|
|
102
131
|
guarded((event, ctx) => {
|
|
103
132
|
tracker.onRunStart(event.prompt);
|
|
104
|
-
|
|
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);
|
|
105
138
|
}),
|
|
106
139
|
);
|
|
107
140
|
|
|
@@ -114,9 +147,10 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
|
|
|
114
147
|
|
|
115
148
|
pi.on(
|
|
116
149
|
"turn_end",
|
|
117
|
-
guarded((event) => {
|
|
150
|
+
guarded((event, ctx) => {
|
|
118
151
|
const message = event.message;
|
|
119
152
|
const usage = message && "usage" in message ? message.usage : undefined;
|
|
153
|
+
const cost = usage && typeof usage.cost === "object" && usage.cost !== null ? usage.cost.total : undefined;
|
|
120
154
|
tracker.onTurnEnd(
|
|
121
155
|
usage
|
|
122
156
|
? {
|
|
@@ -124,10 +158,11 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
|
|
|
124
158
|
output: usage.output,
|
|
125
159
|
cacheRead: usage.cacheRead,
|
|
126
160
|
cacheWrite: usage.cacheWrite,
|
|
127
|
-
cost
|
|
161
|
+
cost,
|
|
128
162
|
}
|
|
129
163
|
: undefined,
|
|
130
164
|
);
|
|
165
|
+
checkpoint(ctx);
|
|
131
166
|
}),
|
|
132
167
|
);
|
|
133
168
|
|
|
@@ -140,8 +175,9 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
|
|
|
140
175
|
|
|
141
176
|
pi.on(
|
|
142
177
|
"tool_execution_end",
|
|
143
|
-
guarded((event) => {
|
|
178
|
+
guarded((event, ctx) => {
|
|
144
179
|
tracker.onToolEnd(event.toolCallId, event.result);
|
|
180
|
+
checkpoint(ctx);
|
|
145
181
|
}),
|
|
146
182
|
);
|
|
147
183
|
|
|
@@ -163,10 +199,17 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
|
|
|
163
199
|
"agent_settled",
|
|
164
200
|
guarded((_event, ctx) => {
|
|
165
201
|
const core = tracker.onSettled();
|
|
166
|
-
|
|
167
|
-
|
|
202
|
+
try {
|
|
203
|
+
if (core) {
|
|
204
|
+
appendRecord(buildRecord(core, ctx));
|
|
205
|
+
}
|
|
206
|
+
} finally {
|
|
207
|
+
// Always clean up, even when appendRecord above threw: an unpersisted
|
|
208
|
+
// checkpoint must not linger, and the status timer must not leak.
|
|
209
|
+
inflight.clear();
|
|
210
|
+
statusBar.stop(ctx);
|
|
211
|
+
sessionClient.endRun();
|
|
168
212
|
}
|
|
169
|
-
stopStatus(ctx);
|
|
170
213
|
}),
|
|
171
214
|
);
|
|
172
215
|
|
|
@@ -174,70 +217,31 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
|
|
|
174
217
|
"session_shutdown",
|
|
175
218
|
guarded((_event, ctx) => {
|
|
176
219
|
const core = tracker.onShutdown();
|
|
177
|
-
|
|
178
|
-
|
|
220
|
+
try {
|
|
221
|
+
if (core) {
|
|
222
|
+
appendRecord(buildRecord(core, ctx));
|
|
223
|
+
}
|
|
224
|
+
} finally {
|
|
225
|
+
inflight.clear();
|
|
226
|
+
statusBar.stop(ctx);
|
|
227
|
+
sessionClient.endRun();
|
|
179
228
|
}
|
|
180
|
-
stopStatus(ctx);
|
|
181
229
|
}),
|
|
182
230
|
);
|
|
183
231
|
|
|
184
|
-
pi.
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
box.addChild(new Text(line, 0, 0));
|
|
190
|
-
}
|
|
191
|
-
return box;
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
function showReport(ctx: ExtensionContext, report: KankakuReportData): void {
|
|
195
|
-
if (ctx.hasUI) {
|
|
196
|
-
pi.appendEntry<KankakuReportData>(REPORT_ENTRY_TYPE, report);
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
ctx.ui.notify(`${report.title}\n${report.lines.join("\n")}`);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
pi.registerCommand("kankaku", {
|
|
203
|
-
description:
|
|
204
|
-
"Show kankaku work-time totals for today. Args (any order): 'all' for every record, " +
|
|
205
|
-
"'tasks' for this session's tasks ('tasks all' for every session), 'sessions' for today's sessions.",
|
|
206
|
-
handler: async (args, ctx) => {
|
|
207
|
-
try {
|
|
208
|
-
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
209
|
-
const all = tokens.includes("all");
|
|
210
|
-
const records = log.readAll();
|
|
211
|
-
const today = localDay(new Date().toISOString());
|
|
212
|
-
|
|
213
|
-
if (tokens.includes("tasks")) {
|
|
214
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
215
|
-
const scoped = all || !sessionId;
|
|
216
|
-
const tasks = buildTasks(records).filter((task) => scoped || task.sessionId === sessionId);
|
|
217
|
-
showReport(ctx, {
|
|
218
|
-
title: scoped ? "tasks (every session)" : "tasks (this session)",
|
|
219
|
-
lines: formatTasks(tasks).split("\n"),
|
|
220
|
-
});
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
if (tokens.includes("sessions")) {
|
|
225
|
-
const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
|
|
226
|
-
showReport(ctx, {
|
|
227
|
-
title: all ? "sessions (all days)" : "sessions (today)",
|
|
228
|
-
lines: formatSessions(buildSessions(tasks)).split("\n"),
|
|
229
|
-
});
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
+
pi.on(
|
|
233
|
+
"session_start",
|
|
234
|
+
guarded((_event, ctx) => {
|
|
235
|
+
sessionClient.restore(ctx);
|
|
236
|
+
statusBar.showIdle(ctx);
|
|
232
237
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
lines: formatReport(summary).split(" | "),
|
|
237
|
-
});
|
|
238
|
-
} catch (error) {
|
|
239
|
-
notifyError(ctx, error);
|
|
238
|
+
const recovered = inflight.recoverStale(isAlive);
|
|
239
|
+
for (const record of recovered) {
|
|
240
|
+
appendRecord(record);
|
|
240
241
|
}
|
|
241
|
-
|
|
242
|
-
|
|
242
|
+
if (recovered.length > 0 && ctx.hasUI) {
|
|
243
|
+
ctx.ui.notify(`kankaku: recovered ${recovered.length} interrupted record(s)`, "warning");
|
|
244
|
+
}
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
243
247
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { resolveKankakuDir } from "./kankaku-dir.ts";
|
|
4
|
+
|
|
5
|
+
const CONFIG_FILE_NAME = "config.json";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Read the project's default billing client from `<dir>/config.json`
|
|
9
|
+
* (`{ "client": "acme" }`), the lowest-precedence source in
|
|
10
|
+
* `domain/client-label.ts#resolveClient`. `dir` is the kankaku dir (same
|
|
11
|
+
* directory as the work log).
|
|
12
|
+
*
|
|
13
|
+
* Tolerates a missing file, malformed JSON, a non-object document, or a
|
|
14
|
+
* `client` field that is not a string — all return `undefined` rather than
|
|
15
|
+
* throwing, since this file is optional and hand-edited.
|
|
16
|
+
*/
|
|
17
|
+
export function readProjectClient(dir: string): string | undefined {
|
|
18
|
+
const filePath = join(dir, CONFIG_FILE_NAME);
|
|
19
|
+
if (!existsSync(filePath)) return undefined;
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const parsed: unknown = JSON.parse(readFileSync(filePath, "utf8"));
|
|
23
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
24
|
+
const client = (parsed as Record<string, unknown>)["client"];
|
|
25
|
+
return typeof client === "string" ? client : undefined;
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Reads the project client from a kankaku dir resolved lazily against `fallbackCwd()` at call time. */
|
|
32
|
+
export class LazyProjectClientSource {
|
|
33
|
+
private readonly dirOrRelative: string;
|
|
34
|
+
private readonly fallbackCwd: () => string;
|
|
35
|
+
|
|
36
|
+
constructor(dirOrRelative: string, fallbackCwd: () => string = () => process.cwd()) {
|
|
37
|
+
this.dirOrRelative = dirOrRelative;
|
|
38
|
+
this.fallbackCwd = fallbackCwd;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
read(): string | undefined {
|
|
42
|
+
return readProjectClient(resolveKankakuDir(this.dirOrRelative, this.fallbackCwd()));
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/adapters/report.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { buildTasks } from "../domain/task-view.ts";
|
|
2
2
|
import type { SessionView, TaskView } from "../domain/task-view.ts";
|
|
3
|
+
import { localDay } from "../domain/day.ts";
|
|
4
|
+
import { finiteOrZero } from "../domain/work-record.ts";
|
|
3
5
|
import type { WorkRecord, WorkRole } from "../domain/work-record.ts";
|
|
4
6
|
|
|
7
|
+
export { localDay } from "../domain/day.ts";
|
|
8
|
+
|
|
5
9
|
export interface RoleTotals {
|
|
6
10
|
workMs: number;
|
|
7
11
|
waitingMs: number;
|
|
@@ -9,6 +13,8 @@ export interface RoleTotals {
|
|
|
9
13
|
count: number;
|
|
10
14
|
/** Estimated cost in USD, as priced by pi's model table. */
|
|
11
15
|
cost: number;
|
|
16
|
+
/** Per-tag total milliseconds summed across every record of this role. */
|
|
17
|
+
segments: Record<string, number>;
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
export interface TaskTotals {
|
|
@@ -17,6 +23,8 @@ export interface TaskTotals {
|
|
|
17
23
|
workMs: number;
|
|
18
24
|
/** Estimated cost in USD, orchestrator and subagents combined. */
|
|
19
25
|
cost: number;
|
|
26
|
+
/** Per-tag total milliseconds summed across every task (orchestrator and subagents). */
|
|
27
|
+
segments: Record<string, number>;
|
|
20
28
|
}
|
|
21
29
|
|
|
22
30
|
export type Summary = Record<WorkRole, RoleTotals> & { tasks: TaskTotals };
|
|
@@ -30,17 +38,15 @@ export interface SummarizeOptions {
|
|
|
30
38
|
|
|
31
39
|
const ROLES: WorkRole[] = ["orchestrator", "subagent"];
|
|
32
40
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const date = new Date(iso);
|
|
36
|
-
const year = date.getFullYear();
|
|
37
|
-
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
38
|
-
const day = String(date.getDate()).padStart(2, "0");
|
|
39
|
-
return `${year}-${month}-${day}`;
|
|
41
|
+
function emptyTotals(): RoleTotals {
|
|
42
|
+
return { workMs: 0, waitingMs: 0, wallMs: 0, count: 0, cost: 0, segments: {} };
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
/** Add per-tag milliseconds from `segments` (missing on older records) into `into`. */
|
|
46
|
+
function addSegments(into: Record<string, number>, segments: Record<string, number> | undefined): void {
|
|
47
|
+
for (const [tag, ms] of Object.entries(segments ?? {})) {
|
|
48
|
+
into[tag] = (into[tag] ?? 0) + ms;
|
|
49
|
+
}
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
/**
|
|
@@ -55,7 +61,7 @@ export function summarize(records: WorkRecord[], options: SummarizeOptions): Sum
|
|
|
55
61
|
const summary: Summary = {
|
|
56
62
|
orchestrator: emptyTotals(),
|
|
57
63
|
subagent: emptyTotals(),
|
|
58
|
-
tasks: { count: 0, wallMs: 0, workMs: 0, cost: 0 },
|
|
64
|
+
tasks: { count: 0, wallMs: 0, workMs: 0, cost: 0, segments: {} },
|
|
59
65
|
};
|
|
60
66
|
|
|
61
67
|
for (const record of records) {
|
|
@@ -65,7 +71,8 @@ export function summarize(records: WorkRecord[], options: SummarizeOptions): Sum
|
|
|
65
71
|
totals.waitingMs += record.waitingMs;
|
|
66
72
|
totals.wallMs += record.wallMs;
|
|
67
73
|
totals.count += 1;
|
|
68
|
-
totals.cost += record.usage.cost;
|
|
74
|
+
totals.cost += finiteOrZero(record.usage.cost);
|
|
75
|
+
addSegments(totals.segments, record.segments);
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
const tasks = buildTasks(records).filter((task) => targetDay === undefined || localDay(task.startedAt) === targetDay);
|
|
@@ -74,6 +81,7 @@ export function summarize(records: WorkRecord[], options: SummarizeOptions): Sum
|
|
|
74
81
|
summary.tasks.wallMs += task.wallMs;
|
|
75
82
|
summary.tasks.workMs += task.workMs;
|
|
76
83
|
summary.tasks.cost += task.usage.cost;
|
|
84
|
+
addSegments(summary.tasks.segments, task.segments);
|
|
77
85
|
}
|
|
78
86
|
|
|
79
87
|
return summary;
|
|
@@ -98,6 +106,15 @@ function formatTime(iso: string): string {
|
|
|
98
106
|
return `${hours}:${minutes}`;
|
|
99
107
|
}
|
|
100
108
|
|
|
109
|
+
/** Render non-zero segment tags as `tag Xm00s` pairs, sorted alphabetically, joined by `, `. Undefined when none are non-zero. */
|
|
110
|
+
function formatSegmentTags(segments: Record<string, number>): string | undefined {
|
|
111
|
+
const tags = Object.keys(segments)
|
|
112
|
+
.filter((tag) => segments[tag]! > 0)
|
|
113
|
+
.sort();
|
|
114
|
+
if (tags.length === 0) return undefined;
|
|
115
|
+
return tags.map((tag) => `${tag} ${formatMinutes(segments[tag]!)}`).join(", ");
|
|
116
|
+
}
|
|
117
|
+
|
|
101
118
|
/** Render a short, human-readable summary for the `/kankaku` command. */
|
|
102
119
|
export function formatReport(summary: Summary): string {
|
|
103
120
|
const lines = ROLES.map((role) => {
|
|
@@ -107,27 +124,89 @@ export function formatReport(summary: Summary): string {
|
|
|
107
124
|
lines.push(
|
|
108
125
|
`tasks: ${summary.tasks.count}, wall ${formatMinutes(summary.tasks.wallMs)}, work ${formatMinutes(summary.tasks.workMs)}, ${formatCost(summary.tasks.cost)}`,
|
|
109
126
|
);
|
|
127
|
+
const segmentTags = formatSegmentTags(summary.tasks.segments);
|
|
128
|
+
if (segmentTags !== undefined) {
|
|
129
|
+
lines.push(`segments: ${segmentTags}`);
|
|
130
|
+
}
|
|
110
131
|
return lines.join(" | ");
|
|
111
132
|
}
|
|
112
133
|
|
|
113
|
-
/**
|
|
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). */
|
|
114
143
|
export function formatTasks(tasks: TaskView[]): string {
|
|
115
144
|
if (tasks.length === 0) return "no tasks";
|
|
116
145
|
return tasks
|
|
117
146
|
.map((task) => {
|
|
118
|
-
const prompt = task.prompt
|
|
119
|
-
|
|
147
|
+
const prompt = truncateWithEllipsis(task.prompt, PROMPT_DISPLAY_LIMIT);
|
|
148
|
+
const segmentTags = formatSegmentTags(task.segments);
|
|
149
|
+
const segmentPart = segmentTags !== undefined ? ` ${segmentTags}` : "";
|
|
150
|
+
const clientPart = task.client !== undefined ? ` client:${task.client}` : "";
|
|
151
|
+
return `${formatTime(task.startedAt)}${clientPart} wall ${formatMinutes(task.wallMs)} work ${formatMinutes(task.workMs)} ${formatCost(task.usage.cost)}${segmentPart} subagents ${task.subagents.length} ${prompt}`;
|
|
120
152
|
})
|
|
121
153
|
.join("\n");
|
|
122
154
|
}
|
|
123
155
|
|
|
124
|
-
|
|
156
|
+
export interface ClientTotals {
|
|
157
|
+
wallMs: number;
|
|
158
|
+
waitingMs: number;
|
|
159
|
+
workMs: number;
|
|
160
|
+
/** Estimated cost in USD, summed across this client's tasks. */
|
|
161
|
+
cost: number;
|
|
162
|
+
count: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Client name under which tasks without a resolved client are grouped. */
|
|
166
|
+
const NO_CLIENT = "(none)";
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Aggregate tasks by billing client (see `domain/client-label.ts`), summing
|
|
170
|
+
* work/waiting/wall time, cost, and task count. Tasks without a `client`
|
|
171
|
+
* are grouped under `"(none)"`. Returned as a `Map` rather than a plain
|
|
172
|
+
* object so an attacker-controlled client name can never repoint a
|
|
173
|
+
* prototype property.
|
|
174
|
+
*/
|
|
175
|
+
export function summarizeByClient(tasks: TaskView[]): Map<string, ClientTotals> {
|
|
176
|
+
const totals = new Map<string, ClientTotals>();
|
|
177
|
+
for (const task of tasks) {
|
|
178
|
+
const key = task.client ?? NO_CLIENT;
|
|
179
|
+
const entry = totals.get(key) ?? { wallMs: 0, waitingMs: 0, workMs: 0, cost: 0, count: 0 };
|
|
180
|
+
entry.wallMs += task.wallMs;
|
|
181
|
+
entry.waitingMs += task.waitingMs;
|
|
182
|
+
entry.workMs += task.workMs;
|
|
183
|
+
entry.cost += finiteOrZero(task.usage.cost);
|
|
184
|
+
entry.count += 1;
|
|
185
|
+
totals.set(key, entry);
|
|
186
|
+
}
|
|
187
|
+
return totals;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Render one line per client, sorted alphabetically, with work/waiting/wall time, cost, and task count. */
|
|
191
|
+
export function formatClients(totals: Map<string, ClientTotals>): string {
|
|
192
|
+
if (totals.size === 0) return "no clients";
|
|
193
|
+
return Array.from(totals.entries())
|
|
194
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
195
|
+
.map(
|
|
196
|
+
([client, t]) =>
|
|
197
|
+
`${client} work ${formatMinutes(t.workMs)} waiting ${formatMinutes(t.waitingMs)} wall ${formatMinutes(t.wallMs)} ${formatCost(t.cost)} tasks ${t.count}`,
|
|
198
|
+
)
|
|
199
|
+
.join("\n");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Render one line per session: truncated id, time range, union-based wall/work, cost, non-zero segment tags, and task count. */
|
|
125
203
|
export function formatSessions(sessions: SessionView[]): string {
|
|
126
204
|
if (sessions.length === 0) return "no sessions";
|
|
127
205
|
return sessions
|
|
128
|
-
.map(
|
|
129
|
-
(session)
|
|
130
|
-
|
|
131
|
-
|
|
206
|
+
.map((session) => {
|
|
207
|
+
const segmentTags = formatSegmentTags(session.segments);
|
|
208
|
+
const segmentPart = segmentTags !== undefined ? ` ${segmentTags}` : "";
|
|
209
|
+
return `${session.sessionId.slice(0, 8)} ${formatTime(session.startedAt)}–${formatTime(session.endedAt)} wall ${formatMinutes(session.wallMs)} work ${formatMinutes(session.workMs)} ${formatCost(session.usage.cost)}${segmentPart} tasks ${session.tasks.length}`;
|
|
210
|
+
})
|
|
132
211
|
.join("\n");
|
|
133
212
|
}
|
|
@@ -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
|
+
}
|