kankaku 0.4.6 → 0.5.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 (39) hide show
  1. package/README.md +952 -13
  2. package/package.json +4 -2
  3. package/src/adapters/agent-info.ts +86 -0
  4. package/src/adapters/ancestry.ts +260 -0
  5. package/src/adapters/cached-catalog.ts +131 -0
  6. package/src/adapters/file-modes.ts +35 -0
  7. package/src/adapters/hub-credentials.ts +95 -0
  8. package/src/adapters/jsonl-work-log.ts +4 -1
  9. package/src/adapters/kankaku-command.ts +510 -7
  10. package/src/adapters/kankaku-dir.ts +92 -0
  11. package/src/adapters/machine-process-registry.ts +256 -0
  12. package/src/adapters/pi-tracker.ts +408 -14
  13. package/src/adapters/pocketbase-catalog.ts +60 -0
  14. package/src/adapters/pocketbase-client.ts +197 -0
  15. package/src/adapters/pocketbase-sink.ts +224 -0
  16. package/src/adapters/process-identity-memo.ts +102 -0
  17. package/src/adapters/process-identity.ts +162 -0
  18. package/src/adapters/project-config.ts +73 -1
  19. package/src/adapters/report.ts +79 -6
  20. package/src/adapters/session-dir.ts +28 -0
  21. package/src/adapters/session-target.ts +262 -0
  22. package/src/adapters/subagent-startup.ts +66 -0
  23. package/src/adapters/sync-runner.ts +301 -0
  24. package/src/adapters/sync-state-store.ts +227 -0
  25. package/src/adapters/target-picker.ts +82 -0
  26. package/src/config.ts +444 -6
  27. package/src/domain/ancestry-match.ts +84 -0
  28. package/src/domain/hub-entry.ts +339 -0
  29. package/src/domain/registry-health.ts +87 -0
  30. package/src/domain/subagent-profile.ts +495 -0
  31. package/src/domain/sync-plan.ts +251 -0
  32. package/src/domain/task-view.ts +307 -23
  33. package/src/domain/work-record.ts +157 -1
  34. package/src/domain/work-target.ts +185 -0
  35. package/src/domain/work-tracker.ts +248 -51
  36. package/src/extension.ts +303 -6
  37. package/src/ports/catalog.ts +31 -0
  38. package/src/ports/process-registry.ts +75 -0
  39. package/src/ports/work-sink.ts +35 -0
@@ -0,0 +1,66 @@
1
+ import { findAncestorEntry } from "../domain/ancestry-match.ts";
2
+ import type { ProcessRegistry, RegistryEntry } from "../ports/process-registry.ts";
3
+ import { ownStartIdFromUptime, snapshotAncestry, walkAncestry } from "./ancestry.ts";
4
+ import type { AncestrySnapshot } from "./ancestry.ts";
5
+
6
+ export interface SubagentStartupDeps {
7
+ registry: ProcessRegistry;
8
+ /** This process's own OS parent pid (`process.ppid`). */
9
+ ppid: number;
10
+ /** `Date.now`, injected. */
11
+ now: () => number;
12
+ /** `process.uptime`, injected. */
13
+ uptimeSeconds: () => number;
14
+ /** Injectable for tests; defaults to the real {@link snapshotAncestry}. */
15
+ snapshotAncestry?: () => AncestrySnapshot;
16
+ }
17
+
18
+ export interface SubagentStartupResult {
19
+ /** Every other kankaku process's registry entry visible at startup (this process has not written its own yet). */
20
+ registryEntries: RegistryEntry[];
21
+ /** The nearest verified tracked ancestor, if any — see `domain/ancestry-match.ts#findAncestorEntry`. */
22
+ ancestorEntry: RegistryEntry | undefined;
23
+ /** This process's own approximate OS start-time identity, derived with no subprocess spawn (F5). */
24
+ ownProcessStartId: number;
25
+ /**
26
+ * Live start-identity lookup from the same ancestry snapshot taken above
27
+ * (or, when no snapshot was needed — F5 — a function that always reports
28
+ * "unknown", the same fail-safe default `RegistrySweepDeps.liveStartId`
29
+ * itself documents). Reused by the caller's own `registry.record()` sweep
30
+ * so registering this process's entry never pays for a *second* snapshot.
31
+ */
32
+ liveStartId: (pid: number) => number | undefined;
33
+ }
34
+
35
+ /**
36
+ * Compose this process's ancestor-registry lookup at startup (F5: cheap in
37
+ * the common case, never on a hot path after this). Reads the machine-wide
38
+ * registry first — `registry.readAll()` never throws by its own port
39
+ * contract, so nothing here defensively re-wraps it — and takes the one OS
40
+ * ancestor-chain snapshot (a `ps` spawn or `/proc` scan) **only** when at
41
+ * least one other entry exists that could possibly be an ancestor; when the
42
+ * registry is empty, there is nothing an ancestor-chain walk could ever
43
+ * find, so the snapshot is skipped entirely rather than paying its cost for
44
+ * a result that would be `undefined` either way. This is the only place
45
+ * `extension.ts` needs to call to learn "who (if anyone) is my tracked
46
+ * ancestor, and what is my own start identity" — see also F1's write
47
+ * routing (`domain/ancestry-match.ts#resolveOrchestratorRef`, which
48
+ * consumes `ancestorEntry`) and F2/F3's role/interactivity decisions
49
+ * (`config.ts#detectRole`, which consume `ancestorEntry !== undefined`).
50
+ */
51
+ export function resolveSubagentStartup(deps: SubagentStartupDeps): SubagentStartupResult {
52
+ const registryEntries = deps.registry.readAll();
53
+ const ownProcessStartId = ownStartIdFromUptime(deps.now(), deps.uptimeSeconds());
54
+
55
+ if (registryEntries.length === 0) {
56
+ return { registryEntries, ancestorEntry: undefined, ownProcessStartId, liveStartId: () => undefined };
57
+ }
58
+
59
+ const takeSnapshot = deps.snapshotAncestry ?? snapshotAncestry;
60
+ const snapshot = takeSnapshot();
61
+ const ancestorPids = walkAncestry(deps.ppid, snapshot.ppidByPid);
62
+ const liveStartId = (pid: number): number | undefined => snapshot.startIdByPid.get(pid);
63
+ const ancestorEntry = findAncestorEntry(ancestorPids, registryEntries, liveStartId);
64
+
65
+ return { registryEntries, ancestorEntry, ownProcessStartId, liveStartId };
66
+ }
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Orchestrates one sync run: read every record, build tasks (never
3
+ * re-implementing the aggregation — `buildTasks` is the only place it
4
+ * lives), plan what needs pushing, push it, and persist the new state.
5
+ * Never throws to its caller: every failure mode is folded into the
6
+ * returned {@link SyncSummary}.
7
+ */
8
+
9
+ import { buildTasks } from "../domain/task-view.ts";
10
+ import { computeTaskContentHash, planSync, pruneHashes } from "../domain/sync-plan.ts";
11
+ import type { SyncState } from "../domain/sync-plan.ts";
12
+ import type { TaskView } from "../domain/task-view.ts";
13
+ import type { Clock } from "../ports/clock.ts";
14
+ import type { WorkLog } from "../ports/work-log.ts";
15
+ import type { WorkSink } from "../ports/work-sink.ts";
16
+ import type { SyncStateStore } from "./sync-state-store.ts";
17
+
18
+ export interface SyncSummary {
19
+ uploaded: number;
20
+ updated: number;
21
+ skipped: number;
22
+ failed: Array<{ id: string; reason: string }>;
23
+ /** Count of tasks routed to the unassigned client this run, grouped by their historical free-text label. */
24
+ unassigned: Record<string, number>;
25
+ syncedThrough: string | undefined;
26
+ durationMs: number;
27
+ /** Set when a network/timeout/5xx/auth failure stopped the run before every candidate task was attempted. */
28
+ error?: string;
29
+ /** `true` when another sync already holds the lock; nothing was attempted this run. */
30
+ locked?: boolean;
31
+ }
32
+
33
+ /**
34
+ * Which automatic trigger asked for this run, or `undefined` for a manual
35
+ * one (`/kankaku sync`, `sync all`, `backfill`) — see `runSync`'s
36
+ * short-circuit and throttle, which apply only to the automatic path.
37
+ * `session_shutdown` (pi awaits this handler — see `adapters/pi-tracker.ts`)
38
+ * is, like `session_start`, never throttled: only `agent_settled` is.
39
+ */
40
+ export type SyncTrigger = "session_start" | "agent_settled" | "session_shutdown";
41
+
42
+ export interface SyncRunnerDeps {
43
+ log: WorkLog;
44
+ sink: WorkSink;
45
+ stateStore: SyncStateStore;
46
+ clock: Clock;
47
+ /** The configured hub URL — a state file synced against a different one triggers a full sync. */
48
+ target: string;
49
+ windowHours?: number;
50
+ /**
51
+ * `KANKAKU_SYNC_MIN_INTERVAL_MINUTES`, already converted to ms. Only
52
+ * applies to the automatic path (`options.trigger` set). Defaults to 5
53
+ * minutes; `0` disables throttling.
54
+ */
55
+ minAutoIntervalMs?: number;
56
+ }
57
+
58
+ const NO_LABEL = "(no label)";
59
+ const DEFAULT_MIN_AUTO_INTERVAL_MS = 5 * 60 * 1000;
60
+
61
+ /** The later of two ISO timestamps, treating `undefined` as earlier than anything. */
62
+ function laterIso(a: string | undefined, b: string): string {
63
+ if (a === undefined) return b;
64
+ return Date.parse(b) > Date.parse(a) ? b : a;
65
+ }
66
+
67
+ function emptySummary(durationMs: number, syncedThrough: string | undefined): SyncSummary {
68
+ return { uploaded: 0, updated: 0, skipped: 0, failed: [], unassigned: {}, syncedThrough, durationMs };
69
+ }
70
+
71
+ /**
72
+ * Whether the automatic path's throttle should block this run right now.
73
+ * Only `agent_settled` — fired once per prompt, far more often than a
74
+ * session starts or ends — is ever throttled; `session_start` and
75
+ * `session_shutdown` always bypass it (a session boundary is a good time
76
+ * to catch up regardless of how recently the last automatic run happened,
77
+ * and the shutdown one is awaited and time-bounded on its own — see
78
+ * `adapters/pi-tracker.ts`). `undefined`/non-finite `lastRunAt` (never
79
+ * run, or a malformed on-disk value) never throttles either — there is
80
+ * nothing to measure the interval against.
81
+ */
82
+ function isThrottled(state: SyncState | undefined, trigger: SyncTrigger, now: number, minIntervalMs: number): boolean {
83
+ if (trigger !== "agent_settled") return false;
84
+ if (minIntervalMs <= 0) return false;
85
+ const lastRunAt = state?.lastRunAt;
86
+ if (!Number.isFinite(lastRunAt)) return false;
87
+ if (now - (lastRunAt as number) >= minIntervalMs) return false;
88
+ return true;
89
+ }
90
+
91
+ /**
92
+ * Run one sync pass. Acquires the cross-process lock for the whole run
93
+ * (never held across `await` boundaries outside this function) so two pi
94
+ * processes never race on the same `sync-state.json`.
95
+ *
96
+ * When `options.trigger` is set (the automatic `session_start`/
97
+ * `agent_settled`/`session_shutdown` path, as opposed to a manual
98
+ * `/kankaku sync`), two cheap gates run before any `WorkLog.readAll()` or
99
+ * network call: (a) if the log's `version()` is unchanged since the last
100
+ * successful sync and that sync did not error, skip entirely, for every
101
+ * trigger; otherwise (b) throttle to at most one real attempt per
102
+ * `minAutoIntervalMs`, but only for `agent_settled` — fired once per
103
+ * prompt, so `version()` almost always differs right after it appended a
104
+ * record. `session_start` and `session_shutdown` never throttle (see
105
+ * `isThrottled`). Neither gate ever applies to a manual sync.
106
+ */
107
+ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; trigger?: SyncTrigger } = {}): Promise<SyncSummary> {
108
+ const startedAt = deps.clock.now();
109
+
110
+ if (options.trigger !== undefined) {
111
+ const peek = deps.stateStore.read();
112
+ const currentVersion = deps.log.version?.();
113
+ const versionUnchanged = currentVersion !== undefined && peek?.logVersion === currentVersion;
114
+ if (versionUnchanged && peek?.lastError === undefined) {
115
+ return emptySummary(deps.clock.now() - startedAt, peek?.syncedThrough);
116
+ }
117
+
118
+ const minIntervalMs = deps.minAutoIntervalMs ?? DEFAULT_MIN_AUTO_INTERVAL_MS;
119
+ if (isThrottled(peek, options.trigger, deps.clock.now(), minIntervalMs)) {
120
+ return emptySummary(deps.clock.now() - startedAt, peek?.syncedThrough);
121
+ }
122
+ }
123
+
124
+ let lockAcquired = false;
125
+ try {
126
+ lockAcquired = deps.stateStore.tryLock();
127
+ if (!lockAcquired) {
128
+ const state = deps.stateStore.read();
129
+ return { ...emptySummary(deps.clock.now() - startedAt, state?.syncedThrough), locked: true };
130
+ }
131
+
132
+ const state = deps.stateStore.read();
133
+ // Captured once, here, and persisted as-is below: this is the version
134
+ // the tasks below were actually built from, not whatever the log might
135
+ // become by the time an awaited push finishes.
136
+ const logVersionAtRead = deps.log.version?.();
137
+ const tasks = buildTasks(deps.log.readAll());
138
+ const plan = planSync(tasks, state, { target: deps.target, ...(deps.windowHours !== undefined ? { windowHours: deps.windowHours } : {}), ...(options.full !== undefined ? { full: options.full } : {}) });
139
+
140
+ let results: Awaited<ReturnType<WorkSink["push"]>>;
141
+ try {
142
+ results = await deps.sink.push(plan.toSync);
143
+ } catch (error) {
144
+ // WorkSink implementations are expected never to throw, but this
145
+ // runner must hold that guarantee even if one does.
146
+ const message = error instanceof Error ? error.message : String(error);
147
+ const summary = emptySummary(deps.clock.now() - startedAt, state?.syncedThrough);
148
+ summary.skipped = plan.unchangedCount;
149
+ summary.error = message;
150
+ persistError(deps, state, message, logVersionAtRead);
151
+ return summary;
152
+ }
153
+
154
+ const byId = new Map(plan.toSync.map((task) => [task.id, task]));
155
+ // Both are keyed by content that ultimately traces back to free-text
156
+ // worklog/legacy-client data (task ids, legacy client labels): built in
157
+ // a `Map` and emitted via `Object.fromEntries` below (never
158
+ // `newHashes[task.id] = ...` on a plain object), so a value like
159
+ // `__proto__` or `constructor` becomes a normal own entry instead of
160
+ // silently colliding with an inherited `Object.prototype` property.
161
+ const newHashes = new Map<string, string>();
162
+ const failed: Array<{ id: string; reason: string }> = [];
163
+ const unassigned = new Map<string, number>();
164
+ let uploaded = 0;
165
+ let updated = 0;
166
+ let syncedThrough = state?.syncedThrough;
167
+ let stopError: string | undefined;
168
+ // Whether at least one task was actually resolved (pushed or recorded
169
+ // as failed) this run — as opposed to the run stopping on its very
170
+ // first attempt. Guards `target` below: a run against a new/unreachable
171
+ // target that resolves nothing must not overwrite the state's `target`,
172
+ // or a later sync against the *real* target would wrongly see it as
173
+ // unchanged and skip the full re-evaluation it needs.
174
+ let progressed = false;
175
+
176
+ for (const result of results) {
177
+ const task = byId.get(result.taskId);
178
+ if (!task) continue; // defensive: a WorkSink implementation misbehaving should not crash the runner.
179
+
180
+ if (result.outcome.kind === "created" || result.outcome.kind === "updated") {
181
+ if (result.outcome.kind === "created") uploaded += 1;
182
+ else updated += 1;
183
+ newHashes.set(task.id, computeTaskContentHash(task));
184
+ syncedThrough = laterIso(syncedThrough, task.endedAt);
185
+ progressed = true;
186
+ if (result.outcome.unassigned) {
187
+ const label = result.outcome.legacyLabel || NO_LABEL;
188
+ unassigned.set(label, (unassigned.get(label) ?? 0) + 1);
189
+ }
190
+ } else if (result.outcome.kind === "failed") {
191
+ // Recorded and skipped, not retried forever: stamp its hash too so
192
+ // an unchanged, permanently-invalid task is not resent every run.
193
+ failed.push({ id: task.id, reason: result.outcome.reason });
194
+ newHashes.set(task.id, computeTaskContentHash(task));
195
+ syncedThrough = laterIso(syncedThrough, task.endedAt);
196
+ progressed = true;
197
+ } else {
198
+ // "error": a network/timeout/5xx/auth failure. Stop here — nothing
199
+ // after this point in the (chronologically sorted) results is
200
+ // considered resolved, so syncedThrough does not advance past it.
201
+ stopError = result.outcome.reason;
202
+ break;
203
+ }
204
+ }
205
+
206
+ const mergedHashes = { ...(state?.hashes ?? {}), ...Object.fromEntries(newHashes) };
207
+ // G2: no longer window-bound — see `domain/sync-plan.ts#pruneHashes`'s
208
+ // doc comment. `tasks` here is every task `buildTasks` currently knows
209
+ // about (the full `readAll()`, not just this run's eligible/window
210
+ // subset), so a hash is only ever dropped for a task id that has
211
+ // genuinely vanished, never merely because it is old.
212
+ const prunedHashes = pruneHashes(mergedHashes, tasks);
213
+ // Only adopt deps.target as the persisted target once this run has
214
+ // actually resolved something against it; otherwise keep whatever
215
+ // target (if any) the previous state was synced against.
216
+ const persistedTarget = progressed || state === undefined ? deps.target : state.target;
217
+
218
+ deps.stateStore.write({
219
+ target: persistedTarget,
220
+ ...(syncedThrough !== undefined ? { syncedThrough } : {}),
221
+ hashes: prunedHashes,
222
+ ...(stopError !== undefined ? { lastError: { message: stopError, at: new Date(deps.clock.now()).toISOString() } } : {}),
223
+ ...(logVersionAtRead !== undefined ? { logVersion: logVersionAtRead } : {}),
224
+ lastRunAt: deps.clock.now(),
225
+ });
226
+
227
+ return {
228
+ uploaded,
229
+ updated,
230
+ skipped: plan.unchangedCount,
231
+ failed,
232
+ unassigned: Object.fromEntries(unassigned),
233
+ syncedThrough,
234
+ durationMs: deps.clock.now() - startedAt,
235
+ ...(stopError !== undefined ? { error: stopError } : {}),
236
+ };
237
+ } finally {
238
+ if (lockAcquired) deps.stateStore.unlock();
239
+ }
240
+ }
241
+
242
+ function persistError(deps: SyncRunnerDeps, state: ReturnType<SyncStateStore["read"]>, message: string, logVersionAtRead: string | number | undefined): void {
243
+ deps.stateStore.write({
244
+ target: deps.target,
245
+ ...(state?.syncedThrough !== undefined ? { syncedThrough: state.syncedThrough } : {}),
246
+ hashes: state?.hashes ?? {},
247
+ lastError: { message, at: new Date(deps.clock.now()).toISOString() },
248
+ ...(logVersionAtRead !== undefined ? { logVersion: logVersionAtRead } : {}),
249
+ lastRunAt: deps.clock.now(),
250
+ });
251
+ }
252
+
253
+ /** Number of tasks pending a sync right now, for `/kankaku sync status` — computed locally, no network. */
254
+ export function pendingCount(tasks: TaskView[], state: ReturnType<SyncStateStore["read"]>, target: string, windowHours?: number): number {
255
+ const plan = planSync(tasks, state, { target, ...(windowHours !== undefined ? { windowHours } : {}) });
256
+ return plan.toSync.length + plan.correctionsDeferred;
257
+ }
258
+
259
+ /**
260
+ * `/kankaku sync status`: the persisted state, a locally-computed pending
261
+ * count, and (R3) how many tasks changed since their last sync but fall
262
+ * outside this run's revisit window — a `sync all` needed to pick them up
263
+ * (see `domain/sync-plan.ts#SyncPlan.staleOutsideWindow`, and README "Hub
264
+ * (PocketBase)" > "Sync" > "Limitations"). No network.
265
+ */
266
+ export function computeSyncStatus(
267
+ log: WorkLog,
268
+ stateStore: SyncStateStore,
269
+ target: string,
270
+ windowHours?: number,
271
+ ): { state: ReturnType<SyncStateStore["read"]>; pending: number; staleOutsideWindow: number } {
272
+ const state = stateStore.read();
273
+ const tasks = buildTasks(log.readAll());
274
+ const plan = planSync(tasks, state, { target, ...(windowHours !== undefined ? { windowHours } : {}) });
275
+ // Deferred corrections are pending too: the cap only spreads them over runs.
276
+ return { state, pending: plan.toSync.length + plan.correctionsDeferred, staleOutsideWindow: plan.staleOutsideWindow.length };
277
+ }
278
+
279
+ /**
280
+ * Wrap an async function so concurrent callers share one in-flight call
281
+ * instead of starting a new one each — kankaku's single-flight guard for
282
+ * sync: `/kankaku sync`, the `session_start` auto-sync and the
283
+ * `agent_settled` auto-sync all go through the same wrapped function, so
284
+ * only one sync is ever running at a time within this process. (The
285
+ * cross-process case is covered separately by `SyncStateStore`'s lock
286
+ * file.) A caller that arrives while one is in flight joins its result
287
+ * rather than queuing a fresh run — the next trigger (the next
288
+ * `session_start` or `agent_settled`) will pick up anything missed, since
289
+ * every sync also revisits the trailing window.
290
+ */
291
+ export function singleFlight<Args extends unknown[], T>(fn: (...args: Args) => Promise<T>): (...args: Args) => Promise<T> {
292
+ let inFlight: Promise<T> | undefined;
293
+ return (...args: Args): Promise<T> => {
294
+ if (!inFlight) {
295
+ inFlight = fn(...args).finally(() => {
296
+ inFlight = undefined;
297
+ });
298
+ }
299
+ return inFlight;
300
+ };
301
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Disk-backed store for `<KANKAKU_DIR>/sync-state.json`, plus a simple
3
+ * cross-process lock so two pi processes (e.g. an orchestrator's
4
+ * auto-sync and a manual `/kankaku sync` in another terminal) never sync
5
+ * the same directory concurrently. Mirrors `file-inflight-store.ts`'s
6
+ * atomic-write and liveness-probe conventions.
7
+ */
8
+
9
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import type { SyncState } from "../domain/sync-plan.ts";
12
+
13
+ const STATE_FILE_NAME = "sync-state.json";
14
+ const LOCK_FILE_NAME = "sync.lock";
15
+ /** A lock older than this is considered abandoned even if its owning pid still (coincidentally) exists. */
16
+ const STALE_LOCK_MS = 5 * 60 * 1000;
17
+
18
+ interface LockFile {
19
+ pid: number;
20
+ at: number;
21
+ }
22
+
23
+ /** The subset of `node:fs` the lock's acquire/recover path needs, injectable so tests can simulate cross-process interleaving deterministically. */
24
+ export interface SyncStateStoreFsOps {
25
+ existsSync: typeof existsSync;
26
+ readFileSync: typeof readFileSync;
27
+ writeFileSync: typeof writeFileSync;
28
+ renameSync: typeof renameSync;
29
+ unlinkSync: typeof unlinkSync;
30
+ openSync: typeof openSync;
31
+ closeSync: typeof closeSync;
32
+ }
33
+
34
+ const defaultFsOps: SyncStateStoreFsOps = { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, openSync, closeSync };
35
+
36
+ function isEnoent(error: unknown): boolean {
37
+ return (error as NodeJS.ErrnoException)?.code === "ENOENT";
38
+ }
39
+
40
+ function isEexist(error: unknown): boolean {
41
+ return (error as NodeJS.ErrnoException)?.code === "EEXIST";
42
+ }
43
+
44
+ function isSyncState(value: unknown): value is SyncState {
45
+ if (!value || typeof value !== "object") return false;
46
+ const record = value as Record<string, unknown>;
47
+ return (
48
+ typeof record["target"] === "string" &&
49
+ typeof record["hashes"] === "object" &&
50
+ record["hashes"] !== null &&
51
+ (record["syncedThrough"] === undefined || typeof record["syncedThrough"] === "string")
52
+ );
53
+ }
54
+
55
+ function isLockFile(value: unknown): value is LockFile {
56
+ if (!value || typeof value !== "object") return false;
57
+ const record = value as Record<string, unknown>;
58
+ return typeof record["pid"] === "number" && typeof record["at"] === "number";
59
+ }
60
+
61
+ function atomicWrite(filePath: string, content: string): void {
62
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
63
+ writeFileSync(tmp, content);
64
+ renameSync(tmp, filePath);
65
+ }
66
+
67
+ function safeUnlink(fs: Pick<SyncStateStoreFsOps, "unlinkSync">, filePath: string): void {
68
+ try {
69
+ fs.unlinkSync(filePath);
70
+ } catch {
71
+ // Best effort: already removed, or never existed.
72
+ }
73
+ }
74
+
75
+ export interface SyncStateStoreDeps {
76
+ dir: string;
77
+ pid: number;
78
+ /** Whether a pid is still alive. Defaults to the same signal-probe used elsewhere. */
79
+ isAlive?: (pid: number) => boolean;
80
+ /** Injectable for tests. Defaults to `Date.now`. */
81
+ now?: () => number;
82
+ /** Injectable `node:fs` primitives for the lock's acquire/recover path. Defaults to the real ones. */
83
+ fs?: SyncStateStoreFsOps;
84
+ }
85
+
86
+ /**
87
+ * `read()`/`write()` tolerate a missing or malformed file (return
88
+ * `undefined` / overwrite, respectively) since this state is disposable —
89
+ * losing it only costs a full re-evaluation on the next sync, never data.
90
+ * `tryLock()`/`unlock()` implement a simple pid+timestamp lock file, stale
91
+ * after {@link STALE_LOCK_MS}.
92
+ */
93
+ export class SyncStateStore {
94
+ private readonly deps: Required<SyncStateStoreDeps>;
95
+
96
+ constructor(deps: SyncStateStoreDeps) {
97
+ this.deps = {
98
+ dir: deps.dir,
99
+ pid: deps.pid,
100
+ isAlive: deps.isAlive ?? defaultIsAlive,
101
+ now: deps.now ?? (() => Date.now()),
102
+ fs: deps.fs ?? defaultFsOps,
103
+ };
104
+ }
105
+
106
+ private get statePath(): string {
107
+ return join(this.deps.dir, STATE_FILE_NAME);
108
+ }
109
+
110
+ private get lockPath(): string {
111
+ return join(this.deps.dir, LOCK_FILE_NAME);
112
+ }
113
+
114
+ read(): SyncState | undefined {
115
+ try {
116
+ if (!existsSync(this.statePath)) return undefined;
117
+ const parsed: unknown = JSON.parse(readFileSync(this.statePath, "utf8"));
118
+ return isSyncState(parsed) ? parsed : undefined;
119
+ } catch {
120
+ return undefined;
121
+ }
122
+ }
123
+
124
+ write(state: SyncState): void {
125
+ mkdirSync(this.deps.dir, { recursive: true });
126
+ atomicWrite(this.statePath, JSON.stringify(state));
127
+ }
128
+
129
+ /**
130
+ * Try to acquire the cross-process sync lock. Acquisition itself is
131
+ * atomic: it always goes through an exclusive create ({@link acquireFresh},
132
+ * `open` with the `wx` flag), never a read-then-write, so two processes
133
+ * racing to create the lock file can never both succeed. Returns `true`
134
+ * (and takes ownership) when there is no lock file, this process already
135
+ * owns it (re-entrant), or the existing one is stale (its pid is no
136
+ * longer alive, or it is older than {@link STALE_LOCK_MS}) and this
137
+ * process wins the race to recover it; `false` when a live, fresh lock is
138
+ * held by another process, or this process loses a stale-lock recovery
139
+ * race to another one.
140
+ */
141
+ tryLock(): boolean {
142
+ mkdirSync(this.deps.dir, { recursive: true });
143
+
144
+ if (this.acquireFresh()) return true;
145
+
146
+ const existing = this.readLock();
147
+ if (!existing) {
148
+ // Raced with a release between our failed create and this read; the
149
+ // slot may be free again now. One more attempt, then give up rather
150
+ // than looping forever.
151
+ return this.acquireFresh();
152
+ }
153
+
154
+ if (existing.pid === this.deps.pid) return true; // re-entrant: we already own it.
155
+
156
+ const age = this.deps.now() - existing.at;
157
+ const stale = age > STALE_LOCK_MS || !this.deps.isAlive(existing.pid);
158
+ if (!stale) return false; // live, fresh lock held by someone else.
159
+
160
+ // Stale-lock recovery, made race-safe: rename the stale file to a
161
+ // unique tombstone name first. `rename` is atomic, so only one racer's
162
+ // call can succeed; every loser gets ENOENT and backs off instead of
163
+ // deleting (or overwriting) a lock it never proved was still stale.
164
+ const tombstone = `${this.lockPath}.stale.${this.deps.pid}.${this.deps.now()}.tmp`;
165
+ try {
166
+ this.deps.fs.renameSync(this.lockPath, tombstone);
167
+ } catch (error) {
168
+ if (isEnoent(error)) return false; // lost the recovery race; back off.
169
+ throw error;
170
+ }
171
+ safeUnlink(this.deps.fs, tombstone);
172
+
173
+ return this.acquireFresh(); // false here means a third racer won it first.
174
+ }
175
+
176
+ /** Create the lock file exclusively (`wx`): fails with EEXIST when another lock already exists, never silently overwrites one. Assumes `this.deps.dir` already exists (`tryLock` ensures it once up front). */
177
+ private acquireFresh(): boolean {
178
+ let fd: number;
179
+ try {
180
+ fd = this.deps.fs.openSync(this.lockPath, "wx");
181
+ } catch (error) {
182
+ if (isEexist(error)) return false;
183
+ throw error;
184
+ }
185
+ try {
186
+ this.deps.fs.writeFileSync(fd, JSON.stringify({ pid: this.deps.pid, at: this.deps.now() }));
187
+ } catch (error) {
188
+ try {
189
+ this.deps.fs.closeSync(fd);
190
+ } catch {
191
+ // Best effort: still try to clean up the partially written file below.
192
+ }
193
+ safeUnlink(this.deps.fs, this.lockPath);
194
+ throw error;
195
+ }
196
+ this.deps.fs.closeSync(fd);
197
+ return true;
198
+ }
199
+
200
+ /** Release the lock, but only if this process still owns it (never clobber someone else's fresher lock). */
201
+ unlock(): void {
202
+ const existing = this.readLock();
203
+ if (existing && existing.pid === this.deps.pid) {
204
+ safeUnlink(this.deps.fs, this.lockPath);
205
+ }
206
+ }
207
+
208
+ private readLock(): LockFile | undefined {
209
+ try {
210
+ if (!this.deps.fs.existsSync(this.lockPath)) return undefined;
211
+ const parsed: unknown = JSON.parse(this.deps.fs.readFileSync(this.lockPath, "utf8"));
212
+ return isLockFile(parsed) ? parsed : undefined;
213
+ } catch {
214
+ return undefined;
215
+ }
216
+ }
217
+ }
218
+
219
+ /** Default `isAlive`: probe with signal 0 — mirrors `pi-tracker.ts`'s default. */
220
+ function defaultIsAlive(pid: number): boolean {
221
+ try {
222
+ process.kill(pid, 0);
223
+ return true;
224
+ } catch (error) {
225
+ return (error as NodeJS.ErrnoException).code === "EPERM";
226
+ }
227
+ }
@@ -0,0 +1,82 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { Client, Project, WorkTarget } from "../domain/work-target.ts";
3
+
4
+ const SKIP_OPTION = "— skip —";
5
+ const NO_PROJECT_OPTION = "(no project)";
6
+
7
+ export interface PickerCatalog {
8
+ clients: Client[];
9
+ projects: Project[];
10
+ }
11
+
12
+ export type PickResult = { kind: "picked"; target: WorkTarget } | { kind: "skipped" };
13
+
14
+ interface LabeledOption<T> {
15
+ label: string;
16
+ item: T;
17
+ }
18
+
19
+ /**
20
+ * Build `label -> item` options, sorted by name. When two items share the
21
+ * same name, disambiguate every colliding label by appending ` (code)` so
22
+ * every option maps back to exactly one id.
23
+ */
24
+ function labelOptions<T extends { name: string; code?: string }>(items: T[]): LabeledOption<T>[] {
25
+ const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
26
+ const nameCounts = new Map<string, number>();
27
+ for (const item of sorted) {
28
+ nameCounts.set(item.name, (nameCounts.get(item.name) ?? 0) + 1);
29
+ }
30
+
31
+ return sorted.map((item) => {
32
+ const collides = (nameCounts.get(item.name) ?? 0) > 1;
33
+ const label = collides && item.code ? `${item.name} (${item.code})` : item.name;
34
+ return { label, item };
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Run the client/project picker (`ctx.ui.select`) against an already-loaded
40
+ * catalog snapshot. Pure UI interaction: no network, no persistence — the
41
+ * caller (`session-target.ts`) decides what to do with the result.
42
+ *
43
+ * Declining at either step — choosing "— skip —" or dismissing the dialog
44
+ * (`undefined`) — cancels the whole pick, not just that step.
45
+ */
46
+ export async function pickTarget(ctx: ExtensionContext, catalog: PickerCatalog): Promise<PickResult> {
47
+ const pickableClients = catalog.clients.filter((client) => client.active && !client.unassigned);
48
+ const clientOptions = labelOptions(pickableClients);
49
+
50
+ const clientChoice = await ctx.ui.select("kankaku — client", [...clientOptions.map((option) => option.label), SKIP_OPTION]);
51
+ if (clientChoice === undefined || clientChoice === SKIP_OPTION) return { kind: "skipped" };
52
+
53
+ const client = clientOptions.find((option) => option.label === clientChoice)?.item;
54
+ if (!client) return { kind: "skipped" };
55
+
56
+ const pickableProjects = catalog.projects.filter((project) => project.active && project.clientId === client.id);
57
+ const projectOptions = labelOptions(pickableProjects);
58
+
59
+ const projectChoice = await ctx.ui.select("kankaku — project", [
60
+ ...projectOptions.map((option) => option.label),
61
+ NO_PROJECT_OPTION,
62
+ SKIP_OPTION,
63
+ ]);
64
+ if (projectChoice === undefined || projectChoice === SKIP_OPTION) return { kind: "skipped" };
65
+
66
+ const project = projectChoice === NO_PROJECT_OPTION ? undefined : projectOptions.find((option) => option.label === projectChoice)?.item;
67
+
68
+ const target: WorkTarget = {
69
+ clientId: client.id,
70
+ clientCode: client.code,
71
+ clientName: client.name,
72
+ ...(project !== undefined
73
+ ? {
74
+ projectId: project.id,
75
+ ...(project.code !== undefined ? { projectCode: project.code } : {}),
76
+ projectName: project.name,
77
+ }
78
+ : {}),
79
+ };
80
+
81
+ return { kind: "picked", target };
82
+ }