@bli-cockpit/cli 0.1.6 → 0.1.8

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.
@@ -1,8 +1,32 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs/promises";
2
3
  import path from "node:path";
3
- const CURSOR_FILENAME = "raw-evidence.json";
4
- const MAX_TRACKED_OBJECTS = 500;
5
- const MAX_TRACKED_SESSIONS = 500;
4
+ /**
5
+ * Durable local cursor for raw-evidence harvesting.
6
+ *
7
+ * - `objects` maps content hashes to durable remote objects so repeated syncs
8
+ * acknowledge identical content instead of re-uploading it.
9
+ * - `sessions` tracks observed agent session files (hash, mtime, processed
10
+ * byte offset, last attribution outcome, last durable upload) so repeated
11
+ * syncs skip unchanged work, growth damping has its inputs, and stale
12
+ * sessions are countable instead of silently forgotten.
13
+ *
14
+ * The cursor stores hashes, ids, sizes, and labels only — never content.
15
+ *
16
+ * Per-source sessions, ONE shared objects map (D4): each source keeps its own
17
+ * sessions file (`raw-evidence.json` for Codex, `claude-raw-evidence.json` for
18
+ * Claude) so session-id namespaces never collide and stale counts stay
19
+ * per-source. The committed-OBJECTS map lives ONLY in the primary cursor; the
20
+ * Claude cursor carries sessions only and its `objects` record is asserted
21
+ * empty on write.
22
+ */
23
+ export const PRIMARY_CURSOR_FILENAME = "raw-evidence.json";
24
+ export const CLAUDE_CURSOR_FILENAME = "claude-raw-evidence.json";
25
+ // Sidecars can push a single sync past 500 objects; pruning at 500 would force
26
+ // re-read + re-begin of durable objects every sync thereafter. Entries are
27
+ // ~150 B, so 5,000 is ~750 KB on disk (D4).
28
+ const MAX_TRACKED_OBJECTS = 5_000;
29
+ const MAX_TRACKED_SESSIONS = 5_000;
6
30
  export function emptyRawEvidenceCursorState() {
7
31
  return {
8
32
  schema_version: "cockpit-raw-evidence-cursor.v1",
@@ -11,22 +35,44 @@ export function emptyRawEvidenceCursorState() {
11
35
  sessions: {},
12
36
  };
13
37
  }
14
- export async function readRawEvidenceCursor(paths) {
38
+ export async function readRawEvidenceCursor(paths, options = {}) {
15
39
  try {
16
- const raw = JSON.parse(await fs.readFile(rawEvidenceCursorPath(paths), "utf8"));
40
+ const raw = JSON.parse(await fs.readFile(rawEvidenceCursorPath(paths, options.filename), "utf8"));
17
41
  return parseCursorState(raw);
18
42
  }
19
43
  catch {
20
44
  return emptyRawEvidenceCursorState();
21
45
  }
22
46
  }
23
- export async function writeRawEvidenceCursor(paths, state) {
24
- const filePath = rawEvidenceCursorPath(paths);
47
+ export async function writeRawEvidenceCursor(paths, state, options = {}) {
48
+ const filePath = rawEvidenceCursorPath(paths, options.filename);
25
49
  await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
26
50
  const pruned = pruneCursorState(state);
27
- await fs.writeFile(filePath, `${JSON.stringify(pruned, null, 2)}\n`, {
28
- mode: 0o600,
29
- });
51
+ if (options.sessionsOnly && Object.keys(pruned.objects).length > 0) {
52
+ // Invariant enforcement (D4): the Claude cursor never owns objects.
53
+ pruned.objects = {};
54
+ }
55
+ const serialized = `${JSON.stringify(pruned, null, 2)}\n`;
56
+ // Atomic write: a launchd sync and a manual sync can race the same cursor, so
57
+ // write a sibling temp file, fsync it, and rename over the target. A crash
58
+ // mid-write leaves either the old or the new state, never a truncated file.
59
+ const tempPath = `${filePath}.tmp-${process.pid}-${crypto.randomUUID()}`;
60
+ let handle = null;
61
+ try {
62
+ handle = await fs.open(tempPath, "w", 0o600);
63
+ await handle.writeFile(serialized);
64
+ await handle.sync();
65
+ }
66
+ finally {
67
+ await handle?.close();
68
+ }
69
+ try {
70
+ await fs.rename(tempPath, filePath);
71
+ }
72
+ catch (error) {
73
+ await fs.rm(tempPath, { force: true }).catch(() => undefined);
74
+ throw error;
75
+ }
30
76
  if (process.platform !== "win32") {
31
77
  await fs.chmod(filePath, 0o600).catch(() => undefined);
32
78
  }
@@ -37,8 +83,8 @@ export function markObjectCommitted(state, contentHash, entry) {
37
83
  export function hasCommittedObject(state, contentHash) {
38
84
  return Boolean(contentHash && state.objects[contentHash]);
39
85
  }
40
- export function recordSessionObservation(state, codexSessionId, entry) {
41
- state.sessions[codexSessionId] = entry;
86
+ export function recordSessionObservation(state, sessionId, entry) {
87
+ state.sessions[sessionId] = entry;
42
88
  }
43
89
  /**
44
90
  * Sessions tracked by the cursor that no longer appear in the current scan
@@ -115,15 +161,20 @@ function parseSessionEntry(value) {
115
161
  reason: optionalString(record["reason"]) ?? "unknown",
116
162
  worktree_fingerprint: optionalString(record["worktree_fingerprint"]),
117
163
  uploaded_object_key: optionalString(record["uploaded_object_key"]),
164
+ uploaded_at: optionalString(record["uploaded_at"]),
165
+ uploaded_byte_size: optionalNumberOrNull(record["uploaded_byte_size"]),
118
166
  last_seen_at: lastSeenAt,
119
167
  };
120
168
  }
121
- function rawEvidenceCursorPath(paths) {
122
- return path.join(paths.cursors_dir, CURSOR_FILENAME);
169
+ function rawEvidenceCursorPath(paths, filename = PRIMARY_CURSOR_FILENAME) {
170
+ return path.join(paths.cursors_dir, filename);
123
171
  }
124
172
  function optionalString(value) {
125
173
  return typeof value === "string" && value.trim() ? value : null;
126
174
  }
127
175
  function optionalNumber(value) {
128
176
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
177
+ }
178
+ function optionalNumberOrNull(value) {
179
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
129
180
  }
@@ -5,8 +5,8 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { resolveRepoWorktreeIdentity, } from "./repo-identity.js";
7
7
  import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
8
- export const LOCAL_COLLECTOR_VERSION = "0.1.6";
9
- export const DEFAULT_DASHBOARD_URL = "http://127.0.0.1:3100";
8
+ export const LOCAL_COLLECTOR_VERSION = "0.1.8";
9
+ export const DEFAULT_DASHBOARD_URL = "https://bli-cockpit-dashboard.vercel.app";
10
10
  export function getCollectorRuntimePaths(homeDir = os.homedir()) {
11
11
  const paths = getUserLocalCockpitPaths(homeDir);
12
12
  return {
@@ -58,14 +58,14 @@ export async function resolveRepoWorktreeIdentity(repoRoot) {
58
58
  }
59
59
  export async function discoverGitWorktrees(root, options = {}) {
60
60
  const resolvedRoot = path.resolve(root);
61
+ const maxWorktrees = options.maxWorktrees ?? 50;
61
62
  const direct = await resolveRepoWorktreeIdentity(resolvedRoot).catch(() => null);
62
63
  if (direct)
63
- return [direct];
64
+ return expandLinkedWorktrees([direct], maxWorktrees);
64
65
  if (await hasGitMarker(resolvedRoot)) {
65
- return [await fallbackFilesystemIdentity(resolvedRoot)];
66
+ return expandLinkedWorktrees([await fallbackFilesystemIdentity(resolvedRoot)], maxWorktrees);
66
67
  }
67
68
  const maxDepth = options.maxDepth ?? 2;
68
- const maxWorktrees = options.maxWorktrees ?? 50;
69
69
  const discovered = new Map();
70
70
  const stack = [{ dir: resolvedRoot, depth: 0 }];
71
71
  const visited = new Set();
@@ -92,7 +92,53 @@ export async function discoverGitWorktrees(root, options = {}) {
92
92
  stack.push({ dir: path.join(current.dir, entry.name), depth: current.depth + 1 });
93
93
  }
94
94
  }
95
- return [...discovered.values()].sort(compareIdentity);
95
+ return expandLinkedWorktrees([...discovered.values()].sort(compareIdentity), maxWorktrees);
96
+ }
97
+ /**
98
+ * Directory discovery skips dot-dirs, so Claude Code's isolation worktrees
99
+ * (`<repo>/.claude/worktrees/<name>`) and any other linked worktree are never
100
+ * found by walking the filesystem. A session whose cwd sits inside one would
101
+ * otherwise satisfy `isPathWithin(cwd, parentRoot)` and attribute confidently
102
+ * to the PARENT with the wrong branch and worktree fingerprint. Enumerating
103
+ * `git worktree list --porcelain` for each discovered repo adds the linked
104
+ * worktrees as first-class candidates with their own branch/fingerprint; the
105
+ * deepest-root tie-break (attribution-core D6) then attributes nested-worktree
106
+ * sessions to the correct linked worktree. Benefits Codex sessions identically.
107
+ */
108
+ async function expandLinkedWorktrees(identities, maxWorktrees) {
109
+ const byFingerprint = new Map(identities.map((identity) => [identity.worktree_fingerprint, identity]));
110
+ const seenRoots = new Set(identities.map((identity) => path.resolve(identity.repo_root)));
111
+ for (const identity of identities) {
112
+ if (byFingerprint.size >= maxWorktrees)
113
+ break;
114
+ // One `git worktree list` from any worktree returns every worktree of that
115
+ // repo, so a single call per already-discovered repo covers its linked set.
116
+ for (const worktreePath of await listLinkedWorktreePaths(identity.repo_root)) {
117
+ if (byFingerprint.size >= maxWorktrees)
118
+ break;
119
+ const resolved = path.resolve(worktreePath);
120
+ if (seenRoots.has(resolved))
121
+ continue;
122
+ seenRoots.add(resolved);
123
+ const linked = await resolveRepoWorktreeIdentity(resolved).catch(() => null);
124
+ if (linked && !byFingerprint.has(linked.worktree_fingerprint)) {
125
+ byFingerprint.set(linked.worktree_fingerprint, linked);
126
+ }
127
+ }
128
+ }
129
+ return [...byFingerprint.values()].sort(compareIdentity);
130
+ }
131
+ async function listLinkedWorktreePaths(repoRoot) {
132
+ const porcelain = await runGit(["worktree", "list", "--porcelain"], repoRoot).catch(() => "");
133
+ const paths = [];
134
+ for (const line of porcelain.split("\n")) {
135
+ if (line.startsWith("worktree ")) {
136
+ const value = line.slice("worktree ".length).trim();
137
+ if (value)
138
+ paths.push(value);
139
+ }
140
+ }
141
+ return paths;
96
142
  }
97
143
  async function hasGitMarker(dir) {
98
144
  return fs.stat(path.join(dir, ".git")).then((stat) => stat.isDirectory() || stat.isFile(), () => false);
@@ -0,0 +1,113 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ /**
5
+ * Single-flight lock for `cockpit sync`. A launchd timer and a manual sync can
6
+ * fire at the same time and interleave the cursor read-modify-write; this lock
7
+ * lets the second invocation exit cleanly with `sync_already_running` instead.
8
+ *
9
+ * A held lock refreshes a heartbeat every 30 s. Takeover happens only when the
10
+ * heartbeat is more than 2 minutes stale — a fixed short timeout would fire
11
+ * during a legitimate long first sync and recreate the interleaving it
12
+ * prevents (D33). The lock file stays local and stores a pid + heartbeat only;
13
+ * no paths or content.
14
+ */
15
+ const LOCK_FILENAME = "sync.lock";
16
+ const HEARTBEAT_INTERVAL_MS = 30_000;
17
+ const STALE_TAKEOVER_MS = 2 * 60_000;
18
+ export async function acquireSyncLock(paths, now = new Date()) {
19
+ const lockPath = path.join(paths.state_dir, LOCK_FILENAME);
20
+ await fs.mkdir(paths.state_dir, { recursive: true, mode: 0o700 });
21
+ const token = crypto.randomUUID();
22
+ const existing = await readLock(lockPath);
23
+ if (existing && now.getTime() - existing.heartbeat_ms <= STALE_TAKEOVER_MS) {
24
+ return { acquired: false, held_since: existing.heartbeat_at };
25
+ }
26
+ // Free or stale: try an exclusive create first; if another process created it
27
+ // in the gap, re-check staleness before taking over.
28
+ let owned = await tryExclusiveCreate(lockPath, token, now);
29
+ if (!owned) {
30
+ const current = await readLock(lockPath);
31
+ if (current && now.getTime() - current.heartbeat_ms <= STALE_TAKEOVER_MS) {
32
+ return { acquired: false, held_since: current.heartbeat_at };
33
+ }
34
+ await writeLock(lockPath, token, now); // take over the stale lock
35
+ owned = true;
36
+ }
37
+ const timer = setInterval(() => {
38
+ // Refresh only while the lock is still ours; if we were taken over, stop
39
+ // touching the file so we cannot steal it back from the new owner.
40
+ void (async () => {
41
+ const current = await readLock(lockPath);
42
+ if (current && current.token === token) {
43
+ await writeLock(lockPath, token, new Date()).catch(() => undefined);
44
+ }
45
+ else {
46
+ clearInterval(timer);
47
+ }
48
+ })().catch(() => undefined);
49
+ }, HEARTBEAT_INTERVAL_MS);
50
+ // Never keep the CLI process alive just to refresh the heartbeat.
51
+ timer.unref?.();
52
+ return {
53
+ acquired: true,
54
+ handle: {
55
+ release: async () => {
56
+ clearInterval(timer);
57
+ // Ownership-safe: only remove the lock if it is still ours. A stale
58
+ // owner that resumes after takeover must not delete the new owner's lock.
59
+ const current = await readLock(lockPath);
60
+ if (current && current.token === token) {
61
+ await fs.rm(lockPath, { force: true }).catch(() => undefined);
62
+ }
63
+ },
64
+ },
65
+ };
66
+ }
67
+ async function tryExclusiveCreate(lockPath, token, now) {
68
+ try {
69
+ const handle = await fs.open(lockPath, "wx", 0o600);
70
+ await handle.writeFile(serializeLock(token, now));
71
+ await handle.close();
72
+ return true;
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ async function writeLock(lockPath, token, now) {
79
+ await fs.writeFile(lockPath, serializeLock(token, now), { mode: 0o600 });
80
+ }
81
+ function serializeLock(token, now) {
82
+ const record = {
83
+ pid: process.pid,
84
+ token,
85
+ heartbeat_at: now.toISOString(),
86
+ heartbeat_ms: now.getTime(),
87
+ };
88
+ return `${JSON.stringify(record)}\n`;
89
+ }
90
+ async function readLock(lockPath) {
91
+ try {
92
+ const raw = JSON.parse(await fs.readFile(lockPath, "utf8"));
93
+ if (!raw || typeof raw !== "object")
94
+ return null;
95
+ const record = raw;
96
+ const heartbeatMs = record["heartbeat_ms"];
97
+ const heartbeatAt = record["heartbeat_at"];
98
+ if (typeof heartbeatMs !== "number" || !Number.isFinite(heartbeatMs)) {
99
+ return null;
100
+ }
101
+ return {
102
+ pid: typeof record["pid"] === "number" ? record["pid"] : -1,
103
+ token: typeof record["token"] === "string" ? record["token"] : "",
104
+ heartbeat_at: typeof heartbeatAt === "string"
105
+ ? heartbeatAt
106
+ : new Date(heartbeatMs).toISOString(),
107
+ heartbeat_ms: heartbeatMs,
108
+ };
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ }
package/dist/upload.js CHANGED
@@ -55,9 +55,15 @@ export async function buildLocalAmbientEnvelope(options = {}) {
55
55
  activeWorkContext: activeContext,
56
56
  rawEvidenceStateDir: paths.state_dir,
57
57
  rawEvidenceSessionsDir: path.join(paths.home_dir, ".codex", "sessions"),
58
+ claudeProjectsDir: path.join(paths.home_dir, ".claude", "projects"),
58
59
  rawEvidenceIncludeCodexJsonl: options.rawEvidenceIncludeCodexJsonl,
60
+ rawEvidenceIncludeClaudeJsonl: options.rawEvidenceIncludeClaudeJsonl,
59
61
  rawEvidenceCodexSessionFiles: options.codexSessionFiles,
62
+ rawEvidenceClaudeSessionFiles: options.claudeSessionFiles,
60
63
  rawEvidenceSkipContentHashes: skipContentHashes,
64
+ rawEvidenceByteBudget: options.rawEvidenceByteBudget,
65
+ rawEvidenceObjectBudget: options.rawEvidenceObjectBudget,
66
+ rawEvidenceBudget: options.rawEvidenceBudget,
61
67
  now,
62
68
  });
63
69
  const binding = sourceCollection.binding;
@@ -356,6 +362,8 @@ function rawEvidenceSummary(built, outcomes, uploadedChunkCount, cursor) {
356
362
  })),
357
363
  ...cursorReusedOutcomes,
358
364
  ],
365
+ raw_evidence_deferred_byte_budget: built.raw_evidence_facts?.deferred_byte_budget_count ?? 0,
366
+ raw_evidence_deferred_object_budget: built.raw_evidence_facts?.deferred_object_budget_count ?? 0,
359
367
  cursor_tracked_object_count: Object.keys(cursor.objects).length,
360
368
  };
361
369
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,6 +26,6 @@
26
26
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
27
27
  },
28
28
  "dependencies": {
29
- "@bli-cockpit/telemetry-core": "0.1.3"
29
+ "@bli-cockpit/telemetry-core": "0.1.4"
30
30
  }
31
31
  }