kankaku-pi 1.0.0

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.
Files changed (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1438 -0
  3. package/dist/adapters/cached-catalog.d.ts +42 -0
  4. package/dist/adapters/cached-catalog.js +121 -0
  5. package/dist/adapters/export-writer.d.ts +13 -0
  6. package/dist/adapters/export-writer.js +28 -0
  7. package/dist/adapters/file-modes.d.ts +20 -0
  8. package/dist/adapters/file-modes.js +34 -0
  9. package/dist/adapters/hub-actions.d.ts +35 -0
  10. package/dist/adapters/hub-actions.js +70 -0
  11. package/dist/adapters/hub-credentials.d.ts +35 -0
  12. package/dist/adapters/hub-credentials.js +58 -0
  13. package/dist/adapters/jsonl-work-log.d.ts +20 -0
  14. package/dist/adapters/jsonl-work-log.js +62 -0
  15. package/dist/adapters/kankaku-dir.d.ts +38 -0
  16. package/dist/adapters/kankaku-dir.js +85 -0
  17. package/dist/adapters/lazy-jsonl-work-log.d.ts +17 -0
  18. package/dist/adapters/lazy-jsonl-work-log.js +31 -0
  19. package/dist/adapters/pocketbase-catalog.d.ts +16 -0
  20. package/dist/adapters/pocketbase-catalog.js +56 -0
  21. package/dist/adapters/pocketbase-client.d.ts +81 -0
  22. package/dist/adapters/pocketbase-client.js +148 -0
  23. package/dist/adapters/pocketbase-sink.d.ts +53 -0
  24. package/dist/adapters/pocketbase-sink.js +181 -0
  25. package/dist/adapters/project-config.d.ts +42 -0
  26. package/dist/adapters/project-config.js +108 -0
  27. package/dist/adapters/report-data.d.ts +12 -0
  28. package/dist/adapters/report-data.js +8 -0
  29. package/dist/adapters/report-views.d.ts +45 -0
  30. package/dist/adapters/report-views.js +73 -0
  31. package/dist/adapters/report.d.ts +112 -0
  32. package/dist/adapters/report.js +236 -0
  33. package/dist/adapters/sync-runner.d.ts +114 -0
  34. package/dist/adapters/sync-runner.js +273 -0
  35. package/dist/adapters/sync-state-store.d.ts +62 -0
  36. package/dist/adapters/sync-state-store.js +188 -0
  37. package/dist/config.d.ts +168 -0
  38. package/dist/config.js +392 -0
  39. package/dist/domain/ancestry-match.d.ts +49 -0
  40. package/dist/domain/ancestry-match.js +82 -0
  41. package/dist/domain/client-label.d.ts +28 -0
  42. package/dist/domain/client-label.js +44 -0
  43. package/dist/domain/day.d.ts +2 -0
  44. package/dist/domain/day.js +8 -0
  45. package/dist/domain/export.d.ts +38 -0
  46. package/dist/domain/export.js +68 -0
  47. package/dist/domain/hub-entry.d.ts +234 -0
  48. package/dist/domain/hub-entry.js +265 -0
  49. package/dist/domain/index.d.ts +19 -0
  50. package/dist/domain/index.js +19 -0
  51. package/dist/domain/intervals.d.ts +17 -0
  52. package/dist/domain/intervals.js +43 -0
  53. package/dist/domain/registry-health.d.ts +49 -0
  54. package/dist/domain/registry-health.js +58 -0
  55. package/dist/domain/segment-rule.d.ts +10 -0
  56. package/dist/domain/segment-rule.js +1 -0
  57. package/dist/domain/subagent-profile.d.ts +278 -0
  58. package/dist/domain/subagent-profile.js +418 -0
  59. package/dist/domain/sync-plan.d.ts +151 -0
  60. package/dist/domain/sync-plan.js +196 -0
  61. package/dist/domain/task-view.d.ts +117 -0
  62. package/dist/domain/task-view.js +428 -0
  63. package/dist/domain/work-record.d.ts +236 -0
  64. package/dist/domain/work-record.js +91 -0
  65. package/dist/domain/work-target.d.ts +101 -0
  66. package/dist/domain/work-target.js +149 -0
  67. package/dist/domain/work-tracker.d.ts +90 -0
  68. package/dist/domain/work-tracker.js +405 -0
  69. package/dist/hub/index.d.ts +25 -0
  70. package/dist/hub/index.js +25 -0
  71. package/dist/ports/catalog.d.ts +31 -0
  72. package/dist/ports/catalog.js +1 -0
  73. package/dist/ports/clock.d.ts +3 -0
  74. package/dist/ports/clock.js +1 -0
  75. package/dist/ports/index.d.ts +11 -0
  76. package/dist/ports/index.js +1 -0
  77. package/dist/ports/inflight-store.d.ts +15 -0
  78. package/dist/ports/inflight-store.js +1 -0
  79. package/dist/ports/process-registry.d.ts +72 -0
  80. package/dist/ports/process-registry.js +1 -0
  81. package/dist/ports/work-log.d.ts +14 -0
  82. package/dist/ports/work-log.js +1 -0
  83. package/dist/ports/work-sink.d.ts +39 -0
  84. package/dist/ports/work-sink.js +1 -0
  85. package/package.json +66 -0
  86. package/src/adapters/agent-info.ts +86 -0
  87. package/src/adapters/ancestry.ts +260 -0
  88. package/src/adapters/cached-catalog.ts +147 -0
  89. package/src/adapters/export-writer.ts +33 -0
  90. package/src/adapters/file-inflight-store.ts +115 -0
  91. package/src/adapters/file-modes.ts +35 -0
  92. package/src/adapters/hub-actions.ts +82 -0
  93. package/src/adapters/hub-credentials.ts +95 -0
  94. package/src/adapters/jsonl-work-log.ts +67 -0
  95. package/src/adapters/kankaku-command.ts +717 -0
  96. package/src/adapters/kankaku-dir.ts +102 -0
  97. package/src/adapters/lazy-file-inflight-store.ts +43 -0
  98. package/src/adapters/lazy-jsonl-work-log.ts +39 -0
  99. package/src/adapters/machine-process-registry.ts +256 -0
  100. package/src/adapters/panel/kankaku-panel.ts +419 -0
  101. package/src/adapters/panel/panel-items.ts +87 -0
  102. package/src/adapters/panel/panel-lines.ts +13 -0
  103. package/src/adapters/panel/panel-theme.ts +32 -0
  104. package/src/adapters/panel/screens/about.ts +69 -0
  105. package/src/adapters/panel/screens/doctor.ts +89 -0
  106. package/src/adapters/panel/screens/export.ts +123 -0
  107. package/src/adapters/panel/screens/report.ts +143 -0
  108. package/src/adapters/panel/screens/sync.ts +136 -0
  109. package/src/adapters/panel/screens/target.ts +384 -0
  110. package/src/adapters/pi-tracker.ts +753 -0
  111. package/src/adapters/pocketbase-catalog.ts +89 -0
  112. package/src/adapters/pocketbase-client.ts +197 -0
  113. package/src/adapters/pocketbase-sink.ts +236 -0
  114. package/src/adapters/process-identity-memo.ts +102 -0
  115. package/src/adapters/process-identity.ts +162 -0
  116. package/src/adapters/project-config.ts +116 -0
  117. package/src/adapters/report-data.ts +13 -0
  118. package/src/adapters/report-views.ts +98 -0
  119. package/src/adapters/report.ts +335 -0
  120. package/src/adapters/session-client.ts +116 -0
  121. package/src/adapters/session-dir.ts +28 -0
  122. package/src/adapters/session-target.ts +431 -0
  123. package/src/adapters/status-bar.ts +86 -0
  124. package/src/adapters/subagent-startup.ts +66 -0
  125. package/src/adapters/sync-runner.ts +340 -0
  126. package/src/adapters/sync-state-store.ts +227 -0
  127. package/src/adapters/target-picker.ts +127 -0
  128. package/src/config.ts +536 -0
  129. package/src/domain/ancestry-match.ts +84 -0
  130. package/src/domain/client-label.ts +56 -0
  131. package/src/domain/day.ts +8 -0
  132. package/src/domain/export.ts +107 -0
  133. package/src/domain/hub-entry.ts +433 -0
  134. package/src/domain/index.ts +19 -0
  135. package/src/domain/intervals.ts +53 -0
  136. package/src/domain/panel-model.ts +270 -0
  137. package/src/domain/registry-health.ts +87 -0
  138. package/src/domain/segment-rule.ts +10 -0
  139. package/src/domain/subagent-profile.ts +495 -0
  140. package/src/domain/sync-plan.ts +266 -0
  141. package/src/domain/task-view.ts +526 -0
  142. package/src/domain/work-record.ts +320 -0
  143. package/src/domain/work-target.ts +234 -0
  144. package/src/domain/work-tracker.ts +485 -0
  145. package/src/extension.ts +346 -0
  146. package/src/hub/index.ts +25 -0
  147. package/src/ports/catalog.ts +33 -0
  148. package/src/ports/clock.ts +3 -0
  149. package/src/ports/index.ts +11 -0
  150. package/src/ports/inflight-store.ts +16 -0
  151. package/src/ports/process-registry.ts +75 -0
  152. package/src/ports/work-log.ts +15 -0
  153. package/src/ports/work-sink.ts +35 -0
@@ -0,0 +1,485 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { Clock } from "../ports/clock.ts";
3
+ import { clampIntervals, unionMs } from "./intervals.ts";
4
+ import { emptyUsage, EXTENSION_RUN_PROMPT, finiteOrZero, WORK_RECORD_SCHEMA } from "./work-record.ts";
5
+ import type { RunTrigger, SubagentSpan, UsageTotals, WorkRecordCore, WorkStatus } from "./work-record.ts";
6
+ import type { SegmentRule } from "./segment-rule.ts";
7
+ import { mergeAgreeingLaunchInfo, readLaunchInfo, readResultInfo, resolveToolProfile, safeAmbiguousResultInfo } from "./subagent-profile.ts";
8
+ import type { SubagentProfile } from "./subagent-profile.ts";
9
+
10
+ export interface WorkTrackerOptions {
11
+ clock: Clock;
12
+ interactiveTools: string[];
13
+ /** Every active {@link SubagentProfile} (ADR 0020); a tool call opens a subagent span when its name matches any profile's `toolNames`. See `config.ts#loadConfig`. */
14
+ subagentProfiles: SubagentProfile[];
15
+ /** Rules that tag a tool execution's span under a named segment. Defaults to none. */
16
+ segmentRules?: SegmentRule[];
17
+ }
18
+
19
+ interface Interval {
20
+ start: number;
21
+ end: number | undefined;
22
+ }
23
+
24
+ interface OpenSubagentSpan {
25
+ toolCallId: string;
26
+ toolName: string;
27
+ agent: string;
28
+ mode: string;
29
+ /** The unambiguously-matched profile's id, or `undefined` when 0 or 2+ profiles registered this tool name (SUBAGENT-REQ-005 — never guessed). */
30
+ profile: string | undefined;
31
+ start: number;
32
+ }
33
+
34
+ interface RunState {
35
+ /** Generated once when the run opens, so it stays stable across `peek` and the final `onSettled`/`onShutdown`. */
36
+ id: string;
37
+ startedAt: number;
38
+ prompt: string;
39
+ trigger?: RunTrigger;
40
+ runs: number;
41
+ turns: number;
42
+ tools: Record<string, number>;
43
+ usage: UsageTotals;
44
+ /** `true` once any turn has reported a real (finite) provider cost figure. See `WorkRecordCore.costObserved`. */
45
+ costObserved: boolean;
46
+ status: WorkStatus;
47
+ waitingSpans: Interval[];
48
+ /** Waiting spans opened by interactive tools, keyed by tool call id. */
49
+ openToolWaits: Map<string, Interval>;
50
+ subagents: SubagentSpan[];
51
+ openSubagents: Map<string, OpenSubagentSpan>;
52
+ /**
53
+ * Segment spans opened by a matching {@link SegmentRule}, keyed by tag.
54
+ * A `Map` rather than a plain object so a tag such as `__proto__` cannot
55
+ * pollute the object prototype while the run is in progress.
56
+ */
57
+ segmentSpans: Map<string, Interval[]>;
58
+ /** The still-open segment span for a tool call id, if any. */
59
+ openSegments: Map<string, Interval>;
60
+ }
61
+
62
+ interface RunEndMessage {
63
+ role: string;
64
+ stopReason?: string;
65
+ }
66
+
67
+ /**
68
+ * Pure domain state machine that turns pi lifecycle events into finished
69
+ * {@link WorkRecord} entries. Holds no I/O; timestamps come from the
70
+ * injected {@link Clock} so behaviour is deterministic under test.
71
+ */
72
+ export class WorkTracker {
73
+ private readonly clock: Clock;
74
+ private readonly interactiveTools: Set<string>;
75
+ private readonly subagentProfiles: SubagentProfile[];
76
+ private readonly segmentRules: SegmentRule[];
77
+ private state: RunState | undefined;
78
+ /** Set by {@link onRunStart}, consumed by {@link onAgentStart}: tells a user-announced run from one an extension started. */
79
+ private runAnnounced = false;
80
+ /** `true` between an agent loop's start and its `agent_end`. */
81
+ private loopActive = false;
82
+ /**
83
+ * The previous record, set aside because a new run began after its last
84
+ * loop ended but BEFORE its `agent_settled` reached us. pi clears its
85
+ * "run active" flag and only then awaits the `agent_settled` handlers, in
86
+ * extension load order; an extension loaded earlier (gentle-pi) can start
87
+ * the next run from its own handler. Whether that `agent_start` was a new
88
+ * run or an `agent.continue()` of the same one is only knowable at settle
89
+ * time — see {@link settleAll}.
90
+ */
91
+ private closing: { state: RunState; at: number } | undefined;
92
+
93
+ constructor(options: WorkTrackerOptions) {
94
+ this.clock = options.clock;
95
+ this.interactiveTools = new Set(options.interactiveTools);
96
+ this.subagentProfiles = options.subagentProfiles;
97
+ this.segmentRules = options.segmentRules ?? [];
98
+ }
99
+
100
+ /**
101
+ * An agent loop actually started. pi announces a USER prompt with
102
+ * `before_agent_start` ({@link onRunStart}) and then this; a run an
103
+ * extension starts itself (`sendCustomMessage(..., { triggerTurn: true })`,
104
+ * how gentle-pi wakes the orchestrator when a background subagent
105
+ * finishes) only ever produces this one. Without it that whole run — its
106
+ * time, its cost, and the subagent spans it opens, which its children need
107
+ * to join — was never recorded.
108
+ */
109
+ onAgentStart(): void {
110
+ if (this.runAnnounced) {
111
+ // The loop of the user prompt onRunStart just opened.
112
+ this.runAnnounced = false;
113
+ this.loopActive = true;
114
+ return;
115
+ }
116
+ if (this.loopActive) return;
117
+ this.openRun(EXTENSION_RUN_PROMPT, "extension");
118
+ this.runAnnounced = false;
119
+ }
120
+
121
+ onRunStart(prompt: string, trigger?: RunTrigger): void {
122
+ this.openRun(prompt, trigger);
123
+ this.runAnnounced = true;
124
+ }
125
+
126
+ private openRun(prompt: string, trigger: RunTrigger | undefined): void {
127
+ if (this.state && this.loopActive) {
128
+ this.state.runs++;
129
+ return;
130
+ }
131
+ if (this.state && !this.closing) {
132
+ // A record is open but its last loop already ended: set it aside and
133
+ // start a fresh one now. settleAll() decides whether that was right.
134
+ this.closing = { state: this.state, at: this.clock.now() };
135
+ this.state = undefined;
136
+ }
137
+ this.loopActive = true;
138
+ if (this.state) {
139
+ this.state.runs++;
140
+ return;
141
+ }
142
+ this.state = {
143
+ id: randomUUID(),
144
+ startedAt: this.clock.now(),
145
+ prompt,
146
+ ...(trigger !== undefined ? { trigger } : {}),
147
+ runs: 1,
148
+ turns: 0,
149
+ tools: {},
150
+ usage: emptyUsage(),
151
+ costObserved: false,
152
+ status: "completed",
153
+ waitingSpans: [],
154
+ openToolWaits: new Map(),
155
+ subagents: [],
156
+ openSubagents: new Map(),
157
+ segmentSpans: new Map(),
158
+ openSegments: new Map(),
159
+ };
160
+ }
161
+
162
+ onTurnEnd(usage: Partial<UsageTotals> | undefined): void {
163
+ if (!this.state) return;
164
+ this.state.turns++;
165
+ if (!usage) return;
166
+ this.state.usage.input += finiteOrZero(usage.input);
167
+ this.state.usage.output += finiteOrZero(usage.output);
168
+ this.state.usage.cacheRead += finiteOrZero(usage.cacheRead);
169
+ this.state.usage.cacheWrite += finiteOrZero(usage.cacheWrite);
170
+ this.state.usage.cost += finiteOrZero(usage.cost);
171
+ // A real, finite cost figure (even an explicit 0) counts as "measured";
172
+ // an absent/non-finite one never un-sets a prior turn's observation.
173
+ if (typeof usage.cost === "number" && Number.isFinite(usage.cost)) {
174
+ this.state.costObserved = true;
175
+ }
176
+ }
177
+
178
+ onToolStart(toolCallId: string, toolName: string, args: Record<string, unknown> | undefined): void {
179
+ if (!this.state) return;
180
+ this.state.tools[toolName] = (this.state.tools[toolName] ?? 0) + 1;
181
+
182
+ const rule = this.segmentRules.find((candidate) => candidate.tool === toolName && candidate.pattern.test(segmentText(args)));
183
+ if (rule) {
184
+ const span: Interval = { start: this.clock.now(), end: undefined };
185
+ const spans = this.state.segmentSpans.get(rule.tag) ?? [];
186
+ spans.push(span);
187
+ this.state.segmentSpans.set(rule.tag, spans);
188
+ this.state.openSegments.set(toolCallId, span);
189
+ }
190
+
191
+ if (this.interactiveTools.has(toolName)) {
192
+ const span: Interval = { start: this.clock.now(), end: undefined };
193
+ this.state.waitingSpans.push(span);
194
+ this.state.openToolWaits.set(toolCallId, span);
195
+ return;
196
+ }
197
+
198
+ // SUBAGENT-REQ-001/005: a tool call opens a subagent span when its name
199
+ // matches ANY active profile's toolNames — generalised from the single
200
+ // hardcoded `subagentTool` string. `resolveToolProfile` resolves which
201
+ // SPECIFIC profile matched (for `readLaunchInfo`/attribution) without
202
+ // ever guessing when 2+ profiles share the same tool name (e.g.
203
+ // "subagent", registered by both the pi reference example and
204
+ // pi-subagents): the span still opens either way (a subagent tool call
205
+ // genuinely happened), with `profile` left undefined. C1: when
206
+ // genuinely ambiguous, agent/mode are read with `mergeAgreeingLaunchInfo`
207
+ // (kept only when every candidate that reports a value agrees) instead
208
+ // of `readLaunchInfo`'s first-defined-wins merge — this is purely
209
+ // descriptive (never money/join-affecting), but "agreeing" is still the
210
+ // more honest answer than silently picking one candidate's guess.
211
+ const { profile, candidates, ambiguous } = resolveToolProfile(this.subagentProfiles, toolName);
212
+ if (candidates.length > 0) {
213
+ const launch = ambiguous ? mergeAgreeingLaunchInfo(candidates, args) : readLaunchInfo(candidates, args);
214
+ this.state.openSubagents.set(toolCallId, {
215
+ toolCallId,
216
+ toolName,
217
+ agent: launch.agent ?? "unknown",
218
+ mode: launch.mode ?? "task",
219
+ profile: profile?.id,
220
+ start: this.clock.now(),
221
+ });
222
+ }
223
+ }
224
+
225
+ onToolEnd(toolCallId: string, result: unknown): void {
226
+ if (!this.state) return;
227
+
228
+ const openSegment = this.state.openSegments.get(toolCallId);
229
+ if (openSegment) {
230
+ this.state.openSegments.delete(toolCallId);
231
+ openSegment.end = this.clock.now();
232
+ }
233
+
234
+ const openSubagent = this.state.openSubagents.get(toolCallId);
235
+ if (openSubagent) {
236
+ // Consumed exactly once: a second onToolEnd for the same toolCallId
237
+ // (should never happen from a well-behaved pi runtime) finds nothing
238
+ // here and falls through — the guard that keeps SUBAGENT-REQ-006's
239
+ // usage forwarding below from ever double-attributing the same call.
240
+ this.state.openSubagents.delete(toolCallId);
241
+
242
+ const { ambiguous, candidates } = resolveToolProfile(this.subagentProfiles, openSubagent.toolName);
243
+ // C1 (CRITICAL fix, SUBAGENT-REQ-005/006): a genuinely ambiguous
244
+ // match (2+ profiles registered this tool name, never told apart)
245
+ // must never forward `usage` or `taskId` — see
246
+ // `subagent-profile.ts#safeAmbiguousResultInfo`'s doc comment for
247
+ // why. The unambiguous case keeps the existing best-effort merge
248
+ // (a no-op merge when there is exactly one candidate).
249
+ const resultInfo = ambiguous ? safeAmbiguousResultInfo(candidates, result) : readResultInfo(candidates, result);
250
+
251
+ // SUBAGENT-REQ-006 (revised by C1): forwarded usage is recorded on
252
+ // the SPAN itself as `forwardedUsage`, never folded into this
253
+ // record's own `usage` totals here — `domain/task-view.ts#buildTasks`
254
+ // (ADR 0006: aggregation across spans/children stays in exactly one
255
+ // place) is the only place that adds it to a task's total, and only
256
+ // for a span whose profile has no joined child record already
257
+ // carrying this same cost through its own confirmed-marker ancestry
258
+ // join (the writer-admitted "configured profile with both a marker
259
+ // AND usage forwarding" hole — see `buildConfiguredProfile`'s doc
260
+ // comment). `costObserved` is still set here, eagerly: a real cost
261
+ // figure genuinely WAS observed by this call, whether or not this
262
+ // specific number ends up in the final sum later.
263
+ const usage = resultInfo.usage;
264
+ if (usage && typeof usage.cost === "number" && Number.isFinite(usage.cost)) {
265
+ this.state.costObserved = true;
266
+ }
267
+
268
+ this.state.subagents.push({
269
+ toolCallId: openSubagent.toolCallId,
270
+ agent: openSubagent.agent,
271
+ mode: openSubagent.mode,
272
+ ...(resultInfo.taskId !== undefined ? { taskId: resultInfo.taskId } : {}),
273
+ ...(openSubagent.profile !== undefined ? { profile: openSubagent.profile } : {}),
274
+ // Clamped to >= 0: a backward clock jump while the subagent was
275
+ // running must never produce a negative duration.
276
+ ms: Math.max(0, this.clock.now() - openSubagent.start),
277
+ ...(usage !== undefined ? { forwardedUsage: usage } : {}),
278
+ });
279
+ return;
280
+ }
281
+
282
+ const openWaitingSpan = this.state.openToolWaits.get(toolCallId);
283
+ if (openWaitingSpan) {
284
+ this.state.openToolWaits.delete(toolCallId);
285
+ openWaitingSpan.end = this.clock.now();
286
+ }
287
+ }
288
+
289
+ onUiPromptStart(_kind: string): void {
290
+ if (!this.state) return;
291
+ this.state.waitingSpans.push({ start: this.clock.now(), end: undefined });
292
+ }
293
+
294
+ onUiPromptEnd(_kind: string): void {
295
+ if (!this.state) return;
296
+ // Prompts are sequential; close the most recently opened span (LIFO).
297
+ for (let i = this.state.waitingSpans.length - 1; i >= 0; i--) {
298
+ const span = this.state.waitingSpans[i];
299
+ if (span && span.end === undefined) {
300
+ span.end = this.clock.now();
301
+ return;
302
+ }
303
+ }
304
+ }
305
+
306
+ onRunEnd(messages: RunEndMessage[]): void {
307
+ this.loopActive = false;
308
+ if (!this.state) return;
309
+ const lastAssistant = [...messages].reverse().find((message) => message.role === "assistant");
310
+ if (lastAssistant?.stopReason === "aborted") {
311
+ this.state.status = "aborted";
312
+ }
313
+ }
314
+
315
+ /** Single-record convenience over {@link settleAll}; production code uses `settleAll`. */
316
+ onSettled(): WorkRecordCore | undefined {
317
+ return this.settleAll()[0];
318
+ }
319
+
320
+ /**
321
+ * `agent_settled` arrived. Normally closes the one open record. When a
322
+ * record was set aside (see {@link closing}):
323
+ * - a loop is still running → the `agent_start` was a genuinely NEW run
324
+ * that overtook this settle: close only the old record, at the instant
325
+ * the new run began, and leave the new one open;
326
+ * - no loop is running → it was an `agent.continue()` of the same run
327
+ * (retry, overflow recovery, queued message): fold it back, one record.
328
+ * A user-announced prompt is never folded — it keeps its own record.
329
+ */
330
+ settleAll(): WorkRecordCore[] {
331
+ const now = this.clock.now();
332
+ const closing = this.closing;
333
+ this.closing = undefined;
334
+ if (closing) {
335
+ if (this.loopActive && this.state) {
336
+ return [this.buildRecordFrom(closing.state, closing.state.status, closing.at)];
337
+ }
338
+ const fresh = this.state;
339
+ this.state = undefined;
340
+ this.runAnnounced = false;
341
+ this.loopActive = false;
342
+ if (!fresh) return [this.buildRecordFrom(closing.state, closing.state.status, now)];
343
+ if (fresh.trigger === "extension") {
344
+ return [this.buildRecordFrom(mergeRunStates(closing.state, fresh), fresh.status !== "completed" ? fresh.status : closing.state.status, now)];
345
+ }
346
+ return [this.buildRecordFrom(closing.state, closing.state.status, closing.at), this.buildRecordFrom(fresh, fresh.status, now)];
347
+ }
348
+ // Settled with nothing set aside: whatever loop we believed active is over.
349
+ this.loopActive = false;
350
+ if (!this.state) return [];
351
+ const record = this.buildRecordFrom(this.state, this.state.status, now);
352
+ this.state = undefined;
353
+ this.runAnnounced = false;
354
+ return [record];
355
+ }
356
+
357
+ /** Single-record convenience over {@link shutdownAll}. */
358
+ onShutdown(): WorkRecordCore | undefined {
359
+ const all = this.shutdownAll();
360
+ return all[all.length - 1];
361
+ }
362
+
363
+ /** The session is going away: every open record is written, the running one as `interrupted`. */
364
+ shutdownAll(): WorkRecordCore[] {
365
+ const now = this.clock.now();
366
+ const records: WorkRecordCore[] = [];
367
+ if (this.closing && this.state) {
368
+ // ONE record, the same shape and id as the crash checkpoint (peek). Two
369
+ // separate records here double-billed: if the process died before the
370
+ // checkpoint was cleared, recovery re-appended the merged snapshot next
371
+ // to the new run's own record — overlapping cost under two ids, which
372
+ // no dedupe by id can see.
373
+ records.push(this.buildRecordFrom(mergeRunStates(this.closing.state, this.state), "interrupted", now));
374
+ } else if (this.closing) {
375
+ records.push(this.buildRecordFrom(this.closing.state, this.closing.state.status, this.closing.at));
376
+ } else if (this.state) {
377
+ records.push(this.buildRecordFrom(this.state, "interrupted", now));
378
+ }
379
+ this.closing = undefined;
380
+ this.state = undefined;
381
+ this.runAnnounced = false;
382
+ this.loopActive = false;
383
+ return records;
384
+ }
385
+
386
+ /**
387
+ * Returns what {@link onSettled}/{@link onShutdown} would produce right
388
+ * now, without mutating any state: open spans are truncated only in the
389
+ * returned snapshot, so the tracker keeps running unaffected and a later
390
+ * `peek` or the eventual settle still sees the spans' true open-ended
391
+ * state. `undefined` when idle. The returned `id` matches the id the
392
+ * eventual settled record will carry, since both are generated once
393
+ * in {@link onRunStart}.
394
+ */
395
+ peek(status: WorkStatus): WorkRecordCore | undefined {
396
+ if (!this.state) return undefined;
397
+ // A record set aside (see `closing`) is nowhere on disk yet: fold it into
398
+ // the snapshot so a crash before the settle cannot lose its time and
399
+ // cost. The snapshot keeps the set-aside record's id.
400
+ const state = this.closing ? mergeRunStates(this.closing.state, this.state) : this.state;
401
+ return this.buildRecordFrom(state, status, this.clock.now());
402
+ }
403
+
404
+ private buildRecordFrom(state: RunState, status: WorkStatus, settledAt: number): WorkRecordCore {
405
+ // Clamped to >= 0: a backward clock jump (system clock adjustment, NTP
406
+ // correction) must never produce a negative duration.
407
+ const wallMs = Math.max(0, settledAt - state.startedAt);
408
+
409
+ const closedSpans = state.waitingSpans.map((span) => ({ start: span.start, end: span.end ?? settledAt }));
410
+ const waitingMs = Math.max(0, unionMs(clampIntervals(closedSpans, state.startedAt, settledAt)));
411
+ const workMs = Math.max(0, wallMs - waitingMs);
412
+
413
+ const segmentEntries: Array<[string, number]> = [];
414
+ for (const [tag, spans] of state.segmentSpans) {
415
+ const closedTagSpans = spans.map((span) => ({ start: span.start, end: span.end ?? settledAt }));
416
+ const tagMs = Math.max(0, unionMs(clampIntervals(closedTagSpans, state.startedAt, settledAt)));
417
+ if (tagMs > 0) {
418
+ segmentEntries.push([tag, tagMs]);
419
+ }
420
+ }
421
+ // Built via Object.fromEntries (never `segments[tag] = ...`) so a tag
422
+ // such as `__proto__` becomes an own data property instead of silently
423
+ // repointing the object's prototype.
424
+ const segments = Object.fromEntries(segmentEntries);
425
+
426
+ return {
427
+ schema: WORK_RECORD_SCHEMA,
428
+ id: state.id,
429
+ prompt: state.prompt,
430
+ ...(state.trigger !== undefined ? { trigger: state.trigger } : {}),
431
+ startedAt: new Date(state.startedAt).toISOString(),
432
+ settledAt: new Date(settledAt).toISOString(),
433
+ wallMs,
434
+ waitingMs,
435
+ workMs,
436
+ runs: state.runs,
437
+ turns: state.turns,
438
+ tools: state.tools,
439
+ subagents: state.subagents,
440
+ segments,
441
+ usage: state.usage,
442
+ ...(state.costObserved ? { costObserved: true as const } : {}),
443
+ status,
444
+ };
445
+ }
446
+ }
447
+
448
+ /** Text to match a {@link SegmentRule} pattern against: the `command` string arg when present, else the whole args object as JSON. */
449
+ function segmentText(args: Record<string, unknown> | undefined): string {
450
+ const command = args?.["command"];
451
+ return typeof command === "string" ? command : JSON.stringify(args ?? {});
452
+ }
453
+
454
+
455
+ /** Fold a continuation's counters back into the record it continued. Additive only: nothing is ever dropped. */
456
+ function mergeRunStates(base: RunState, continuation: RunState): RunState {
457
+ const tools: Record<string, number> = { ...base.tools };
458
+ for (const [name, count] of Object.entries(continuation.tools)) {
459
+ tools[name] = (tools[name] ?? 0) + count;
460
+ }
461
+ const segmentSpans = new Map(base.segmentSpans);
462
+ for (const [tag, spans] of continuation.segmentSpans) {
463
+ segmentSpans.set(tag, [...(segmentSpans.get(tag) ?? []), ...spans]);
464
+ }
465
+ return {
466
+ ...base,
467
+ runs: base.runs + continuation.runs,
468
+ turns: base.turns + continuation.turns,
469
+ tools,
470
+ usage: {
471
+ input: base.usage.input + continuation.usage.input,
472
+ output: base.usage.output + continuation.usage.output,
473
+ cacheRead: base.usage.cacheRead + continuation.usage.cacheRead,
474
+ cacheWrite: base.usage.cacheWrite + continuation.usage.cacheWrite,
475
+ cost: base.usage.cost + continuation.usage.cost,
476
+ },
477
+ costObserved: base.costObserved || continuation.costObserved,
478
+ waitingSpans: [...base.waitingSpans, ...continuation.waitingSpans],
479
+ openToolWaits: new Map([...base.openToolWaits, ...continuation.openToolWaits]),
480
+ subagents: [...base.subagents, ...continuation.subagents],
481
+ openSubagents: new Map([...base.openSubagents, ...continuation.openSubagents]),
482
+ segmentSpans,
483
+ openSegments: new Map([...base.openSegments, ...continuation.openSegments]),
484
+ };
485
+ }