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,431 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { formatWorkTargetLabel, resolveWorkTarget, resolveWorkTargetSource } from "../domain/work-target.ts";
3
+ import type { WorkTarget, WorkTargetCandidate, WorkTargetSessionOverride, WorkTargetSourceName } from "../domain/work-target.ts";
4
+ import type { WorkRole } from "../domain/work-record.ts";
5
+ import type { Catalog, CatalogSnapshot } from "../ports/catalog.ts";
6
+ import { pickHubTask, pickTarget } from "./target-picker.ts";
7
+
8
+ /** Persisted as a `kankaku-target` custom session entry so the session-level target survives a reload. */
9
+ export interface KankakuTargetEntryData {
10
+ clientId?: string;
11
+ projectId?: string;
12
+ /** `true` when the user explicitly declined the picker; distinct from "no entry yet". */
13
+ skipped?: boolean;
14
+ /**
15
+ * A hub task picked for this session (`/kankaku task pick`). Session-only:
16
+ * never written to the project's `.kankaku/config.json`, and dropped by
17
+ * any target change (`pick`/`setExplicit`/`clear`/`ensurePicked`'s
18
+ * silent resolution) since those all build a fresh candidate without it.
19
+ */
20
+ hubTaskId?: string;
21
+ }
22
+
23
+ export const TARGET_ENTRY_TYPE = "kankaku-target";
24
+
25
+ export interface SessionTargetDeps {
26
+ role: WorkRole;
27
+ catalog: Catalog;
28
+ /** Lazily reads `clientId`/`projectId` from `<kankaku dir>/config.json`. */
29
+ resolveProjectConfigIds: () => WorkTargetCandidate | undefined;
30
+ /** Persist `clientId`/`projectId` into `<kankaku dir>/config.json`, merging existing keys. */
31
+ persistProjectConfig: (ids: WorkTargetCandidate) => void;
32
+ /** Current working directory, matched against catalog `repo_paths`. Defaults to `process.cwd()`. */
33
+ cwd?: () => string;
34
+ /**
35
+ * Overall deadline, in ms, for the very first (no-cache) catalog fetch โ€”
36
+ * see {@link getSnapshot}. Bounds auth, pagination and the 401 retry
37
+ * together via an `AbortSignal` composed with each request's own
38
+ * per-request timeout, instead of leaving that awaited path bounded only
39
+ * per-request. Defaults to 5000.
40
+ */
41
+ firstFetchDeadlineMs?: number;
42
+ /**
43
+ * Deadline, in ms, for awaiting an already-in-flight background refresh
44
+ * before showing the picker with the cached snapshot instead โ€” see
45
+ * {@link getSnapshotForPicker}. The refresh itself is never aborted at
46
+ * this deadline; it keeps running, and a later `catalog.read()` call
47
+ * (e.g. the next run) sees its result once it lands. Defaults to 1500.
48
+ */
49
+ pickerRefreshDeadlineMs?: number;
50
+ /** Injectable for tests; defaults to the global timer functions. */
51
+ setTimeout?: (handler: () => void, ms: number) => NodeJS.Timeout;
52
+ clearTimeout?: (timer: NodeJS.Timeout) => void;
53
+ }
54
+
55
+ const DEFAULT_FIRST_FETCH_DEADLINE_MS = 5000;
56
+ const DEFAULT_PICKER_REFRESH_DEADLINE_MS = 1500;
57
+
58
+ export interface SessionTarget {
59
+ /** Restore the session-level target (or its remembered "skipped" state) from the last `kankaku-target` entry. */
60
+ restore(ctx: ExtensionContext): void;
61
+ /**
62
+ * The `session_start` flow: resolves silently from the project config
63
+ * file or catalog `repo_paths`, or โ€” only when nothing resolves and no
64
+ * session entry (pick or skip) already exists โ€” shows the picker. A
65
+ * no-op unless `role === "orchestrator"` and `ctx.hasUI`.
66
+ */
67
+ ensurePicked(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void>;
68
+ /** Force the picker again, e.g. `/kankaku target pick`. Ignores any existing session override. */
69
+ pick(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void>;
70
+ /** Set the session target directly, bypassing the picker (the legacy `/kankaku client <name>` compatibility path). */
71
+ setExplicit(pi: ExtensionAPI, ids: WorkTargetCandidate): void;
72
+ /** Clear the session-level override; resolution falls back to the project config file / `repo_paths`. */
73
+ clear(pi: ExtensionAPI): void;
74
+ /**
75
+ * `/kankaku task pick`: shows the hub-task picker for the effective
76
+ * project's open/doing tasks and links the pick to the session (never
77
+ * persisted to the project config file). Notifies when there is no
78
+ * effective client/project yet, or the project has no open/doing task.
79
+ * A no-op unless `role === "orchestrator"` and `ctx.hasUI`.
80
+ */
81
+ pickTask(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void>;
82
+ /** `/kankaku task clear`: drops the linked hub task, keeping the rest of the session target. A no-op when none is linked. */
83
+ clearTask(pi: ExtensionAPI): void;
84
+ /**
85
+ * Set the linked hub task directly, bypassing the picker โ€” the panel's
86
+ * target screen (`adapters/panel/screens/target.ts`) uses this once the
87
+ * user has already chosen a task from its own `SelectList`. Builds the
88
+ * session override from the current {@link effectiveTarget}'s candidate
89
+ * plus `hubTaskId` and appends the session entry, exactly like
90
+ * {@link pickTask}'s own persistence step. A no-op when there is no
91
+ * effective target with a project.
92
+ */
93
+ setTask(pi: ExtensionAPI, hubTaskId: string): void;
94
+ /**
95
+ * `/kankaku target remember` / the panel's "Remember" row: persists the
96
+ * current {@link effectiveTarget}'s `clientId`/`projectId` (never
97
+ * `hubTaskId` โ€” task linking stays session-only) to the project's
98
+ * `.kankaku/config.json`. Returns `false`, without persisting anything,
99
+ * when there is no effective target.
100
+ */
101
+ rememberTarget(): boolean;
102
+ /** Current effective target (session > project config > repoPaths), regardless of role. */
103
+ effectiveTarget(): WorkTarget | undefined;
104
+ /** Which source produced {@link effectiveTarget}. */
105
+ effectiveSource(): WorkTargetSourceName | undefined;
106
+ /** Role-gated target for the in-progress run (`undefined` for a subagent); caches the project config read for the run. */
107
+ runTarget(): WorkTarget | undefined;
108
+ /** Role-gated target to show while idle, reusing the run's cached project config read when still held. */
109
+ idleTarget(): WorkTarget | undefined;
110
+ /** Drop the per-run cached project config read; call when a run settles or the session shuts down. */
111
+ endRun(): void;
112
+ }
113
+
114
+ function candidateFrom(target: WorkTarget): WorkTargetCandidate {
115
+ return { clientId: target.clientId, ...(target.projectId !== undefined ? { projectId: target.projectId } : {}) };
116
+ }
117
+
118
+ function entryDataFrom(ids: WorkTargetCandidate): KankakuTargetEntryData {
119
+ return {
120
+ clientId: ids.clientId,
121
+ ...(ids.projectId !== undefined ? { projectId: ids.projectId } : {}),
122
+ ...(ids.hubTaskId !== undefined ? { hubTaskId: ids.hubTaskId } : {}),
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Owns the session-level hub target override (`/kankaku target pick`), its
128
+ * restore/persist round-trip through session entries, and target
129
+ * resolution for both the in-progress run and the idle status line. See
130
+ * README "Hub (PocketBase)" and `domain/work-target.ts#resolveWorkTarget`.
131
+ */
132
+ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
133
+ const cwd = deps.cwd ?? (() => process.cwd());
134
+ const scheduleTimeout = deps.setTimeout ?? setTimeout;
135
+ const cancelTimeout = deps.clearTimeout ?? clearTimeout;
136
+ const firstFetchDeadlineMs = deps.firstFetchDeadlineMs ?? DEFAULT_FIRST_FETCH_DEADLINE_MS;
137
+ const pickerRefreshDeadlineMs = deps.pickerRefreshDeadlineMs ?? DEFAULT_PICKER_REFRESH_DEADLINE_MS;
138
+
139
+ /** Session-level override, restored on `session_start` or set by an explicit pick/skip/legacy command. */
140
+ let sessionOverride: WorkTargetSessionOverride;
141
+ /** Project config ids read once per run (first record build) so checkpoints do not hit the filesystem repeatedly. */
142
+ let runProjectIds: { value: WorkTargetCandidate | undefined } | undefined;
143
+ /** Only notify "hub unreachable" once per process for the silent `ensurePicked`/`pick` path. */
144
+ let notifiedUnreachable = false;
145
+
146
+ function restore(ctx: ExtensionContext): void {
147
+ const entries = ctx.sessionManager.getEntries();
148
+ for (let i = entries.length - 1; i >= 0; i--) {
149
+ const entry = entries[i] as { type: string; customType?: string; data?: unknown };
150
+ if (entry.type === "custom" && entry.customType === TARGET_ENTRY_TYPE) {
151
+ const data = entry.data as KankakuTargetEntryData | undefined;
152
+ if (data?.skipped === true) {
153
+ sessionOverride = "skipped";
154
+ } else if (typeof data?.clientId === "string") {
155
+ sessionOverride = {
156
+ clientId: data.clientId,
157
+ ...(typeof data.projectId === "string" ? { projectId: data.projectId } : {}),
158
+ ...(typeof data.hubTaskId === "string" ? { hubTaskId: data.hubTaskId } : {}),
159
+ };
160
+ } else {
161
+ sessionOverride = undefined;
162
+ }
163
+ return;
164
+ }
165
+ }
166
+ sessionOverride = undefined;
167
+ }
168
+
169
+ function computeTarget(projectIds: WorkTargetCandidate | undefined): WorkTarget | undefined {
170
+ const snapshot = deps.catalog.read();
171
+ return resolveWorkTarget({
172
+ session: sessionOverride,
173
+ project: projectIds,
174
+ cwd: cwd(),
175
+ clients: snapshot?.clients ?? [],
176
+ projects: snapshot?.projects ?? [],
177
+ tasks: snapshot?.tasks ?? [],
178
+ });
179
+ }
180
+
181
+ function effectiveTarget(): WorkTarget | undefined {
182
+ return computeTarget(deps.resolveProjectConfigIds());
183
+ }
184
+
185
+ function effectiveSource(): WorkTargetSourceName | undefined {
186
+ const snapshot = deps.catalog.read();
187
+ return resolveWorkTargetSource({
188
+ session: sessionOverride,
189
+ project: deps.resolveProjectConfigIds(),
190
+ cwd: cwd(),
191
+ clients: snapshot?.clients ?? [],
192
+ projects: snapshot?.projects ?? [],
193
+ });
194
+ }
195
+
196
+ function runIds(): WorkTargetCandidate | undefined {
197
+ if (!runProjectIds) {
198
+ runProjectIds = { value: deps.resolveProjectConfigIds() };
199
+ }
200
+ return runProjectIds.value;
201
+ }
202
+
203
+ function runTarget(): WorkTarget | undefined {
204
+ return deps.role === "orchestrator" ? computeTarget(runIds()) : undefined;
205
+ }
206
+
207
+ function idleTarget(): WorkTarget | undefined {
208
+ const ids = runProjectIds ? runProjectIds.value : deps.resolveProjectConfigIds();
209
+ return deps.role === "orchestrator" ? computeTarget(ids) : undefined;
210
+ }
211
+
212
+ function endRun(): void {
213
+ runProjectIds = undefined;
214
+ }
215
+
216
+ function notifyUnreachableOnce(ctx: ExtensionContext): void {
217
+ if (notifiedUnreachable) return;
218
+ notifiedUnreachable = true;
219
+ if (ctx.hasUI) ctx.ui.notify("kankaku: hub unreachable, using local labels", "warning");
220
+ }
221
+
222
+ /**
223
+ * The no-cache-at-all path: one awaited refresh bounded by an *overall*
224
+ * deadline ({@link SessionTargetDeps.firstFetchDeadlineMs}, default
225
+ * 5000ms) โ€” not merely the hub client's own per-request timeout, which
226
+ * alone does not bound the whole sequence of a lazy auth, pagination,
227
+ * and a possible 401 retry. The deadline is enforced with an
228
+ * `AbortSignal` composed, per request, with that request's own
229
+ * per-request timeout (see `pocketbase-client.ts#rawFetch`). Notifies
230
+ * "hub unreachable" at most once when no snapshot is available at all,
231
+ * whether because the hub failed outright or because the deadline fired
232
+ * first โ€” both are treated identically.
233
+ */
234
+ async function awaitFirstFetch(ctx: ExtensionContext): Promise<CatalogSnapshot | undefined> {
235
+ const controller = new AbortController();
236
+ const timer = scheduleTimeout(() => controller.abort(), firstFetchDeadlineMs);
237
+ let fresh: CatalogSnapshot | undefined;
238
+ try {
239
+ fresh = await deps.catalog.refresh(controller.signal);
240
+ } finally {
241
+ cancelTimeout(timer);
242
+ }
243
+ if (!fresh) {
244
+ notifyUnreachableOnce(ctx);
245
+ }
246
+ return fresh;
247
+ }
248
+
249
+ /**
250
+ * Races an already-started background refresh against
251
+ * {@link SessionTargetDeps.pickerRefreshDeadlineMs} (default 1500ms): if
252
+ * the refresh lands in time (and did not fail โ€” `refresh()` resolving
253
+ * `undefined` falls back exactly like a deadline miss, with no
254
+ * notification), the picker shows the fresh snapshot; otherwise it
255
+ * shows `cached`. The refresh is never aborted here โ€” it keeps running
256
+ * in the background, and `catalog.read()` reflects it once it resolves,
257
+ * exactly as it would without a picker in the way.
258
+ */
259
+ function awaitPickerRefresh(
260
+ refreshPromise: Promise<CatalogSnapshot | undefined>,
261
+ cached: CatalogSnapshot,
262
+ ): Promise<CatalogSnapshot> {
263
+ return new Promise((resolve) => {
264
+ let settled = false;
265
+ const timer = scheduleTimeout(() => {
266
+ if (settled) return;
267
+ settled = true;
268
+ resolve(cached);
269
+ }, pickerRefreshDeadlineMs);
270
+ void refreshPromise.then((fresh) => {
271
+ if (settled) return;
272
+ settled = true;
273
+ cancelTimeout(timer);
274
+ resolve(fresh ?? cached);
275
+ });
276
+ });
277
+ }
278
+
279
+ /** Resolves silently (project config / `repoPaths`) against `snapshot`, or shows the picker with it. */
280
+ async function resolveOrShowPicker(pi: ExtensionAPI, ctx: ExtensionContext, snapshot: CatalogSnapshot): Promise<void> {
281
+ const projectIds = deps.resolveProjectConfigIds();
282
+ const resolved = resolveWorkTarget({
283
+ project: projectIds,
284
+ cwd: cwd(),
285
+ clients: snapshot.clients,
286
+ projects: snapshot.projects,
287
+ });
288
+ if (resolved) return;
289
+
290
+ await runPicker(pi, ctx, snapshot);
291
+ }
292
+
293
+ async function runPicker(pi: ExtensionAPI, ctx: ExtensionContext, snapshot: CatalogSnapshot): Promise<void> {
294
+ const result = await pickTarget(ctx, snapshot);
295
+
296
+ if (result.kind === "skipped") {
297
+ sessionOverride = "skipped";
298
+ pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, { skipped: true });
299
+ return;
300
+ }
301
+
302
+ const ids = candidateFrom(result.target);
303
+ sessionOverride = ids;
304
+ pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, entryDataFrom(ids));
305
+
306
+ const remember = await ctx.ui.confirm("kankaku", `Remember ${formatWorkTargetLabel(result.target)} for this repository?`);
307
+ if (remember) {
308
+ deps.persistProjectConfig(ids);
309
+ }
310
+ }
311
+
312
+ async function ensurePicked(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
313
+ if (deps.role !== "orchestrator" || !ctx.hasUI) return;
314
+ if (sessionOverride !== undefined) return;
315
+
316
+ const cached = deps.catalog.read();
317
+ if (!cached) {
318
+ const fresh = await awaitFirstFetch(ctx);
319
+ if (!fresh) return;
320
+ await resolveOrShowPicker(pi, ctx, fresh);
321
+ return;
322
+ }
323
+
324
+ // Always start a refresh, regardless of staleness (the owner hit a
325
+ // clients/projects change that a merely-stale-TTL check missed). Silent
326
+ // resolution below returns without ever awaiting it; the refresh keeps
327
+ // running and `catalog.read()` reflects it once it lands.
328
+ const refreshPromise = deps.catalog.refresh();
329
+
330
+ const projectIds = deps.resolveProjectConfigIds();
331
+ const resolved = resolveWorkTarget({
332
+ project: projectIds,
333
+ cwd: cwd(),
334
+ clients: cached.clients,
335
+ projects: cached.projects,
336
+ });
337
+ if (resolved) return;
338
+
339
+ const snapshot = await awaitPickerRefresh(refreshPromise, cached);
340
+ await runPicker(pi, ctx, snapshot);
341
+ }
342
+
343
+ async function pick(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
344
+ const cached = deps.catalog.read();
345
+ if (!cached) {
346
+ const fresh = await awaitFirstFetch(ctx);
347
+ if (!fresh) return;
348
+ await runPicker(pi, ctx, fresh);
349
+ return;
350
+ }
351
+
352
+ const refreshPromise = deps.catalog.refresh();
353
+ const snapshot = await awaitPickerRefresh(refreshPromise, cached);
354
+ await runPicker(pi, ctx, snapshot);
355
+ }
356
+
357
+ function setExplicit(pi: ExtensionAPI, ids: WorkTargetCandidate): void {
358
+ sessionOverride = ids;
359
+ pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, entryDataFrom(ids));
360
+ }
361
+
362
+ function clear(pi: ExtensionAPI): void {
363
+ sessionOverride = undefined;
364
+ pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, {});
365
+ }
366
+
367
+ function setTask(pi: ExtensionAPI, hubTaskId: string): void {
368
+ const target = effectiveTarget();
369
+ if (!target || target.projectId === undefined) return;
370
+
371
+ const ids: WorkTargetCandidate = { ...candidateFrom(target), hubTaskId };
372
+ sessionOverride = ids;
373
+ pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, entryDataFrom(ids));
374
+ }
375
+
376
+ function rememberTarget(): boolean {
377
+ const target = effectiveTarget();
378
+ if (!target) return false;
379
+ deps.persistProjectConfig(candidateFrom(target));
380
+ return true;
381
+ }
382
+
383
+ async function pickTask(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
384
+ if (deps.role !== "orchestrator" || !ctx.hasUI) return;
385
+
386
+ const target = effectiveTarget();
387
+ if (!target || target.projectId === undefined) {
388
+ ctx.ui.notify("kankaku: Pick a client and project first (/kankaku target pick)", "warning");
389
+ return;
390
+ }
391
+
392
+ const tasks = deps.catalog.read()?.tasks ?? [];
393
+ const result = await pickHubTask(ctx, tasks, target.projectId);
394
+
395
+ if (result.kind === "empty") {
396
+ ctx.ui.notify(`kankaku: No open tasks for ${target.projectName ?? target.projectId} in the hub`, "warning");
397
+ return;
398
+ }
399
+ if (result.kind === "skipped") return;
400
+
401
+ setTask(pi, result.task.id);
402
+
403
+ const updated = effectiveTarget();
404
+ if (updated) ctx.ui.notify(`kankaku: task set to ${formatWorkTargetLabel(updated)}`);
405
+ }
406
+
407
+ function clearTask(pi: ExtensionAPI): void {
408
+ if (sessionOverride === undefined || sessionOverride === "skipped") return;
409
+ if (sessionOverride.hubTaskId === undefined) return;
410
+ const { hubTaskId: _hubTaskId, ...rest } = sessionOverride;
411
+ sessionOverride = rest;
412
+ pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, entryDataFrom(rest));
413
+ }
414
+
415
+ return {
416
+ restore,
417
+ ensurePicked,
418
+ pick,
419
+ setExplicit,
420
+ clear,
421
+ pickTask,
422
+ clearTask,
423
+ setTask,
424
+ rememberTarget,
425
+ effectiveTarget,
426
+ effectiveSource,
427
+ runTarget,
428
+ idleTarget,
429
+ endRun,
430
+ };
431
+ }
@@ -0,0 +1,86 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ // U+FE0F forces emoji presentation so terminals do not fall back to monochrome text glyphs.
4
+ const CLOCK_EMOJI = "\u{1F552}๏ธ";
5
+ const CLIENT_EMOJI = "\u{1F4BC}๏ธ";
6
+
7
+ /** Footer status key; footer statuses are sorted alphabetically by key, "zz-" keeps kankaku last. */
8
+ export const STATUS_KEY = "zz-kankaku";
9
+
10
+ export function formatElapsed(ms: number, client?: string): string {
11
+ const totalSeconds = Math.max(0, Math.round(ms / 1000));
12
+ const minutes = Math.floor(totalSeconds / 60);
13
+ const seconds = totalSeconds % 60;
14
+ const elapsed = `${CLOCK_EMOJI} ${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
15
+ return client ? `${elapsed} ยท ${client}` : elapsed;
16
+ }
17
+
18
+ export interface StatusBarDeps {
19
+ /** Status line refresh interval in ms while a run is active. Defaults to 1000. */
20
+ intervalMs?: number;
21
+ /** Resolve the client label to show for the run that is starting now. */
22
+ resolveRunClient: () => string | undefined;
23
+ /** Resolve the client label to show while idle. */
24
+ resolveIdleClient: () => string | undefined;
25
+ /** Injectable for tests; defaults to the global timer functions. */
26
+ setInterval?: (handler: () => void, ms: number) => NodeJS.Timeout;
27
+ clearInterval?: (timer: NodeJS.Timeout) => void;
28
+ /** Injectable clock for tests; defaults to `Date.now`. */
29
+ now?: () => number;
30
+ }
31
+
32
+ export interface StatusBar {
33
+ /** Start (or, if already running, leave untouched) the elapsed-time status; a no-op without a UI. */
34
+ start(ctx: ExtensionContext): void;
35
+ /** Stop the elapsed-time status and fall back to the idle status. */
36
+ stop(ctx: ExtensionContext): void;
37
+ /** While idle, keep the billing client visible (`๐Ÿ’ผ <client>`), or clear the status when none resolves. */
38
+ showIdle(ctx: ExtensionContext): void;
39
+ }
40
+
41
+ /**
42
+ * Owns the `/kankaku` footer status: the running elapsed-time clock (with
43
+ * its refresh timer) while a run is active, and the idle billing-client
44
+ * label otherwise.
45
+ */
46
+ export function createStatusBar(deps: StatusBarDeps): StatusBar {
47
+ const intervalMs = deps.intervalMs ?? 1000;
48
+ const scheduleInterval = deps.setInterval ?? setInterval;
49
+ const cancelInterval = deps.clearInterval ?? clearInterval;
50
+ const now = deps.now ?? Date.now;
51
+
52
+ let runStartedAt: number | undefined;
53
+ let statusTimer: NodeJS.Timeout | undefined;
54
+
55
+ function showIdle(ctx: ExtensionContext): void {
56
+ if (!ctx.hasUI) return;
57
+ const client = deps.resolveIdleClient();
58
+ ctx.ui.setStatus(STATUS_KEY, client ? `${CLIENT_EMOJI} ${client}` : undefined);
59
+ }
60
+
61
+ function start(ctx: ExtensionContext): void {
62
+ // Retries within the same run fire before_agent_start again; only the
63
+ // first one starts the timer.
64
+ if (runStartedAt !== undefined) return;
65
+ if (!ctx.hasUI) return;
66
+ runStartedAt = now();
67
+ const client = deps.resolveRunClient();
68
+ ctx.ui.setStatus(STATUS_KEY, formatElapsed(0, client));
69
+ statusTimer = scheduleInterval(() => {
70
+ if (runStartedAt === undefined) return;
71
+ ctx.ui.setStatus(STATUS_KEY, formatElapsed(now() - runStartedAt, client));
72
+ }, intervalMs);
73
+ statusTimer.unref?.();
74
+ }
75
+
76
+ function stop(ctx: ExtensionContext): void {
77
+ if (statusTimer) {
78
+ cancelInterval(statusTimer);
79
+ statusTimer = undefined;
80
+ }
81
+ runStartedAt = undefined;
82
+ showIdle(ctx);
83
+ }
84
+
85
+ return { start, stop, showIdle };
86
+ }
@@ -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
+ }