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.
- package/LICENSE +21 -0
- package/README.md +1438 -0
- package/dist/adapters/cached-catalog.d.ts +42 -0
- package/dist/adapters/cached-catalog.js +121 -0
- package/dist/adapters/export-writer.d.ts +13 -0
- package/dist/adapters/export-writer.js +28 -0
- package/dist/adapters/file-modes.d.ts +20 -0
- package/dist/adapters/file-modes.js +34 -0
- package/dist/adapters/hub-actions.d.ts +35 -0
- package/dist/adapters/hub-actions.js +70 -0
- package/dist/adapters/hub-credentials.d.ts +35 -0
- package/dist/adapters/hub-credentials.js +58 -0
- package/dist/adapters/jsonl-work-log.d.ts +20 -0
- package/dist/adapters/jsonl-work-log.js +62 -0
- package/dist/adapters/kankaku-dir.d.ts +38 -0
- package/dist/adapters/kankaku-dir.js +85 -0
- package/dist/adapters/lazy-jsonl-work-log.d.ts +17 -0
- package/dist/adapters/lazy-jsonl-work-log.js +31 -0
- package/dist/adapters/pocketbase-catalog.d.ts +16 -0
- package/dist/adapters/pocketbase-catalog.js +56 -0
- package/dist/adapters/pocketbase-client.d.ts +81 -0
- package/dist/adapters/pocketbase-client.js +148 -0
- package/dist/adapters/pocketbase-sink.d.ts +53 -0
- package/dist/adapters/pocketbase-sink.js +181 -0
- package/dist/adapters/project-config.d.ts +42 -0
- package/dist/adapters/project-config.js +108 -0
- package/dist/adapters/report-data.d.ts +12 -0
- package/dist/adapters/report-data.js +8 -0
- package/dist/adapters/report-views.d.ts +45 -0
- package/dist/adapters/report-views.js +73 -0
- package/dist/adapters/report.d.ts +112 -0
- package/dist/adapters/report.js +236 -0
- package/dist/adapters/sync-runner.d.ts +114 -0
- package/dist/adapters/sync-runner.js +273 -0
- package/dist/adapters/sync-state-store.d.ts +62 -0
- package/dist/adapters/sync-state-store.js +188 -0
- package/dist/config.d.ts +168 -0
- package/dist/config.js +392 -0
- package/dist/domain/ancestry-match.d.ts +49 -0
- package/dist/domain/ancestry-match.js +82 -0
- package/dist/domain/client-label.d.ts +28 -0
- package/dist/domain/client-label.js +44 -0
- package/dist/domain/day.d.ts +2 -0
- package/dist/domain/day.js +8 -0
- package/dist/domain/export.d.ts +38 -0
- package/dist/domain/export.js +68 -0
- package/dist/domain/hub-entry.d.ts +234 -0
- package/dist/domain/hub-entry.js +265 -0
- package/dist/domain/index.d.ts +19 -0
- package/dist/domain/index.js +19 -0
- package/dist/domain/intervals.d.ts +17 -0
- package/dist/domain/intervals.js +43 -0
- package/dist/domain/registry-health.d.ts +49 -0
- package/dist/domain/registry-health.js +58 -0
- package/dist/domain/segment-rule.d.ts +10 -0
- package/dist/domain/segment-rule.js +1 -0
- package/dist/domain/subagent-profile.d.ts +278 -0
- package/dist/domain/subagent-profile.js +418 -0
- package/dist/domain/sync-plan.d.ts +151 -0
- package/dist/domain/sync-plan.js +196 -0
- package/dist/domain/task-view.d.ts +117 -0
- package/dist/domain/task-view.js +428 -0
- package/dist/domain/work-record.d.ts +236 -0
- package/dist/domain/work-record.js +91 -0
- package/dist/domain/work-target.d.ts +101 -0
- package/dist/domain/work-target.js +149 -0
- package/dist/domain/work-tracker.d.ts +90 -0
- package/dist/domain/work-tracker.js +405 -0
- package/dist/hub/index.d.ts +25 -0
- package/dist/hub/index.js +25 -0
- package/dist/ports/catalog.d.ts +31 -0
- package/dist/ports/catalog.js +1 -0
- package/dist/ports/clock.d.ts +3 -0
- package/dist/ports/clock.js +1 -0
- package/dist/ports/index.d.ts +11 -0
- package/dist/ports/index.js +1 -0
- package/dist/ports/inflight-store.d.ts +15 -0
- package/dist/ports/inflight-store.js +1 -0
- package/dist/ports/process-registry.d.ts +72 -0
- package/dist/ports/process-registry.js +1 -0
- package/dist/ports/work-log.d.ts +14 -0
- package/dist/ports/work-log.js +1 -0
- package/dist/ports/work-sink.d.ts +39 -0
- package/dist/ports/work-sink.js +1 -0
- package/package.json +66 -0
- package/src/adapters/agent-info.ts +86 -0
- package/src/adapters/ancestry.ts +260 -0
- package/src/adapters/cached-catalog.ts +147 -0
- package/src/adapters/export-writer.ts +33 -0
- package/src/adapters/file-inflight-store.ts +115 -0
- package/src/adapters/file-modes.ts +35 -0
- package/src/adapters/hub-actions.ts +82 -0
- package/src/adapters/hub-credentials.ts +95 -0
- package/src/adapters/jsonl-work-log.ts +67 -0
- package/src/adapters/kankaku-command.ts +717 -0
- package/src/adapters/kankaku-dir.ts +102 -0
- package/src/adapters/lazy-file-inflight-store.ts +43 -0
- package/src/adapters/lazy-jsonl-work-log.ts +39 -0
- package/src/adapters/machine-process-registry.ts +256 -0
- package/src/adapters/panel/kankaku-panel.ts +419 -0
- package/src/adapters/panel/panel-items.ts +87 -0
- package/src/adapters/panel/panel-lines.ts +13 -0
- package/src/adapters/panel/panel-theme.ts +32 -0
- package/src/adapters/panel/screens/about.ts +69 -0
- package/src/adapters/panel/screens/doctor.ts +89 -0
- package/src/adapters/panel/screens/export.ts +123 -0
- package/src/adapters/panel/screens/report.ts +143 -0
- package/src/adapters/panel/screens/sync.ts +136 -0
- package/src/adapters/panel/screens/target.ts +384 -0
- package/src/adapters/pi-tracker.ts +753 -0
- package/src/adapters/pocketbase-catalog.ts +89 -0
- package/src/adapters/pocketbase-client.ts +197 -0
- package/src/adapters/pocketbase-sink.ts +236 -0
- package/src/adapters/process-identity-memo.ts +102 -0
- package/src/adapters/process-identity.ts +162 -0
- package/src/adapters/project-config.ts +116 -0
- package/src/adapters/report-data.ts +13 -0
- package/src/adapters/report-views.ts +98 -0
- package/src/adapters/report.ts +335 -0
- package/src/adapters/session-client.ts +116 -0
- package/src/adapters/session-dir.ts +28 -0
- package/src/adapters/session-target.ts +431 -0
- package/src/adapters/status-bar.ts +86 -0
- package/src/adapters/subagent-startup.ts +66 -0
- package/src/adapters/sync-runner.ts +340 -0
- package/src/adapters/sync-state-store.ts +227 -0
- package/src/adapters/target-picker.ts +127 -0
- package/src/config.ts +536 -0
- package/src/domain/ancestry-match.ts +84 -0
- package/src/domain/client-label.ts +56 -0
- package/src/domain/day.ts +8 -0
- package/src/domain/export.ts +107 -0
- package/src/domain/hub-entry.ts +433 -0
- package/src/domain/index.ts +19 -0
- package/src/domain/intervals.ts +53 -0
- package/src/domain/panel-model.ts +270 -0
- package/src/domain/registry-health.ts +87 -0
- package/src/domain/segment-rule.ts +10 -0
- package/src/domain/subagent-profile.ts +495 -0
- package/src/domain/sync-plan.ts +266 -0
- package/src/domain/task-view.ts +526 -0
- package/src/domain/work-record.ts +320 -0
- package/src/domain/work-target.ts +234 -0
- package/src/domain/work-tracker.ts +485 -0
- package/src/extension.ts +346 -0
- package/src/hub/index.ts +25 -0
- package/src/ports/catalog.ts +33 -0
- package/src/ports/clock.ts +3 -0
- package/src/ports/index.ts +11 -0
- package/src/ports/inflight-store.ts +16 -0
- package/src/ports/process-registry.ts +75 -0
- package/src/ports/work-log.ts +15 -0
- 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 @@
|
|
|
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 {};
|