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,346 @@
1
+ import { homedir, hostname, tmpdir } from "node:os";
2
+ import { join, dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import { detectRole, loadConfig, loadMachine, loadSyncConfig } from "./config.ts";
6
+ import { WorkTracker } from "./domain/work-tracker.ts";
7
+ import { LazyJsonlWorkLog } from "./adapters/lazy-jsonl-work-log.ts";
8
+ import { LazyFileInflightStore } from "./adapters/lazy-file-inflight-store.ts";
9
+ import { createPiTracker } from "./adapters/pi-tracker.ts";
10
+ import { LazyProjectClientSource, LazyProjectTargetSource } from "./adapters/project-config.ts";
11
+ import { LazyExportWriter } from "./adapters/export-writer.ts";
12
+ import { resolveHubCredentials, safeHomeDir } from "./adapters/hub-credentials.ts";
13
+ import { PocketBaseClient } from "./adapters/pocketbase-client.ts";
14
+ import { createPocketBaseCatalogFetcher } from "./adapters/pocketbase-catalog.ts";
15
+ import { CachedCatalog } from "./adapters/cached-catalog.ts";
16
+ import { createSessionTarget } from "./adapters/session-target.ts";
17
+ import { resolveKankakuDir, resolveWritableTarget } from "./adapters/kankaku-dir.ts";
18
+ import { SyncStateStore } from "./adapters/sync-state-store.ts";
19
+ import { PocketBaseSink } from "./adapters/pocketbase-sink.ts";
20
+ import { computeSyncStatus, runSync, singleFlight } from "./adapters/sync-runner.ts";
21
+ import type { SyncTrigger } from "./adapters/sync-runner.ts";
22
+ import { snapshotAncestry } from "./adapters/ancestry.ts";
23
+ import { MachineProcessRegistry } from "./adapters/machine-process-registry.ts";
24
+ import { JsonlWorkLog } from "./adapters/jsonl-work-log.ts";
25
+ import { FileInflightStore } from "./adapters/file-inflight-store.ts";
26
+ import { getProcessIdentityMemo } from "./adapters/process-identity-memo.ts";
27
+ import { resolveAgentVersion, resolvePluginVersion } from "./adapters/agent-info.ts";
28
+ import type { Catalog } from "./ports/catalog.ts";
29
+ import type { SessionTarget } from "./adapters/session-target.ts";
30
+ import type { SyncCommandDeps } from "./adapters/kankaku-command.ts";
31
+ import type { WorkLog } from "./ports/work-log.ts";
32
+ import type { InflightStore } from "./ports/inflight-store.ts";
33
+
34
+ export default function kankaku(pi: ExtensionAPI): void {
35
+ const config = loadConfig();
36
+
37
+ // Machine-wide process registry (ADR 0023, rewritten by F1): independent
38
+ // of any project's KANKAKU_DIR, so a subagent running in a different git
39
+ // worktree can still discover its true orchestrator. The registry is a
40
+ // STARTUP LOOKUP ONLY — "who is my tracked ancestor, and where does it
41
+ // keep its log" — never a later pointer a reader chases again (see
42
+ // adapters/subagent-startup.ts). Every operation on `registry` degrades
43
+ // to a no-op/empty-read on its own when the registry is unavailable (no
44
+ // home dir, no permission) — see adapters/machine-process-registry.ts.
45
+ const registry = new MachineProcessRegistry(homedir);
46
+
47
+ // G1 (HIGH, verified): pi re-invokes this factory function IN THE SAME
48
+ // OS PROCESS on `/new`, `/resume`, `/fork` and `/reload` ("reloads and
49
+ // rebinds extensions for the new session" — see
50
+ // node_modules/@earendil-works/pi-coding-agent/docs/extensions.md).
51
+ // Everything this block used to compute inline — `role`, `roleOverride`
52
+ // (read once, then stripped from `process.env` so a child never inherits
53
+ // it — R1), `childMarkerPresent`, `hasTrackedAncestor`/`ancestorEntry`
54
+ // (F5's registry+ancestor-chain walk), `orchestratorRef` (F4), and
55
+ // `ownProcessStartId` (F5) — is a fact about this OS PROCESS, not this pi
56
+ // session, and several of them can only ever be read correctly ONCE: a
57
+ // second invocation would see `roleOverride` as already stripped, so an
58
+ // explicitly forced role would silently fall back to ordinary detection
59
+ // (and, for `orchestrator`, could be demoted to `roleConfidence:
60
+ // "uncertain"`, silently dropping genuine billable work). `identity`
61
+ // (`adapters/process-identity.ts`) is computed once per OS process and
62
+ // memoized (`adapters/process-identity-memo.ts`) so a second, third,
63
+ // fourth... invocation always reuses the exact first result. A cheap,
64
+ // synchronous interactivity proxy feeds `role`'s one interactivity
65
+ // exception (see `config.ts#detectRole`'s doc comment) — it is available
66
+ // before pi's own ExtensionContext exists, unlike the authoritative
67
+ // `ctx.mode === "tui"`, only known later at `session_start`.
68
+ const isInteractiveGuess = Boolean(process.stdout.isTTY);
69
+ const identity = getProcessIdentityMemo().resolve({
70
+ env: process.env,
71
+ registry,
72
+ ppid: process.ppid,
73
+ now: () => Date.now(),
74
+ uptimeSeconds: () => process.uptime(),
75
+ isInteractiveGuess,
76
+ // SUBAGENT-REQ-001/002/003/005: the full active profile set (built-ins
77
+ // plus any KANKAKU_SUBAGENT_TOOLS/KANKAKU_SUBAGENT_CHILD_ENV-configured
78
+ // one) — generalises child-marker recognition beyond gentle-pi's own.
79
+ subagentProfiles: config.subagentProfiles,
80
+ });
81
+ const {
82
+ role,
83
+ roleOverride,
84
+ childMarkerPresent,
85
+ overrideIgnoredInteractive,
86
+ configuredMarkerIgnoredInteractive,
87
+ hasTrackedAncestor,
88
+ orchestratorRef,
89
+ ownProcessStartId,
90
+ liveStartId,
91
+ profile,
92
+ } = identity;
93
+
94
+ // `resolvedDir` (this session's project/write target) is deliberately
95
+ // NOT part of the frozen process identity above: pi can enter a
96
+ // different cwd across a session switch in the same process (see
97
+ // extensions.md's trust-resolution note on `/resume`), so this stays a
98
+ // per-session fact, recomputed on every invocation exactly like before.
99
+ const resolvedDir = resolveKankakuDir(config.dir, process.cwd());
100
+ registry.record(
101
+ {
102
+ pid: process.pid,
103
+ parentPid: process.ppid,
104
+ role,
105
+ project: process.cwd(),
106
+ dir: resolvedDir,
107
+ startedAt: new Date().toISOString(),
108
+ ...(orchestratorRef !== undefined ? { orchestratorRef } : {}),
109
+ ...(ownProcessStartId !== undefined ? { processStartId: ownProcessStartId } : {}),
110
+ },
111
+ undefined,
112
+ { liveStartId },
113
+ );
114
+
115
+ // Best-effort cleanup of this process's own registry file: on a normal
116
+ // exit (covers session_shutdown too, whichever fires first — `removeOwn`
117
+ // is idempotent, a second call simply finds nothing to do) and directly
118
+ // on `session_shutdown` for the common graceful-shutdown path. Never
119
+ // relies on this alone for correctness — a crash still leaves the entry
120
+ // for the next process's sweep to discard (dead-pid, or later
121
+ // stale-by-reuse) — this only keeps `run/` tidy sooner in the common case.
122
+ const removeOwnRegistryEntry = (): void => {
123
+ registry.removeOwn?.(process.pid, ownProcessStartId);
124
+ };
125
+ // G1: `process` is the one real OS-process-wide singleton every factory
126
+ // invocation shares (unlike `pi`, a fresh `ExtensionAPI` per invocation,
127
+ // which does need its own `session_shutdown` listener below every time).
128
+ // Registering `process.on("exit", ...)` unconditionally on every
129
+ // `/new`/`/resume`/`/fork`/`/reload` would pile up one listener per
130
+ // invocation for the life of the process; `registerExitCleanupOnce`
131
+ // registers at most one, ever, for this process.
132
+ getProcessIdentityMemo().registerExitCleanupOnce((listener) => process.on("exit", listener), removeOwnRegistryEntry);
133
+ pi.on("session_shutdown", () => {
134
+ try {
135
+ removeOwnRegistryEntry();
136
+ } catch {
137
+ // Never let registry cleanup break shutdown.
138
+ }
139
+ });
140
+
141
+ const tracker = new WorkTracker({
142
+ clock: { now: () => Date.now() },
143
+ interactiveTools: config.interactiveTools,
144
+ subagentProfiles: config.subagentProfiles,
145
+ segmentRules: config.segmentRules,
146
+ });
147
+
148
+ // F1: a verified subagent whose real orchestrator's kankaku dir differs
149
+ // from this process's own writes its work log AND its inflight
150
+ // checkpoints straight into that dir — so parent and child records live
151
+ // in the same `worklog.jsonl` forever, joined by `buildTasks`'s existing
152
+ // pid/parentPid/project-hint keys, with no later discovery through a live
153
+ // registry pointer ever required again (the old `RegistryAwareWorkLog`
154
+ // read-time merge is gone: this write-side routing makes it redundant —
155
+ // see AGENTS.md). A record is written to exactly ONE log, always: either
156
+ // branch below constructs exactly one `WorkLog`/`InflightStore` pair,
157
+ // pointed at the same resolved directory. `workLogRouting` is only set
158
+ // (and only surfaced to `/kankaku doctor`) when this process actually
159
+ // attempted routing — never for the common orchestrator/local-subagent
160
+ // path, which keeps today's lazy, lower-cost resolution unchanged.
161
+ let log: WorkLog;
162
+ let inflight: InflightStore;
163
+ let workLogRouting: { usedFallback: boolean; parentDir: string } | undefined;
164
+
165
+ if (role === "subagent" && orchestratorRef?.dir !== undefined && orchestratorRef.dir !== resolvedDir) {
166
+ const routed = resolveWritableTarget(orchestratorRef.dir, resolvedDir);
167
+ log = new JsonlWorkLog(routed.dir);
168
+ inflight = new FileInflightStore(routed.dir, process.pid);
169
+ workLogRouting = { usedFallback: routed.usedFallback, parentDir: orchestratorRef.dir };
170
+ } else {
171
+ log = new LazyJsonlWorkLog(config.dir);
172
+ inflight = new LazyFileInflightStore(config.dir, process.pid);
173
+ }
174
+
175
+ const projectClient = new LazyProjectClientSource(config.dir);
176
+ const exportWriter = new LazyExportWriter(config.dir);
177
+
178
+ // Hub (PocketBase) wiring: entirely optional. When unconfigured, every
179
+ // variable below stays undefined and createPiTracker's behaviour is
180
+ // exactly as it is without this feature. See README "Hub (PocketBase)".
181
+ let catalog: Catalog | undefined;
182
+ let sessionTarget: SessionTarget | undefined;
183
+ let machine: string | undefined;
184
+ let hubConfigError: string | undefined;
185
+ let sync: SyncCommandDeps | undefined;
186
+ let autoSyncEnabled: boolean | undefined;
187
+ let hubUrl: string | undefined;
188
+
189
+ // Resolved once, here (never on a hot path): see README "Hub
190
+ // (PocketBase)" > "Agent and measurement quality" and the panel's `about`
191
+ // screen (`adapters/panel/screens/about.ts`). Both degrade to `undefined`
192
+ // on any failure rather than guessing. Unlike the sync sink below (which
193
+ // needs them only when the hub is configured), the `about` screen shows
194
+ // these regardless — so they are resolved unconditionally here.
195
+ const agentVersion = resolveAgentVersion();
196
+ // extension.ts lives at <package root>/src/extension.ts.
197
+ const pluginPackageRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
198
+ const pluginVersion = resolvePluginVersion(pluginPackageRoot);
199
+
200
+ // `homeDir` is passed as a reference, never invoked here: any failure
201
+ // resolving it (no HOME, a sandbox) must not fail extension load for
202
+ // every process, hub-configured or not. resolveHubCredentials guards the
203
+ // call itself and treats it the same as "no home directory".
204
+ const hub = resolveHubCredentials({ env: process.env, homeDir: homedir });
205
+ hubConfigError = hub.invalidReason;
206
+
207
+ if (hub.credentials) {
208
+ const credentials = hub.credentials;
209
+ hubUrl = credentials.url;
210
+ const client = new PocketBaseClient({ url: credentials.url, email: credentials.email, password: credentials.password });
211
+ // Same defensive resolution as above; falls back to the OS temp dir
212
+ // when no home directory is available so an env-only hub configuration
213
+ // still works without one (the cache just does not survive a reboot).
214
+ const homeDirForCache = safeHomeDir(homedir) ?? tmpdir();
215
+ catalog = new CachedCatalog({
216
+ // Machine-wide cache: several projects on the same machine share one
217
+ // catalog fetch, and it survives across projects.
218
+ filePath: join(homeDirForCache, ".kankaku", "catalog.json"),
219
+ url: credentials.url,
220
+ clock: { now: () => Date.now() },
221
+ fetchCatalog: createPocketBaseCatalogFetcher(client),
222
+ });
223
+
224
+ const projectTarget = new LazyProjectTargetSource(config.dir);
225
+ sessionTarget = createSessionTarget({
226
+ role,
227
+ catalog,
228
+ resolveProjectConfigIds: () => projectTarget.read(),
229
+ persistProjectConfig: (ids) => projectTarget.write(ids),
230
+ });
231
+
232
+ machine = loadMachine(process.env, () => hostname());
233
+
234
+ // Sync (Phase 2): pushes consolidated task rows to the hub. See README
235
+ // "Hub (PocketBase)" sync section. `worklog.jsonl` and the crash-recovery
236
+ // checkpoints above are entirely unaffected by any of this.
237
+ const syncConfig = loadSyncConfig(process.env);
238
+ const syncStateStore = new SyncStateStore({ dir: resolveKankakuDir(config.dir, process.cwd()), pid: process.pid });
239
+ const catalogRef = catalog;
240
+ const machineName = machine;
241
+
242
+ const runOnce = (options?: { full?: boolean; trigger?: SyncTrigger }) => {
243
+ const snapshot = catalogRef.read();
244
+ const sink = new PocketBaseSink({
245
+ client,
246
+ clients: snapshot?.clients ?? [],
247
+ projects: snapshot?.projects ?? [],
248
+ tasks: snapshot?.tasks ?? [],
249
+ machine: machineName,
250
+ promptMode: syncConfig.promptMode,
251
+ syncRecords: syncConfig.syncRecords,
252
+ agent: "pi",
253
+ ...(agentVersion !== undefined ? { agentVersion } : {}),
254
+ plugin: "kankaku",
255
+ ...(pluginVersion !== undefined ? { pluginVersion } : {}),
256
+ });
257
+ return runSync(
258
+ {
259
+ log,
260
+ sink,
261
+ stateStore: syncStateStore,
262
+ clock: { now: () => Date.now() },
263
+ target: credentials.url,
264
+ windowHours: syncConfig.windowHours,
265
+ minAutoIntervalMs: syncConfig.minIntervalMinutes * 60 * 1000,
266
+ },
267
+ options,
268
+ );
269
+ };
270
+
271
+ sync = {
272
+ // Single-flight: the same wrapped function backs the manual /kankaku
273
+ // sync command and both automatic triggers (pi-tracker.ts), so they
274
+ // never race within this process.
275
+ run: singleFlight(runOnce),
276
+ status: () => computeSyncStatus(log, syncStateStore, credentials.url, syncConfig.windowHours),
277
+ };
278
+ autoSyncEnabled = syncConfig.auto;
279
+ }
280
+
281
+ createPiTracker(pi, {
282
+ tracker,
283
+ log,
284
+ inflight,
285
+ role,
286
+ // F3: interactivity (ctx.mode === "tui") is only knowable once pi's own
287
+ // ExtensionContext is available, at session_start — later than role
288
+ // itself must be decided above. `hasTrackedAncestor` is already final
289
+ // here; only isInteractive is supplied later, by pi-tracker.ts.
290
+ //
291
+ // R1: gated on `roleOverride === undefined` — `roleOverride` is the
292
+ // frozen, process-wide fact from `identity` above (G1: it stays
293
+ // truthful across a same-process factory re-invocation even though
294
+ // `process.env["KANKAKU_ROLE"]` was stripped, possibly invocations ago)
295
+ // — once KANKAKU_ROLE decided (or, for a `subagent` value ignored via
296
+ // the interactive contradiction above, resolved) this process's role at
297
+ // factory time, that decision stays final and is never later demoted to
298
+ // `uncertain`; this refinement only ever applies to the genuine
299
+ // no-override path (and `process.env` is safe to re-read here for
300
+ // `detectRole`'s OWN internal override check, since this branch is only
301
+ // reached when `roleOverride` was never set — there is nothing left in
302
+ // `process.env` that could change what that internal check sees).
303
+ resolveRoleConfidence: (isInteractive) =>
304
+ role === "orchestrator" && roleOverride === undefined ? detectRole(process.env, hasTrackedAncestor, isInteractive).roleConfidence : undefined,
305
+ ...(orchestratorRef !== undefined ? { orchestratorRef } : {}),
306
+ pid: process.pid,
307
+ parentPid: process.ppid,
308
+ ...(config.client !== undefined ? { envClient: config.client } : {}),
309
+ resolveProjectClient: () => projectClient.read(),
310
+ writeExportFile: (name, content) => exportWriter.write(name, content),
311
+ ...(sessionTarget !== undefined ? { sessionTarget } : {}),
312
+ ...(catalog !== undefined ? { catalog } : {}),
313
+ ...(machine !== undefined ? { machine } : {}),
314
+ ...(hubConfigError !== undefined ? { hubConfigError } : {}),
315
+ ...(sync !== undefined ? { sync } : {}),
316
+ ...(autoSyncEnabled !== undefined ? { autoSyncEnabled } : {}),
317
+ config,
318
+ kankakuDir: resolvedDir,
319
+ ...(agentVersion !== undefined ? { agentVersion } : {}),
320
+ ...(pluginVersion !== undefined ? { pluginVersion } : {}),
321
+ ...(hubUrl !== undefined ? { hubUrl } : {}),
322
+ ...(roleOverride !== undefined ? { roleOverride } : {}),
323
+ ...(childMarkerPresent ? { childMarkerPresent } : {}),
324
+ ...(profile !== undefined ? { profile } : {}),
325
+ subagentProfiles: config.subagentProfiles,
326
+ ...(overrideIgnoredInteractive ? { overrideIgnoredInteractive } : {}),
327
+ ...(configuredMarkerIgnoredInteractive ? { configuredMarkerIgnoredInteractive } : {}),
328
+ ...(hasTrackedAncestor ? { hasTrackedAncestor } : {}),
329
+ ...(config.rejectedSubagentChildEnvMarkers.length > 0 ? { rejectedSubagentChildEnvMarkers: config.rejectedSubagentChildEnvMarkers } : {}),
330
+ ...(workLogRouting !== undefined ? { workLogRouting } : {}),
331
+ // Fresh ancestry snapshot on demand, only when `/kankaku doctor` is
332
+ // actually invoked (never on a hot path): a stale snapshot from
333
+ // extension startup could no longer tell a genuine pid reuse apart
334
+ // from a still-live process for a session that has been running a while.
335
+ registryHealth: () => {
336
+ const freshSnapshot = snapshotAncestry();
337
+ return registry.health({ liveStartId: (pid) => freshSnapshot.startIdByPid.get(pid) });
338
+ },
339
+ // F2: whether the ancestor-chain mechanism itself is actually usable
340
+ // right now — Windows, or a failed/unavailable ps/proc read on any
341
+ // platform, both report false here. A fresh snapshot (never the one
342
+ // taken at startup, which may have been skipped entirely — F5) since
343
+ // this only runs when a human actually asks for `/kankaku doctor`.
344
+ ancestorDetectionAvailable: () => snapshotAncestry().ppidByPid.size > 0,
345
+ });
346
+ }
@@ -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,33 @@
1
+ import type { Client, HubTask, Project } from "../domain/work-target.ts";
2
+
3
+ /** A cached read of the hub's clients/projects/tasks, plus when and against which hub URL it was fetched. */
4
+ export interface CatalogSnapshot {
5
+ fetchedAt: number;
6
+ url: string;
7
+ clients: Client[];
8
+ projects: Project[];
9
+ /** 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." */
10
+ tasks?: HubTask[];
11
+ }
12
+
13
+ /**
14
+ * Read-only access to the hub catalog (clients/projects). `read()` is a
15
+ * synchronous, cheap access to the last known snapshot so session start
16
+ * never blocks on it; `refresh()` is the async network path. See
17
+ * `adapters/cached-catalog.ts` for the disk-backed implementation.
18
+ */
19
+ export interface Catalog {
20
+ /** The last known snapshot, or `undefined` when none has ever been fetched or cached. */
21
+ read(): CatalogSnapshot | undefined;
22
+ /** `true` when there is no snapshot, or the cached one is older than the configured TTL. */
23
+ isStale(): boolean;
24
+ /**
25
+ * Fetch a fresh snapshot, cache it, and return it. Never throws; resolves
26
+ * `undefined` on failure. `signal`, when given, is composed with each
27
+ * underlying request's own per-request timeout (see
28
+ * `adapters/pocketbase-client.ts`) so a caller can bound the whole fetch
29
+ * (auth, pagination, retries) with one overall deadline; an abort is
30
+ * just another failure mode and also resolves `undefined`.
31
+ */
32
+ refresh(signal?: AbortSignal): Promise<CatalogSnapshot | undefined>;
33
+ }
@@ -0,0 +1,3 @@
1
+ export interface Clock {
2
+ now(): number;
3
+ }
@@ -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,16 @@
1
+ import type { WorkRecord } from "../domain/work-record.ts";
2
+
3
+ /**
4
+ * Crash-recovery checkpoint store: one process periodically saves the
5
+ * record its {@link WorkTracker} would produce right now, so a hard crash
6
+ * (kill -9, power loss) still leaves a record behind — recovered as
7
+ * `interrupted` on the next pi start rather than lost entirely.
8
+ */
9
+ export interface InflightStore {
10
+ /** Upsert this process's checkpoint. */
11
+ save(record: WorkRecord): void;
12
+ /** Remove this process's checkpoint (normal settle/shutdown). */
13
+ clear(): void;
14
+ /** Return and delete every checkpoint whose owning pid is no longer alive. */
15
+ recoverStale(isAlive: (pid: number) => boolean): WorkRecord[];
16
+ }
@@ -0,0 +1,75 @@
1
+ import type { OrchestratorRef, WorkRole } from "../domain/work-record.ts";
2
+
3
+ /**
4
+ * One machine-wide, live-process registry entry
5
+ * (`~/.kankaku/run/<pid>.json`, see `adapters/machine-process-registry.ts`),
6
+ * independent of any project's `KANKAKU_DIR` (ADR 0023). Written by every
7
+ * kankaku process at extension-factory time, so a later process (a child,
8
+ * possibly in another worktree) can discover it by walking its own OS
9
+ * ancestor chain.
10
+ */
11
+ export interface RegistryEntry {
12
+ pid: number;
13
+ parentPid: number;
14
+ role: WorkRole;
15
+ /** This process's own cwd at the time it wrote this entry. A hint for matching, never a hard filter (ADR 0021). */
16
+ project: string;
17
+ /** Absolute, resolved `KANKAKU_DIR` for this process, so another process can read its `worklog.jsonl` directly without guessing at a shared relative directory name. */
18
+ dir: string;
19
+ startedAt: string;
20
+ /** The tracked ancestor this process itself discovered via the same registry, when any. */
21
+ orchestratorRef?: OrchestratorRef;
22
+ /**
23
+ * This process's own OS-reported start-time identity, as an
24
+ * approximate-but-self-consistent epoch-ms estimate (see
25
+ * `adapters/ancestry.ts`). Used to prove — not just guess by pid number
26
+ * — that a later reader's ancestor pid is still the *same* process
27
+ * instance this entry was written for, since the OS reuses pids
28
+ * (`domain/ancestry-match.ts#findAncestorEntry`). `undefined` when this
29
+ * process could not obtain it (Windows, or any read failure) — such an
30
+ * entry can never be trusted for identity matching and is swept as
31
+ * unverifiable (see `domain/registry-health.ts`).
32
+ */
33
+ processStartId?: number;
34
+ }
35
+
36
+ /** Extra, optional dependencies for {@link ProcessRegistry.record}'s opportunistic sweep. See `domain/registry-health.ts`. */
37
+ export interface RegistrySweepDeps {
38
+ /** 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. */
39
+ liveStartId?: (pid: number) => number | undefined;
40
+ /** Defaults to `Date.now()`. Injectable for deterministic tests. */
41
+ now?: number;
42
+ }
43
+
44
+ /**
45
+ * Machine-wide registry of live (or recently-live) kankaku processes.
46
+ * Every operation is expected to degrade gracefully — an unavailable home
47
+ * directory or any filesystem failure yields an empty read / a no-op write
48
+ * rather than throwing, since this is an enhancement to subagent detection
49
+ * and must never block or crash the extension it improves.
50
+ */
51
+ export interface ProcessRegistry {
52
+ /**
53
+ * Upsert this process's own entry, atomically. Also opportunistically
54
+ * sweeps entries the registry can no longer trust it should keep — dead
55
+ * pids, entries whose recorded identity no longer matches the live
56
+ * process holding that pid (pid reuse), entries with no verifiable
57
+ * identity at all (legacy/malformed), and entries older than a sane
58
+ * maximum age — so `run/` does not grow unbounded and never serves a
59
+ * stale identity (SUBAGENT-REQ-010, and the PID-reuse fix). Never
60
+ * removes the entry this call itself just wrote. Never throws.
61
+ */
62
+ record(entry: RegistryEntry, isAlive?: (pid: number) => boolean, sweepDeps?: RegistrySweepDeps): void;
63
+ /** Every currently-persisted entry, tolerating a corrupt/torn file by skipping it. Never throws; returns `[]` when the registry is unavailable. */
64
+ readAll(): RegistryEntry[];
65
+ /**
66
+ * Best-effort remove of this process's *own* entry file
67
+ * (`session_shutdown`, process `exit`). Reads the on-disk entry back
68
+ * first and only unlinks it when both `pid` and `processStartId` still
69
+ * match exactly what is on disk — so a file already overwritten by a
70
+ * pid-reuse successor (unlikely, but never assumed away) is never
71
+ * touched. Optional: an older `ProcessRegistry` implementation (e.g. a
72
+ * test fake) may omit it; callers must guard the call. Never throws.
73
+ */
74
+ removeOwn?(pid: number, processStartId: number | undefined): void;
75
+ }
@@ -0,0 +1,15 @@
1
+ import type { WorkRecord } from "../domain/work-record.ts";
2
+
3
+ export interface WorkLog {
4
+ append(record: WorkRecord): void;
5
+ readAll(): WorkRecord[];
6
+ /**
7
+ * Optional cheap change signal: a value that changes whenever `append()`
8
+ * would change what `readAll()` returns, computable without reading the
9
+ * whole log (e.g. from file stat metadata). Callers that cache a view
10
+ * derived from `readAll()` (see `pi-tracker.ts`'s client-name completion
11
+ * cache) may use this to invalidate cheaply; a `WorkLog` that omits it
12
+ * simply leaves such callers relying on their own explicit invalidation.
13
+ */
14
+ version?(): string | number;
15
+ }
@@ -0,0 +1,35 @@
1
+ import type { TaskView } from "../domain/task-view.ts";
2
+
3
+ /** Which client a successfully pushed task ended up linked to, for the sync summary's `unassigned` grouping. See `domain/hub-entry.ts#resolveTaskAssignment`. */
4
+ export interface PushAssignment {
5
+ /** `true` when the task was routed to the catalog's unassigned ("Sin determinar") client. */
6
+ unassigned: boolean;
7
+ /** The task's historical free-text/denormalised client label, only meaningful when `unassigned` is `true`. */
8
+ legacyLabel?: string;
9
+ }
10
+
11
+ /** Outcome of pushing one task's `task_entries` row (and, if enabled, its `work_records` children). */
12
+ export type PushOutcome =
13
+ | ({ kind: "created" } & PushAssignment)
14
+ | ({ kind: "updated" } & PushAssignment)
15
+ /** A non-retryable rejection (e.g. a real validation error) — recorded and skipped, not retried automatically. */
16
+ | { kind: "failed"; reason: string }
17
+ /** A network/timeout/5xx/auth failure. The caller must stop processing further tasks and not advance past this point. */
18
+ | { kind: "error"; reason: string };
19
+
20
+ export interface PushTaskResult {
21
+ taskId: string;
22
+ outcome: PushOutcome;
23
+ }
24
+
25
+ /**
26
+ * Uploads consolidated task rows to the hub. `push` is given tasks already
27
+ * sorted chronologically by the caller and returns one result per task
28
+ * attempted, in the same order — stopping (returning fewer results than
29
+ * tasks given) at the first `"error"` outcome, since that signals a
30
+ * systemic failure (network/timeout/5xx/auth) rather than a per-task one.
31
+ * Never throws.
32
+ */
33
+ export interface WorkSink {
34
+ push(tasks: TaskView[]): Promise<PushTaskResult[]>;
35
+ }