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
@@ -1,11 +1,21 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { WorkRecord, WorkRecordCore, WorkRole } from "../domain/work-record.ts";
2
+ import { isValidClient } from "../domain/client-label.ts";
3
+ import { formatWorkTargetLabel } from "../domain/work-target.ts";
4
+ import type { RegistryClassification } from "../domain/registry-health.ts";
5
+ import type { SubagentProfile } from "../domain/subagent-profile.ts";
6
+ import type { RejectedChildEnvMarker } from "../config.ts";
7
+ import type { OrchestratorRef, WorkRecord, WorkRecordCore, WorkRole } from "../domain/work-record.ts";
3
8
  import type { WorkTracker } from "../domain/work-tracker.ts";
9
+ import type { Catalog } from "../ports/catalog.ts";
4
10
  import type { InflightStore } from "../ports/inflight-store.ts";
5
11
  import type { WorkLog } from "../ports/work-log.ts";
6
12
  import { createSessionClient } from "./session-client.ts";
13
+ import { readNonDefaultSessionDir } from "./session-dir.ts";
14
+ import type { SessionTarget } from "./session-target.ts";
7
15
  import { createStatusBar } from "./status-bar.ts";
8
16
  import { notifyError, registerKankakuCommand } from "./kankaku-command.ts";
17
+ import type { SyncCommandDeps } from "./kankaku-command.ts";
18
+ import type { SyncTrigger } from "./sync-runner.ts";
9
19
 
10
20
  export type { KankakuReportData } from "./kankaku-command.ts";
11
21
 
@@ -15,6 +25,54 @@ export interface PiTrackerDeps {
15
25
  /** Crash-recovery checkpoint store; see the "Crash recovery" README section. */
16
26
  inflight: InflightStore;
17
27
  role: WorkRole;
28
+ /**
29
+ * Static `roleConfidence`, applied from the very first record this
30
+ * process builds. Back-compat / direct-injection path: prefer
31
+ * {@link resolveRoleConfidence} for real wiring (`extension.ts`), since
32
+ * interactivity (F3) is normally only knowable once pi's own
33
+ * `ExtensionContext` is available at `session_start`, later than this
34
+ * object is constructed. When both are set, `resolveRoleConfidence`
35
+ * (once it has run, at `session_start`) wins. Never counted as a new task
36
+ * locally or synced to the hub when `"uncertain"` — see
37
+ * `domain/task-view.ts#buildTasks`/`uncertainRecords` and
38
+ * `triggerAutoSync` below.
39
+ */
40
+ roleConfidence?: "uncertain";
41
+ /**
42
+ * Deferred `roleConfidence` resolution (F3, ADR 0022 refined): called
43
+ * once, at `session_start`, with whether this is an interactive TUI
44
+ * session (`ctx.mode === "tui"`) — the signal a verified tracked ancestor
45
+ * alone cannot supply, since every subagent mechanism kankaku recognises
46
+ * launches its child non-interactively. `role` itself never depends on
47
+ * this (only `GENTLE_PI_AGENTS_CHILD`/`KANKAKU_ROLE` decide it, both
48
+ * already final at factory time); only whether an `"orchestrator"` record
49
+ * is further flagged `uncertain`. Once set here, stays stable for the
50
+ * rest of this process's life (every `session_start` after the first
51
+ * simply recomputes the same answer, since interactivity cannot change
52
+ * mid-process).
53
+ */
54
+ resolveRoleConfidence?: (isInteractive: boolean) => "uncertain" | undefined;
55
+ /**
56
+ * Set only when `role` is `"subagent"` and this process discovered a
57
+ * tracked ancestor via the machine-wide process registry (ADR 0023,
58
+ * F1/F4's rewrite). Attached to every record this process appends so
59
+ * `matchChildren` can reunite it with its orchestrator even across a
60
+ * different project/`KANKAKU_DIR` — its `dir` field is also what
61
+ * `extension.ts` uses to route this process's own work log/inflight
62
+ * checkpoints straight into the real orchestrator's directory, so the
63
+ * two records end up in the same `worklog.jsonl` to begin with.
64
+ */
65
+ orchestratorRef?: OrchestratorRef;
66
+ /**
67
+ * SUBAGENT-REQ-005/017: the {@link SubagentProfile} `id` whose child-env
68
+ * marker(s) confirmed this process's `role: "subagent"` (see
69
+ * `adapters/process-identity.ts#ProcessIdentity.profile`). Attached to
70
+ * every record this process appends so `/kankaku doctor` can report which
71
+ * profile matched each record. Never set for an `orchestrator` record.
72
+ */
73
+ profile?: string;
74
+ /** The full active subagent-profile set, forwarded to `/kankaku doctor` — see `kankaku-command.ts#KankakuCommandDeps.subagentProfiles`. */
75
+ subagentProfiles?: SubagentProfile[];
18
76
  pid: number;
19
77
  parentPid: number;
20
78
  /** Status line refresh interval in ms. Defaults to 1000. */
@@ -36,8 +94,91 @@ export interface PiTrackerDeps {
36
94
  * is not configured.
37
95
  */
38
96
  writeExportFile?: (name: string, content: string) => string;
97
+ /**
98
+ * Present only when the hub (PocketBase) is configured; see README "Hub
99
+ * (PocketBase)". Drives the session-start picker and the `/kankaku
100
+ * target`/`catalog` commands. Absent entirely when the hub is not
101
+ * configured, so behaviour and record shape are unchanged for users
102
+ * without one.
103
+ */
104
+ sessionTarget?: SessionTarget;
105
+ /** This machine's hostname or `KANKAKU_MACHINE`; only attached to records when `sessionTarget` is present. */
106
+ machine?: string;
107
+ /** Present only when the hub is configured; forwarded to `/kankaku catalog refresh` and the hub-aware `/kankaku client`. */
108
+ catalog?: Catalog;
109
+ /** A configured-but-rejected hub URL (see `adapters/hub-credentials.ts`); surfaced once via `ctx.ui.notify` on the first `session_start`. */
110
+ hubConfigError?: string;
111
+ /** Present only when the hub is configured; forwarded to `/kankaku sync [all|status]` and `/kankaku backfill`. */
112
+ sync?: SyncCommandDeps;
113
+ /** Forwarded to `/kankaku doctor`; see `kankaku-command.ts#KankakuCommandDeps.registryHealth`. */
114
+ registryHealth?: () => RegistryClassification;
115
+ /** Forwarded to `/kankaku doctor` (F2); see `kankaku-command.ts#KankakuCommandDeps.ancestorDetectionAvailable`. */
116
+ ancestorDetectionAvailable?: () => boolean;
117
+ /** Forwarded to `/kankaku doctor` (F3); see `kankaku-command.ts#KankakuCommandDeps.roleOverride`. */
118
+ roleOverride?: "orchestrator" | "subagent";
119
+ /**
120
+ * Whether `GENTLE_PI_AGENTS_CHILD=1` (the confirmed child marker) was
121
+ * also present on this process (R1); forwarded to `/kankaku doctor` so it
122
+ * can flag "override present AND child marker present" with the resolved
123
+ * outcome — see `kankaku-command.ts#KankakuCommandDeps.childMarkerPresent`.
124
+ */
125
+ childMarkerPresent?: boolean;
126
+ /**
127
+ * Set when `KANKAKU_ROLE=subagent` was present, with no confirmed child
128
+ * marker, but was ignored because this process looked interactive (R1 —
129
+ * see `config.ts#detectRole`'s precedence doc). Surfaced once via
130
+ * `ctx.ui.notify` at `session_start` and forwarded to `/kankaku doctor`.
131
+ */
132
+ overrideIgnoredInteractive?: boolean;
133
+ /**
134
+ * C2 (CRITICAL fix): set when a USER-CONFIGURED child-env marker
135
+ * (`KANKAKU_SUBAGENT_CHILD_ENV`) was present, but was ignored because
136
+ * this process looked interactive — a configured marker never demotes an
137
+ * interactive session (see `config.ts#detectRole`'s precedence doc).
138
+ * Surfaced once via `ctx.ui.notify` at `session_start` and forwarded to
139
+ * `/kankaku doctor`; escalated there (C2 item 3's self-check) when this
140
+ * process also has no tracked ancestor at all — the strongest signal the
141
+ * marker is genuinely ambient.
142
+ */
143
+ configuredMarkerIgnoredInteractive?: boolean;
144
+ /** C2 item 3: whether a live tracked ancestor was found for this process (`adapters/process-identity.ts#ProcessIdentity.hasTrackedAncestor`) — combined with `configuredMarkerIgnoredInteractive` above to decide the doctor self-check's wording. */
145
+ hasTrackedAncestor?: boolean;
146
+ /**
147
+ * C2 (CRITICAL fix, item 1): every `KANKAKU_SUBAGENT_CHILD_ENV` marker
148
+ * `config.ts#validateSubagentChildEnvMarkers` rejected as looking
149
+ * pi/shell/OS/npm-owned rather than genuinely child-only. Surfaced once
150
+ * via `ctx.ui.notify` at `session_start` and forwarded to `/kankaku
151
+ * doctor`. Absent (never an empty array) when nothing was rejected.
152
+ */
153
+ rejectedSubagentChildEnvMarkers?: RejectedChildEnvMarker[];
154
+ /** Forwarded to `/kankaku doctor` (F1); see `kankaku-command.ts#KankakuCommandDeps.workLogRouting`. */
155
+ workLogRouting?: { usedFallback: boolean; parentDir: string };
156
+ /**
157
+ * `KANKAKU_SYNC_AUTO` (default enabled): when `true` and `sync` is
158
+ * present, fire-and-forget a sync on `session_start` (orchestrator role
159
+ * only, after crash recovery) and again after `agent_settled`. Both go
160
+ * through `sync.run`, which callers are expected to wrap with a
161
+ * single-flight guard (see `adapters/sync-runner.ts#singleFlight`) so
162
+ * these two triggers never race. Never awaited; errors are swallowed
163
+ * (`sync.run` never throws) and surfaced at most once per session via a
164
+ * quiet notification, never on success.
165
+ */
166
+ autoSyncEnabled?: boolean;
167
+ /**
168
+ * Upper bound (ms) on how long the `session_shutdown` handler awaits
169
+ * `sync.run({ trigger: "session_shutdown" })` before giving up and
170
+ * letting cleanup proceed regardless. pi awaits `session_shutdown`
171
+ * handlers with no timeout of its own (verified in pi's dist), so this
172
+ * is the only thing bounding how long quitting can take when the hub is
173
+ * unreachable. Defaults to 3000; injectable so tests never wait on a
174
+ * real timer.
175
+ */
176
+ shutdownSyncTimeoutMs?: number;
39
177
  }
40
178
 
179
+ /** Default for {@link PiTrackerDeps.shutdownSyncTimeoutMs}. */
180
+ const DEFAULT_SHUTDOWN_SYNC_TIMEOUT_MS = 3000;
181
+
41
182
  /** Default `isAlive`: probe with signal 0 — no signal is sent, only existence/permission is checked. */
42
183
  function defaultIsAlive(pid: number): boolean {
43
184
  try {
@@ -60,6 +201,17 @@ function guarded<E>(fn: (event: E, ctx: ExtensionContext) => void): (event: E, c
60
201
  };
61
202
  }
62
203
 
204
+ /** Async counterpart of {@link guarded}: also catches a rejected promise, e.g. from the target picker's `ctx.ui.select`. */
205
+ function guardedAsync<E>(fn: (event: E, ctx: ExtensionContext) => Promise<void>): (event: E, ctx: ExtensionContext) => Promise<void> {
206
+ return async (event, ctx) => {
207
+ try {
208
+ await fn(event, ctx);
209
+ } catch (error) {
210
+ notifyError(ctx, error);
211
+ }
212
+ };
213
+ }
214
+
63
215
  /**
64
216
  * Wires pi lifecycle events to a {@link WorkTracker}, persisting finished
65
217
  * records to a {@link WorkLog} and exposing the `/kankaku` report command.
@@ -68,16 +220,36 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
68
220
  const { tracker, log, inflight, role, pid, parentPid } = deps;
69
221
  const isAlive = deps.isAlive ?? defaultIsAlive;
70
222
 
223
+ /**
224
+ * F3: mutable so `session_start` can finalise it once `ctx.mode` (and
225
+ * therefore interactivity) is known — see `resolveRoleConfidence` above.
226
+ * Starts from the static `roleConfidence`, if any, so a caller that never
227
+ * fires `session_start` at all (e.g. most existing tests) still behaves
228
+ * exactly as before this change.
229
+ */
230
+ let roleConfidence: "uncertain" | undefined = deps.roleConfidence;
231
+
71
232
  const sessionClient = createSessionClient({
72
233
  role,
73
234
  envClient: deps.envClient,
74
235
  resolveProjectClient: deps.resolveProjectClient,
75
236
  });
76
237
 
238
+ /** Prefer the hub target's display label over the legacy client label, when one is active. */
239
+ function runDisplayLabel(): string | undefined {
240
+ const target = deps.sessionTarget?.runTarget();
241
+ return target ? formatWorkTargetLabel(target) : sessionClient.runClient();
242
+ }
243
+
244
+ function idleDisplayLabel(): string | undefined {
245
+ const target = deps.sessionTarget?.idleTarget();
246
+ return target ? formatWorkTargetLabel(target) : sessionClient.idleClient();
247
+ }
248
+
77
249
  const statusBar = createStatusBar({
78
250
  intervalMs: deps.statusIntervalMs,
79
- resolveRunClient: () => sessionClient.runClient(),
80
- resolveIdleClient: () => sessionClient.idleClient(),
251
+ resolveRunClient: runDisplayLabel,
252
+ resolveIdleClient: idleDisplayLabel,
81
253
  });
82
254
 
83
255
  const kankakuCommand = registerKankakuCommand(pi, {
@@ -85,8 +257,96 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
85
257
  sessionClient,
86
258
  refreshIdleStatus: (ctx) => statusBar.showIdle(ctx),
87
259
  writeExportFile: deps.writeExportFile,
260
+ sessionTarget: deps.sessionTarget,
261
+ catalog: deps.catalog,
262
+ sync: deps.sync,
263
+ registryHealth: deps.registryHealth,
264
+ ancestorDetectionAvailable: deps.ancestorDetectionAvailable,
265
+ roleOverride: deps.roleOverride,
266
+ childMarkerPresent: deps.childMarkerPresent,
267
+ overrideIgnoredInteractive: deps.overrideIgnoredInteractive,
268
+ configuredMarkerIgnoredInteractive: deps.configuredMarkerIgnoredInteractive,
269
+ hasTrackedAncestor: deps.hasTrackedAncestor,
270
+ rejectedSubagentChildEnvMarkers: deps.rejectedSubagentChildEnvMarkers,
271
+ workLogRouting: deps.workLogRouting,
272
+ subagentProfiles: deps.subagentProfiles,
88
273
  });
89
274
 
275
+ /** At most one quiet auto-sync failure notification per session; never notified on success. Shared by the fire-and-forget `triggerAutoSync` and the awaited shutdown sync below. */
276
+ let autoSyncErrorNotified = false;
277
+
278
+ function notifyAutoSyncFailureOnce(ctx: ExtensionContext, message: string): void {
279
+ if (autoSyncErrorNotified) return;
280
+ autoSyncErrorNotified = true;
281
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
282
+ }
283
+
284
+ // An uncertain-role process (ADR 0022) never anchors a task (see
285
+ // `domain/task-view.ts#buildTasks`), so a sync attempt from it would
286
+ // only ever find nothing new to push — skip it outright, exactly like a
287
+ // subagent, rather than pay for a pointless run. Shared by every
288
+ // automatic trigger, fire-and-forget or awaited.
289
+ function autoSyncEligible(): boolean {
290
+ return Boolean(deps.sync) && role === "orchestrator" && roleConfidence !== "uncertain" && deps.autoSyncEnabled !== false;
291
+ }
292
+
293
+ /** Fire-and-forget a sync (orchestrator role, `sync` configured, auto-sync enabled). Never awaited, never throws. `trigger` lets the automatic path's version short-circuit and throttle (see `adapters/sync-runner.ts#runSync`) tell apart `session_start` from `agent_settled`. */
294
+ function triggerAutoSync(ctx: ExtensionContext, trigger: SyncTrigger): void {
295
+ if (!deps.sync || !autoSyncEligible()) return;
296
+ void deps.sync
297
+ .run({ trigger })
298
+ .then((summary) => {
299
+ if (summary.error) notifyAutoSyncFailureOnce(ctx, `kankaku: sync failed: ${summary.error}`);
300
+ })
301
+ .catch(() => {
302
+ // sync.run is expected to never throw (see adapters/sync-runner.ts);
303
+ // this catch only guards against a misbehaving implementation.
304
+ });
305
+ }
306
+
307
+ /**
308
+ * Awaited counterpart of {@link triggerAutoSync}, for `session_shutdown`
309
+ * only: called after every settled record for this shutdown has already
310
+ * been appended (see the handler below), so the sync it runs includes
311
+ * them. Races `sync.run({ trigger: "session_shutdown" })` against
312
+ * `shutdownSyncTimeoutMs` (default {@link DEFAULT_SHUTDOWN_SYNC_TIMEOUT_MS})
313
+ * so an unreachable hub costs at most that timeout, never longer, and pi
314
+ * awaits this handler with no timeout of its own. Never throws: a
315
+ * rejection from `sync.run` (never expected — see
316
+ * `adapters/sync-runner.ts`) is caught exactly like `triggerAutoSync`'s
317
+ * own `.catch`, and is never left as an unhandled rejection even when the
318
+ * timeout branch of the race wins first. Notifies at most once per
319
+ * session, sharing {@link notifyAutoSyncFailureOnce}'s guard.
320
+ */
321
+ async function runShutdownSync(ctx: ExtensionContext): Promise<void> {
322
+ if (!deps.sync || !autoSyncEligible()) return;
323
+ const sync = deps.sync;
324
+
325
+ const timeoutMs = deps.shutdownSyncTimeoutMs ?? DEFAULT_SHUTDOWN_SYNC_TIMEOUT_MS;
326
+ let timer: NodeJS.Timeout | undefined;
327
+ // Caught right here, unconditionally, so this promise never becomes an
328
+ // unhandled rejection regardless of which side of the race below wins.
329
+ const settled = sync
330
+ .run({ trigger: "session_shutdown" })
331
+ .then((summary) => ({ kind: "summary", summary }) as const)
332
+ .catch((error: unknown) => ({ kind: "rejected", error }) as const);
333
+ const timedOut = new Promise<{ kind: "timeout" }>((resolve) => {
334
+ timer = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
335
+ });
336
+
337
+ const outcome = await Promise.race([settled, timedOut]);
338
+ if (timer !== undefined) clearTimeout(timer);
339
+
340
+ if (outcome.kind === "timeout") {
341
+ notifyAutoSyncFailureOnce(ctx, "kankaku: shutdown sync timed out");
342
+ } else if (outcome.kind === "rejected") {
343
+ const message = outcome.error instanceof Error ? outcome.error.message : String(outcome.error);
344
+ notifyAutoSyncFailureOnce(ctx, `kankaku: sync failed: ${message}`);
345
+ } else if (outcome.summary.error) {
346
+ notifyAutoSyncFailureOnce(ctx, `kankaku: sync failed: ${outcome.summary.error}`);
347
+ }
348
+ }
349
+
90
350
  /** `log.append` plus cache invalidation, so every append this process makes keeps the completion cache correct. */
91
351
  function appendRecord(record: WorkRecord): void {
92
352
  log.append(record);
@@ -95,10 +355,17 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
95
355
 
96
356
  function buildRecord(core: WorkRecordCore, ctx: ExtensionContext): WorkRecord {
97
357
  const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
98
- // Subagent children never carry their own client: they inherit the
99
- // orchestrator's label at task level (see task-view.ts).
100
- const client = sessionClient.runClient();
358
+ const thinkingLevel = readThinkingLevel(pi);
359
+ // Subagent children never carry their own client/target: they inherit
360
+ // the orchestrator's at task level (see task-view.ts).
361
+ const target = deps.sessionTarget?.runTarget();
362
+ // When a hub target is active, the legacy `client` label is the
363
+ // client's code (kept valid against CLIENT_PATTERN so every existing
364
+ // report/export keeps working); an invalid code is omitted rather than
365
+ // breaking the record. Without a hub target, behaviour is unchanged.
366
+ const client = target ? (isValidClient(target.clientCode) ? target.clientCode : undefined) : sessionClient.runClient();
101
367
  const sessionName = pi.getSessionName();
368
+ const sessionDir = readNonDefaultSessionDir(ctx.sessionManager);
102
369
  return {
103
370
  ...core,
104
371
  role,
@@ -109,8 +376,22 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
109
376
  sessionFile: ctx.sessionManager.getSessionFile(),
110
377
  mode: ctx.mode,
111
378
  ...(model !== undefined ? { model } : {}),
379
+ ...(thinkingLevel !== undefined ? { thinkingLevel } : {}),
112
380
  ...(client !== undefined ? { client } : {}),
113
381
  ...(sessionName !== undefined ? { sessionName } : {}),
382
+ ...(sessionDir !== undefined ? { sessionDir } : {}),
383
+ ...(target !== undefined
384
+ ? {
385
+ clientId: target.clientId,
386
+ clientName: target.clientName,
387
+ ...(target.projectId !== undefined ? { projectId: target.projectId } : {}),
388
+ ...(target.projectName !== undefined ? { projectName: target.projectName } : {}),
389
+ }
390
+ : {}),
391
+ ...(deps.machine !== undefined ? { machine: deps.machine } : {}),
392
+ ...(roleConfidence !== undefined ? { roleConfidence } : {}),
393
+ ...(deps.orchestratorRef !== undefined ? { orchestratorRef: deps.orchestratorRef } : {}),
394
+ ...(deps.profile !== undefined ? { profile: deps.profile } : {}),
114
395
  };
115
396
  }
116
397
 
@@ -138,6 +419,21 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
138
419
  }),
139
420
  );
140
421
 
422
+ pi.on(
423
+ "agent_start",
424
+ guarded((_event, ctx) => {
425
+ // A run an extension started itself never fires before_agent_start
426
+ // (see WorkTracker.onAgentStart): open the record, the status clock and
427
+ // the crash-recovery checkpoint here instead. A no-op for a user prompt.
428
+ const wasIdle = tracker.peek("interrupted") === undefined;
429
+ tracker.onAgentStart();
430
+ if (wasIdle) statusBar.start(ctx);
431
+ // Always: when this start set a record aside, the checkpoint on disk
432
+ // must become the merged snapshot now, not at the next turn_end.
433
+ checkpoint(ctx);
434
+ }),
435
+ );
436
+
141
437
  pi.on(
142
438
  "agent_end",
143
439
  guarded((event) => {
@@ -198,41 +494,116 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
198
494
  pi.on(
199
495
  "agent_settled",
200
496
  guarded((_event, ctx) => {
201
- const core = tracker.onSettled();
497
+ const cores = tracker.settleAll();
202
498
  try {
203
- if (core) {
499
+ for (const core of cores) {
204
500
  appendRecord(buildRecord(core, ctx));
205
501
  }
206
502
  } finally {
207
503
  // Always clean up, even when appendRecord above threw: an unpersisted
208
504
  // checkpoint must not linger, and the status timer must not leak.
209
505
  inflight.clear();
210
- statusBar.stop(ctx);
211
- sessionClient.endRun();
506
+ if (tracker.peek("interrupted") !== undefined) {
507
+ // A new run overtook this settle (see WorkTracker.settleAll): it is
508
+ // still open, so its clock keeps running and it gets its own
509
+ // checkpoint back — the clear above only dropped the old record's.
510
+ checkpoint(ctx);
511
+ } else {
512
+ statusBar.stop(ctx);
513
+ sessionClient.endRun();
514
+ deps.sessionTarget?.endRun();
515
+ }
516
+ triggerAutoSync(ctx, "agent_settled");
212
517
  }
213
518
  }),
214
519
  );
215
520
 
216
521
  pi.on(
217
522
  "session_shutdown",
218
- guarded((_event, ctx) => {
219
- const core = tracker.onShutdown();
523
+ guardedAsync(async (_event, ctx) => {
524
+ const cores = tracker.shutdownAll();
220
525
  try {
221
- if (core) {
526
+ for (const core of cores) {
222
527
  appendRecord(buildRecord(core, ctx));
223
528
  }
529
+ // Only reached once every record above was appended: the shutdown
530
+ // sync (bounded by runShutdownSync's own timeout, see above) must
531
+ // include them. If appendRecord threw, this is skipped and the
532
+ // finally below still runs cleanup — never left half-done.
533
+ await runShutdownSync(ctx);
224
534
  } finally {
225
535
  inflight.clear();
226
536
  statusBar.stop(ctx);
227
537
  sessionClient.endRun();
538
+ deps.sessionTarget?.endRun();
228
539
  }
229
540
  }),
230
541
  );
231
542
 
543
+ let hubConfigErrorNotified = false;
544
+ let overrideIgnoredInteractiveNotified = false;
545
+ let configuredMarkerIgnoredInteractiveNotified = false;
546
+ let rejectedSubagentChildEnvMarkersNotified = false;
547
+
232
548
  pi.on(
233
549
  "session_start",
234
- guarded((_event, ctx) => {
550
+ guardedAsync(async (_event, ctx) => {
551
+ // F3: finalise roleConfidence now that ctx.mode (and therefore
552
+ // interactivity) is known — see resolveRoleConfidence's doc comment.
553
+ // A no-op when extension.ts did not wire it (deps.roleConfidence, if
554
+ // any, is left exactly as constructed).
555
+ if (deps.resolveRoleConfidence) {
556
+ roleConfidence = deps.resolveRoleConfidence(ctx.mode === "tui");
557
+ }
558
+
559
+ if (deps.hubConfigError && !hubConfigErrorNotified) {
560
+ hubConfigErrorNotified = true;
561
+ if (ctx.hasUI) ctx.ui.notify(deps.hubConfigError, "error");
562
+ }
563
+
564
+ // R1: KANKAKU_ROLE=subagent was ignored at factory time (no confirmed
565
+ // child marker, and this process looked interactive) — this is
566
+ // almost always a leaked shell export, and honouring it would have
567
+ // silently dropped this session's own work. Never silent: warn once.
568
+ if (deps.overrideIgnoredInteractive && !overrideIgnoredInteractiveNotified) {
569
+ overrideIgnoredInteractiveNotified = true;
570
+ if (ctx.hasUI) {
571
+ ctx.ui.notify(
572
+ "kankaku: ignoring KANKAKU_ROLE=subagent for this interactive session (likely a leaked shell export) — treating it as orchestrator; see /kankaku doctor",
573
+ "warning",
574
+ );
575
+ }
576
+ }
577
+
578
+ // C2 item 2/3: a KANKAKU_SUBAGENT_CHILD_ENV marker matched, but was
579
+ // ignored because this process looked interactive — a configured
580
+ // marker never demotes an interactive session. Escalate the wording
581
+ // when this process ALSO has no tracked ancestor at all (the
582
+ // strongest signal the marker is genuinely ambient, not a real
583
+ // subagent mechanism — C2 item 3's self-check).
584
+ if (deps.configuredMarkerIgnoredInteractive && !configuredMarkerIgnoredInteractiveNotified) {
585
+ configuredMarkerIgnoredInteractiveNotified = true;
586
+ if (ctx.hasUI) {
587
+ const message = deps.hasTrackedAncestor
588
+ ? "kankaku: ignoring a KANKAKU_SUBAGENT_CHILD_ENV marker for this interactive session — treating it as orchestrator; see /kankaku doctor"
589
+ : "kankaku: a KANKAKU_SUBAGENT_CHILD_ENV marker matched this interactive, top-level session (no tracked ancestor) — the marker is likely ambient, not a real subagent mechanism; treating it as orchestrator; see /kankaku doctor";
590
+ ctx.ui.notify(message, "warning");
591
+ }
592
+ }
593
+
594
+ // C2 item 1: one or more KANKAKU_SUBAGENT_CHILD_ENV entries were
595
+ // rejected at config load time (looked pi/shell/OS/npm-owned, not
596
+ // genuinely child-only) — never silent.
597
+ if (deps.rejectedSubagentChildEnvMarkers?.length && !rejectedSubagentChildEnvMarkersNotified) {
598
+ rejectedSubagentChildEnvMarkersNotified = true;
599
+ if (ctx.hasUI) {
600
+ const names = deps.rejectedSubagentChildEnvMarkers.map((rejected) => rejected.name).join(", ");
601
+ ctx.ui.notify(`kankaku: ignoring KANKAKU_SUBAGENT_CHILD_ENV marker(s) that look ambient, not child-only: ${names}; see /kankaku doctor`, "warning");
602
+ }
603
+ }
604
+
235
605
  sessionClient.restore(ctx);
606
+ deps.sessionTarget?.restore(ctx);
236
607
  statusBar.showIdle(ctx);
237
608
 
238
609
  const recovered = inflight.recoverStale(isAlive);
@@ -242,6 +613,29 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
242
613
  if (recovered.length > 0 && ctx.hasUI) {
243
614
  ctx.ui.notify(`kankaku: recovered ${recovered.length} interrupted record(s)`, "warning");
244
615
  }
616
+
617
+ // Runs after recovery so a freshly picked target does not affect
618
+ // records recovered from before this session started. See README
619
+ // "Hub (PocketBase)": a no-op unless orchestrator + hasUI + configured.
620
+ if (deps.sessionTarget) {
621
+ await deps.sessionTarget.ensurePicked(pi, ctx);
622
+ statusBar.showIdle(ctx);
623
+ }
624
+
625
+ // Fire-and-forget, after recovery so a just-recovered interrupted
626
+ // record is included. See README "Hub (PocketBase)" sync section.
627
+ triggerAutoSync(ctx, "session_start");
245
628
  }),
246
629
  );
247
630
  }
631
+
632
+ /** pi's current reasoning effort, or `undefined` on a pi that predates `getThinkingLevel` or has no session to ask yet. Never throws. */
633
+ function readThinkingLevel(pi: ExtensionAPI): string | undefined {
634
+ try {
635
+ const read = (pi as { getThinkingLevel?: () => unknown }).getThinkingLevel;
636
+ const level = typeof read === "function" ? read.call(pi) : undefined;
637
+ return typeof level === "string" && level.length > 0 ? level : undefined;
638
+ } catch {
639
+ return undefined;
640
+ }
641
+ }
@@ -0,0 +1,60 @@
1
+ import type { Client, Project } from "../domain/work-target.ts";
2
+ import type { PocketBaseClient, PocketBaseRecord } from "./pocketbase-client.ts";
3
+
4
+ interface ClientRecord extends PocketBaseRecord {
5
+ name: string;
6
+ code: string;
7
+ active?: boolean;
8
+ unassigned?: boolean;
9
+ }
10
+
11
+ interface ProjectRecord extends PocketBaseRecord {
12
+ name: string;
13
+ code?: string;
14
+ client: string;
15
+ repo_paths?: unknown;
16
+ active?: boolean;
17
+ }
18
+
19
+ function mapClient(record: ClientRecord): Client {
20
+ return {
21
+ id: record.id,
22
+ name: record.name,
23
+ code: record.code,
24
+ active: record.active === true,
25
+ ...(record.unassigned === true ? { unassigned: true } : {}),
26
+ };
27
+ }
28
+
29
+ function mapProject(record: ProjectRecord): Project {
30
+ return {
31
+ id: record.id,
32
+ name: record.name,
33
+ ...(record.code !== undefined ? { code: record.code } : {}),
34
+ clientId: record.client,
35
+ repoPaths: Array.isArray(record.repo_paths) ? record.repo_paths.filter((path): path is string => typeof path === "string") : [],
36
+ active: record.active === true,
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Build the `fetchCatalog` function {@link CachedCatalog} (`cached-catalog.ts`)
42
+ * needs, backed by a {@link PocketBaseClient}. Fetches every `clients` and
43
+ * `projects` record (no `active` filter, so the domain resolver can tell
44
+ * "deleted" from "inactive"; see `domain/work-target.ts`), sorted by name.
45
+ * `signal`, when given, bounds both fetches (see
46
+ * `pocketbase-client.ts#request`).
47
+ */
48
+ export function createPocketBaseCatalogFetcher(client: PocketBaseClient): (signal?: AbortSignal) => Promise<{ clients: Client[]; projects: Project[] }> {
49
+ return async (signal?: AbortSignal) => {
50
+ const [clientRecords, projectRecords] = await Promise.all([
51
+ client.list<ClientRecord>("clients", { sort: "name" }, signal),
52
+ client.list<ProjectRecord>("projects", { sort: "name" }, signal),
53
+ ]);
54
+
55
+ return {
56
+ clients: clientRecords.map(mapClient),
57
+ projects: projectRecords.map(mapProject),
58
+ };
59
+ };
60
+ }