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,405 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { clampIntervals, unionMs } from "./intervals.js";
3
+ import { emptyUsage, EXTENSION_RUN_PROMPT, finiteOrZero, WORK_RECORD_SCHEMA } from "./work-record.js";
4
+ import { mergeAgreeingLaunchInfo, readLaunchInfo, readResultInfo, resolveToolProfile, safeAmbiguousResultInfo } from "./subagent-profile.js";
5
+ /**
6
+ * Pure domain state machine that turns pi lifecycle events into finished
7
+ * {@link WorkRecord} entries. Holds no I/O; timestamps come from the
8
+ * injected {@link Clock} so behaviour is deterministic under test.
9
+ */
10
+ export class WorkTracker {
11
+ clock;
12
+ interactiveTools;
13
+ subagentProfiles;
14
+ segmentRules;
15
+ state;
16
+ /** Set by {@link onRunStart}, consumed by {@link onAgentStart}: tells a user-announced run from one an extension started. */
17
+ runAnnounced = false;
18
+ /** `true` between an agent loop's start and its `agent_end`. */
19
+ loopActive = false;
20
+ /**
21
+ * The previous record, set aside because a new run began after its last
22
+ * loop ended but BEFORE its `agent_settled` reached us. pi clears its
23
+ * "run active" flag and only then awaits the `agent_settled` handlers, in
24
+ * extension load order; an extension loaded earlier (gentle-pi) can start
25
+ * the next run from its own handler. Whether that `agent_start` was a new
26
+ * run or an `agent.continue()` of the same one is only knowable at settle
27
+ * time — see {@link settleAll}.
28
+ */
29
+ closing;
30
+ constructor(options) {
31
+ this.clock = options.clock;
32
+ this.interactiveTools = new Set(options.interactiveTools);
33
+ this.subagentProfiles = options.subagentProfiles;
34
+ this.segmentRules = options.segmentRules ?? [];
35
+ }
36
+ /**
37
+ * An agent loop actually started. pi announces a USER prompt with
38
+ * `before_agent_start` ({@link onRunStart}) and then this; a run an
39
+ * extension starts itself (`sendCustomMessage(..., { triggerTurn: true })`,
40
+ * how gentle-pi wakes the orchestrator when a background subagent
41
+ * finishes) only ever produces this one. Without it that whole run — its
42
+ * time, its cost, and the subagent spans it opens, which its children need
43
+ * to join — was never recorded.
44
+ */
45
+ onAgentStart() {
46
+ if (this.runAnnounced) {
47
+ // The loop of the user prompt onRunStart just opened.
48
+ this.runAnnounced = false;
49
+ this.loopActive = true;
50
+ return;
51
+ }
52
+ if (this.loopActive)
53
+ return;
54
+ this.openRun(EXTENSION_RUN_PROMPT, "extension");
55
+ this.runAnnounced = false;
56
+ }
57
+ onRunStart(prompt, trigger) {
58
+ this.openRun(prompt, trigger);
59
+ this.runAnnounced = true;
60
+ }
61
+ openRun(prompt, trigger) {
62
+ if (this.state && this.loopActive) {
63
+ this.state.runs++;
64
+ return;
65
+ }
66
+ if (this.state && !this.closing) {
67
+ // A record is open but its last loop already ended: set it aside and
68
+ // start a fresh one now. settleAll() decides whether that was right.
69
+ this.closing = { state: this.state, at: this.clock.now() };
70
+ this.state = undefined;
71
+ }
72
+ this.loopActive = true;
73
+ if (this.state) {
74
+ this.state.runs++;
75
+ return;
76
+ }
77
+ this.state = {
78
+ id: randomUUID(),
79
+ startedAt: this.clock.now(),
80
+ prompt,
81
+ ...(trigger !== undefined ? { trigger } : {}),
82
+ runs: 1,
83
+ turns: 0,
84
+ tools: {},
85
+ usage: emptyUsage(),
86
+ costObserved: false,
87
+ status: "completed",
88
+ waitingSpans: [],
89
+ openToolWaits: new Map(),
90
+ subagents: [],
91
+ openSubagents: new Map(),
92
+ segmentSpans: new Map(),
93
+ openSegments: new Map(),
94
+ };
95
+ }
96
+ onTurnEnd(usage) {
97
+ if (!this.state)
98
+ return;
99
+ this.state.turns++;
100
+ if (!usage)
101
+ return;
102
+ this.state.usage.input += finiteOrZero(usage.input);
103
+ this.state.usage.output += finiteOrZero(usage.output);
104
+ this.state.usage.cacheRead += finiteOrZero(usage.cacheRead);
105
+ this.state.usage.cacheWrite += finiteOrZero(usage.cacheWrite);
106
+ this.state.usage.cost += finiteOrZero(usage.cost);
107
+ // A real, finite cost figure (even an explicit 0) counts as "measured";
108
+ // an absent/non-finite one never un-sets a prior turn's observation.
109
+ if (typeof usage.cost === "number" && Number.isFinite(usage.cost)) {
110
+ this.state.costObserved = true;
111
+ }
112
+ }
113
+ onToolStart(toolCallId, toolName, args) {
114
+ if (!this.state)
115
+ return;
116
+ this.state.tools[toolName] = (this.state.tools[toolName] ?? 0) + 1;
117
+ const rule = this.segmentRules.find((candidate) => candidate.tool === toolName && candidate.pattern.test(segmentText(args)));
118
+ if (rule) {
119
+ const span = { start: this.clock.now(), end: undefined };
120
+ const spans = this.state.segmentSpans.get(rule.tag) ?? [];
121
+ spans.push(span);
122
+ this.state.segmentSpans.set(rule.tag, spans);
123
+ this.state.openSegments.set(toolCallId, span);
124
+ }
125
+ if (this.interactiveTools.has(toolName)) {
126
+ const span = { start: this.clock.now(), end: undefined };
127
+ this.state.waitingSpans.push(span);
128
+ this.state.openToolWaits.set(toolCallId, span);
129
+ return;
130
+ }
131
+ // SUBAGENT-REQ-001/005: a tool call opens a subagent span when its name
132
+ // matches ANY active profile's toolNames — generalised from the single
133
+ // hardcoded `subagentTool` string. `resolveToolProfile` resolves which
134
+ // SPECIFIC profile matched (for `readLaunchInfo`/attribution) without
135
+ // ever guessing when 2+ profiles share the same tool name (e.g.
136
+ // "subagent", registered by both the pi reference example and
137
+ // pi-subagents): the span still opens either way (a subagent tool call
138
+ // genuinely happened), with `profile` left undefined. C1: when
139
+ // genuinely ambiguous, agent/mode are read with `mergeAgreeingLaunchInfo`
140
+ // (kept only when every candidate that reports a value agrees) instead
141
+ // of `readLaunchInfo`'s first-defined-wins merge — this is purely
142
+ // descriptive (never money/join-affecting), but "agreeing" is still the
143
+ // more honest answer than silently picking one candidate's guess.
144
+ const { profile, candidates, ambiguous } = resolveToolProfile(this.subagentProfiles, toolName);
145
+ if (candidates.length > 0) {
146
+ const launch = ambiguous ? mergeAgreeingLaunchInfo(candidates, args) : readLaunchInfo(candidates, args);
147
+ this.state.openSubagents.set(toolCallId, {
148
+ toolCallId,
149
+ toolName,
150
+ agent: launch.agent ?? "unknown",
151
+ mode: launch.mode ?? "task",
152
+ profile: profile?.id,
153
+ start: this.clock.now(),
154
+ });
155
+ }
156
+ }
157
+ onToolEnd(toolCallId, result) {
158
+ if (!this.state)
159
+ return;
160
+ const openSegment = this.state.openSegments.get(toolCallId);
161
+ if (openSegment) {
162
+ this.state.openSegments.delete(toolCallId);
163
+ openSegment.end = this.clock.now();
164
+ }
165
+ const openSubagent = this.state.openSubagents.get(toolCallId);
166
+ if (openSubagent) {
167
+ // Consumed exactly once: a second onToolEnd for the same toolCallId
168
+ // (should never happen from a well-behaved pi runtime) finds nothing
169
+ // here and falls through — the guard that keeps SUBAGENT-REQ-006's
170
+ // usage forwarding below from ever double-attributing the same call.
171
+ this.state.openSubagents.delete(toolCallId);
172
+ const { ambiguous, candidates } = resolveToolProfile(this.subagentProfiles, openSubagent.toolName);
173
+ // C1 (CRITICAL fix, SUBAGENT-REQ-005/006): a genuinely ambiguous
174
+ // match (2+ profiles registered this tool name, never told apart)
175
+ // must never forward `usage` or `taskId` — see
176
+ // `subagent-profile.ts#safeAmbiguousResultInfo`'s doc comment for
177
+ // why. The unambiguous case keeps the existing best-effort merge
178
+ // (a no-op merge when there is exactly one candidate).
179
+ const resultInfo = ambiguous ? safeAmbiguousResultInfo(candidates, result) : readResultInfo(candidates, result);
180
+ // SUBAGENT-REQ-006 (revised by C1): forwarded usage is recorded on
181
+ // the SPAN itself as `forwardedUsage`, never folded into this
182
+ // record's own `usage` totals here — `domain/task-view.ts#buildTasks`
183
+ // (ADR 0006: aggregation across spans/children stays in exactly one
184
+ // place) is the only place that adds it to a task's total, and only
185
+ // for a span whose profile has no joined child record already
186
+ // carrying this same cost through its own confirmed-marker ancestry
187
+ // join (the writer-admitted "configured profile with both a marker
188
+ // AND usage forwarding" hole — see `buildConfiguredProfile`'s doc
189
+ // comment). `costObserved` is still set here, eagerly: a real cost
190
+ // figure genuinely WAS observed by this call, whether or not this
191
+ // specific number ends up in the final sum later.
192
+ const usage = resultInfo.usage;
193
+ if (usage && typeof usage.cost === "number" && Number.isFinite(usage.cost)) {
194
+ this.state.costObserved = true;
195
+ }
196
+ this.state.subagents.push({
197
+ toolCallId: openSubagent.toolCallId,
198
+ agent: openSubagent.agent,
199
+ mode: openSubagent.mode,
200
+ ...(resultInfo.taskId !== undefined ? { taskId: resultInfo.taskId } : {}),
201
+ ...(openSubagent.profile !== undefined ? { profile: openSubagent.profile } : {}),
202
+ // Clamped to >= 0: a backward clock jump while the subagent was
203
+ // running must never produce a negative duration.
204
+ ms: Math.max(0, this.clock.now() - openSubagent.start),
205
+ ...(usage !== undefined ? { forwardedUsage: usage } : {}),
206
+ });
207
+ return;
208
+ }
209
+ const openWaitingSpan = this.state.openToolWaits.get(toolCallId);
210
+ if (openWaitingSpan) {
211
+ this.state.openToolWaits.delete(toolCallId);
212
+ openWaitingSpan.end = this.clock.now();
213
+ }
214
+ }
215
+ onUiPromptStart(_kind) {
216
+ if (!this.state)
217
+ return;
218
+ this.state.waitingSpans.push({ start: this.clock.now(), end: undefined });
219
+ }
220
+ onUiPromptEnd(_kind) {
221
+ if (!this.state)
222
+ return;
223
+ // Prompts are sequential; close the most recently opened span (LIFO).
224
+ for (let i = this.state.waitingSpans.length - 1; i >= 0; i--) {
225
+ const span = this.state.waitingSpans[i];
226
+ if (span && span.end === undefined) {
227
+ span.end = this.clock.now();
228
+ return;
229
+ }
230
+ }
231
+ }
232
+ onRunEnd(messages) {
233
+ this.loopActive = false;
234
+ if (!this.state)
235
+ return;
236
+ const lastAssistant = [...messages].reverse().find((message) => message.role === "assistant");
237
+ if (lastAssistant?.stopReason === "aborted") {
238
+ this.state.status = "aborted";
239
+ }
240
+ }
241
+ /** Single-record convenience over {@link settleAll}; production code uses `settleAll`. */
242
+ onSettled() {
243
+ return this.settleAll()[0];
244
+ }
245
+ /**
246
+ * `agent_settled` arrived. Normally closes the one open record. When a
247
+ * record was set aside (see {@link closing}):
248
+ * - a loop is still running → the `agent_start` was a genuinely NEW run
249
+ * that overtook this settle: close only the old record, at the instant
250
+ * the new run began, and leave the new one open;
251
+ * - no loop is running → it was an `agent.continue()` of the same run
252
+ * (retry, overflow recovery, queued message): fold it back, one record.
253
+ * A user-announced prompt is never folded — it keeps its own record.
254
+ */
255
+ settleAll() {
256
+ const now = this.clock.now();
257
+ const closing = this.closing;
258
+ this.closing = undefined;
259
+ if (closing) {
260
+ if (this.loopActive && this.state) {
261
+ return [this.buildRecordFrom(closing.state, closing.state.status, closing.at)];
262
+ }
263
+ const fresh = this.state;
264
+ this.state = undefined;
265
+ this.runAnnounced = false;
266
+ this.loopActive = false;
267
+ if (!fresh)
268
+ return [this.buildRecordFrom(closing.state, closing.state.status, now)];
269
+ if (fresh.trigger === "extension") {
270
+ return [this.buildRecordFrom(mergeRunStates(closing.state, fresh), fresh.status !== "completed" ? fresh.status : closing.state.status, now)];
271
+ }
272
+ return [this.buildRecordFrom(closing.state, closing.state.status, closing.at), this.buildRecordFrom(fresh, fresh.status, now)];
273
+ }
274
+ // Settled with nothing set aside: whatever loop we believed active is over.
275
+ this.loopActive = false;
276
+ if (!this.state)
277
+ return [];
278
+ const record = this.buildRecordFrom(this.state, this.state.status, now);
279
+ this.state = undefined;
280
+ this.runAnnounced = false;
281
+ return [record];
282
+ }
283
+ /** Single-record convenience over {@link shutdownAll}. */
284
+ onShutdown() {
285
+ const all = this.shutdownAll();
286
+ return all[all.length - 1];
287
+ }
288
+ /** The session is going away: every open record is written, the running one as `interrupted`. */
289
+ shutdownAll() {
290
+ const now = this.clock.now();
291
+ const records = [];
292
+ if (this.closing && this.state) {
293
+ // ONE record, the same shape and id as the crash checkpoint (peek). Two
294
+ // separate records here double-billed: if the process died before the
295
+ // checkpoint was cleared, recovery re-appended the merged snapshot next
296
+ // to the new run's own record — overlapping cost under two ids, which
297
+ // no dedupe by id can see.
298
+ records.push(this.buildRecordFrom(mergeRunStates(this.closing.state, this.state), "interrupted", now));
299
+ }
300
+ else if (this.closing) {
301
+ records.push(this.buildRecordFrom(this.closing.state, this.closing.state.status, this.closing.at));
302
+ }
303
+ else if (this.state) {
304
+ records.push(this.buildRecordFrom(this.state, "interrupted", now));
305
+ }
306
+ this.closing = undefined;
307
+ this.state = undefined;
308
+ this.runAnnounced = false;
309
+ this.loopActive = false;
310
+ return records;
311
+ }
312
+ /**
313
+ * Returns what {@link onSettled}/{@link onShutdown} would produce right
314
+ * now, without mutating any state: open spans are truncated only in the
315
+ * returned snapshot, so the tracker keeps running unaffected and a later
316
+ * `peek` or the eventual settle still sees the spans' true open-ended
317
+ * state. `undefined` when idle. The returned `id` matches the id the
318
+ * eventual settled record will carry, since both are generated once
319
+ * in {@link onRunStart}.
320
+ */
321
+ peek(status) {
322
+ if (!this.state)
323
+ return undefined;
324
+ // A record set aside (see `closing`) is nowhere on disk yet: fold it into
325
+ // the snapshot so a crash before the settle cannot lose its time and
326
+ // cost. The snapshot keeps the set-aside record's id.
327
+ const state = this.closing ? mergeRunStates(this.closing.state, this.state) : this.state;
328
+ return this.buildRecordFrom(state, status, this.clock.now());
329
+ }
330
+ buildRecordFrom(state, status, settledAt) {
331
+ // Clamped to >= 0: a backward clock jump (system clock adjustment, NTP
332
+ // correction) must never produce a negative duration.
333
+ const wallMs = Math.max(0, settledAt - state.startedAt);
334
+ const closedSpans = state.waitingSpans.map((span) => ({ start: span.start, end: span.end ?? settledAt }));
335
+ const waitingMs = Math.max(0, unionMs(clampIntervals(closedSpans, state.startedAt, settledAt)));
336
+ const workMs = Math.max(0, wallMs - waitingMs);
337
+ const segmentEntries = [];
338
+ for (const [tag, spans] of state.segmentSpans) {
339
+ const closedTagSpans = spans.map((span) => ({ start: span.start, end: span.end ?? settledAt }));
340
+ const tagMs = Math.max(0, unionMs(clampIntervals(closedTagSpans, state.startedAt, settledAt)));
341
+ if (tagMs > 0) {
342
+ segmentEntries.push([tag, tagMs]);
343
+ }
344
+ }
345
+ // Built via Object.fromEntries (never `segments[tag] = ...`) so a tag
346
+ // such as `__proto__` becomes an own data property instead of silently
347
+ // repointing the object's prototype.
348
+ const segments = Object.fromEntries(segmentEntries);
349
+ return {
350
+ schema: WORK_RECORD_SCHEMA,
351
+ id: state.id,
352
+ prompt: state.prompt,
353
+ ...(state.trigger !== undefined ? { trigger: state.trigger } : {}),
354
+ startedAt: new Date(state.startedAt).toISOString(),
355
+ settledAt: new Date(settledAt).toISOString(),
356
+ wallMs,
357
+ waitingMs,
358
+ workMs,
359
+ runs: state.runs,
360
+ turns: state.turns,
361
+ tools: state.tools,
362
+ subagents: state.subagents,
363
+ segments,
364
+ usage: state.usage,
365
+ ...(state.costObserved ? { costObserved: true } : {}),
366
+ status,
367
+ };
368
+ }
369
+ }
370
+ /** Text to match a {@link SegmentRule} pattern against: the `command` string arg when present, else the whole args object as JSON. */
371
+ function segmentText(args) {
372
+ const command = args?.["command"];
373
+ return typeof command === "string" ? command : JSON.stringify(args ?? {});
374
+ }
375
+ /** Fold a continuation's counters back into the record it continued. Additive only: nothing is ever dropped. */
376
+ function mergeRunStates(base, continuation) {
377
+ const tools = { ...base.tools };
378
+ for (const [name, count] of Object.entries(continuation.tools)) {
379
+ tools[name] = (tools[name] ?? 0) + count;
380
+ }
381
+ const segmentSpans = new Map(base.segmentSpans);
382
+ for (const [tag, spans] of continuation.segmentSpans) {
383
+ segmentSpans.set(tag, [...(segmentSpans.get(tag) ?? []), ...spans]);
384
+ }
385
+ return {
386
+ ...base,
387
+ runs: base.runs + continuation.runs,
388
+ turns: base.turns + continuation.turns,
389
+ tools,
390
+ usage: {
391
+ input: base.usage.input + continuation.usage.input,
392
+ output: base.usage.output + continuation.usage.output,
393
+ cacheRead: base.usage.cacheRead + continuation.usage.cacheRead,
394
+ cacheWrite: base.usage.cacheWrite + continuation.usage.cacheWrite,
395
+ cost: base.usage.cost + continuation.usage.cost,
396
+ },
397
+ costObserved: base.costObserved || continuation.costObserved,
398
+ waitingSpans: [...base.waitingSpans, ...continuation.waitingSpans],
399
+ openToolWaits: new Map([...base.openToolWaits, ...continuation.openToolWaits]),
400
+ subagents: [...base.subagents, ...continuation.subagents],
401
+ openSubagents: new Map([...base.openSubagents, ...continuation.openSubagents]),
402
+ segmentSpans,
403
+ openSegments: new Map([...base.openSegments, ...continuation.openSegments]),
404
+ };
405
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Public library entrypoint (`kankaku-pi/hub`): the pi-free adapters that talk
3
+ * to the filesystem and the hub's HTTP layer, for a plain Node consumer
4
+ * that wants to read a worklog and/or sync it to the hub without pi. Every
5
+ * adapter that imports a pi package type is deliberately left out — see
6
+ * AGENTS.md "Architecture (hexagonal)" and odd/tasks/library-exports.md
7
+ * for which ones and why.
8
+ */
9
+ export * from "../adapters/cached-catalog.ts";
10
+ export * from "../adapters/export-writer.ts";
11
+ export * from "../adapters/file-modes.ts";
12
+ export * from "../adapters/hub-actions.ts";
13
+ export * from "../adapters/hub-credentials.ts";
14
+ export * from "../adapters/jsonl-work-log.ts";
15
+ export * from "../adapters/kankaku-dir.ts";
16
+ export * from "../adapters/lazy-jsonl-work-log.ts";
17
+ export * from "../adapters/pocketbase-catalog.ts";
18
+ export * from "../adapters/pocketbase-client.ts";
19
+ export * from "../adapters/pocketbase-sink.ts";
20
+ export * from "../adapters/project-config.ts";
21
+ export * from "../adapters/report-data.ts";
22
+ export * from "../adapters/report.ts";
23
+ export * from "../adapters/report-views.ts";
24
+ export * from "../adapters/sync-runner.ts";
25
+ export * from "../adapters/sync-state-store.ts";
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Public library entrypoint (`kankaku-pi/hub`): the pi-free adapters that talk
3
+ * to the filesystem and the hub's HTTP layer, for a plain Node consumer
4
+ * that wants to read a worklog and/or sync it to the hub without pi. Every
5
+ * adapter that imports a pi package type is deliberately left out — see
6
+ * AGENTS.md "Architecture (hexagonal)" and odd/tasks/library-exports.md
7
+ * for which ones and why.
8
+ */
9
+ export * from "../adapters/cached-catalog.js";
10
+ export * from "../adapters/export-writer.js";
11
+ export * from "../adapters/file-modes.js";
12
+ export * from "../adapters/hub-actions.js";
13
+ export * from "../adapters/hub-credentials.js";
14
+ export * from "../adapters/jsonl-work-log.js";
15
+ export * from "../adapters/kankaku-dir.js";
16
+ export * from "../adapters/lazy-jsonl-work-log.js";
17
+ export * from "../adapters/pocketbase-catalog.js";
18
+ export * from "../adapters/pocketbase-client.js";
19
+ export * from "../adapters/pocketbase-sink.js";
20
+ export * from "../adapters/project-config.js";
21
+ export * from "../adapters/report-data.js";
22
+ export * from "../adapters/report.js";
23
+ export * from "../adapters/report-views.js";
24
+ export * from "../adapters/sync-runner.js";
25
+ export * from "../adapters/sync-state-store.js";
@@ -0,0 +1,31 @@
1
+ import type { Client, HubTask, Project } from "../domain/work-target.ts";
2
+ /** A cached read of the hub's clients/projects/tasks, plus when and against which hub URL it was fetched. */
3
+ export interface CatalogSnapshot {
4
+ fetchedAt: number;
5
+ url: string;
6
+ clients: Client[];
7
+ projects: Project[];
8
+ /** Optional because a cache written by an older build (before hub task linking) never had this field; a missing value reads as "no tasks known yet." */
9
+ tasks?: HubTask[];
10
+ }
11
+ /**
12
+ * Read-only access to the hub catalog (clients/projects). `read()` is a
13
+ * synchronous, cheap access to the last known snapshot so session start
14
+ * never blocks on it; `refresh()` is the async network path. See
15
+ * `adapters/cached-catalog.ts` for the disk-backed implementation.
16
+ */
17
+ export interface Catalog {
18
+ /** The last known snapshot, or `undefined` when none has ever been fetched or cached. */
19
+ read(): CatalogSnapshot | undefined;
20
+ /** `true` when there is no snapshot, or the cached one is older than the configured TTL. */
21
+ isStale(): boolean;
22
+ /**
23
+ * Fetch a fresh snapshot, cache it, and return it. Never throws; resolves
24
+ * `undefined` on failure. `signal`, when given, is composed with each
25
+ * underlying request's own per-request timeout (see
26
+ * `adapters/pocketbase-client.ts`) so a caller can bound the whole fetch
27
+ * (auth, pagination, retries) with one overall deadline; an abort is
28
+ * just another failure mode and also resolves `undefined`.
29
+ */
30
+ refresh(signal?: AbortSignal): Promise<CatalogSnapshot | undefined>;
31
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ export interface Clock {
2
+ now(): number;
3
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Public library entrypoint (`kankaku-pi/ports`): the port interfaces, with
3
+ * no implementations. See AGENTS.md "Architecture (hexagonal)" and
4
+ * odd/tasks/library-exports.md.
5
+ */
6
+ export type * from "./catalog.ts";
7
+ export type * from "./clock.ts";
8
+ export type * from "./inflight-store.ts";
9
+ export type * from "./process-registry.ts";
10
+ export type * from "./work-log.ts";
11
+ export type * from "./work-sink.ts";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import type { WorkRecord } from "../domain/work-record.ts";
2
+ /**
3
+ * Crash-recovery checkpoint store: one process periodically saves the
4
+ * record its {@link WorkTracker} would produce right now, so a hard crash
5
+ * (kill -9, power loss) still leaves a record behind — recovered as
6
+ * `interrupted` on the next pi start rather than lost entirely.
7
+ */
8
+ export interface InflightStore {
9
+ /** Upsert this process's checkpoint. */
10
+ save(record: WorkRecord): void;
11
+ /** Remove this process's checkpoint (normal settle/shutdown). */
12
+ clear(): void;
13
+ /** Return and delete every checkpoint whose owning pid is no longer alive. */
14
+ recoverStale(isAlive: (pid: number) => boolean): WorkRecord[];
15
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,72 @@
1
+ import type { OrchestratorRef, WorkRole } from "../domain/work-record.ts";
2
+ /**
3
+ * One machine-wide, live-process registry entry
4
+ * (`~/.kankaku/run/<pid>.json`, see `adapters/machine-process-registry.ts`),
5
+ * independent of any project's `KANKAKU_DIR` (ADR 0023). Written by every
6
+ * kankaku process at extension-factory time, so a later process (a child,
7
+ * possibly in another worktree) can discover it by walking its own OS
8
+ * ancestor chain.
9
+ */
10
+ export interface RegistryEntry {
11
+ pid: number;
12
+ parentPid: number;
13
+ role: WorkRole;
14
+ /** This process's own cwd at the time it wrote this entry. A hint for matching, never a hard filter (ADR 0021). */
15
+ project: string;
16
+ /** Absolute, resolved `KANKAKU_DIR` for this process, so another process can read its `worklog.jsonl` directly without guessing at a shared relative directory name. */
17
+ dir: string;
18
+ startedAt: string;
19
+ /** The tracked ancestor this process itself discovered via the same registry, when any. */
20
+ orchestratorRef?: OrchestratorRef;
21
+ /**
22
+ * This process's own OS-reported start-time identity, as an
23
+ * approximate-but-self-consistent epoch-ms estimate (see
24
+ * `adapters/ancestry.ts`). Used to prove — not just guess by pid number
25
+ * — that a later reader's ancestor pid is still the *same* process
26
+ * instance this entry was written for, since the OS reuses pids
27
+ * (`domain/ancestry-match.ts#findAncestorEntry`). `undefined` when this
28
+ * process could not obtain it (Windows, or any read failure) — such an
29
+ * entry can never be trusted for identity matching and is swept as
30
+ * unverifiable (see `domain/registry-health.ts`).
31
+ */
32
+ processStartId?: number;
33
+ }
34
+ /** Extra, optional dependencies for {@link ProcessRegistry.record}'s opportunistic sweep. See `domain/registry-health.ts`. */
35
+ export interface RegistrySweepDeps {
36
+ /** Live start identity for a pid, from the same ancestry snapshot this process already took at startup. `undefined` means "unknown" — such a pid is never swept as stale-by-reuse (fails safe). Defaults to always-unknown. */
37
+ liveStartId?: (pid: number) => number | undefined;
38
+ /** Defaults to `Date.now()`. Injectable for deterministic tests. */
39
+ now?: number;
40
+ }
41
+ /**
42
+ * Machine-wide registry of live (or recently-live) kankaku processes.
43
+ * Every operation is expected to degrade gracefully — an unavailable home
44
+ * directory or any filesystem failure yields an empty read / a no-op write
45
+ * rather than throwing, since this is an enhancement to subagent detection
46
+ * and must never block or crash the extension it improves.
47
+ */
48
+ export interface ProcessRegistry {
49
+ /**
50
+ * Upsert this process's own entry, atomically. Also opportunistically
51
+ * sweeps entries the registry can no longer trust it should keep — dead
52
+ * pids, entries whose recorded identity no longer matches the live
53
+ * process holding that pid (pid reuse), entries with no verifiable
54
+ * identity at all (legacy/malformed), and entries older than a sane
55
+ * maximum age — so `run/` does not grow unbounded and never serves a
56
+ * stale identity (SUBAGENT-REQ-010, and the PID-reuse fix). Never
57
+ * removes the entry this call itself just wrote. Never throws.
58
+ */
59
+ record(entry: RegistryEntry, isAlive?: (pid: number) => boolean, sweepDeps?: RegistrySweepDeps): void;
60
+ /** Every currently-persisted entry, tolerating a corrupt/torn file by skipping it. Never throws; returns `[]` when the registry is unavailable. */
61
+ readAll(): RegistryEntry[];
62
+ /**
63
+ * Best-effort remove of this process's *own* entry file
64
+ * (`session_shutdown`, process `exit`). Reads the on-disk entry back
65
+ * first and only unlinks it when both `pid` and `processStartId` still
66
+ * match exactly what is on disk — so a file already overwritten by a
67
+ * pid-reuse successor (unlikely, but never assumed away) is never
68
+ * touched. Optional: an older `ProcessRegistry` implementation (e.g. a
69
+ * test fake) may omit it; callers must guard the call. Never throws.
70
+ */
71
+ removeOwn?(pid: number, processStartId: number | undefined): void;
72
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import type { WorkRecord } from "../domain/work-record.ts";
2
+ export interface WorkLog {
3
+ append(record: WorkRecord): void;
4
+ readAll(): WorkRecord[];
5
+ /**
6
+ * Optional cheap change signal: a value that changes whenever `append()`
7
+ * would change what `readAll()` returns, computable without reading the
8
+ * whole log (e.g. from file stat metadata). Callers that cache a view
9
+ * derived from `readAll()` (see `pi-tracker.ts`'s client-name completion
10
+ * cache) may use this to invalidate cheaply; a `WorkLog` that omits it
11
+ * simply leaves such callers relying on their own explicit invalidation.
12
+ */
13
+ version?(): string | number;
14
+ }
@@ -0,0 +1 @@
1
+ export {};