kankaku-pi 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1438 -0
  3. package/dist/adapters/cached-catalog.d.ts +42 -0
  4. package/dist/adapters/cached-catalog.js +121 -0
  5. package/dist/adapters/export-writer.d.ts +13 -0
  6. package/dist/adapters/export-writer.js +28 -0
  7. package/dist/adapters/file-modes.d.ts +20 -0
  8. package/dist/adapters/file-modes.js +34 -0
  9. package/dist/adapters/hub-actions.d.ts +35 -0
  10. package/dist/adapters/hub-actions.js +70 -0
  11. package/dist/adapters/hub-credentials.d.ts +35 -0
  12. package/dist/adapters/hub-credentials.js +58 -0
  13. package/dist/adapters/jsonl-work-log.d.ts +20 -0
  14. package/dist/adapters/jsonl-work-log.js +62 -0
  15. package/dist/adapters/kankaku-dir.d.ts +38 -0
  16. package/dist/adapters/kankaku-dir.js +85 -0
  17. package/dist/adapters/lazy-jsonl-work-log.d.ts +17 -0
  18. package/dist/adapters/lazy-jsonl-work-log.js +31 -0
  19. package/dist/adapters/pocketbase-catalog.d.ts +16 -0
  20. package/dist/adapters/pocketbase-catalog.js +56 -0
  21. package/dist/adapters/pocketbase-client.d.ts +81 -0
  22. package/dist/adapters/pocketbase-client.js +148 -0
  23. package/dist/adapters/pocketbase-sink.d.ts +53 -0
  24. package/dist/adapters/pocketbase-sink.js +181 -0
  25. package/dist/adapters/project-config.d.ts +42 -0
  26. package/dist/adapters/project-config.js +108 -0
  27. package/dist/adapters/report-data.d.ts +12 -0
  28. package/dist/adapters/report-data.js +8 -0
  29. package/dist/adapters/report-views.d.ts +45 -0
  30. package/dist/adapters/report-views.js +73 -0
  31. package/dist/adapters/report.d.ts +112 -0
  32. package/dist/adapters/report.js +236 -0
  33. package/dist/adapters/sync-runner.d.ts +114 -0
  34. package/dist/adapters/sync-runner.js +273 -0
  35. package/dist/adapters/sync-state-store.d.ts +62 -0
  36. package/dist/adapters/sync-state-store.js +188 -0
  37. package/dist/config.d.ts +168 -0
  38. package/dist/config.js +392 -0
  39. package/dist/domain/ancestry-match.d.ts +49 -0
  40. package/dist/domain/ancestry-match.js +82 -0
  41. package/dist/domain/client-label.d.ts +28 -0
  42. package/dist/domain/client-label.js +44 -0
  43. package/dist/domain/day.d.ts +2 -0
  44. package/dist/domain/day.js +8 -0
  45. package/dist/domain/export.d.ts +38 -0
  46. package/dist/domain/export.js +68 -0
  47. package/dist/domain/hub-entry.d.ts +234 -0
  48. package/dist/domain/hub-entry.js +265 -0
  49. package/dist/domain/index.d.ts +19 -0
  50. package/dist/domain/index.js +19 -0
  51. package/dist/domain/intervals.d.ts +17 -0
  52. package/dist/domain/intervals.js +43 -0
  53. package/dist/domain/registry-health.d.ts +49 -0
  54. package/dist/domain/registry-health.js +58 -0
  55. package/dist/domain/segment-rule.d.ts +10 -0
  56. package/dist/domain/segment-rule.js +1 -0
  57. package/dist/domain/subagent-profile.d.ts +278 -0
  58. package/dist/domain/subagent-profile.js +418 -0
  59. package/dist/domain/sync-plan.d.ts +151 -0
  60. package/dist/domain/sync-plan.js +196 -0
  61. package/dist/domain/task-view.d.ts +117 -0
  62. package/dist/domain/task-view.js +428 -0
  63. package/dist/domain/work-record.d.ts +236 -0
  64. package/dist/domain/work-record.js +91 -0
  65. package/dist/domain/work-target.d.ts +101 -0
  66. package/dist/domain/work-target.js +149 -0
  67. package/dist/domain/work-tracker.d.ts +90 -0
  68. package/dist/domain/work-tracker.js +405 -0
  69. package/dist/hub/index.d.ts +25 -0
  70. package/dist/hub/index.js +25 -0
  71. package/dist/ports/catalog.d.ts +31 -0
  72. package/dist/ports/catalog.js +1 -0
  73. package/dist/ports/clock.d.ts +3 -0
  74. package/dist/ports/clock.js +1 -0
  75. package/dist/ports/index.d.ts +11 -0
  76. package/dist/ports/index.js +1 -0
  77. package/dist/ports/inflight-store.d.ts +15 -0
  78. package/dist/ports/inflight-store.js +1 -0
  79. package/dist/ports/process-registry.d.ts +72 -0
  80. package/dist/ports/process-registry.js +1 -0
  81. package/dist/ports/work-log.d.ts +14 -0
  82. package/dist/ports/work-log.js +1 -0
  83. package/dist/ports/work-sink.d.ts +39 -0
  84. package/dist/ports/work-sink.js +1 -0
  85. package/package.json +66 -0
  86. package/src/adapters/agent-info.ts +86 -0
  87. package/src/adapters/ancestry.ts +260 -0
  88. package/src/adapters/cached-catalog.ts +147 -0
  89. package/src/adapters/export-writer.ts +33 -0
  90. package/src/adapters/file-inflight-store.ts +115 -0
  91. package/src/adapters/file-modes.ts +35 -0
  92. package/src/adapters/hub-actions.ts +82 -0
  93. package/src/adapters/hub-credentials.ts +95 -0
  94. package/src/adapters/jsonl-work-log.ts +67 -0
  95. package/src/adapters/kankaku-command.ts +717 -0
  96. package/src/adapters/kankaku-dir.ts +102 -0
  97. package/src/adapters/lazy-file-inflight-store.ts +43 -0
  98. package/src/adapters/lazy-jsonl-work-log.ts +39 -0
  99. package/src/adapters/machine-process-registry.ts +256 -0
  100. package/src/adapters/panel/kankaku-panel.ts +419 -0
  101. package/src/adapters/panel/panel-items.ts +87 -0
  102. package/src/adapters/panel/panel-lines.ts +13 -0
  103. package/src/adapters/panel/panel-theme.ts +32 -0
  104. package/src/adapters/panel/screens/about.ts +69 -0
  105. package/src/adapters/panel/screens/doctor.ts +89 -0
  106. package/src/adapters/panel/screens/export.ts +123 -0
  107. package/src/adapters/panel/screens/report.ts +143 -0
  108. package/src/adapters/panel/screens/sync.ts +136 -0
  109. package/src/adapters/panel/screens/target.ts +384 -0
  110. package/src/adapters/pi-tracker.ts +753 -0
  111. package/src/adapters/pocketbase-catalog.ts +89 -0
  112. package/src/adapters/pocketbase-client.ts +197 -0
  113. package/src/adapters/pocketbase-sink.ts +236 -0
  114. package/src/adapters/process-identity-memo.ts +102 -0
  115. package/src/adapters/process-identity.ts +162 -0
  116. package/src/adapters/project-config.ts +116 -0
  117. package/src/adapters/report-data.ts +13 -0
  118. package/src/adapters/report-views.ts +98 -0
  119. package/src/adapters/report.ts +335 -0
  120. package/src/adapters/session-client.ts +116 -0
  121. package/src/adapters/session-dir.ts +28 -0
  122. package/src/adapters/session-target.ts +431 -0
  123. package/src/adapters/status-bar.ts +86 -0
  124. package/src/adapters/subagent-startup.ts +66 -0
  125. package/src/adapters/sync-runner.ts +340 -0
  126. package/src/adapters/sync-state-store.ts +227 -0
  127. package/src/adapters/target-picker.ts +127 -0
  128. package/src/config.ts +536 -0
  129. package/src/domain/ancestry-match.ts +84 -0
  130. package/src/domain/client-label.ts +56 -0
  131. package/src/domain/day.ts +8 -0
  132. package/src/domain/export.ts +107 -0
  133. package/src/domain/hub-entry.ts +433 -0
  134. package/src/domain/index.ts +19 -0
  135. package/src/domain/intervals.ts +53 -0
  136. package/src/domain/panel-model.ts +270 -0
  137. package/src/domain/registry-health.ts +87 -0
  138. package/src/domain/segment-rule.ts +10 -0
  139. package/src/domain/subagent-profile.ts +495 -0
  140. package/src/domain/sync-plan.ts +266 -0
  141. package/src/domain/task-view.ts +526 -0
  142. package/src/domain/work-record.ts +320 -0
  143. package/src/domain/work-target.ts +234 -0
  144. package/src/domain/work-tracker.ts +485 -0
  145. package/src/extension.ts +346 -0
  146. package/src/hub/index.ts +25 -0
  147. package/src/ports/catalog.ts +33 -0
  148. package/src/ports/clock.ts +3 -0
  149. package/src/ports/index.ts +11 -0
  150. package/src/ports/inflight-store.ts +16 -0
  151. package/src/ports/process-registry.ts +75 -0
  152. package/src/ports/work-log.ts +15 -0
  153. package/src/ports/work-sink.ts +35 -0
@@ -0,0 +1,39 @@
1
+ import type { TaskView } from "../domain/task-view.ts";
2
+ /** Which client a successfully pushed task ended up linked to, for the sync summary's `unassigned` grouping. See `domain/hub-entry.ts#resolveTaskAssignment`. */
3
+ export interface PushAssignment {
4
+ /** `true` when the task was routed to the catalog's unassigned ("Sin determinar") client. */
5
+ unassigned: boolean;
6
+ /** The task's historical free-text/denormalised client label, only meaningful when `unassigned` is `true`. */
7
+ legacyLabel?: string;
8
+ }
9
+ /** Outcome of pushing one task's `task_entries` row (and, if enabled, its `work_records` children). */
10
+ export type PushOutcome = ({
11
+ kind: "created";
12
+ } & PushAssignment) | ({
13
+ kind: "updated";
14
+ } & PushAssignment)
15
+ /** A non-retryable rejection (e.g. a real validation error) — recorded and skipped, not retried automatically. */
16
+ | {
17
+ kind: "failed";
18
+ reason: string;
19
+ }
20
+ /** A network/timeout/5xx/auth failure. The caller must stop processing further tasks and not advance past this point. */
21
+ | {
22
+ kind: "error";
23
+ reason: string;
24
+ };
25
+ export interface PushTaskResult {
26
+ taskId: string;
27
+ outcome: PushOutcome;
28
+ }
29
+ /**
30
+ * Uploads consolidated task rows to the hub. `push` is given tasks already
31
+ * sorted chronologically by the caller and returns one result per task
32
+ * attempted, in the same order — stopping (returning fewer results than
33
+ * tasks given) at the first `"error"` outcome, since that signals a
34
+ * systemic failure (network/timeout/5xx/auth) rather than a per-task one.
35
+ * Never throws.
36
+ */
37
+ export interface WorkSink {
38
+ push(tasks: TaskView[]): Promise<PushTaskResult[]>;
39
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "kankaku-pi",
3
+ "version": "1.0.0",
4
+ "description": "pi extension that records agent work time per prompt, excluding waits for the user, with subagent linkage and task/session views",
5
+ "license": "MIT",
6
+ "author": "soyunninja",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/soyunninja/kankaku.git",
10
+ "directory": "packages/pi"
11
+ },
12
+ "homepage": "https://kankaku.io",
13
+ "bugs": {
14
+ "url": "https://github.com/soyunninja/kankaku/issues"
15
+ },
16
+ "type": "module",
17
+ "keywords": [
18
+ "pi-package",
19
+ "pi",
20
+ "pi-extension",
21
+ "time-tracking",
22
+ "agents"
23
+ ],
24
+ "files": [
25
+ "src/",
26
+ "dist/",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "exports": {
31
+ "./domain": {
32
+ "types": "./dist/domain/index.d.ts",
33
+ "import": "./dist/domain/index.js"
34
+ },
35
+ "./ports": {
36
+ "types": "./dist/ports/index.d.ts",
37
+ "import": "./dist/ports/index.js"
38
+ },
39
+ "./hub": {
40
+ "types": "./dist/hub/index.d.ts",
41
+ "import": "./dist/hub/index.js"
42
+ },
43
+ "./package.json": "./package.json",
44
+ "./src/*": "./src/*"
45
+ },
46
+ "pi": {
47
+ "extensions": [
48
+ "./src/extension.ts"
49
+ ]
50
+ },
51
+ "scripts": {
52
+ "build": "tsc -p tsconfig.build.json",
53
+ "test": "node --test tests/*.test.ts",
54
+ "typecheck": "tsc --noEmit",
55
+ "check": "npm run build && npm run typecheck && npm test",
56
+ "e2e:hub": "node scripts/e2e-hub.ts",
57
+ "e2e:cross-worktree": "node scripts/e2e-cross-worktree-real-processes.ts",
58
+ "prepublishOnly": "npm run check"
59
+ },
60
+ "devDependencies": {
61
+ "@earendil-works/pi-coding-agent": "0.85.1",
62
+ "@earendil-works/pi-tui": "^0.85.1",
63
+ "@types/node": "^24.13.4",
64
+ "typescript": "^5.7.0"
65
+ }
66
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Resolve `agent_version`/`plugin_version` for the hub's "Agent and
3
+ * measurement quality" fields (`domain/hub-entry.ts`). Neither pi's
4
+ * `ExtensionAPI` nor `ExtensionContext` exposes a version today (checked
5
+ * against `@earendil-works/pi-coding-agent`'s type definitions), so pi's
6
+ * own version is instead read from its installed package's `package.json`
7
+ * — the same file Node's module resolution already points at. Both
8
+ * resolvers run once, at extension load time (never a hot path), and
9
+ * degrade to `undefined` on any failure: this is a best-effort label, and
10
+ * a wrong guess is worse than an absent one.
11
+ */
12
+
13
+ import { readFileSync } from "node:fs";
14
+ import { fileURLToPath } from "node:url";
15
+ import { dirname, join } from "node:path";
16
+
17
+ export interface AgentInfoDeps {
18
+ /** Reads a file's contents as text. Defaults to `fs.readFileSync(path, "utf8")`. Injectable for tests. */
19
+ readFile?: (path: string) => string;
20
+ /** Resolves a bare module specifier to a `file://` URL, like `import.meta.resolve`. Injectable for tests. */
21
+ resolveModule?: (specifier: string) => string;
22
+ }
23
+
24
+ function defaultReadFile(path: string): string {
25
+ return readFileSync(path, "utf8");
26
+ }
27
+
28
+ function readVersionField(path: string, readFile: (path: string) => string): string | undefined {
29
+ try {
30
+ const parsed = JSON.parse(readFile(path)) as { version?: unknown };
31
+ return typeof parsed.version === "string" ? parsed.version : undefined;
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+
37
+ /** kankaku's own version, from the `package.json` at `packageRoot` (the directory containing `src/`). */
38
+ export function resolvePluginVersion(packageRoot: string, deps: AgentInfoDeps = {}): string | undefined {
39
+ const readFile = deps.readFile ?? defaultReadFile;
40
+ return readVersionField(join(packageRoot, "package.json"), readFile);
41
+ }
42
+
43
+ const MAX_UPWARD_HOPS = 6;
44
+
45
+ /**
46
+ * pi's own version, resolved from the installed
47
+ * `@earendil-works/pi-coding-agent` package: resolve its entry module, then
48
+ * walk upward from that file looking for the nearest `package.json` whose
49
+ * own `name` field actually matches the package (handling a nested
50
+ * `dist/...` entry point), so a coincidental unrelated `package.json`
51
+ * higher up a directory tree is never trusted.
52
+ */
53
+ export function resolveAgentVersion(deps: AgentInfoDeps = {}): string | undefined {
54
+ const readFile = deps.readFile ?? defaultReadFile;
55
+ const resolveModule = deps.resolveModule ?? ((specifier: string) => import.meta.resolve(specifier));
56
+
57
+ let entryUrl: string;
58
+ try {
59
+ entryUrl = resolveModule("@earendil-works/pi-coding-agent");
60
+ } catch {
61
+ return undefined;
62
+ }
63
+
64
+ let dir: string;
65
+ try {
66
+ dir = dirname(fileURLToPath(entryUrl));
67
+ } catch {
68
+ return undefined;
69
+ }
70
+
71
+ for (let hop = 0; hop < MAX_UPWARD_HOPS; hop++) {
72
+ try {
73
+ const parsed = JSON.parse(readFile(join(dir, "package.json"))) as { name?: unknown; version?: unknown };
74
+ if (parsed.name === "@earendil-works/pi-coding-agent" && typeof parsed.version === "string") {
75
+ return parsed.version;
76
+ }
77
+ } catch {
78
+ // Not here (or unreadable/malformed) — keep walking up.
79
+ }
80
+ const parent = dirname(dir);
81
+ if (parent === dir) break;
82
+ dir = parent;
83
+ }
84
+
85
+ return undefined;
86
+ }
@@ -0,0 +1,260 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { readFileSync, readdirSync } from "node:fs";
3
+ import { platform } from "node:os";
4
+
5
+ /**
6
+ * One OS ancestor-chain snapshot: `pid -> ppid` for every process visible to
7
+ * this user at the moment it was taken, plus each pid's approximate OS
8
+ * start-time identity (`startIdByPid`, epoch-ms estimate). The identity map
9
+ * is used to prove a pid still refers to the same process instance a
10
+ * registry entry was written for (see `domain/ancestry-match.ts`); it is
11
+ * empty for a pid whose start time could not be determined (also always
12
+ * empty on Windows), never a reason to fail the ppid-map part of the
13
+ * snapshot.
14
+ */
15
+ export interface AncestrySnapshot {
16
+ ppidByPid: Map<number, number>;
17
+ startIdByPid: Map<number, number>;
18
+ }
19
+
20
+ /** One raw `pid -> ppid` and `pid -> startId` read, before either mechanism is chosen. */
21
+ export interface ProcSnapshot {
22
+ ppidByPid: Map<number, number>;
23
+ startIdByPid: Map<number, number>;
24
+ }
25
+
26
+ /** Parse `ps -eo pid,ppid[,...]` output (header line plus one `<pid> <ppid> ...` row per line) into a `pid -> ppid` map. Extra trailing columns (e.g. `etimes`) are ignored here. */
27
+ export function parsePsOutput(output: string): Map<number, number> {
28
+ const map = new Map<number, number>();
29
+ const lines = output.split("\n").slice(1); // drop the header line
30
+ for (const line of lines) {
31
+ const trimmed = line.trim();
32
+ if (!trimmed) continue;
33
+ const parts = trimmed.split(/\s+/);
34
+ const pid = Number(parts[0]);
35
+ const ppid = Number(parts[1]);
36
+ if (Number.isFinite(pid) && Number.isFinite(ppid)) map.set(pid, ppid);
37
+ }
38
+ return map;
39
+ }
40
+
41
+ /**
42
+ * `ps`'s portable elapsed-time column is `etime` (`[[dd-]hh:]mm:ss`), not
43
+ * the GNU-only `etimes` (plain seconds): BSD `ps` — macOS included — has no
44
+ * `etimes` keyword at all and errors out on it, which would silently break
45
+ * ancestor-chain detection everywhere on that platform (the `ps` fallback
46
+ * throws, degrading straight to an empty snapshot) — a real regression
47
+ * caught by testing this against a real `ps` before trusting it. `etime`,
48
+ * by contrast, is supported by both BSD and GNU `ps`.
49
+ */
50
+ const ETIME_PATTERN = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/;
51
+
52
+ /** Parse one `ps` `etime` value (`[[dd-]hh:]mm:ss`) into whole seconds, or `undefined` if it does not match the expected shape. */
53
+ export function parseEtimeSeconds(etime: string): number | undefined {
54
+ const match = ETIME_PATTERN.exec(etime.trim());
55
+ if (!match) return undefined;
56
+ const days = match[1] !== undefined ? Number(match[1]) : 0;
57
+ const hours = match[2] !== undefined ? Number(match[2]) : 0;
58
+ const minutes = Number(match[3]);
59
+ const seconds = Number(match[4]);
60
+ if (![days, hours, minutes, seconds].every(Number.isFinite)) return undefined;
61
+ return days * 86400 + hours * 3600 + minutes * 60 + seconds;
62
+ }
63
+
64
+ /**
65
+ * Parse the `etime` (elapsed time since start, `[[dd-]hh:]mm:ss`) column
66
+ * out of `ps -eo pid,ppid,etime` output into an approximate start-epoch-ms
67
+ * estimate per pid: `nowMs - etimeSeconds * 1000`. `etime` truncates to
68
+ * whole seconds, so this is accurate to within about a second either way —
69
+ * well within `domain/ancestry-match.ts`'s tolerance.
70
+ */
71
+ export function parsePsEtimes(output: string, nowMs: number): Map<number, number> {
72
+ const map = new Map<number, number>();
73
+ const lines = output.split("\n").slice(1);
74
+ for (const line of lines) {
75
+ const trimmed = line.trim();
76
+ if (!trimmed) continue;
77
+ const parts = trimmed.split(/\s+/);
78
+ const pid = Number(parts[0]);
79
+ const etimeSeconds = parts[2] !== undefined ? parseEtimeSeconds(parts[2]) : undefined;
80
+ if (Number.isFinite(pid) && etimeSeconds !== undefined) map.set(pid, nowMs - etimeSeconds * 1000);
81
+ }
82
+ return map;
83
+ }
84
+
85
+ const PPID_LINE = /^PPid:\s*(\d+)/m;
86
+
87
+ /** Extract the `PPid:` value from one `/proc/<pid>/status` file's contents, or `undefined` when the line is missing/malformed. Kept as a small tested utility; the live snapshot path below reads `/proc/<pid>/stat` instead, since that file carries both ppid and start time in a single read. */
88
+ export function parseProcStatus(status: string): number | undefined {
89
+ const match = PPID_LINE.exec(status);
90
+ if (!match) return undefined;
91
+ const ppid = Number(match[1]);
92
+ return Number.isFinite(ppid) ? ppid : undefined;
93
+ }
94
+
95
+ /**
96
+ * Parse one `/proc/<pid>/stat` line: `pid (comm) state ppid ... starttime ...`.
97
+ * `comm` (the process name) is parenthesized and may itself contain spaces
98
+ * or parens, so every field is located relative to the *last* `)` on the
99
+ * line, never by naive whitespace-splitting from the start. `ppid` is field
100
+ * 4 overall (index 1 after the comm) and `starttime` is field 22 overall
101
+ * (index 19) — the number of clock ticks since boot at which this process
102
+ * started, per `proc(5)`.
103
+ */
104
+ export function parseProcStat(stat: string): { ppid: number; starttimeTicks: number } | undefined {
105
+ const closeParen = stat.lastIndexOf(")");
106
+ if (closeParen === -1) return undefined;
107
+ const rest = stat.slice(closeParen + 1).trim();
108
+ if (!rest) return undefined;
109
+ const fields = rest.split(/\s+/);
110
+ const ppid = Number(fields[1]);
111
+ const starttimeTicks = Number(fields[19]);
112
+ if (!Number.isFinite(ppid) || !Number.isFinite(starttimeTicks)) return undefined;
113
+ return { ppid, starttimeTicks };
114
+ }
115
+
116
+ /** Parse the first number (system uptime in seconds) out of `/proc/uptime`'s contents. */
117
+ export function parseProcUptimeSeconds(content: string): number | undefined {
118
+ const first = content.trim().split(/\s+/)[0];
119
+ const value = first !== undefined ? Number(first) : NaN;
120
+ return Number.isFinite(value) ? value : undefined;
121
+ }
122
+
123
+ /**
124
+ * The near-universal Linux `USER_HZ` (clock ticks per second used by
125
+ * `/proc/<pid>/stat`'s `starttime` field), assumed rather than queried
126
+ * (Node has no `sysconf` binding). A wrong assumption never produces a
127
+ * false *match*: the same (possibly wrong) constant is used both when an
128
+ * entry is first written and whenever it is later re-verified, and
129
+ * `starttimeTicks` is fixed for a process's lifetime, so two readings of
130
+ * the *same* process instance still agree closely regardless of the true
131
+ * `USER_HZ` — only the (unused) absolute-epoch interpretation would be
132
+ * off. See `domain/ancestry-match.ts`.
133
+ */
134
+ const ASSUMED_USER_HZ = 100;
135
+
136
+ /** One `ps -eo pid,ppid,etimes` snapshot — a single subprocess spawn regardless of how many ancestor hops are later walked (SUBAGENT-REQ-011), carrying start-time identity alongside the ppid map. */
137
+ function readSnapshotViaPs(): ProcSnapshot {
138
+ const output = execFileSync("ps", ["-eo", "pid,ppid,etime"], { encoding: "utf8", timeout: 2000 });
139
+ const nowMs = Date.now();
140
+ return { ppidByPid: parsePsOutput(output), startIdByPid: parsePsEtimes(output, nowMs) };
141
+ }
142
+
143
+ /** `/proc/<pid>/stat` reads — no subprocess spawn at all, cheaper than the `ps` snapshot, Linux-only. Carries start-time identity (`/proc/uptime` + `starttime` ticks) alongside the ppid map, at no extra spawn cost. */
144
+ function readSnapshotViaProc(): ProcSnapshot {
145
+ const ppidByPid = new Map<number, number>();
146
+ const starttimeTicksByPid = new Map<number, number>();
147
+
148
+ for (const name of readdirSync("/proc")) {
149
+ const pid = Number(name);
150
+ if (!Number.isFinite(pid)) continue;
151
+ try {
152
+ const parsed = parseProcStat(readFileSync(`/proc/${name}/stat`, "utf8"));
153
+ if (parsed) {
154
+ ppidByPid.set(pid, parsed.ppid);
155
+ starttimeTicksByPid.set(pid, parsed.starttimeTicks);
156
+ }
157
+ } catch {
158
+ // The process exited between listing and reading, or is unreadable; skip it.
159
+ }
160
+ }
161
+
162
+ const startIdByPid = new Map<number, number>();
163
+ if (starttimeTicksByPid.size > 0) {
164
+ try {
165
+ const uptimeSec = parseProcUptimeSeconds(readFileSync("/proc/uptime", "utf8"));
166
+ if (uptimeSec !== undefined) {
167
+ const bootEpochMs = Date.now() - uptimeSec * 1000;
168
+ for (const [pid, ticks] of starttimeTicksByPid) {
169
+ startIdByPid.set(pid, bootEpochMs + (ticks / ASSUMED_USER_HZ) * 1000);
170
+ }
171
+ }
172
+ } catch {
173
+ // /proc/uptime unavailable: the ppid map above is still useful on its own; start ids just stay empty.
174
+ }
175
+ }
176
+
177
+ return { ppidByPid, startIdByPid };
178
+ }
179
+
180
+ export interface SnapshotAncestryDeps {
181
+ /** Defaults to `os.platform()`. Windows (`"win32"`) always yields an empty snapshot — no supported mechanism, never a spawn attempt (SUBAGENT-REQ-012). */
182
+ platform?: NodeJS.Platform;
183
+ /** Injectable for tests, so no test ever reads the real `/proc`. */
184
+ readProc?: () => ProcSnapshot;
185
+ /** Injectable for tests, so no test ever spawns a real `ps`. */
186
+ readPs?: () => ProcSnapshot;
187
+ }
188
+
189
+ /**
190
+ * Take one ancestor-chain snapshot: `/proc` first (cheapest, Linux), falling
191
+ * back to `ps -eo pid,ppid,etimes` (macOS, or a Linux without `/proc`
192
+ * mounted). Windows — and any environment where both mechanisms fail —
193
+ * degrades to an empty snapshot rather than throwing or blocking
194
+ * (SUBAGENT-REQ-012): callers see "no tracked ancestor found," never a
195
+ * crash. `startIdByPid` may legitimately be sparser than `ppidByPid` (a
196
+ * pid whose start time could not be read); callers must treat a missing
197
+ * start id as "unprovable," never as a match.
198
+ */
199
+ export function snapshotAncestry(deps: SnapshotAncestryDeps = {}): AncestrySnapshot {
200
+ const osPlatform = deps.platform ?? platform();
201
+ if (osPlatform === "win32") return { ppidByPid: new Map(), startIdByPid: new Map() };
202
+
203
+ const readProc = deps.readProc ?? readSnapshotViaProc;
204
+ const readPs = deps.readPs ?? readSnapshotViaPs;
205
+
206
+ try {
207
+ return readProc();
208
+ } catch {
209
+ // /proc unavailable (macOS, or a Linux without it mounted); fall through to ps.
210
+ }
211
+ try {
212
+ return readPs();
213
+ } catch {
214
+ return { ppidByPid: new Map(), startIdByPid: new Map() };
215
+ }
216
+ }
217
+
218
+ /**
219
+ * This process's own approximate OS start-time identity, derived with no
220
+ * subprocess spawn and no `/proc` read at all: `nowMs - uptimeSeconds *
221
+ * 1000`, both sampled once at factory time (`process.uptime()` is measured
222
+ * from this process's own actual start, so it is unaffected by system
223
+ * sleep/wake — unlike a wall-clock-only estimate, it keeps counting only
224
+ * while this process itself has been running). Kept separate from
225
+ * `startIdByPid` (which comes from an ancestry snapshot that costs a `ps`
226
+ * spawn or `/proc` scan) so the common case — no other kankaku process on
227
+ * the machine, nothing to compare against — never pays for one just to
228
+ * record this process's own identity (see `adapters/subagent-startup.ts`).
229
+ * Expected to agree with the `ps`/`/proc`-derived value for the same
230
+ * process, within `domain/ancestry-match.ts#START_ID_TOLERANCE_MS`, since
231
+ * both are estimates of the same real start time from different sources.
232
+ */
233
+ export function ownStartIdFromUptime(nowMs: number, uptimeSeconds: number): number {
234
+ return nowMs - uptimeSeconds * 1000;
235
+ }
236
+
237
+ const DEFAULT_MAX_HOPS = 20;
238
+
239
+ /**
240
+ * Walk from `startPid` (typically this process's own `parentPid`) upward
241
+ * via `ppidByPid`, collecting ancestor pids in order, nearest first.
242
+ * Stops at `maxHops`, at pid 0/1 (the OS/init root), or when the chain
243
+ * leaves the snapshot (a pid with no known ppid) — a non-tracked
244
+ * intermediate hop (e.g. a thin shell wrapper) is walked past, not stopped
245
+ * at, since it simply has no entry of its own in the caller's registry
246
+ * lookup (SUBAGENT-REQ-011).
247
+ */
248
+ export function walkAncestry(startPid: number, ppidByPid: Map<number, number>, maxHops: number = DEFAULT_MAX_HOPS): number[] {
249
+ const ancestors: number[] = [];
250
+ const seen = new Set<number>();
251
+ let current: number | undefined = startPid;
252
+
253
+ for (let hop = 0; hop < maxHops && current !== undefined && current > 1 && !seen.has(current); hop++) {
254
+ ancestors.push(current);
255
+ seen.add(current);
256
+ current = ppidByPid.get(current);
257
+ }
258
+
259
+ return ancestors;
260
+ }
@@ -0,0 +1,147 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import type { Client, HubTask, Project } from "../domain/work-target.ts";
4
+ import type { Clock } from "../ports/clock.ts";
5
+ import type { Catalog, CatalogSnapshot } from "../ports/catalog.ts";
6
+ import { OWNER_DIR_MODE, OWNER_FILE_MODE } from "./file-modes.ts";
7
+
8
+ /** Six hours in milliseconds — clients and projects change rarely. */
9
+ const DEFAULT_TTL_MS = 6 * 60 * 60 * 1000;
10
+
11
+ function isClientArray(value: unknown): value is Client[] {
12
+ return Array.isArray(value) && value.every((item) => item && typeof item === "object" && typeof (item as Client).id === "string");
13
+ }
14
+
15
+ function isProjectArray(value: unknown): value is Project[] {
16
+ return Array.isArray(value) && value.every((item) => item && typeof item === "object" && typeof (item as Project).id === "string");
17
+ }
18
+
19
+ function isHubTaskArray(value: unknown): value is HubTask[] {
20
+ return Array.isArray(value) && value.every((item) => item && typeof item === "object" && typeof (item as HubTask).id === "string");
21
+ }
22
+
23
+ function isCatalogSnapshot(value: unknown): value is CatalogSnapshot {
24
+ if (!value || typeof value !== "object") return false;
25
+ const record = value as Record<string, unknown>;
26
+ return (
27
+ typeof record["fetchedAt"] === "number" &&
28
+ typeof record["url"] === "string" &&
29
+ isClientArray(record["clients"]) &&
30
+ isProjectArray(record["projects"]) &&
31
+ (record["tasks"] === undefined || isHubTaskArray(record["tasks"]))
32
+ );
33
+ }
34
+
35
+ export interface CachedCatalogDeps {
36
+ /** Absolute path to the cache file, e.g. `~/.kankaku/catalog.json`. */
37
+ filePath: string;
38
+ /** The configured hub URL; a cache written for a different URL is ignored. */
39
+ url: string;
40
+ clock: Clock;
41
+ /** Cache TTL in ms. Defaults to 6 hours. */
42
+ ttlMs?: number;
43
+ /**
44
+ * Fetch a fresh `{ clients, projects, tasks }` triple, e.g.
45
+ * `createPocketBaseCatalogFetcher(...)`. `tasks` is optional here (unlike
46
+ * on the real PocketBase fetcher) so a test double that only cares about
47
+ * clients/projects keeps compiling unchanged.
48
+ */
49
+ fetchCatalog: (signal?: AbortSignal) => Promise<{ clients: Client[]; projects: Project[]; tasks?: HubTask[] }>;
50
+ }
51
+
52
+ /**
53
+ * Disk-backed {@link Catalog}: `read()` is a synchronous, cheap read of the
54
+ * last known snapshot (memoised after the first disk read so repeated
55
+ * calls in one process never re-stat/re-parse); `refresh()` fetches, caches
56
+ * to disk atomically (tmp + rename, mirroring `file-inflight-store.ts`),
57
+ * and never throws — a failed refresh resolves `undefined` and leaves the
58
+ * previous snapshot (if any) untouched.
59
+ */
60
+ export class CachedCatalog implements Catalog {
61
+ private readonly deps: CachedCatalogDeps;
62
+ private memo: CatalogSnapshot | undefined;
63
+ private memoized = false;
64
+
65
+ constructor(deps: CachedCatalogDeps) {
66
+ this.deps = deps;
67
+ }
68
+
69
+ read(): CatalogSnapshot | undefined {
70
+ if (!this.memoized) {
71
+ this.memo = this.readDisk();
72
+ this.memoized = true;
73
+ }
74
+ return this.memo;
75
+ }
76
+
77
+ isStale(): boolean {
78
+ const snapshot = this.read();
79
+ if (!snapshot) return true;
80
+ const ttl = this.deps.ttlMs ?? DEFAULT_TTL_MS;
81
+ return this.deps.clock.now() - snapshot.fetchedAt > ttl;
82
+ }
83
+
84
+ async refresh(signal?: AbortSignal): Promise<CatalogSnapshot | undefined> {
85
+ try {
86
+ const { clients, projects, tasks } = await this.deps.fetchCatalog(signal);
87
+ const snapshot: CatalogSnapshot = {
88
+ fetchedAt: this.deps.clock.now(),
89
+ url: this.deps.url,
90
+ clients,
91
+ projects,
92
+ ...(tasks !== undefined ? { tasks } : {}),
93
+ };
94
+ this.writeDisk(snapshot);
95
+ this.memo = snapshot;
96
+ this.memoized = true;
97
+ return snapshot;
98
+ } catch {
99
+ return undefined;
100
+ }
101
+ }
102
+
103
+ private readDisk(): CatalogSnapshot | undefined {
104
+ try {
105
+ if (!existsSync(this.deps.filePath)) return undefined;
106
+ const parsed: unknown = JSON.parse(readFileSync(this.deps.filePath, "utf8"));
107
+ if (!isCatalogSnapshot(parsed)) return undefined;
108
+ if (parsed.url !== this.deps.url) return undefined;
109
+ return parsed;
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ }
114
+
115
+ private writeDisk(snapshot: CatalogSnapshot): void {
116
+ try {
117
+ // This cache lives under `~/.kankaku` (machine-wide, not a project's
118
+ // own KANKAKU_DIR) — but `~/.kankaku` itself is never mode-tightened
119
+ // here (R2): when pi runs with cwd === $HOME, a project's own default
120
+ // KANKAKU_DIR (`.kankaku`, relative) resolves to this exact same
121
+ // path, and a project's kankaku dir must never be tightened
122
+ // (AGENTS.md). Only what this package exclusively owns is touched:
123
+ // an EXISTING directory is never chmod'd — `mkdirSync`'s own `mode`
124
+ // option only ever applies to a directory this call actually
125
+ // creates, never one that already existed (Node skips the mkdir
126
+ // syscall for an existing path segment entirely, mode and all), so
127
+ // passing `mode` here is safe even though this path may be a
128
+ // project's own dir (G3). When this call IS the first writer (no
129
+ // `~/.kankaku` yet at all, e.g. a machine with the hub configured but
130
+ // no project ever run from $HOME), it is created owner-only
131
+ // (`OWNER_DIR_MODE`, 0700) rather than left at the umask default. The
132
+ // cache file itself is always written fresh via tmp+rename with
133
+ // OWNER_FILE_MODE below, which already guarantees 0600 on every
134
+ // write regardless of whatever mode an older file at this path (or
135
+ // an older kankaku build) left behind — a rename replaces the whole
136
+ // inode, so a stale looser mode can never survive a write.
137
+ mkdirSync(dirname(this.deps.filePath), { recursive: true, mode: OWNER_DIR_MODE });
138
+ const tmp = `${this.deps.filePath}.${process.pid}.${Date.now()}.tmp`;
139
+ // Owner-only: this file names every client/project the machine's user has touched.
140
+ writeFileSync(tmp, JSON.stringify(snapshot), { mode: OWNER_FILE_MODE });
141
+ renameSync(tmp, this.deps.filePath);
142
+ } catch {
143
+ // Best-effort cache write: a failure here must not fail the refresh
144
+ // itself, since the in-memory snapshot is still usable this process.
145
+ }
146
+ }
147
+ }
@@ -0,0 +1,33 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { resolveKankakuDir } from "./kankaku-dir.ts";
4
+
5
+ const EXPORT_SUBDIR = "export";
6
+
7
+ /**
8
+ * Write `content` to `<dir>/export/<name>` (creating the `export`
9
+ * subdirectory as needed, overwriting any existing file with the same
10
+ * name) and return the absolute path it was written to.
11
+ */
12
+ export function writeExport(dir: string, name: string, content: string): string {
13
+ const exportDir = join(dir, EXPORT_SUBDIR);
14
+ mkdirSync(exportDir, { recursive: true });
15
+ const filePath = resolve(join(exportDir, name));
16
+ writeFileSync(filePath, content);
17
+ return filePath;
18
+ }
19
+
20
+ /** Writes exports under a kankaku dir resolved lazily against `fallbackCwd()` at call time. */
21
+ export class LazyExportWriter {
22
+ private readonly dirOrRelative: string;
23
+ private readonly fallbackCwd: () => string;
24
+
25
+ constructor(dirOrRelative: string, fallbackCwd: () => string = () => process.cwd()) {
26
+ this.dirOrRelative = dirOrRelative;
27
+ this.fallbackCwd = fallbackCwd;
28
+ }
29
+
30
+ write(name: string, content: string): string {
31
+ return writeExport(resolveKankakuDir(this.dirOrRelative, this.fallbackCwd()), name, content);
32
+ }
33
+ }