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