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.
@@ -1,41 +1,52 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { Box, Text } from "@earendil-works/pi-tui";
3
- import { WorkTracker } from "../domain/work-tracker.ts";
4
- import { buildSessions, buildTasks } from "../domain/task-view.ts";
5
- import type { WorkRecord, WorkRole } from "../domain/work-record.ts";
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 { formatReport, formatSessions, formatTasks, localDay, summarize } from "./report.ts";
6
+ import { createSessionClient } from "./session-client.ts";
7
+ import { createStatusBar } from "./status-bar.ts";
8
+ import { notifyError, registerKankakuCommand } from "./kankaku-command.ts";
9
+
10
+ export type { KankakuReportData } from "./kankaku-command.ts";
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
- const STATUS_KEY = "kankaku";
20
- const REPORT_ENTRY_TYPE = "kankaku-report";
21
-
22
- /** Durable report rendered inside the chat transcript; never sent to the LLM. */
23
- export interface KankakuReportData {
24
- title: string;
25
- lines: string[];
26
- }
27
-
28
- function formatElapsed(ms: number): string {
29
- const totalSeconds = Math.max(0, Math.round(ms / 1000));
30
- const minutes = Math.floor(totalSeconds / 60);
31
- const seconds = totalSeconds % 60;
32
- return `⏱ ${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
33
- }
34
-
35
- function notifyError(ctx: ExtensionContext, error: unknown): void {
36
- if (!ctx.hasUI) return;
37
- const message = error instanceof Error ? error.message : String(error);
38
- ctx.ui.notify(`kankaku: ${message}`, "error");
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 statusIntervalMs = deps.statusIntervalMs ?? 1000;
68
+ const { tracker, log, inflight, role, pid, parentPid } = deps;
69
+ const isAlive = deps.isAlive ?? defaultIsAlive;
59
70
 
60
- let runStartedAt: number | undefined;
61
- let statusTimer: NodeJS.Timeout | undefined;
71
+ const sessionClient = createSessionClient({
72
+ role,
73
+ envClient: deps.envClient,
74
+ resolveProjectClient: deps.resolveProjectClient,
75
+ });
62
76
 
63
- function stopStatus(ctx: ExtensionContext): void {
64
- if (statusTimer) {
65
- clearInterval(statusTimer);
66
- statusTimer = undefined;
67
- }
68
- if (ctx.hasUI) {
69
- ctx.ui.setStatus(STATUS_KEY, undefined);
70
- }
71
- runStartedAt = undefined;
72
- }
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
- function startStatus(ctx: ExtensionContext): void {
75
- if (!ctx.hasUI) return;
76
- runStartedAt = Date.now();
77
- ctx.ui.setStatus(STATUS_KEY, formatElapsed(0));
78
- statusTimer = setInterval(() => {
79
- if (runStartedAt === undefined) return;
80
- ctx.ui.setStatus(STATUS_KEY, formatElapsed(Date.now() - runStartedAt));
81
- }, statusIntervalMs);
82
- statusTimer.unref?.();
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: NonNullable<ReturnType<WorkTracker["onSettled"]>>, ctx: ExtensionContext): WorkRecord {
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
- if (runStartedAt === undefined) startStatus(ctx);
133
+ statusBar.start(ctx);
134
+ // Checkpoint right away so a crash on the very first turn (before any
135
+ // turn_end/tool_execution_end) still leaves a recoverable in-flight
136
+ // record; see the "Crash recovery" README section.
137
+ checkpoint(ctx);
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: usage.cost.total,
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
- if (core) {
167
- log.append(buildRecord(core, ctx));
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
- if (core) {
178
- log.append(buildRecord(core, ctx));
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.registerEntryRenderer<KankakuReportData>(REPORT_ENTRY_TYPE, (entry, _options, theme) => {
185
- const data = entry.data ?? { title: "kankaku", lines: [] };
186
- const box = new Box(1, 0, (text) => theme.bg("customMessageBg", text));
187
- box.addChild(new Text(`${theme.fg("accent", "kankaku")} ${data.title}`, 0, 0));
188
- for (const line of data.lines) {
189
- box.addChild(new Text(line, 0, 0));
190
- }
191
- return box;
192
- });
193
-
194
- function showReport(ctx: ExtensionContext, report: KankakuReportData): void {
195
- if (ctx.hasUI) {
196
- pi.appendEntry<KankakuReportData>(REPORT_ENTRY_TYPE, report);
197
- return;
198
- }
199
- ctx.ui.notify(`${report.title}\n${report.lines.join("\n")}`);
200
- }
201
-
202
- pi.registerCommand("kankaku", {
203
- description:
204
- "Show kankaku work-time totals for today. Args (any order): 'all' for every record, " +
205
- "'tasks' for this session's tasks ('tasks all' for every session), 'sessions' for today's sessions.",
206
- handler: async (args, ctx) => {
207
- try {
208
- const tokens = args.trim().split(/\s+/).filter(Boolean);
209
- const all = tokens.includes("all");
210
- const records = log.readAll();
211
- const today = localDay(new Date().toISOString());
212
-
213
- if (tokens.includes("tasks")) {
214
- const sessionId = ctx.sessionManager.getSessionId();
215
- const scoped = all || !sessionId;
216
- const tasks = buildTasks(records).filter((task) => scoped || task.sessionId === sessionId);
217
- showReport(ctx, {
218
- title: scoped ? "tasks (every session)" : "tasks (this session)",
219
- lines: formatTasks(tasks).split("\n"),
220
- });
221
- return;
222
- }
223
-
224
- if (tokens.includes("sessions")) {
225
- const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
226
- showReport(ctx, {
227
- title: all ? "sessions (all days)" : "sessions (today)",
228
- lines: formatSessions(buildSessions(tasks)).split("\n"),
229
- });
230
- return;
231
- }
232
+ pi.on(
233
+ "session_start",
234
+ guarded((_event, ctx) => {
235
+ sessionClient.restore(ctx);
236
+ statusBar.showIdle(ctx);
232
237
 
233
- const summary = summarize(records, { all });
234
- showReport(ctx, {
235
- title: all ? "summary (all days)" : "summary (today)",
236
- lines: formatReport(summary).split(" | "),
237
- });
238
- } catch (error) {
239
- notifyError(ctx, error);
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
+ }
@@ -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,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
- /** Render one line per task: time, union-based wall/work, cost, subagent count, and a truncated prompt. */
134
+ /** Maximum visible width of a task prompt before it is truncated with an ellipsis marker. */
135
+ const PROMPT_DISPLAY_LIMIT = 60;
136
+
137
+ /** Truncate `text` to `limit` visible characters, appending `…` (counted within the limit) when it was cut. */
138
+ function truncateWithEllipsis(text: string, limit: number): string {
139
+ return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
140
+ }
141
+
142
+ /** Render one line per task: time, client (when present), union-based wall/work, cost, non-zero segment tags, subagent count, and a truncated prompt (`…` marks a cut). */
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.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}`;
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
- /** Render one line per session: truncated id, time range, union-based wall/work, cost, and task count. */
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
- `${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
- )
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
+ }