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
package/src/extension.ts
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { homedir, hostname, tmpdir } from "node:os";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { detectRole, loadConfig, loadMachine, loadSyncConfig } from "./config.ts";
|
|
6
|
+
import { WorkTracker } from "./domain/work-tracker.ts";
|
|
7
|
+
import { LazyJsonlWorkLog } from "./adapters/lazy-jsonl-work-log.ts";
|
|
8
|
+
import { LazyFileInflightStore } from "./adapters/lazy-file-inflight-store.ts";
|
|
9
|
+
import { createPiTracker } from "./adapters/pi-tracker.ts";
|
|
10
|
+
import { LazyProjectClientSource, LazyProjectTargetSource } from "./adapters/project-config.ts";
|
|
11
|
+
import { LazyExportWriter } from "./adapters/export-writer.ts";
|
|
12
|
+
import { resolveHubCredentials, safeHomeDir } from "./adapters/hub-credentials.ts";
|
|
13
|
+
import { PocketBaseClient } from "./adapters/pocketbase-client.ts";
|
|
14
|
+
import { createPocketBaseCatalogFetcher } from "./adapters/pocketbase-catalog.ts";
|
|
15
|
+
import { CachedCatalog } from "./adapters/cached-catalog.ts";
|
|
16
|
+
import { createSessionTarget } from "./adapters/session-target.ts";
|
|
17
|
+
import { resolveKankakuDir, resolveWritableTarget } from "./adapters/kankaku-dir.ts";
|
|
18
|
+
import { SyncStateStore } from "./adapters/sync-state-store.ts";
|
|
19
|
+
import { PocketBaseSink } from "./adapters/pocketbase-sink.ts";
|
|
20
|
+
import { computeSyncStatus, runSync, singleFlight } from "./adapters/sync-runner.ts";
|
|
21
|
+
import type { SyncTrigger } from "./adapters/sync-runner.ts";
|
|
22
|
+
import { snapshotAncestry } from "./adapters/ancestry.ts";
|
|
23
|
+
import { MachineProcessRegistry } from "./adapters/machine-process-registry.ts";
|
|
24
|
+
import { JsonlWorkLog } from "./adapters/jsonl-work-log.ts";
|
|
25
|
+
import { FileInflightStore } from "./adapters/file-inflight-store.ts";
|
|
26
|
+
import { getProcessIdentityMemo } from "./adapters/process-identity-memo.ts";
|
|
27
|
+
import { resolveAgentVersion, resolvePluginVersion } from "./adapters/agent-info.ts";
|
|
28
|
+
import type { Catalog } from "./ports/catalog.ts";
|
|
29
|
+
import type { SessionTarget } from "./adapters/session-target.ts";
|
|
30
|
+
import type { SyncCommandDeps } from "./adapters/kankaku-command.ts";
|
|
31
|
+
import type { WorkLog } from "./ports/work-log.ts";
|
|
32
|
+
import type { InflightStore } from "./ports/inflight-store.ts";
|
|
33
|
+
|
|
34
|
+
export default function kankaku(pi: ExtensionAPI): void {
|
|
35
|
+
const config = loadConfig();
|
|
36
|
+
|
|
37
|
+
// Machine-wide process registry (ADR 0023, rewritten by F1): independent
|
|
38
|
+
// of any project's KANKAKU_DIR, so a subagent running in a different git
|
|
39
|
+
// worktree can still discover its true orchestrator. The registry is a
|
|
40
|
+
// STARTUP LOOKUP ONLY — "who is my tracked ancestor, and where does it
|
|
41
|
+
// keep its log" — never a later pointer a reader chases again (see
|
|
42
|
+
// adapters/subagent-startup.ts). Every operation on `registry` degrades
|
|
43
|
+
// to a no-op/empty-read on its own when the registry is unavailable (no
|
|
44
|
+
// home dir, no permission) — see adapters/machine-process-registry.ts.
|
|
45
|
+
const registry = new MachineProcessRegistry(homedir);
|
|
46
|
+
|
|
47
|
+
// G1 (HIGH, verified): pi re-invokes this factory function IN THE SAME
|
|
48
|
+
// OS PROCESS on `/new`, `/resume`, `/fork` and `/reload` ("reloads and
|
|
49
|
+
// rebinds extensions for the new session" — see
|
|
50
|
+
// node_modules/@earendil-works/pi-coding-agent/docs/extensions.md).
|
|
51
|
+
// Everything this block used to compute inline — `role`, `roleOverride`
|
|
52
|
+
// (read once, then stripped from `process.env` so a child never inherits
|
|
53
|
+
// it — R1), `childMarkerPresent`, `hasTrackedAncestor`/`ancestorEntry`
|
|
54
|
+
// (F5's registry+ancestor-chain walk), `orchestratorRef` (F4), and
|
|
55
|
+
// `ownProcessStartId` (F5) — is a fact about this OS PROCESS, not this pi
|
|
56
|
+
// session, and several of them can only ever be read correctly ONCE: a
|
|
57
|
+
// second invocation would see `roleOverride` as already stripped, so an
|
|
58
|
+
// explicitly forced role would silently fall back to ordinary detection
|
|
59
|
+
// (and, for `orchestrator`, could be demoted to `roleConfidence:
|
|
60
|
+
// "uncertain"`, silently dropping genuine billable work). `identity`
|
|
61
|
+
// (`adapters/process-identity.ts`) is computed once per OS process and
|
|
62
|
+
// memoized (`adapters/process-identity-memo.ts`) so a second, third,
|
|
63
|
+
// fourth... invocation always reuses the exact first result. A cheap,
|
|
64
|
+
// synchronous interactivity proxy feeds `role`'s one interactivity
|
|
65
|
+
// exception (see `config.ts#detectRole`'s doc comment) — it is available
|
|
66
|
+
// before pi's own ExtensionContext exists, unlike the authoritative
|
|
67
|
+
// `ctx.mode === "tui"`, only known later at `session_start`.
|
|
68
|
+
const isInteractiveGuess = Boolean(process.stdout.isTTY);
|
|
69
|
+
const identity = getProcessIdentityMemo().resolve({
|
|
70
|
+
env: process.env,
|
|
71
|
+
registry,
|
|
72
|
+
ppid: process.ppid,
|
|
73
|
+
now: () => Date.now(),
|
|
74
|
+
uptimeSeconds: () => process.uptime(),
|
|
75
|
+
isInteractiveGuess,
|
|
76
|
+
// SUBAGENT-REQ-001/002/003/005: the full active profile set (built-ins
|
|
77
|
+
// plus any KANKAKU_SUBAGENT_TOOLS/KANKAKU_SUBAGENT_CHILD_ENV-configured
|
|
78
|
+
// one) — generalises child-marker recognition beyond gentle-pi's own.
|
|
79
|
+
subagentProfiles: config.subagentProfiles,
|
|
80
|
+
});
|
|
81
|
+
const {
|
|
82
|
+
role,
|
|
83
|
+
roleOverride,
|
|
84
|
+
childMarkerPresent,
|
|
85
|
+
overrideIgnoredInteractive,
|
|
86
|
+
configuredMarkerIgnoredInteractive,
|
|
87
|
+
hasTrackedAncestor,
|
|
88
|
+
orchestratorRef,
|
|
89
|
+
ownProcessStartId,
|
|
90
|
+
liveStartId,
|
|
91
|
+
profile,
|
|
92
|
+
} = identity;
|
|
93
|
+
|
|
94
|
+
// `resolvedDir` (this session's project/write target) is deliberately
|
|
95
|
+
// NOT part of the frozen process identity above: pi can enter a
|
|
96
|
+
// different cwd across a session switch in the same process (see
|
|
97
|
+
// extensions.md's trust-resolution note on `/resume`), so this stays a
|
|
98
|
+
// per-session fact, recomputed on every invocation exactly like before.
|
|
99
|
+
const resolvedDir = resolveKankakuDir(config.dir, process.cwd());
|
|
100
|
+
registry.record(
|
|
101
|
+
{
|
|
102
|
+
pid: process.pid,
|
|
103
|
+
parentPid: process.ppid,
|
|
104
|
+
role,
|
|
105
|
+
project: process.cwd(),
|
|
106
|
+
dir: resolvedDir,
|
|
107
|
+
startedAt: new Date().toISOString(),
|
|
108
|
+
...(orchestratorRef !== undefined ? { orchestratorRef } : {}),
|
|
109
|
+
...(ownProcessStartId !== undefined ? { processStartId: ownProcessStartId } : {}),
|
|
110
|
+
},
|
|
111
|
+
undefined,
|
|
112
|
+
{ liveStartId },
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// Best-effort cleanup of this process's own registry file: on a normal
|
|
116
|
+
// exit (covers session_shutdown too, whichever fires first — `removeOwn`
|
|
117
|
+
// is idempotent, a second call simply finds nothing to do) and directly
|
|
118
|
+
// on `session_shutdown` for the common graceful-shutdown path. Never
|
|
119
|
+
// relies on this alone for correctness — a crash still leaves the entry
|
|
120
|
+
// for the next process's sweep to discard (dead-pid, or later
|
|
121
|
+
// stale-by-reuse) — this only keeps `run/` tidy sooner in the common case.
|
|
122
|
+
const removeOwnRegistryEntry = (): void => {
|
|
123
|
+
registry.removeOwn?.(process.pid, ownProcessStartId);
|
|
124
|
+
};
|
|
125
|
+
// G1: `process` is the one real OS-process-wide singleton every factory
|
|
126
|
+
// invocation shares (unlike `pi`, a fresh `ExtensionAPI` per invocation,
|
|
127
|
+
// which does need its own `session_shutdown` listener below every time).
|
|
128
|
+
// Registering `process.on("exit", ...)` unconditionally on every
|
|
129
|
+
// `/new`/`/resume`/`/fork`/`/reload` would pile up one listener per
|
|
130
|
+
// invocation for the life of the process; `registerExitCleanupOnce`
|
|
131
|
+
// registers at most one, ever, for this process.
|
|
132
|
+
getProcessIdentityMemo().registerExitCleanupOnce((listener) => process.on("exit", listener), removeOwnRegistryEntry);
|
|
133
|
+
pi.on("session_shutdown", () => {
|
|
134
|
+
try {
|
|
135
|
+
removeOwnRegistryEntry();
|
|
136
|
+
} catch {
|
|
137
|
+
// Never let registry cleanup break shutdown.
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const tracker = new WorkTracker({
|
|
142
|
+
clock: { now: () => Date.now() },
|
|
143
|
+
interactiveTools: config.interactiveTools,
|
|
144
|
+
subagentProfiles: config.subagentProfiles,
|
|
145
|
+
segmentRules: config.segmentRules,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// F1: a verified subagent whose real orchestrator's kankaku dir differs
|
|
149
|
+
// from this process's own writes its work log AND its inflight
|
|
150
|
+
// checkpoints straight into that dir — so parent and child records live
|
|
151
|
+
// in the same `worklog.jsonl` forever, joined by `buildTasks`'s existing
|
|
152
|
+
// pid/parentPid/project-hint keys, with no later discovery through a live
|
|
153
|
+
// registry pointer ever required again (the old `RegistryAwareWorkLog`
|
|
154
|
+
// read-time merge is gone: this write-side routing makes it redundant —
|
|
155
|
+
// see AGENTS.md). A record is written to exactly ONE log, always: either
|
|
156
|
+
// branch below constructs exactly one `WorkLog`/`InflightStore` pair,
|
|
157
|
+
// pointed at the same resolved directory. `workLogRouting` is only set
|
|
158
|
+
// (and only surfaced to `/kankaku doctor`) when this process actually
|
|
159
|
+
// attempted routing — never for the common orchestrator/local-subagent
|
|
160
|
+
// path, which keeps today's lazy, lower-cost resolution unchanged.
|
|
161
|
+
let log: WorkLog;
|
|
162
|
+
let inflight: InflightStore;
|
|
163
|
+
let workLogRouting: { usedFallback: boolean; parentDir: string } | undefined;
|
|
164
|
+
|
|
165
|
+
if (role === "subagent" && orchestratorRef?.dir !== undefined && orchestratorRef.dir !== resolvedDir) {
|
|
166
|
+
const routed = resolveWritableTarget(orchestratorRef.dir, resolvedDir);
|
|
167
|
+
log = new JsonlWorkLog(routed.dir);
|
|
168
|
+
inflight = new FileInflightStore(routed.dir, process.pid);
|
|
169
|
+
workLogRouting = { usedFallback: routed.usedFallback, parentDir: orchestratorRef.dir };
|
|
170
|
+
} else {
|
|
171
|
+
log = new LazyJsonlWorkLog(config.dir);
|
|
172
|
+
inflight = new LazyFileInflightStore(config.dir, process.pid);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const projectClient = new LazyProjectClientSource(config.dir);
|
|
176
|
+
const exportWriter = new LazyExportWriter(config.dir);
|
|
177
|
+
|
|
178
|
+
// Hub (PocketBase) wiring: entirely optional. When unconfigured, every
|
|
179
|
+
// variable below stays undefined and createPiTracker's behaviour is
|
|
180
|
+
// exactly as it is without this feature. See README "Hub (PocketBase)".
|
|
181
|
+
let catalog: Catalog | undefined;
|
|
182
|
+
let sessionTarget: SessionTarget | undefined;
|
|
183
|
+
let machine: string | undefined;
|
|
184
|
+
let hubConfigError: string | undefined;
|
|
185
|
+
let sync: SyncCommandDeps | undefined;
|
|
186
|
+
let autoSyncEnabled: boolean | undefined;
|
|
187
|
+
let hubUrl: string | undefined;
|
|
188
|
+
|
|
189
|
+
// Resolved once, here (never on a hot path): see README "Hub
|
|
190
|
+
// (PocketBase)" > "Agent and measurement quality" and the panel's `about`
|
|
191
|
+
// screen (`adapters/panel/screens/about.ts`). Both degrade to `undefined`
|
|
192
|
+
// on any failure rather than guessing. Unlike the sync sink below (which
|
|
193
|
+
// needs them only when the hub is configured), the `about` screen shows
|
|
194
|
+
// these regardless — so they are resolved unconditionally here.
|
|
195
|
+
const agentVersion = resolveAgentVersion();
|
|
196
|
+
// extension.ts lives at <package root>/src/extension.ts.
|
|
197
|
+
const pluginPackageRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
198
|
+
const pluginVersion = resolvePluginVersion(pluginPackageRoot);
|
|
199
|
+
|
|
200
|
+
// `homeDir` is passed as a reference, never invoked here: any failure
|
|
201
|
+
// resolving it (no HOME, a sandbox) must not fail extension load for
|
|
202
|
+
// every process, hub-configured or not. resolveHubCredentials guards the
|
|
203
|
+
// call itself and treats it the same as "no home directory".
|
|
204
|
+
const hub = resolveHubCredentials({ env: process.env, homeDir: homedir });
|
|
205
|
+
hubConfigError = hub.invalidReason;
|
|
206
|
+
|
|
207
|
+
if (hub.credentials) {
|
|
208
|
+
const credentials = hub.credentials;
|
|
209
|
+
hubUrl = credentials.url;
|
|
210
|
+
const client = new PocketBaseClient({ url: credentials.url, email: credentials.email, password: credentials.password });
|
|
211
|
+
// Same defensive resolution as above; falls back to the OS temp dir
|
|
212
|
+
// when no home directory is available so an env-only hub configuration
|
|
213
|
+
// still works without one (the cache just does not survive a reboot).
|
|
214
|
+
const homeDirForCache = safeHomeDir(homedir) ?? tmpdir();
|
|
215
|
+
catalog = new CachedCatalog({
|
|
216
|
+
// Machine-wide cache: several projects on the same machine share one
|
|
217
|
+
// catalog fetch, and it survives across projects.
|
|
218
|
+
filePath: join(homeDirForCache, ".kankaku", "catalog.json"),
|
|
219
|
+
url: credentials.url,
|
|
220
|
+
clock: { now: () => Date.now() },
|
|
221
|
+
fetchCatalog: createPocketBaseCatalogFetcher(client),
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const projectTarget = new LazyProjectTargetSource(config.dir);
|
|
225
|
+
sessionTarget = createSessionTarget({
|
|
226
|
+
role,
|
|
227
|
+
catalog,
|
|
228
|
+
resolveProjectConfigIds: () => projectTarget.read(),
|
|
229
|
+
persistProjectConfig: (ids) => projectTarget.write(ids),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
machine = loadMachine(process.env, () => hostname());
|
|
233
|
+
|
|
234
|
+
// Sync (Phase 2): pushes consolidated task rows to the hub. See README
|
|
235
|
+
// "Hub (PocketBase)" sync section. `worklog.jsonl` and the crash-recovery
|
|
236
|
+
// checkpoints above are entirely unaffected by any of this.
|
|
237
|
+
const syncConfig = loadSyncConfig(process.env);
|
|
238
|
+
const syncStateStore = new SyncStateStore({ dir: resolveKankakuDir(config.dir, process.cwd()), pid: process.pid });
|
|
239
|
+
const catalogRef = catalog;
|
|
240
|
+
const machineName = machine;
|
|
241
|
+
|
|
242
|
+
const runOnce = (options?: { full?: boolean; trigger?: SyncTrigger }) => {
|
|
243
|
+
const snapshot = catalogRef.read();
|
|
244
|
+
const sink = new PocketBaseSink({
|
|
245
|
+
client,
|
|
246
|
+
clients: snapshot?.clients ?? [],
|
|
247
|
+
projects: snapshot?.projects ?? [],
|
|
248
|
+
tasks: snapshot?.tasks ?? [],
|
|
249
|
+
machine: machineName,
|
|
250
|
+
promptMode: syncConfig.promptMode,
|
|
251
|
+
syncRecords: syncConfig.syncRecords,
|
|
252
|
+
agent: "pi",
|
|
253
|
+
...(agentVersion !== undefined ? { agentVersion } : {}),
|
|
254
|
+
plugin: "kankaku",
|
|
255
|
+
...(pluginVersion !== undefined ? { pluginVersion } : {}),
|
|
256
|
+
});
|
|
257
|
+
return runSync(
|
|
258
|
+
{
|
|
259
|
+
log,
|
|
260
|
+
sink,
|
|
261
|
+
stateStore: syncStateStore,
|
|
262
|
+
clock: { now: () => Date.now() },
|
|
263
|
+
target: credentials.url,
|
|
264
|
+
windowHours: syncConfig.windowHours,
|
|
265
|
+
minAutoIntervalMs: syncConfig.minIntervalMinutes * 60 * 1000,
|
|
266
|
+
},
|
|
267
|
+
options,
|
|
268
|
+
);
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
sync = {
|
|
272
|
+
// Single-flight: the same wrapped function backs the manual /kankaku
|
|
273
|
+
// sync command and both automatic triggers (pi-tracker.ts), so they
|
|
274
|
+
// never race within this process.
|
|
275
|
+
run: singleFlight(runOnce),
|
|
276
|
+
status: () => computeSyncStatus(log, syncStateStore, credentials.url, syncConfig.windowHours),
|
|
277
|
+
};
|
|
278
|
+
autoSyncEnabled = syncConfig.auto;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
createPiTracker(pi, {
|
|
282
|
+
tracker,
|
|
283
|
+
log,
|
|
284
|
+
inflight,
|
|
285
|
+
role,
|
|
286
|
+
// F3: interactivity (ctx.mode === "tui") is only knowable once pi's own
|
|
287
|
+
// ExtensionContext is available, at session_start — later than role
|
|
288
|
+
// itself must be decided above. `hasTrackedAncestor` is already final
|
|
289
|
+
// here; only isInteractive is supplied later, by pi-tracker.ts.
|
|
290
|
+
//
|
|
291
|
+
// R1: gated on `roleOverride === undefined` — `roleOverride` is the
|
|
292
|
+
// frozen, process-wide fact from `identity` above (G1: it stays
|
|
293
|
+
// truthful across a same-process factory re-invocation even though
|
|
294
|
+
// `process.env["KANKAKU_ROLE"]` was stripped, possibly invocations ago)
|
|
295
|
+
// — once KANKAKU_ROLE decided (or, for a `subagent` value ignored via
|
|
296
|
+
// the interactive contradiction above, resolved) this process's role at
|
|
297
|
+
// factory time, that decision stays final and is never later demoted to
|
|
298
|
+
// `uncertain`; this refinement only ever applies to the genuine
|
|
299
|
+
// no-override path (and `process.env` is safe to re-read here for
|
|
300
|
+
// `detectRole`'s OWN internal override check, since this branch is only
|
|
301
|
+
// reached when `roleOverride` was never set — there is nothing left in
|
|
302
|
+
// `process.env` that could change what that internal check sees).
|
|
303
|
+
resolveRoleConfidence: (isInteractive) =>
|
|
304
|
+
role === "orchestrator" && roleOverride === undefined ? detectRole(process.env, hasTrackedAncestor, isInteractive).roleConfidence : undefined,
|
|
305
|
+
...(orchestratorRef !== undefined ? { orchestratorRef } : {}),
|
|
306
|
+
pid: process.pid,
|
|
307
|
+
parentPid: process.ppid,
|
|
308
|
+
...(config.client !== undefined ? { envClient: config.client } : {}),
|
|
309
|
+
resolveProjectClient: () => projectClient.read(),
|
|
310
|
+
writeExportFile: (name, content) => exportWriter.write(name, content),
|
|
311
|
+
...(sessionTarget !== undefined ? { sessionTarget } : {}),
|
|
312
|
+
...(catalog !== undefined ? { catalog } : {}),
|
|
313
|
+
...(machine !== undefined ? { machine } : {}),
|
|
314
|
+
...(hubConfigError !== undefined ? { hubConfigError } : {}),
|
|
315
|
+
...(sync !== undefined ? { sync } : {}),
|
|
316
|
+
...(autoSyncEnabled !== undefined ? { autoSyncEnabled } : {}),
|
|
317
|
+
config,
|
|
318
|
+
kankakuDir: resolvedDir,
|
|
319
|
+
...(agentVersion !== undefined ? { agentVersion } : {}),
|
|
320
|
+
...(pluginVersion !== undefined ? { pluginVersion } : {}),
|
|
321
|
+
...(hubUrl !== undefined ? { hubUrl } : {}),
|
|
322
|
+
...(roleOverride !== undefined ? { roleOverride } : {}),
|
|
323
|
+
...(childMarkerPresent ? { childMarkerPresent } : {}),
|
|
324
|
+
...(profile !== undefined ? { profile } : {}),
|
|
325
|
+
subagentProfiles: config.subagentProfiles,
|
|
326
|
+
...(overrideIgnoredInteractive ? { overrideIgnoredInteractive } : {}),
|
|
327
|
+
...(configuredMarkerIgnoredInteractive ? { configuredMarkerIgnoredInteractive } : {}),
|
|
328
|
+
...(hasTrackedAncestor ? { hasTrackedAncestor } : {}),
|
|
329
|
+
...(config.rejectedSubagentChildEnvMarkers.length > 0 ? { rejectedSubagentChildEnvMarkers: config.rejectedSubagentChildEnvMarkers } : {}),
|
|
330
|
+
...(workLogRouting !== undefined ? { workLogRouting } : {}),
|
|
331
|
+
// Fresh ancestry snapshot on demand, only when `/kankaku doctor` is
|
|
332
|
+
// actually invoked (never on a hot path): a stale snapshot from
|
|
333
|
+
// extension startup could no longer tell a genuine pid reuse apart
|
|
334
|
+
// from a still-live process for a session that has been running a while.
|
|
335
|
+
registryHealth: () => {
|
|
336
|
+
const freshSnapshot = snapshotAncestry();
|
|
337
|
+
return registry.health({ liveStartId: (pid) => freshSnapshot.startIdByPid.get(pid) });
|
|
338
|
+
},
|
|
339
|
+
// F2: whether the ancestor-chain mechanism itself is actually usable
|
|
340
|
+
// right now — Windows, or a failed/unavailable ps/proc read on any
|
|
341
|
+
// platform, both report false here. A fresh snapshot (never the one
|
|
342
|
+
// taken at startup, which may have been skipped entirely — F5) since
|
|
343
|
+
// this only runs when a human actually asks for `/kankaku doctor`.
|
|
344
|
+
ancestorDetectionAvailable: () => snapshotAncestry().ppidByPid.size > 0,
|
|
345
|
+
});
|
|
346
|
+
}
|
package/src/hub/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public library entrypoint (`kankaku-pi/hub`): the pi-free adapters that talk
|
|
3
|
+
* to the filesystem and the hub's HTTP layer, for a plain Node consumer
|
|
4
|
+
* that wants to read a worklog and/or sync it to the hub without pi. Every
|
|
5
|
+
* adapter that imports a pi package type is deliberately left out — see
|
|
6
|
+
* AGENTS.md "Architecture (hexagonal)" and odd/tasks/library-exports.md
|
|
7
|
+
* for which ones and why.
|
|
8
|
+
*/
|
|
9
|
+
export * from "../adapters/cached-catalog.ts";
|
|
10
|
+
export * from "../adapters/export-writer.ts";
|
|
11
|
+
export * from "../adapters/file-modes.ts";
|
|
12
|
+
export * from "../adapters/hub-actions.ts";
|
|
13
|
+
export * from "../adapters/hub-credentials.ts";
|
|
14
|
+
export * from "../adapters/jsonl-work-log.ts";
|
|
15
|
+
export * from "../adapters/kankaku-dir.ts";
|
|
16
|
+
export * from "../adapters/lazy-jsonl-work-log.ts";
|
|
17
|
+
export * from "../adapters/pocketbase-catalog.ts";
|
|
18
|
+
export * from "../adapters/pocketbase-client.ts";
|
|
19
|
+
export * from "../adapters/pocketbase-sink.ts";
|
|
20
|
+
export * from "../adapters/project-config.ts";
|
|
21
|
+
export * from "../adapters/report-data.ts";
|
|
22
|
+
export * from "../adapters/report.ts";
|
|
23
|
+
export * from "../adapters/report-views.ts";
|
|
24
|
+
export * from "../adapters/sync-runner.ts";
|
|
25
|
+
export * from "../adapters/sync-state-store.ts";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Client, HubTask, Project } from "../domain/work-target.ts";
|
|
2
|
+
|
|
3
|
+
/** A cached read of the hub's clients/projects/tasks, plus when and against which hub URL it was fetched. */
|
|
4
|
+
export interface CatalogSnapshot {
|
|
5
|
+
fetchedAt: number;
|
|
6
|
+
url: string;
|
|
7
|
+
clients: Client[];
|
|
8
|
+
projects: Project[];
|
|
9
|
+
/** Optional because a cache written by an older build (before hub task linking) never had this field; a missing value reads as "no tasks known yet." */
|
|
10
|
+
tasks?: HubTask[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Read-only access to the hub catalog (clients/projects). `read()` is a
|
|
15
|
+
* synchronous, cheap access to the last known snapshot so session start
|
|
16
|
+
* never blocks on it; `refresh()` is the async network path. See
|
|
17
|
+
* `adapters/cached-catalog.ts` for the disk-backed implementation.
|
|
18
|
+
*/
|
|
19
|
+
export interface Catalog {
|
|
20
|
+
/** The last known snapshot, or `undefined` when none has ever been fetched or cached. */
|
|
21
|
+
read(): CatalogSnapshot | undefined;
|
|
22
|
+
/** `true` when there is no snapshot, or the cached one is older than the configured TTL. */
|
|
23
|
+
isStale(): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Fetch a fresh snapshot, cache it, and return it. Never throws; resolves
|
|
26
|
+
* `undefined` on failure. `signal`, when given, is composed with each
|
|
27
|
+
* underlying request's own per-request timeout (see
|
|
28
|
+
* `adapters/pocketbase-client.ts`) so a caller can bound the whole fetch
|
|
29
|
+
* (auth, pagination, retries) with one overall deadline; an abort is
|
|
30
|
+
* just another failure mode and also resolves `undefined`.
|
|
31
|
+
*/
|
|
32
|
+
refresh(signal?: AbortSignal): Promise<CatalogSnapshot | undefined>;
|
|
33
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public library entrypoint (`kankaku-pi/ports`): the port interfaces, with
|
|
3
|
+
* no implementations. See AGENTS.md "Architecture (hexagonal)" and
|
|
4
|
+
* odd/tasks/library-exports.md.
|
|
5
|
+
*/
|
|
6
|
+
export type * from "./catalog.ts";
|
|
7
|
+
export type * from "./clock.ts";
|
|
8
|
+
export type * from "./inflight-store.ts";
|
|
9
|
+
export type * from "./process-registry.ts";
|
|
10
|
+
export type * from "./work-log.ts";
|
|
11
|
+
export type * from "./work-sink.ts";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { WorkRecord } from "../domain/work-record.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Crash-recovery checkpoint store: one process periodically saves the
|
|
5
|
+
* record its {@link WorkTracker} would produce right now, so a hard crash
|
|
6
|
+
* (kill -9, power loss) still leaves a record behind — recovered as
|
|
7
|
+
* `interrupted` on the next pi start rather than lost entirely.
|
|
8
|
+
*/
|
|
9
|
+
export interface InflightStore {
|
|
10
|
+
/** Upsert this process's checkpoint. */
|
|
11
|
+
save(record: WorkRecord): void;
|
|
12
|
+
/** Remove this process's checkpoint (normal settle/shutdown). */
|
|
13
|
+
clear(): void;
|
|
14
|
+
/** Return and delete every checkpoint whose owning pid is no longer alive. */
|
|
15
|
+
recoverStale(isAlive: (pid: number) => boolean): WorkRecord[];
|
|
16
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { OrchestratorRef, WorkRole } from "../domain/work-record.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One machine-wide, live-process registry entry
|
|
5
|
+
* (`~/.kankaku/run/<pid>.json`, see `adapters/machine-process-registry.ts`),
|
|
6
|
+
* independent of any project's `KANKAKU_DIR` (ADR 0023). Written by every
|
|
7
|
+
* kankaku process at extension-factory time, so a later process (a child,
|
|
8
|
+
* possibly in another worktree) can discover it by walking its own OS
|
|
9
|
+
* ancestor chain.
|
|
10
|
+
*/
|
|
11
|
+
export interface RegistryEntry {
|
|
12
|
+
pid: number;
|
|
13
|
+
parentPid: number;
|
|
14
|
+
role: WorkRole;
|
|
15
|
+
/** This process's own cwd at the time it wrote this entry. A hint for matching, never a hard filter (ADR 0021). */
|
|
16
|
+
project: string;
|
|
17
|
+
/** Absolute, resolved `KANKAKU_DIR` for this process, so another process can read its `worklog.jsonl` directly without guessing at a shared relative directory name. */
|
|
18
|
+
dir: string;
|
|
19
|
+
startedAt: string;
|
|
20
|
+
/** The tracked ancestor this process itself discovered via the same registry, when any. */
|
|
21
|
+
orchestratorRef?: OrchestratorRef;
|
|
22
|
+
/**
|
|
23
|
+
* This process's own OS-reported start-time identity, as an
|
|
24
|
+
* approximate-but-self-consistent epoch-ms estimate (see
|
|
25
|
+
* `adapters/ancestry.ts`). Used to prove — not just guess by pid number
|
|
26
|
+
* — that a later reader's ancestor pid is still the *same* process
|
|
27
|
+
* instance this entry was written for, since the OS reuses pids
|
|
28
|
+
* (`domain/ancestry-match.ts#findAncestorEntry`). `undefined` when this
|
|
29
|
+
* process could not obtain it (Windows, or any read failure) — such an
|
|
30
|
+
* entry can never be trusted for identity matching and is swept as
|
|
31
|
+
* unverifiable (see `domain/registry-health.ts`).
|
|
32
|
+
*/
|
|
33
|
+
processStartId?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Extra, optional dependencies for {@link ProcessRegistry.record}'s opportunistic sweep. See `domain/registry-health.ts`. */
|
|
37
|
+
export interface RegistrySweepDeps {
|
|
38
|
+
/** Live start identity for a pid, from the same ancestry snapshot this process already took at startup. `undefined` means "unknown" — such a pid is never swept as stale-by-reuse (fails safe). Defaults to always-unknown. */
|
|
39
|
+
liveStartId?: (pid: number) => number | undefined;
|
|
40
|
+
/** Defaults to `Date.now()`. Injectable for deterministic tests. */
|
|
41
|
+
now?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Machine-wide registry of live (or recently-live) kankaku processes.
|
|
46
|
+
* Every operation is expected to degrade gracefully — an unavailable home
|
|
47
|
+
* directory or any filesystem failure yields an empty read / a no-op write
|
|
48
|
+
* rather than throwing, since this is an enhancement to subagent detection
|
|
49
|
+
* and must never block or crash the extension it improves.
|
|
50
|
+
*/
|
|
51
|
+
export interface ProcessRegistry {
|
|
52
|
+
/**
|
|
53
|
+
* Upsert this process's own entry, atomically. Also opportunistically
|
|
54
|
+
* sweeps entries the registry can no longer trust it should keep — dead
|
|
55
|
+
* pids, entries whose recorded identity no longer matches the live
|
|
56
|
+
* process holding that pid (pid reuse), entries with no verifiable
|
|
57
|
+
* identity at all (legacy/malformed), and entries older than a sane
|
|
58
|
+
* maximum age — so `run/` does not grow unbounded and never serves a
|
|
59
|
+
* stale identity (SUBAGENT-REQ-010, and the PID-reuse fix). Never
|
|
60
|
+
* removes the entry this call itself just wrote. Never throws.
|
|
61
|
+
*/
|
|
62
|
+
record(entry: RegistryEntry, isAlive?: (pid: number) => boolean, sweepDeps?: RegistrySweepDeps): void;
|
|
63
|
+
/** Every currently-persisted entry, tolerating a corrupt/torn file by skipping it. Never throws; returns `[]` when the registry is unavailable. */
|
|
64
|
+
readAll(): RegistryEntry[];
|
|
65
|
+
/**
|
|
66
|
+
* Best-effort remove of this process's *own* entry file
|
|
67
|
+
* (`session_shutdown`, process `exit`). Reads the on-disk entry back
|
|
68
|
+
* first and only unlinks it when both `pid` and `processStartId` still
|
|
69
|
+
* match exactly what is on disk — so a file already overwritten by a
|
|
70
|
+
* pid-reuse successor (unlikely, but never assumed away) is never
|
|
71
|
+
* touched. Optional: an older `ProcessRegistry` implementation (e.g. a
|
|
72
|
+
* test fake) may omit it; callers must guard the call. Never throws.
|
|
73
|
+
*/
|
|
74
|
+
removeOwn?(pid: number, processStartId: number | undefined): void;
|
|
75
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { WorkRecord } from "../domain/work-record.ts";
|
|
2
|
+
|
|
3
|
+
export interface WorkLog {
|
|
4
|
+
append(record: WorkRecord): void;
|
|
5
|
+
readAll(): WorkRecord[];
|
|
6
|
+
/**
|
|
7
|
+
* Optional cheap change signal: a value that changes whenever `append()`
|
|
8
|
+
* would change what `readAll()` returns, computable without reading the
|
|
9
|
+
* whole log (e.g. from file stat metadata). Callers that cache a view
|
|
10
|
+
* derived from `readAll()` (see `pi-tracker.ts`'s client-name completion
|
|
11
|
+
* cache) may use this to invalidate cheaply; a `WorkLog` that omits it
|
|
12
|
+
* simply leaves such callers relying on their own explicit invalidation.
|
|
13
|
+
*/
|
|
14
|
+
version?(): string | number;
|
|
15
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { TaskView } from "../domain/task-view.ts";
|
|
2
|
+
|
|
3
|
+
/** Which client a successfully pushed task ended up linked to, for the sync summary's `unassigned` grouping. See `domain/hub-entry.ts#resolveTaskAssignment`. */
|
|
4
|
+
export interface PushAssignment {
|
|
5
|
+
/** `true` when the task was routed to the catalog's unassigned ("Sin determinar") client. */
|
|
6
|
+
unassigned: boolean;
|
|
7
|
+
/** The task's historical free-text/denormalised client label, only meaningful when `unassigned` is `true`. */
|
|
8
|
+
legacyLabel?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Outcome of pushing one task's `task_entries` row (and, if enabled, its `work_records` children). */
|
|
12
|
+
export type PushOutcome =
|
|
13
|
+
| ({ kind: "created" } & PushAssignment)
|
|
14
|
+
| ({ kind: "updated" } & PushAssignment)
|
|
15
|
+
/** A non-retryable rejection (e.g. a real validation error) — recorded and skipped, not retried automatically. */
|
|
16
|
+
| { kind: "failed"; reason: string }
|
|
17
|
+
/** A network/timeout/5xx/auth failure. The caller must stop processing further tasks and not advance past this point. */
|
|
18
|
+
| { kind: "error"; reason: string };
|
|
19
|
+
|
|
20
|
+
export interface PushTaskResult {
|
|
21
|
+
taskId: string;
|
|
22
|
+
outcome: PushOutcome;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Uploads consolidated task rows to the hub. `push` is given tasks already
|
|
27
|
+
* sorted chronologically by the caller and returns one result per task
|
|
28
|
+
* attempted, in the same order — stopping (returning fewer results than
|
|
29
|
+
* tasks given) at the first `"error"` outcome, since that signals a
|
|
30
|
+
* systemic failure (network/timeout/5xx/auth) rather than a per-task one.
|
|
31
|
+
* Never throws.
|
|
32
|
+
*/
|
|
33
|
+
export interface WorkSink {
|
|
34
|
+
push(tasks: TaskView[]): Promise<PushTaskResult[]>;
|
|
35
|
+
}
|