kankaku 0.1.0 → 0.4.5

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.
@@ -1,22 +1,64 @@
1
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
1
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
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";
3
6
  import { WorkTracker } from "../domain/work-tracker.ts";
4
7
  import { buildSessions, buildTasks } from "../domain/task-view.ts";
5
- import type { WorkRecord, WorkRole } from "../domain/work-record.ts";
8
+ import type { WorkRecord, WorkRecordCore, WorkRole } from "../domain/work-record.ts";
9
+ import type { InflightStore } from "../ports/inflight-store.ts";
6
10
  import type { WorkLog } from "../ports/work-log.ts";
7
- import { formatReport, formatSessions, formatTasks, localDay, summarize } from "./report.ts";
11
+ import { formatClients, formatReport, formatSessions, formatTasks, localDay, summarize, summarizeByClient } from "./report.ts";
8
12
 
9
13
  export interface PiTrackerDeps {
10
14
  tracker: WorkTracker;
11
15
  log: WorkLog;
16
+ /** Crash-recovery checkpoint store; see the "Crash recovery" README section. */
17
+ inflight: InflightStore;
12
18
  role: WorkRole;
13
19
  pid: number;
14
20
  parentPid: number;
15
21
  /** Status line refresh interval in ms. Defaults to 1000. */
16
22
  statusIntervalMs?: number;
23
+ /** Whether a pid is still alive. Defaults to signal-probing with `process.kill(pid, 0)`. */
24
+ isAlive?: (pid: number) => boolean;
25
+ /** Default billing client for this project, from `KANKAKU_CLIENT` (config.ts). See `domain/client-label.ts`. */
26
+ envClient?: string;
27
+ /**
28
+ * Lazily reads the project's default billing client from
29
+ * `<kankaku dir>/config.json`. Injected from `extension.ts` so this
30
+ * adapter stays free of filesystem code.
31
+ */
32
+ resolveProjectClient?: () => string | undefined;
33
+ /**
34
+ * Write an export file (name, content) under the kankaku dir and return
35
+ * its absolute path. Injected from `extension.ts` to keep this adapter
36
+ * free of filesystem code. `/kankaku export` notifies an error when this
37
+ * is not configured.
38
+ */
39
+ writeExportFile?: (name: string, content: string) => string;
17
40
  }
18
41
 
19
- const STATUS_KEY = "kankaku";
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
+ /** Default `isAlive`: probe with signal 0 — no signal is sent, only existence/permission is checked. */
50
+ function defaultIsAlive(pid: number): boolean {
51
+ try {
52
+ process.kill(pid, 0);
53
+ return true;
54
+ } catch (error) {
55
+ // EPERM means the process exists but we lack permission to signal it — still alive.
56
+ return (error as NodeJS.ErrnoException).code === "EPERM";
57
+ }
58
+ }
59
+
60
+ // Footer statuses are sorted alphabetically by key; "zz-" keeps kankaku last.
61
+ const STATUS_KEY = "zz-kankaku";
20
62
  const REPORT_ENTRY_TYPE = "kankaku-report";
21
63
 
22
64
  /** Durable report rendered inside the chat transcript; never sent to the LLM. */
@@ -25,11 +67,16 @@ export interface KankakuReportData {
25
67
  lines: string[];
26
68
  }
27
69
 
28
- function formatElapsed(ms: number): string {
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 {
29
75
  const totalSeconds = Math.max(0, Math.round(ms / 1000));
30
76
  const minutes = Math.floor(totalSeconds / 60);
31
77
  const seconds = totalSeconds % 60;
32
- return `⏱ ${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
78
+ const elapsed = `${CLOCK_EMOJI} ${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
79
+ return client ? `${elapsed} · ${client}` : elapsed;
33
80
  }
34
81
 
35
82
  function notifyError(ctx: ExtensionContext, error: unknown): void {
@@ -54,36 +101,86 @@ function guarded<E>(fn: (event: E, ctx: ExtensionContext) => void): (event: E, c
54
101
  * records to a {@link WorkLog} and exposing the `/kankaku` report command.
55
102
  */
56
103
  export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
57
- const { tracker, log, role, pid, parentPid } = deps;
104
+ const { tracker, log, inflight, role, pid, parentPid } = deps;
58
105
  const statusIntervalMs = deps.statusIntervalMs ?? 1000;
106
+ const isAlive = deps.isAlive ?? defaultIsAlive;
59
107
 
60
108
  let runStartedAt: number | undefined;
61
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;
62
114
 
63
115
  function stopStatus(ctx: ExtensionContext): void {
64
116
  if (statusTimer) {
65
117
  clearInterval(statusTimer);
66
118
  statusTimer = undefined;
67
119
  }
68
- if (ctx.hasUI) {
69
- ctx.ui.setStatus(STATUS_KEY, undefined);
70
- }
71
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);
72
132
  }
73
133
 
74
134
  function startStatus(ctx: ExtensionContext): void {
75
135
  if (!ctx.hasUI) return;
76
136
  runStartedAt = Date.now();
77
- ctx.ui.setStatus(STATUS_KEY, formatElapsed(0));
137
+ const client = role === "orchestrator" ? resolveClient(runClientSources()) : undefined;
138
+ ctx.ui.setStatus(STATUS_KEY, formatElapsed(0, client));
78
139
  statusTimer = setInterval(() => {
79
140
  if (runStartedAt === undefined) return;
80
- ctx.ui.setStatus(STATUS_KEY, formatElapsed(Date.now() - runStartedAt));
141
+ ctx.ui.setStatus(STATUS_KEY, formatElapsed(Date.now() - runStartedAt, client));
81
142
  }, statusIntervalMs);
82
143
  statusTimer.unref?.();
83
144
  }
84
145
 
85
- function buildRecord(core: NonNullable<ReturnType<WorkTracker["onSettled"]>>, ctx: ExtensionContext): WorkRecord {
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
+ }
162
+
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
+ }
170
+
171
+ function runClientSources(): ReturnType<typeof clientSources> {
172
+ if (!runProjectClient) {
173
+ runProjectClient = { value: deps.resolveProjectClient?.() };
174
+ }
175
+ return clientSources(runProjectClient.value);
176
+ }
177
+
178
+ function buildRecord(core: WorkRecordCore, ctx: ExtensionContext): WorkRecord {
86
179
  const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
180
+ // Subagent children never carry their own client: they inherit the
181
+ // orchestrator's label at task level (see task-view.ts).
182
+ const client = role === "orchestrator" ? resolveClient(runClientSources()) : undefined;
183
+ const sessionName = pi.getSessionName();
87
184
  return {
88
185
  ...core,
89
186
  role,
@@ -94,9 +191,23 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
94
191
  sessionFile: ctx.sessionManager.getSessionFile(),
95
192
  mode: ctx.mode,
96
193
  ...(model !== undefined ? { model } : {}),
194
+ ...(client !== undefined ? { client } : {}),
195
+ ...(sessionName !== undefined ? { sessionName } : {}),
97
196
  };
98
197
  }
99
198
 
199
+ /**
200
+ * Save an in-flight checkpoint of the run's current state, so a hard
201
+ * crash before the next one (or the final settle) still leaves a
202
+ * recoverable `interrupted` record. A no-op while idle.
203
+ */
204
+ function checkpoint(ctx: ExtensionContext): void {
205
+ const core = tracker.peek("interrupted");
206
+ if (core) {
207
+ inflight.save(buildRecord(core, ctx));
208
+ }
209
+ }
210
+
100
211
  pi.on(
101
212
  "before_agent_start",
102
213
  guarded((event, ctx) => {
@@ -114,9 +225,10 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
114
225
 
115
226
  pi.on(
116
227
  "turn_end",
117
- guarded((event) => {
228
+ guarded((event, ctx) => {
118
229
  const message = event.message;
119
230
  const usage = message && "usage" in message ? message.usage : undefined;
231
+ const cost = usage && typeof usage.cost === "object" && usage.cost !== null ? usage.cost.total : undefined;
120
232
  tracker.onTurnEnd(
121
233
  usage
122
234
  ? {
@@ -124,10 +236,11 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
124
236
  output: usage.output,
125
237
  cacheRead: usage.cacheRead,
126
238
  cacheWrite: usage.cacheWrite,
127
- cost: usage.cost.total,
239
+ cost,
128
240
  }
129
241
  : undefined,
130
242
  );
243
+ checkpoint(ctx);
131
244
  }),
132
245
  );
133
246
 
@@ -140,8 +253,9 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
140
253
 
141
254
  pi.on(
142
255
  "tool_execution_end",
143
- guarded((event) => {
256
+ guarded((event, ctx) => {
144
257
  tracker.onToolEnd(event.toolCallId, event.result);
258
+ checkpoint(ctx);
145
259
  }),
146
260
  );
147
261
 
@@ -163,10 +277,17 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
163
277
  "agent_settled",
164
278
  guarded((_event, ctx) => {
165
279
  const core = tracker.onSettled();
166
- if (core) {
167
- log.append(buildRecord(core, ctx));
280
+ try {
281
+ if (core) {
282
+ log.append(buildRecord(core, ctx));
283
+ }
284
+ } finally {
285
+ // Always clean up, even when log.append above threw: an unpersisted
286
+ // checkpoint must not linger, and the status timer must not leak.
287
+ inflight.clear();
288
+ stopStatus(ctx);
289
+ runProjectClient = undefined;
168
290
  }
169
- stopStatus(ctx);
170
291
  }),
171
292
  );
172
293
 
@@ -174,10 +295,31 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
174
295
  "session_shutdown",
175
296
  guarded((_event, ctx) => {
176
297
  const core = tracker.onShutdown();
177
- if (core) {
178
- log.append(buildRecord(core, ctx));
298
+ try {
299
+ if (core) {
300
+ log.append(buildRecord(core, ctx));
301
+ }
302
+ } finally {
303
+ inflight.clear();
304
+ stopStatus(ctx);
305
+ runProjectClient = undefined;
306
+ }
307
+ }),
308
+ );
309
+
310
+ pi.on(
311
+ "session_start",
312
+ guarded((_event, ctx) => {
313
+ sessionClient = restoreSessionClient(ctx);
314
+ showIdleStatus(ctx);
315
+
316
+ const recovered = inflight.recoverStale(isAlive);
317
+ for (const record of recovered) {
318
+ log.append(record);
319
+ }
320
+ if (recovered.length > 0 && ctx.hasUI) {
321
+ ctx.ui.notify(`kankaku: recovered ${recovered.length} interrupted record(s)`, "warning");
179
322
  }
180
- stopStatus(ctx);
181
323
  }),
182
324
  );
183
325
 
@@ -199,17 +341,107 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
199
341
  ctx.ui.notify(`${report.title}\n${report.lines.join("\n")}`);
200
342
  }
201
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
+
202
395
  pi.registerCommand("kankaku", {
203
396
  description:
204
397
  "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.",
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
+ },
206
418
  handler: async (args, ctx) => {
207
419
  try {
208
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
+
209
432
  const all = tokens.includes("all");
210
433
  const records = log.readAll();
211
434
  const today = localDay(new Date().toISOString());
212
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
+
213
445
  if (tokens.includes("tasks")) {
214
446
  const sessionId = ctx.sessionManager.getSessionId();
215
447
  const scoped = all || !sessionId;
@@ -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
+ }
@@ -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
- /** Local (not UTC) calendar day of an ISO timestamp, as `YYYY-MM-DD`. */
34
- export function localDay(iso: string): string {
35
- const date = new Date(iso);
36
- const year = date.getFullYear();
37
- const month = String(date.getMonth() + 1).padStart(2, "0");
38
- const day = String(date.getDate()).padStart(2, "0");
39
- return `${year}-${month}-${day}`;
41
+ function emptyTotals(): RoleTotals {
42
+ return { workMs: 0, waitingMs: 0, wallMs: 0, count: 0, cost: 0, segments: {} };
40
43
  }
41
44
 
42
- function emptyTotals(): RoleTotals {
43
- return { workMs: 0, waitingMs: 0, wallMs: 0, count: 0, cost: 0 };
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,81 @@ 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
- /** Render one line per task: time, union-based wall/work, cost, subagent count, and a truncated prompt. */
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. */
114
135
  export function formatTasks(tasks: TaskView[]): string {
115
136
  if (tasks.length === 0) return "no tasks";
116
137
  return tasks
117
138
  .map((task) => {
118
139
  const prompt = task.prompt.length > 60 ? task.prompt.slice(0, 60) : task.prompt;
119
- return `${formatTime(task.startedAt)} wall ${formatMinutes(task.wallMs)} work ${formatMinutes(task.workMs)} ${formatCost(task.usage.cost)} subagents ${task.subagents.length} ${prompt}`;
140
+ const segmentTags = formatSegmentTags(task.segments);
141
+ const segmentPart = segmentTags !== undefined ? ` ${segmentTags}` : "";
142
+ const clientPart = task.client !== undefined ? ` client:${task.client}` : "";
143
+ return `${formatTime(task.startedAt)}${clientPart} wall ${formatMinutes(task.wallMs)} work ${formatMinutes(task.workMs)} ${formatCost(task.usage.cost)}${segmentPart} subagents ${task.subagents.length} ${prompt}`;
120
144
  })
121
145
  .join("\n");
122
146
  }
123
147
 
124
- /** Render one line per session: truncated id, time range, union-based wall/work, cost, and task count. */
148
+ export interface ClientTotals {
149
+ wallMs: number;
150
+ waitingMs: number;
151
+ workMs: number;
152
+ /** Estimated cost in USD, summed across this client's tasks. */
153
+ cost: number;
154
+ count: number;
155
+ }
156
+
157
+ /** Client name under which tasks without a resolved client are grouped. */
158
+ const NO_CLIENT = "(none)";
159
+
160
+ /**
161
+ * Aggregate tasks by billing client (see `domain/client-label.ts`), summing
162
+ * work/waiting/wall time, cost, and task count. Tasks without a `client`
163
+ * are grouped under `"(none)"`. Returned as a `Map` rather than a plain
164
+ * object so an attacker-controlled client name can never repoint a
165
+ * prototype property.
166
+ */
167
+ export function summarizeByClient(tasks: TaskView[]): Map<string, ClientTotals> {
168
+ const totals = new Map<string, ClientTotals>();
169
+ for (const task of tasks) {
170
+ const key = task.client ?? NO_CLIENT;
171
+ const entry = totals.get(key) ?? { wallMs: 0, waitingMs: 0, workMs: 0, cost: 0, count: 0 };
172
+ entry.wallMs += task.wallMs;
173
+ entry.waitingMs += task.waitingMs;
174
+ entry.workMs += task.workMs;
175
+ entry.cost += finiteOrZero(task.usage.cost);
176
+ entry.count += 1;
177
+ totals.set(key, entry);
178
+ }
179
+ return totals;
180
+ }
181
+
182
+ /** Render one line per client, sorted alphabetically, with work/waiting/wall time, cost, and task count. */
183
+ export function formatClients(totals: Map<string, ClientTotals>): string {
184
+ if (totals.size === 0) return "no clients";
185
+ return Array.from(totals.entries())
186
+ .sort(([a], [b]) => a.localeCompare(b))
187
+ .map(
188
+ ([client, t]) =>
189
+ `${client} work ${formatMinutes(t.workMs)} waiting ${formatMinutes(t.waitingMs)} wall ${formatMinutes(t.wallMs)} ${formatCost(t.cost)} tasks ${t.count}`,
190
+ )
191
+ .join("\n");
192
+ }
193
+
194
+ /** Render one line per session: truncated id, time range, union-based wall/work, cost, non-zero segment tags, and task count. */
125
195
  export function formatSessions(sessions: SessionView[]): string {
126
196
  if (sessions.length === 0) return "no sessions";
127
197
  return sessions
128
- .map(
129
- (session) =>
130
- `${session.sessionId.slice(0, 8)} ${formatTime(session.startedAt)}–${formatTime(session.endedAt)} wall ${formatMinutes(session.wallMs)} work ${formatMinutes(session.workMs)} ${formatCost(session.usage.cost)} tasks ${session.tasks.length}`,
131
- )
198
+ .map((session) => {
199
+ const segmentTags = formatSegmentTags(session.segments);
200
+ const segmentPart = segmentTags !== undefined ? ` ${segmentTags}` : "";
201
+ 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}`;
202
+ })
132
203
  .join("\n");
133
204
  }