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.
- package/LICENSE +21 -0
- package/README.md +1438 -0
- package/dist/adapters/cached-catalog.d.ts +42 -0
- package/dist/adapters/cached-catalog.js +121 -0
- package/dist/adapters/export-writer.d.ts +13 -0
- package/dist/adapters/export-writer.js +28 -0
- package/dist/adapters/file-modes.d.ts +20 -0
- package/dist/adapters/file-modes.js +34 -0
- package/dist/adapters/hub-actions.d.ts +35 -0
- package/dist/adapters/hub-actions.js +70 -0
- package/dist/adapters/hub-credentials.d.ts +35 -0
- package/dist/adapters/hub-credentials.js +58 -0
- package/dist/adapters/jsonl-work-log.d.ts +20 -0
- package/dist/adapters/jsonl-work-log.js +62 -0
- package/dist/adapters/kankaku-dir.d.ts +38 -0
- package/dist/adapters/kankaku-dir.js +85 -0
- package/dist/adapters/lazy-jsonl-work-log.d.ts +17 -0
- package/dist/adapters/lazy-jsonl-work-log.js +31 -0
- package/dist/adapters/pocketbase-catalog.d.ts +16 -0
- package/dist/adapters/pocketbase-catalog.js +56 -0
- package/dist/adapters/pocketbase-client.d.ts +81 -0
- package/dist/adapters/pocketbase-client.js +148 -0
- package/dist/adapters/pocketbase-sink.d.ts +53 -0
- package/dist/adapters/pocketbase-sink.js +181 -0
- package/dist/adapters/project-config.d.ts +42 -0
- package/dist/adapters/project-config.js +108 -0
- package/dist/adapters/report-data.d.ts +12 -0
- package/dist/adapters/report-data.js +8 -0
- package/dist/adapters/report-views.d.ts +45 -0
- package/dist/adapters/report-views.js +73 -0
- package/dist/adapters/report.d.ts +112 -0
- package/dist/adapters/report.js +236 -0
- package/dist/adapters/sync-runner.d.ts +114 -0
- package/dist/adapters/sync-runner.js +273 -0
- package/dist/adapters/sync-state-store.d.ts +62 -0
- package/dist/adapters/sync-state-store.js +188 -0
- package/dist/config.d.ts +168 -0
- package/dist/config.js +392 -0
- package/dist/domain/ancestry-match.d.ts +49 -0
- package/dist/domain/ancestry-match.js +82 -0
- package/dist/domain/client-label.d.ts +28 -0
- package/dist/domain/client-label.js +44 -0
- package/dist/domain/day.d.ts +2 -0
- package/dist/domain/day.js +8 -0
- package/dist/domain/export.d.ts +38 -0
- package/dist/domain/export.js +68 -0
- package/dist/domain/hub-entry.d.ts +234 -0
- package/dist/domain/hub-entry.js +265 -0
- package/dist/domain/index.d.ts +19 -0
- package/dist/domain/index.js +19 -0
- package/dist/domain/intervals.d.ts +17 -0
- package/dist/domain/intervals.js +43 -0
- package/dist/domain/registry-health.d.ts +49 -0
- package/dist/domain/registry-health.js +58 -0
- package/dist/domain/segment-rule.d.ts +10 -0
- package/dist/domain/segment-rule.js +1 -0
- package/dist/domain/subagent-profile.d.ts +278 -0
- package/dist/domain/subagent-profile.js +418 -0
- package/dist/domain/sync-plan.d.ts +151 -0
- package/dist/domain/sync-plan.js +196 -0
- package/dist/domain/task-view.d.ts +117 -0
- package/dist/domain/task-view.js +428 -0
- package/dist/domain/work-record.d.ts +236 -0
- package/dist/domain/work-record.js +91 -0
- package/dist/domain/work-target.d.ts +101 -0
- package/dist/domain/work-target.js +149 -0
- package/dist/domain/work-tracker.d.ts +90 -0
- package/dist/domain/work-tracker.js +405 -0
- package/dist/hub/index.d.ts +25 -0
- package/dist/hub/index.js +25 -0
- package/dist/ports/catalog.d.ts +31 -0
- package/dist/ports/catalog.js +1 -0
- package/dist/ports/clock.d.ts +3 -0
- package/dist/ports/clock.js +1 -0
- package/dist/ports/index.d.ts +11 -0
- package/dist/ports/index.js +1 -0
- package/dist/ports/inflight-store.d.ts +15 -0
- package/dist/ports/inflight-store.js +1 -0
- package/dist/ports/process-registry.d.ts +72 -0
- package/dist/ports/process-registry.js +1 -0
- package/dist/ports/work-log.d.ts +14 -0
- package/dist/ports/work-log.js +1 -0
- package/dist/ports/work-sink.d.ts +39 -0
- package/dist/ports/work-sink.js +1 -0
- package/package.json +66 -0
- package/src/adapters/agent-info.ts +86 -0
- package/src/adapters/ancestry.ts +260 -0
- package/src/adapters/cached-catalog.ts +147 -0
- package/src/adapters/export-writer.ts +33 -0
- package/src/adapters/file-inflight-store.ts +115 -0
- package/src/adapters/file-modes.ts +35 -0
- package/src/adapters/hub-actions.ts +82 -0
- package/src/adapters/hub-credentials.ts +95 -0
- package/src/adapters/jsonl-work-log.ts +67 -0
- package/src/adapters/kankaku-command.ts +717 -0
- package/src/adapters/kankaku-dir.ts +102 -0
- package/src/adapters/lazy-file-inflight-store.ts +43 -0
- package/src/adapters/lazy-jsonl-work-log.ts +39 -0
- package/src/adapters/machine-process-registry.ts +256 -0
- package/src/adapters/panel/kankaku-panel.ts +419 -0
- package/src/adapters/panel/panel-items.ts +87 -0
- package/src/adapters/panel/panel-lines.ts +13 -0
- package/src/adapters/panel/panel-theme.ts +32 -0
- package/src/adapters/panel/screens/about.ts +69 -0
- package/src/adapters/panel/screens/doctor.ts +89 -0
- package/src/adapters/panel/screens/export.ts +123 -0
- package/src/adapters/panel/screens/report.ts +143 -0
- package/src/adapters/panel/screens/sync.ts +136 -0
- package/src/adapters/panel/screens/target.ts +384 -0
- package/src/adapters/pi-tracker.ts +753 -0
- package/src/adapters/pocketbase-catalog.ts +89 -0
- package/src/adapters/pocketbase-client.ts +197 -0
- package/src/adapters/pocketbase-sink.ts +236 -0
- package/src/adapters/process-identity-memo.ts +102 -0
- package/src/adapters/process-identity.ts +162 -0
- package/src/adapters/project-config.ts +116 -0
- package/src/adapters/report-data.ts +13 -0
- package/src/adapters/report-views.ts +98 -0
- package/src/adapters/report.ts +335 -0
- package/src/adapters/session-client.ts +116 -0
- package/src/adapters/session-dir.ts +28 -0
- package/src/adapters/session-target.ts +431 -0
- package/src/adapters/status-bar.ts +86 -0
- package/src/adapters/subagent-startup.ts +66 -0
- package/src/adapters/sync-runner.ts +340 -0
- package/src/adapters/sync-state-store.ts +227 -0
- package/src/adapters/target-picker.ts +127 -0
- package/src/config.ts +536 -0
- package/src/domain/ancestry-match.ts +84 -0
- package/src/domain/client-label.ts +56 -0
- package/src/domain/day.ts +8 -0
- package/src/domain/export.ts +107 -0
- package/src/domain/hub-entry.ts +433 -0
- package/src/domain/index.ts +19 -0
- package/src/domain/intervals.ts +53 -0
- package/src/domain/panel-model.ts +270 -0
- package/src/domain/registry-health.ts +87 -0
- package/src/domain/segment-rule.ts +10 -0
- package/src/domain/subagent-profile.ts +495 -0
- package/src/domain/sync-plan.ts +266 -0
- package/src/domain/task-view.ts +526 -0
- package/src/domain/work-record.ts +320 -0
- package/src/domain/work-target.ts +234 -0
- package/src/domain/work-tracker.ts +485 -0
- package/src/extension.ts +346 -0
- package/src/hub/index.ts +25 -0
- package/src/ports/catalog.ts +33 -0
- package/src/ports/clock.ts +3 -0
- package/src/ports/index.ts +11 -0
- package/src/ports/inflight-store.ts +16 -0
- package/src/ports/process-registry.ts +75 -0
- package/src/ports/work-log.ts +15 -0
- 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
|
+
}
|