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,753 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
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 { KankakuConfig, RejectedChildEnvMarker } from "../config.ts";
7
+ import type { OrchestratorRef, WorkRecord, WorkRecordCore, WorkRole } from "../domain/work-record.ts";
8
+ import type { WorkTracker } from "../domain/work-tracker.ts";
9
+ import type { Catalog } from "../ports/catalog.ts";
10
+ import type { InflightStore } from "../ports/inflight-store.ts";
11
+ import type { WorkLog } from "../ports/work-log.ts";
12
+ import { createSessionClient } from "./session-client.ts";
13
+ import { readNonDefaultSessionDir } from "./session-dir.ts";
14
+ import type { SessionTarget } from "./session-target.ts";
15
+ import { createStatusBar } from "./status-bar.ts";
16
+ import { appendReportEntry, notifyError, registerKankakuCommand } from "./kankaku-command.ts";
17
+ import type { KankakuCommandDeps, SyncCommandDeps } from "./kankaku-command.ts";
18
+ import { openKankakuPanel } from "./panel/kankaku-panel.ts";
19
+ import { createAboutScreen } from "./panel/screens/about.ts";
20
+ import { createDoctorScreen } from "./panel/screens/doctor.ts";
21
+ import { createExportScreen } from "./panel/screens/export.ts";
22
+ import { createReportScreen } from "./panel/screens/report.ts";
23
+ import { createSyncScreen } from "./panel/screens/sync.ts";
24
+ import { createTargetScreen } from "./panel/screens/target.ts";
25
+ import type { SyncTrigger } from "./sync-runner.ts";
26
+
27
+ export type { KankakuReportData } from "./kankaku-command.ts";
28
+
29
+ export interface PiTrackerDeps {
30
+ tracker: WorkTracker;
31
+ log: WorkLog;
32
+ /** Crash-recovery checkpoint store; see the "Crash recovery" README section. */
33
+ inflight: InflightStore;
34
+ role: WorkRole;
35
+ /**
36
+ * Static `roleConfidence`, applied from the very first record this
37
+ * process builds. Back-compat / direct-injection path: prefer
38
+ * {@link resolveRoleConfidence} for real wiring (`extension.ts`), since
39
+ * interactivity (F3) is normally only knowable once pi's own
40
+ * `ExtensionContext` is available at `session_start`, later than this
41
+ * object is constructed. When both are set, `resolveRoleConfidence`
42
+ * (once it has run, at `session_start`) wins. Never counted as a new task
43
+ * locally or synced to the hub when `"uncertain"` — see
44
+ * `domain/task-view.ts#buildTasks`/`uncertainRecords` and
45
+ * `triggerAutoSync` below.
46
+ */
47
+ roleConfidence?: "uncertain";
48
+ /**
49
+ * Deferred `roleConfidence` resolution (F3, ADR 0022 refined): called
50
+ * once, at `session_start`, with whether this is an interactive TUI
51
+ * session (`ctx.mode === "tui"`) — the signal a verified tracked ancestor
52
+ * alone cannot supply, since every subagent mechanism kankaku recognises
53
+ * launches its child non-interactively. `role` itself never depends on
54
+ * this (only `GENTLE_PI_AGENTS_CHILD`/`KANKAKU_ROLE` decide it, both
55
+ * already final at factory time); only whether an `"orchestrator"` record
56
+ * is further flagged `uncertain`. Once set here, stays stable for the
57
+ * rest of this process's life (every `session_start` after the first
58
+ * simply recomputes the same answer, since interactivity cannot change
59
+ * mid-process).
60
+ */
61
+ resolveRoleConfidence?: (isInteractive: boolean) => "uncertain" | undefined;
62
+ /**
63
+ * Set only when `role` is `"subagent"` and this process discovered a
64
+ * tracked ancestor via the machine-wide process registry (ADR 0023,
65
+ * F1/F4's rewrite). Attached to every record this process appends so
66
+ * `matchChildren` can reunite it with its orchestrator even across a
67
+ * different project/`KANKAKU_DIR` — its `dir` field is also what
68
+ * `extension.ts` uses to route this process's own work log/inflight
69
+ * checkpoints straight into the real orchestrator's directory, so the
70
+ * two records end up in the same `worklog.jsonl` to begin with.
71
+ */
72
+ orchestratorRef?: OrchestratorRef;
73
+ /**
74
+ * SUBAGENT-REQ-005/017: the {@link SubagentProfile} `id` whose child-env
75
+ * marker(s) confirmed this process's `role: "subagent"` (see
76
+ * `adapters/process-identity.ts#ProcessIdentity.profile`). Attached to
77
+ * every record this process appends so `/kankaku doctor` can report which
78
+ * profile matched each record. Never set for an `orchestrator` record.
79
+ */
80
+ profile?: string;
81
+ /** The full active subagent-profile set, forwarded to `/kankaku doctor` — see `kankaku-command.ts#KankakuCommandDeps.subagentProfiles`. */
82
+ subagentProfiles?: SubagentProfile[];
83
+ pid: number;
84
+ parentPid: number;
85
+ /** Status line refresh interval in ms. Defaults to 1000. */
86
+ statusIntervalMs?: number;
87
+ /** Whether a pid is still alive. Defaults to signal-probing with `process.kill(pid, 0)`. */
88
+ isAlive?: (pid: number) => boolean;
89
+ /** Default billing client for this project, from `KANKAKU_CLIENT` (config.ts). See `domain/client-label.ts`. */
90
+ envClient?: string;
91
+ /**
92
+ * Lazily reads the project's default billing client from
93
+ * `<kankaku dir>/config.json`. Injected from `extension.ts` so this
94
+ * adapter stays free of filesystem code.
95
+ */
96
+ resolveProjectClient?: () => string | undefined;
97
+ /**
98
+ * Write an export file (name, content) under the kankaku dir and return
99
+ * its absolute path. Injected from `extension.ts` to keep this adapter
100
+ * free of filesystem code. `/kankaku export` notifies an error when this
101
+ * is not configured.
102
+ */
103
+ writeExportFile?: (name: string, content: string) => string;
104
+ /**
105
+ * Present only when the hub (PocketBase) is configured; see README "Hub
106
+ * (PocketBase)". Drives the session-start picker and the `/kankaku
107
+ * target`/`catalog` commands. Absent entirely when the hub is not
108
+ * configured, so behaviour and record shape are unchanged for users
109
+ * without one.
110
+ */
111
+ sessionTarget?: SessionTarget;
112
+ /** This machine's hostname or `KANKAKU_MACHINE`; only attached to records when `sessionTarget` is present. */
113
+ machine?: string;
114
+ /** Present only when the hub is configured; forwarded to `/kankaku catalog refresh` and the hub-aware `/kankaku client`. */
115
+ catalog?: Catalog;
116
+ /** A configured-but-rejected hub URL (see `adapters/hub-credentials.ts`); surfaced once via `ctx.ui.notify` on the first `session_start`. */
117
+ hubConfigError?: string;
118
+ /** Present only when the hub is configured; forwarded to `/kankaku sync [all|status]` and `/kankaku backfill`. */
119
+ sync?: SyncCommandDeps;
120
+ /** Forwarded to `/kankaku doctor`; see `kankaku-command.ts#KankakuCommandDeps.registryHealth`. */
121
+ registryHealth?: () => RegistryClassification;
122
+ /** Forwarded to `/kankaku doctor` (F2); see `kankaku-command.ts#KankakuCommandDeps.ancestorDetectionAvailable`. */
123
+ ancestorDetectionAvailable?: () => boolean;
124
+ /** Forwarded to `/kankaku doctor` (F3); see `kankaku-command.ts#KankakuCommandDeps.roleOverride`. */
125
+ roleOverride?: "orchestrator" | "subagent";
126
+ /**
127
+ * Whether `GENTLE_PI_AGENTS_CHILD=1` (the confirmed child marker) was
128
+ * also present on this process (R1); forwarded to `/kankaku doctor` so it
129
+ * can flag "override present AND child marker present" with the resolved
130
+ * outcome — see `kankaku-command.ts#KankakuCommandDeps.childMarkerPresent`.
131
+ */
132
+ childMarkerPresent?: boolean;
133
+ /**
134
+ * Set when `KANKAKU_ROLE=subagent` was present, with no confirmed child
135
+ * marker, but was ignored because this process looked interactive (R1 —
136
+ * see `config.ts#detectRole`'s precedence doc). Surfaced once via
137
+ * `ctx.ui.notify` at `session_start` and forwarded to `/kankaku doctor`.
138
+ */
139
+ overrideIgnoredInteractive?: boolean;
140
+ /**
141
+ * C2 (CRITICAL fix): set when a USER-CONFIGURED child-env marker
142
+ * (`KANKAKU_SUBAGENT_CHILD_ENV`) was present, but was ignored because
143
+ * this process looked interactive — a configured marker never demotes an
144
+ * interactive session (see `config.ts#detectRole`'s precedence doc).
145
+ * Surfaced once via `ctx.ui.notify` at `session_start` and forwarded to
146
+ * `/kankaku doctor`; escalated there (C2 item 3's self-check) when this
147
+ * process also has no tracked ancestor at all — the strongest signal the
148
+ * marker is genuinely ambient.
149
+ */
150
+ configuredMarkerIgnoredInteractive?: boolean;
151
+ /** 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. */
152
+ hasTrackedAncestor?: boolean;
153
+ /**
154
+ * C2 (CRITICAL fix, item 1): every `KANKAKU_SUBAGENT_CHILD_ENV` marker
155
+ * `config.ts#validateSubagentChildEnvMarkers` rejected as looking
156
+ * pi/shell/OS/npm-owned rather than genuinely child-only. Surfaced once
157
+ * via `ctx.ui.notify` at `session_start` and forwarded to `/kankaku
158
+ * doctor`. Absent (never an empty array) when nothing was rejected.
159
+ */
160
+ rejectedSubagentChildEnvMarkers?: RejectedChildEnvMarker[];
161
+ /** Forwarded to `/kankaku doctor` (F1); see `kankaku-command.ts#KankakuCommandDeps.workLogRouting`. */
162
+ workLogRouting?: { usedFallback: boolean; parentDir: string };
163
+ /**
164
+ * `KANKAKU_SYNC_AUTO` (default enabled): when `true` and `sync` is
165
+ * present, fire-and-forget a sync on `session_start` (orchestrator role
166
+ * only, after crash recovery) and again after `agent_settled`. Both go
167
+ * through `sync.run`, which callers are expected to wrap with a
168
+ * single-flight guard (see `adapters/sync-runner.ts#singleFlight`) so
169
+ * these two triggers never race. Never awaited; errors are swallowed
170
+ * (`sync.run` never throws) and surfaced at most once per session via a
171
+ * quiet notification, never on success.
172
+ */
173
+ autoSyncEnabled?: boolean;
174
+ /**
175
+ * Upper bound (ms) on how long the `session_shutdown` handler awaits
176
+ * `sync.run({ trigger: "session_shutdown" })` before giving up and
177
+ * letting cleanup proceed regardless. pi awaits `session_shutdown`
178
+ * handlers with no timeout of its own (verified in pi's dist), so this
179
+ * is the only thing bounding how long quitting can take when the hub is
180
+ * unreachable. Defaults to 3000; injectable so tests never wait on a
181
+ * real timer.
182
+ */
183
+ shutdownSyncTimeoutMs?: number;
184
+ /**
185
+ * The full active kankaku configuration (`config.ts#loadConfig`), for the
186
+ * panel's `about` screen (P3). Optional so every existing direct
187
+ * `createPiTracker` caller (e.g. `tests/pi-tracker.test.ts`) keeps
188
+ * working unchanged — falls back to {@link FALLBACK_ABOUT_CONFIG} (an
189
+ * empty configuration) when absent. `extension.ts` always supplies the
190
+ * real one.
191
+ */
192
+ config?: KankakuConfig;
193
+ /** This session's resolved kankaku directory (`adapters/kankaku-dir.ts#resolveKankakuDir`), for the `about` screen. */
194
+ kankakuDir?: string;
195
+ /** pi's version (`adapters/agent-info.ts#resolveAgentVersion`), for the `about` screen. */
196
+ agentVersion?: string;
197
+ /** kankaku's own version (`adapters/agent-info.ts#resolvePluginVersion`), for the `about` screen. */
198
+ pluginVersion?: string;
199
+ /** The hub (PocketBase) URL, when configured, for the `about` screen. */
200
+ hubUrl?: string;
201
+ }
202
+
203
+ /** Default for {@link PiTrackerDeps.shutdownSyncTimeoutMs}. */
204
+ const DEFAULT_SHUTDOWN_SYNC_TIMEOUT_MS = 3000;
205
+
206
+ /** {@link PiTrackerDeps.config}'s fallback for a caller that has not wired the real one yet. */
207
+ const FALLBACK_ABOUT_CONFIG: KankakuConfig = {
208
+ dir: ".kankaku",
209
+ interactiveTools: [],
210
+ subagentProfiles: [],
211
+ segmentRules: [],
212
+ rejectedSubagentChildEnvMarkers: [],
213
+ };
214
+
215
+ /** Default `isAlive`: probe with signal 0 — no signal is sent, only existence/permission is checked. */
216
+ function defaultIsAlive(pid: number): boolean {
217
+ try {
218
+ process.kill(pid, 0);
219
+ return true;
220
+ } catch (error) {
221
+ // EPERM means the process exists but we lack permission to signal it — still alive.
222
+ return (error as NodeJS.ErrnoException).code === "EPERM";
223
+ }
224
+ }
225
+
226
+ /** Wraps a handler so it never throws out of the pi event loop. */
227
+ function guarded<E>(fn: (event: E, ctx: ExtensionContext) => void): (event: E, ctx: ExtensionContext) => void {
228
+ return (event, ctx) => {
229
+ try {
230
+ fn(event, ctx);
231
+ } catch (error) {
232
+ notifyError(ctx, error);
233
+ }
234
+ };
235
+ }
236
+
237
+ /** Async counterpart of {@link guarded}: also catches a rejected promise, e.g. from the target picker's `ctx.ui.select`. */
238
+ function guardedAsync<E>(fn: (event: E, ctx: ExtensionContext) => Promise<void>): (event: E, ctx: ExtensionContext) => Promise<void> {
239
+ return async (event, ctx) => {
240
+ try {
241
+ await fn(event, ctx);
242
+ } catch (error) {
243
+ notifyError(ctx, error);
244
+ }
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Wires pi lifecycle events to a {@link WorkTracker}, persisting finished
250
+ * records to a {@link WorkLog} and exposing the `/kankaku` report command.
251
+ */
252
+ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
253
+ const { tracker, log, inflight, role, pid, parentPid } = deps;
254
+ const isAlive = deps.isAlive ?? defaultIsAlive;
255
+
256
+ /**
257
+ * F3: mutable so `session_start` can finalise it once `ctx.mode` (and
258
+ * therefore interactivity) is known — see `resolveRoleConfidence` above.
259
+ * Starts from the static `roleConfidence`, if any, so a caller that never
260
+ * fires `session_start` at all (e.g. most existing tests) still behaves
261
+ * exactly as before this change.
262
+ */
263
+ let roleConfidence: "uncertain" | undefined = deps.roleConfidence;
264
+
265
+ const sessionClient = createSessionClient({
266
+ role,
267
+ envClient: deps.envClient,
268
+ resolveProjectClient: deps.resolveProjectClient,
269
+ });
270
+
271
+ /** Prefer the hub target's display label over the legacy client label, when one is active. */
272
+ function runDisplayLabel(): string | undefined {
273
+ const target = deps.sessionTarget?.runTarget();
274
+ return target ? formatWorkTargetLabel(target) : sessionClient.runClient();
275
+ }
276
+
277
+ function idleDisplayLabel(): string | undefined {
278
+ const target = deps.sessionTarget?.idleTarget();
279
+ return target ? formatWorkTargetLabel(target) : sessionClient.idleClient();
280
+ }
281
+
282
+ const statusBar = createStatusBar({
283
+ intervalMs: deps.statusIntervalMs,
284
+ resolveRunClient: runDisplayLabel,
285
+ resolveIdleClient: idleDisplayLabel,
286
+ });
287
+
288
+ const refreshIdleStatus = (ctx: ExtensionContext) => statusBar.showIdle(ctx);
289
+
290
+ // Built as a named const (rather than inlined into `registerKankakuCommand`
291
+ // below) so `openPanel`'s doctor screen can reuse the exact same
292
+ // `KankakuCommandDeps` `buildDoctorLines` needs — the panel and
293
+ // `/kankaku doctor` can then never drift. Safe to reference `commandDeps`
294
+ // from within its own `openPanel` closure: the closure body only runs
295
+ // once `/kankaku` (no args) is actually invoked, long after this `const`
296
+ // has finished initializing.
297
+ const commandDeps: KankakuCommandDeps = {
298
+ log,
299
+ sessionClient,
300
+ refreshIdleStatus,
301
+ writeExportFile: deps.writeExportFile,
302
+ sessionTarget: deps.sessionTarget,
303
+ catalog: deps.catalog,
304
+ sync: deps.sync,
305
+ registryHealth: deps.registryHealth,
306
+ ancestorDetectionAvailable: deps.ancestorDetectionAvailable,
307
+ roleOverride: deps.roleOverride,
308
+ childMarkerPresent: deps.childMarkerPresent,
309
+ overrideIgnoredInteractive: deps.overrideIgnoredInteractive,
310
+ configuredMarkerIgnoredInteractive: deps.configuredMarkerIgnoredInteractive,
311
+ hasTrackedAncestor: deps.hasTrackedAncestor,
312
+ rejectedSubagentChildEnvMarkers: deps.rejectedSubagentChildEnvMarkers,
313
+ workLogRouting: deps.workLogRouting,
314
+ subagentProfiles: deps.subagentProfiles,
315
+ // The hub is configured exactly when a session target is wired (see
316
+ // PiTrackerDeps.sessionTarget's doc comment): the panel's root menu
317
+ // uses the same signal to decide whether to offer `sync` — `target`
318
+ // itself is offered either way, since the legacy `/kankaku client
319
+ // <name>` label lives on that same screen and works with no hub at
320
+ // all (see `domain/panel-model.ts#rootMenu`'s `target` row).
321
+ openPanel: (ctx) =>
322
+ openKankakuPanel(ctx, {
323
+ hubConfigured: deps.sessionTarget !== undefined,
324
+ screens: {
325
+ target: createTargetScreen({
326
+ pi,
327
+ ctx,
328
+ role,
329
+ sessionTarget: deps.sessionTarget,
330
+ sessionClient,
331
+ catalog: deps.catalog,
332
+ refreshIdleStatus,
333
+ }),
334
+ report: createReportScreen({
335
+ log,
336
+ ctx,
337
+ pi,
338
+ sessionId: () => ctx.sessionManager.getSessionId(),
339
+ pinReport: (report) => appendReportEntry(pi, ctx, report),
340
+ }),
341
+ doctor: createDoctorScreen({
342
+ commandDeps,
343
+ ctx,
344
+ pinReport: (report) => appendReportEntry(pi, ctx, report),
345
+ }),
346
+ about: createAboutScreen({
347
+ config: deps.config ?? FALLBACK_ABOUT_CONFIG,
348
+ kankakuDir: deps.kankakuDir ?? deps.config?.dir ?? FALLBACK_ABOUT_CONFIG.dir,
349
+ agentVersion: deps.agentVersion,
350
+ pluginVersion: deps.pluginVersion,
351
+ hubUrl: deps.hubUrl,
352
+ }),
353
+ // Present only when the hub is configured — mirrors `sync`'s own
354
+ // hub-only root-menu row (`domain/panel-model.ts#rootMenu`).
355
+ ...(deps.sync
356
+ ? {
357
+ sync: createSyncScreen({
358
+ sync: deps.sync,
359
+ catalog: deps.catalog,
360
+ ctx,
361
+ refreshIdleStatus,
362
+ pinReport: (report) => appendReportEntry(pi, ctx, report),
363
+ }),
364
+ }
365
+ : {}),
366
+ export: createExportScreen({
367
+ log,
368
+ writeExportFile: deps.writeExportFile,
369
+ ctx,
370
+ pinReport: (report) => appendReportEntry(pi, ctx, report),
371
+ }),
372
+ },
373
+ }),
374
+ };
375
+ const kankakuCommand = registerKankakuCommand(pi, commandDeps);
376
+
377
+ /** 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. */
378
+ let autoSyncErrorNotified = false;
379
+
380
+ function notifyAutoSyncFailureOnce(ctx: ExtensionContext, message: string): void {
381
+ if (autoSyncErrorNotified) return;
382
+ autoSyncErrorNotified = true;
383
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
384
+ }
385
+
386
+ // An uncertain-role process (ADR 0022) never anchors a task (see
387
+ // `domain/task-view.ts#buildTasks`), so a sync attempt from it would
388
+ // only ever find nothing new to push — skip it outright, exactly like a
389
+ // subagent, rather than pay for a pointless run. Shared by every
390
+ // automatic trigger, fire-and-forget or awaited.
391
+ function autoSyncEligible(): boolean {
392
+ return Boolean(deps.sync) && role === "orchestrator" && roleConfidence !== "uncertain" && deps.autoSyncEnabled !== false;
393
+ }
394
+
395
+ /** 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`. */
396
+ function triggerAutoSync(ctx: ExtensionContext, trigger: SyncTrigger): void {
397
+ if (!deps.sync || !autoSyncEligible()) return;
398
+ void deps.sync
399
+ .run({ trigger })
400
+ .then((summary) => {
401
+ if (summary.error) notifyAutoSyncFailureOnce(ctx, `kankaku: sync failed: ${summary.error}`);
402
+ })
403
+ .catch(() => {
404
+ // sync.run is expected to never throw (see adapters/sync-runner.ts);
405
+ // this catch only guards against a misbehaving implementation.
406
+ });
407
+ }
408
+
409
+ /**
410
+ * Awaited counterpart of {@link triggerAutoSync}, for `session_shutdown`
411
+ * only: called after every settled record for this shutdown has already
412
+ * been appended (see the handler below), so the sync it runs includes
413
+ * them. Races `sync.run({ trigger: "session_shutdown" })` against
414
+ * `shutdownSyncTimeoutMs` (default {@link DEFAULT_SHUTDOWN_SYNC_TIMEOUT_MS})
415
+ * so an unreachable hub costs at most that timeout, never longer, and pi
416
+ * awaits this handler with no timeout of its own. Never throws: a
417
+ * rejection from `sync.run` (never expected — see
418
+ * `adapters/sync-runner.ts`) is caught exactly like `triggerAutoSync`'s
419
+ * own `.catch`, and is never left as an unhandled rejection even when the
420
+ * timeout branch of the race wins first. Notifies at most once per
421
+ * session, sharing {@link notifyAutoSyncFailureOnce}'s guard.
422
+ */
423
+ async function runShutdownSync(ctx: ExtensionContext): Promise<void> {
424
+ if (!deps.sync || !autoSyncEligible()) return;
425
+ const sync = deps.sync;
426
+
427
+ const timeoutMs = deps.shutdownSyncTimeoutMs ?? DEFAULT_SHUTDOWN_SYNC_TIMEOUT_MS;
428
+ let timer: NodeJS.Timeout | undefined;
429
+ // Caught right here, unconditionally, so this promise never becomes an
430
+ // unhandled rejection regardless of which side of the race below wins.
431
+ const settled = sync
432
+ .run({ trigger: "session_shutdown" })
433
+ .then((summary) => ({ kind: "summary", summary }) as const)
434
+ .catch((error: unknown) => ({ kind: "rejected", error }) as const);
435
+ const timedOut = new Promise<{ kind: "timeout" }>((resolve) => {
436
+ timer = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
437
+ });
438
+
439
+ const outcome = await Promise.race([settled, timedOut]);
440
+ if (timer !== undefined) clearTimeout(timer);
441
+
442
+ if (outcome.kind === "timeout") {
443
+ notifyAutoSyncFailureOnce(ctx, "kankaku: shutdown sync timed out");
444
+ } else if (outcome.kind === "rejected") {
445
+ const message = outcome.error instanceof Error ? outcome.error.message : String(outcome.error);
446
+ notifyAutoSyncFailureOnce(ctx, `kankaku: sync failed: ${message}`);
447
+ } else if (outcome.summary.error) {
448
+ notifyAutoSyncFailureOnce(ctx, `kankaku: sync failed: ${outcome.summary.error}`);
449
+ }
450
+ }
451
+
452
+ /** `log.append` plus cache invalidation, so every append this process makes keeps the completion cache correct. */
453
+ function appendRecord(record: WorkRecord): void {
454
+ log.append(record);
455
+ kankakuCommand.invalidateClientNames();
456
+ }
457
+
458
+ function buildRecord(core: WorkRecordCore, ctx: ExtensionContext): WorkRecord {
459
+ const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
460
+ const thinkingLevel = readThinkingLevel(pi);
461
+ // Subagent children never carry their own client/target: they inherit
462
+ // the orchestrator's at task level (see task-view.ts).
463
+ const target = deps.sessionTarget?.runTarget();
464
+ // When a hub target is active, the legacy `client` label is the
465
+ // client's code (kept valid against CLIENT_PATTERN so every existing
466
+ // report/export keeps working); an invalid code is omitted rather than
467
+ // breaking the record. Without a hub target, behaviour is unchanged.
468
+ const client = target ? (isValidClient(target.clientCode) ? target.clientCode : undefined) : sessionClient.runClient();
469
+ const sessionName = pi.getSessionName();
470
+ const sessionDir = readNonDefaultSessionDir(ctx.sessionManager);
471
+ return {
472
+ ...core,
473
+ role,
474
+ pid,
475
+ parentPid,
476
+ project: ctx.cwd,
477
+ sessionId: ctx.sessionManager.getSessionId(),
478
+ sessionFile: ctx.sessionManager.getSessionFile(),
479
+ mode: ctx.mode,
480
+ ...(model !== undefined ? { model } : {}),
481
+ ...(thinkingLevel !== undefined ? { thinkingLevel } : {}),
482
+ ...(client !== undefined ? { client } : {}),
483
+ ...(sessionName !== undefined ? { sessionName } : {}),
484
+ ...(sessionDir !== undefined ? { sessionDir } : {}),
485
+ ...(target !== undefined
486
+ ? {
487
+ clientId: target.clientId,
488
+ clientName: target.clientName,
489
+ ...(target.projectId !== undefined ? { projectId: target.projectId } : {}),
490
+ ...(target.projectName !== undefined ? { projectName: target.projectName } : {}),
491
+ ...(target.hubTaskId !== undefined ? { hubTaskId: target.hubTaskId } : {}),
492
+ ...(target.hubTaskTitle !== undefined ? { hubTaskTitle: target.hubTaskTitle } : {}),
493
+ }
494
+ : {}),
495
+ ...(deps.machine !== undefined ? { machine: deps.machine } : {}),
496
+ ...(roleConfidence !== undefined ? { roleConfidence } : {}),
497
+ ...(deps.orchestratorRef !== undefined ? { orchestratorRef: deps.orchestratorRef } : {}),
498
+ ...(deps.profile !== undefined ? { profile: deps.profile } : {}),
499
+ // Who measured this record, not who later syncs it — see
500
+ // `domain/work-record.ts#WorkRecordMetadata.agent`. Always stamped for
501
+ // every record this process appends; the versions are the same ones
502
+ // `extension.ts` already resolves for the `about` screen.
503
+ agent: "pi",
504
+ ...(deps.agentVersion !== undefined ? { agentVersion: deps.agentVersion } : {}),
505
+ plugin: "kankaku",
506
+ ...(deps.pluginVersion !== undefined ? { pluginVersion: deps.pluginVersion } : {}),
507
+ };
508
+ }
509
+
510
+ /**
511
+ * Save an in-flight checkpoint of the run's current state, so a hard
512
+ * crash before the next one (or the final settle) still leaves a
513
+ * recoverable `interrupted` record. A no-op while idle.
514
+ */
515
+ function checkpoint(ctx: ExtensionContext): void {
516
+ const core = tracker.peek("interrupted");
517
+ if (core) {
518
+ inflight.save(buildRecord(core, ctx));
519
+ }
520
+ }
521
+
522
+ pi.on(
523
+ "before_agent_start",
524
+ guarded((event, ctx) => {
525
+ tracker.onRunStart(event.prompt);
526
+ statusBar.start(ctx);
527
+ // Checkpoint right away so a crash on the very first turn (before any
528
+ // turn_end/tool_execution_end) still leaves a recoverable in-flight
529
+ // record; see the "Crash recovery" README section.
530
+ checkpoint(ctx);
531
+ }),
532
+ );
533
+
534
+ pi.on(
535
+ "agent_start",
536
+ guarded((_event, ctx) => {
537
+ // A run an extension started itself never fires before_agent_start
538
+ // (see WorkTracker.onAgentStart): open the record, the status clock and
539
+ // the crash-recovery checkpoint here instead. A no-op for a user prompt.
540
+ const wasIdle = tracker.peek("interrupted") === undefined;
541
+ tracker.onAgentStart();
542
+ if (wasIdle) statusBar.start(ctx);
543
+ // Always: when this start set a record aside, the checkpoint on disk
544
+ // must become the merged snapshot now, not at the next turn_end.
545
+ checkpoint(ctx);
546
+ }),
547
+ );
548
+
549
+ pi.on(
550
+ "agent_end",
551
+ guarded((event) => {
552
+ tracker.onRunEnd(event.messages);
553
+ }),
554
+ );
555
+
556
+ pi.on(
557
+ "turn_end",
558
+ guarded((event, ctx) => {
559
+ const message = event.message;
560
+ const usage = message && "usage" in message ? message.usage : undefined;
561
+ const cost = usage && typeof usage.cost === "object" && usage.cost !== null ? usage.cost.total : undefined;
562
+ tracker.onTurnEnd(
563
+ usage
564
+ ? {
565
+ input: usage.input,
566
+ output: usage.output,
567
+ cacheRead: usage.cacheRead,
568
+ cacheWrite: usage.cacheWrite,
569
+ cost,
570
+ }
571
+ : undefined,
572
+ );
573
+ checkpoint(ctx);
574
+ }),
575
+ );
576
+
577
+ pi.on(
578
+ "tool_execution_start",
579
+ guarded((event) => {
580
+ tracker.onToolStart(event.toolCallId, event.toolName, event.args);
581
+ }),
582
+ );
583
+
584
+ pi.on(
585
+ "tool_execution_end",
586
+ guarded((event, ctx) => {
587
+ tracker.onToolEnd(event.toolCallId, event.result);
588
+ checkpoint(ctx);
589
+ }),
590
+ );
591
+
592
+ pi.on(
593
+ "ui_prompt_start",
594
+ guarded((event) => {
595
+ tracker.onUiPromptStart(event.kind);
596
+ }),
597
+ );
598
+
599
+ pi.on(
600
+ "ui_prompt_end",
601
+ guarded((event) => {
602
+ tracker.onUiPromptEnd(event.kind);
603
+ }),
604
+ );
605
+
606
+ pi.on(
607
+ "agent_settled",
608
+ guarded((_event, ctx) => {
609
+ const cores = tracker.settleAll();
610
+ try {
611
+ for (const core of cores) {
612
+ appendRecord(buildRecord(core, ctx));
613
+ }
614
+ } finally {
615
+ // Always clean up, even when appendRecord above threw: an unpersisted
616
+ // checkpoint must not linger, and the status timer must not leak.
617
+ inflight.clear();
618
+ if (tracker.peek("interrupted") !== undefined) {
619
+ // A new run overtook this settle (see WorkTracker.settleAll): it is
620
+ // still open, so its clock keeps running and it gets its own
621
+ // checkpoint back — the clear above only dropped the old record's.
622
+ checkpoint(ctx);
623
+ } else {
624
+ statusBar.stop(ctx);
625
+ sessionClient.endRun();
626
+ deps.sessionTarget?.endRun();
627
+ }
628
+ triggerAutoSync(ctx, "agent_settled");
629
+ }
630
+ }),
631
+ );
632
+
633
+ pi.on(
634
+ "session_shutdown",
635
+ guardedAsync(async (_event, ctx) => {
636
+ const cores = tracker.shutdownAll();
637
+ try {
638
+ for (const core of cores) {
639
+ appendRecord(buildRecord(core, ctx));
640
+ }
641
+ // Only reached once every record above was appended: the shutdown
642
+ // sync (bounded by runShutdownSync's own timeout, see above) must
643
+ // include them. If appendRecord threw, this is skipped and the
644
+ // finally below still runs cleanup — never left half-done.
645
+ await runShutdownSync(ctx);
646
+ } finally {
647
+ inflight.clear();
648
+ statusBar.stop(ctx);
649
+ sessionClient.endRun();
650
+ deps.sessionTarget?.endRun();
651
+ }
652
+ }),
653
+ );
654
+
655
+ let hubConfigErrorNotified = false;
656
+ let overrideIgnoredInteractiveNotified = false;
657
+ let configuredMarkerIgnoredInteractiveNotified = false;
658
+ let rejectedSubagentChildEnvMarkersNotified = false;
659
+
660
+ pi.on(
661
+ "session_start",
662
+ guardedAsync(async (_event, ctx) => {
663
+ // F3: finalise roleConfidence now that ctx.mode (and therefore
664
+ // interactivity) is known — see resolveRoleConfidence's doc comment.
665
+ // A no-op when extension.ts did not wire it (deps.roleConfidence, if
666
+ // any, is left exactly as constructed).
667
+ if (deps.resolveRoleConfidence) {
668
+ roleConfidence = deps.resolveRoleConfidence(ctx.mode === "tui");
669
+ }
670
+
671
+ if (deps.hubConfigError && !hubConfigErrorNotified) {
672
+ hubConfigErrorNotified = true;
673
+ if (ctx.hasUI) ctx.ui.notify(deps.hubConfigError, "error");
674
+ }
675
+
676
+ // R1: KANKAKU_ROLE=subagent was ignored at factory time (no confirmed
677
+ // child marker, and this process looked interactive) — this is
678
+ // almost always a leaked shell export, and honouring it would have
679
+ // silently dropped this session's own work. Never silent: warn once.
680
+ if (deps.overrideIgnoredInteractive && !overrideIgnoredInteractiveNotified) {
681
+ overrideIgnoredInteractiveNotified = true;
682
+ if (ctx.hasUI) {
683
+ ctx.ui.notify(
684
+ "kankaku: ignoring KANKAKU_ROLE=subagent for this interactive session (likely a leaked shell export) — treating it as orchestrator; see /kankaku doctor",
685
+ "warning",
686
+ );
687
+ }
688
+ }
689
+
690
+ // C2 item 2/3: a KANKAKU_SUBAGENT_CHILD_ENV marker matched, but was
691
+ // ignored because this process looked interactive — a configured
692
+ // marker never demotes an interactive session. Escalate the wording
693
+ // when this process ALSO has no tracked ancestor at all (the
694
+ // strongest signal the marker is genuinely ambient, not a real
695
+ // subagent mechanism — C2 item 3's self-check).
696
+ if (deps.configuredMarkerIgnoredInteractive && !configuredMarkerIgnoredInteractiveNotified) {
697
+ configuredMarkerIgnoredInteractiveNotified = true;
698
+ if (ctx.hasUI) {
699
+ const message = deps.hasTrackedAncestor
700
+ ? "kankaku: ignoring a KANKAKU_SUBAGENT_CHILD_ENV marker for this interactive session — treating it as orchestrator; see /kankaku doctor"
701
+ : "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";
702
+ ctx.ui.notify(message, "warning");
703
+ }
704
+ }
705
+
706
+ // C2 item 1: one or more KANKAKU_SUBAGENT_CHILD_ENV entries were
707
+ // rejected at config load time (looked pi/shell/OS/npm-owned, not
708
+ // genuinely child-only) — never silent.
709
+ if (deps.rejectedSubagentChildEnvMarkers?.length && !rejectedSubagentChildEnvMarkersNotified) {
710
+ rejectedSubagentChildEnvMarkersNotified = true;
711
+ if (ctx.hasUI) {
712
+ const names = deps.rejectedSubagentChildEnvMarkers.map((rejected) => rejected.name).join(", ");
713
+ ctx.ui.notify(`kankaku: ignoring KANKAKU_SUBAGENT_CHILD_ENV marker(s) that look ambient, not child-only: ${names}; see /kankaku doctor`, "warning");
714
+ }
715
+ }
716
+
717
+ sessionClient.restore(ctx);
718
+ deps.sessionTarget?.restore(ctx);
719
+ statusBar.showIdle(ctx);
720
+
721
+ const recovered = inflight.recoverStale(isAlive);
722
+ for (const record of recovered) {
723
+ appendRecord(record);
724
+ }
725
+ if (recovered.length > 0 && ctx.hasUI) {
726
+ ctx.ui.notify(`kankaku: recovered ${recovered.length} interrupted record(s)`, "warning");
727
+ }
728
+
729
+ // Runs after recovery so a freshly picked target does not affect
730
+ // records recovered from before this session started. See README
731
+ // "Hub (PocketBase)": a no-op unless orchestrator + hasUI + configured.
732
+ if (deps.sessionTarget) {
733
+ await deps.sessionTarget.ensurePicked(pi, ctx);
734
+ statusBar.showIdle(ctx);
735
+ }
736
+
737
+ // Fire-and-forget, after recovery so a just-recovered interrupted
738
+ // record is included. See README "Hub (PocketBase)" sync section.
739
+ triggerAutoSync(ctx, "session_start");
740
+ }),
741
+ );
742
+ }
743
+
744
+ /** pi's current reasoning effort, or `undefined` on a pi that predates `getThinkingLevel` or has no session to ask yet. Never throws. */
745
+ function readThinkingLevel(pi: ExtensionAPI): string | undefined {
746
+ try {
747
+ const read = (pi as { getThinkingLevel?: () => unknown }).getThinkingLevel;
748
+ const level = typeof read === "function" ? read.call(pi) : undefined;
749
+ return typeof level === "string" && level.length > 0 ? level : undefined;
750
+ } catch {
751
+ return undefined;
752
+ }
753
+ }