@bli-cockpit/cli 0.2.294 → 0.2.295

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.
@@ -19,7 +19,7 @@ import { roomToStreamToStaging } from "./raw-evidence-budget-admission.js";
19
19
  import { readTranscriptWhole, streamTranscriptBody, volatilityOf } from "./raw-evidence-transcript-stream.js";
20
20
  /** Which agent's transcript rules apply to this evidence kind; null for anything the image step does not know. */
21
21
  export function agentOfKind(kind) {
22
- return kind === "opencode_jsonl" ? "opencode" : kind.startsWith("codex") ? "codex" : kind.startsWith("claude") ? "claude_code" : null;
22
+ return kind === "pi_jsonl" ? "pi" : kind === "opencode_jsonl" ? "opencode" : kind.startsWith("codex") ? "codex" : kind.startsWith("claude") ? "claude_code" : null;
23
23
  }
24
24
  /** Decode once and release the bytes, unless the decoding was lossy and the bytes are the only exact copy. */
25
25
  export function holdDecodedText(source) {
@@ -56,7 +56,7 @@ function releaseBackingStore(bytes) {
56
56
  * the caller turns into the `file_read_failed` and `file_too_large` skips.
57
57
  */
58
58
  export async function prepareTranscriptBody(collection, file) {
59
- if (file.kind === "opencode_jsonl" && file.source.stream) {
59
+ if ((file.kind === "opencode_jsonl" || file.kind === "pi_jsonl") && file.source.stream) {
60
60
  const source = file.source.stream;
61
61
  source.openSource ??= () => openPinnedTranscript(source.filePath, { maxFileBytes: source.maxFileBytes });
62
62
  }
@@ -311,7 +311,7 @@ function logDeliveryBackoffHold(kind, sourceKey) {
311
311
  }
312
312
  /** The size from which this pass streams a transcript instead of reading it whole; null when its image session cannot (BLI-23317). */
313
313
  function streamMinBytesFor(collection, kind) {
314
- if (kind === "opencode_jsonl")
314
+ if ((kind === "opencode_jsonl" || kind === "pi_jsonl"))
315
315
  return 1024 * 1024;
316
316
  const session = collection.budget.images;
317
317
  if (!session?.prepareTranscriptFile || !agentOfKind(kind))
@@ -145,12 +145,12 @@ async function collectEverySource(collection, options, sinceMinutes) {
145
145
  await collectClaudeJsonlFiles(collection, options.claudeSessionFiles);
146
146
  }
147
147
  for (const file of options.nativeSessionFiles ?? []) {
148
- recordScanned(collection, "opencode_jsonl");
148
+ recordScanned(collection, file.source === "pi" ? "pi_jsonl" : "opencode_jsonl");
149
149
  await collectOneEvidenceFile(collection, {
150
- filePath: file.local_path, kind: "opencode_jsonl", sessionId: file.session_id, priorRedaction: file.redaction,
150
+ filePath: file.local_path, kind: file.source === "pi" ? "pi_jsonl" : "opencode_jsonl", sessionId: file.session_id, priorRedaction: file.redaction,
151
151
  mediaType: "application/jsonl", maxFileBytes: RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES,
152
- redactedSummary: "OpenCode native session with retained messages, tools, usage and unknown records.",
153
- contentAddress: (hash16) => `opencode/${safeKeySegment(file.session_id)}/${hash16}.jsonl`,
152
+ redactedSummary: "Native coding session with retained messages, tools, usage and unknown records.",
153
+ contentAddress: (hash16) => `${file.source}/${safeKeySegment(file.session_id)}/${hash16}.jsonl`,
154
154
  });
155
155
  }
156
156
  await collectGitDiffFiles(collection, options.repoRoot);
@@ -16,6 +16,12 @@ export function writeDryRunSummary(io, options) {
16
16
  writeLine(io.stdout, `Verify after upload: ${options.dashboardUrl}/my-work`);
17
17
  }
18
18
  export function writeHumanBackfillResult(result, io) {
19
+ if (result.pi) {
20
+ const source = result.pi;
21
+ writeLine(io.stdout, `Pi: ${source.discovered} discovered, ${source.eligible} eligible, ${source.delivered} delivered, ${source.pending} pending, ${source.withheld} withheld (${source.coverage}).`);
22
+ for (const reason of source.reasons)
23
+ writeLine(io.stderr, `Pi: ${reason}`);
24
+ }
19
25
  if (result.opencode) {
20
26
  const source = result.opencode;
21
27
  writeLine(io.stdout, `OpenCode: ${source.discovered} discovered, ${source.eligible} eligible, ${source.delivered} delivered, ${source.pending} pending, ${source.withheld} withheld (${source.coverage}).`);
@@ -98,7 +98,7 @@ async function discoverBackfillWorktrees(roots, command) {
98
98
  });
99
99
  }
100
100
  export function selectedSources(source) {
101
- if (source === "opencode")
101
+ if (source === "opencode" || source === "pi")
102
102
  return []; // Native inventory and cursor are handled by the shared native pass.
103
103
  if (source === "codex")
104
104
  return ["codex"];
@@ -38,6 +38,7 @@
38
38
  * misjudged "resolved" loses that history for good.
39
39
  */
40
40
  import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET } from "../adapters/raw-evidence.js";
41
+ import { syncPiSessions } from "./pi-sync.js";
41
42
  import { syncOpenCodeSessions } from "./opencode-sync.js";
42
43
  import { acquireBackfillLock } from "../backfill-lock.js";
43
44
  import { writeBackfillCursor } from "../cursors/backfill-cursor.js";
@@ -115,6 +116,12 @@ export async function runBackfill(command, io) {
115
116
  if (plan.result.opencode.coverage === "partial")
116
117
  plan.result.status = "partial";
117
118
  }
119
+ if (command.dryRun && (!command.source || command.source === "pi") && plan.result.status !== "blocked") {
120
+ plan.result.pi = await syncPiSessions({ homeDir: scanned.paths.home_dir, worktrees: scanned.worktrees, collectionRoots: scanned.collectionRoots, dashboardUrl: scanned.dashboardUrl, fetchImpl: io.fetch ?? globalThis.fetch, dryRun: true,
121
+ sinceMs: command.all ? undefined : now.getTime() - (command.sinceDays ?? DEFAULT_BACKFILL_SINCE_DAYS) * 86400000 });
122
+ if (plan.result.pi.coverage === "partial")
123
+ plan.result.status = "partial";
124
+ }
118
125
  return plan.result;
119
126
  }
120
127
  const { paths } = scanned;
@@ -159,9 +166,17 @@ export async function runBackfill(command, io) {
159
166
  const opencode = (!command.source || command.source === "opencode") ? await syncOpenCodeSessions({ homeDir: paths.home_dir, worktrees: scanned.worktrees, collectionRoots: scanned.collectionRoots,
160
167
  dashboardUrl: scanned.dashboardUrl, fetchImpl: io.fetch ?? globalThis.fetch, budget, deadlineMs: command.deadlineMs,
161
168
  sinceMs: command.all ? undefined : now.getTime() - (command.sinceDays ?? DEFAULT_BACKFILL_SINCE_DAYS) * 86400000 }) : undefined;
162
- const result = await reportBackfillOutcome(command, io, scanned, upload, opencode?.coverage === "partial");
169
+ const pi = (!command.source || command.source === "pi") ? await syncPiSessions({ homeDir: paths.home_dir, worktrees: scanned.worktrees, collectionRoots: scanned.collectionRoots,
170
+ dashboardUrl: scanned.dashboardUrl, fetchImpl: io.fetch ?? globalThis.fetch, budget, deadlineMs: command.deadlineMs,
171
+ sinceMs: command.all ? undefined : now.getTime() - (command.sinceDays ?? DEFAULT_BACKFILL_SINCE_DAYS) * 86400000 }) : null;
172
+ const result = await reportBackfillOutcome(command, io, scanned, upload, opencode?.coverage === "partial" || pi?.coverage === "partial");
163
173
  if (opencode)
164
174
  result.opencode = opencode;
175
+ if (pi) {
176
+ result.pi = pi;
177
+ if (pi.coverage === "partial" && opencode?.coverage !== "partial" && result.failure_reason === "opencode_backfill_pending")
178
+ result.failure_reason = pi.reasons[0] ?? "pi_backfill_pending";
179
+ }
165
180
  return result;
166
181
  }
167
182
  finally {
@@ -38,6 +38,12 @@ export function writeAgentSessionSummary(io, summary) {
38
38
  for (const reason of source.reasons)
39
39
  writeLine(io.stdout, `OpenCode: ${reason}`);
40
40
  }
41
+ if (summary.pi) {
42
+ const source = summary.pi;
43
+ writeLine(io.stdout, `Pi sessions: ${source.discovered} discovered, ${source.eligible} eligible, ${source.delivered} delivered, ${source.pending} pending, ${source.withheld} withheld (${source.coverage}).`);
44
+ for (const reason of source.reasons)
45
+ writeLine(io.stdout, `Pi: ${reason}`);
46
+ }
41
47
  const claudeDiagnostics = claudeDiagnosticsLine(summary);
42
48
  if (claudeDiagnostics)
43
49
  writeLine(io.stdout, claudeDiagnostics);
@@ -69,8 +69,8 @@ export function parseSessionsArgs(args) {
69
69
  });
70
70
  assertNoPositionals(values.positionals, "sessions");
71
71
  const source = values.flags.get("--source");
72
- if (source !== undefined && source !== "codex" && source !== "claude" && source !== "opencode") {
73
- throw new Error("--source must be 'codex', 'claude' or 'opencode'. Omit it to scan all sources.");
72
+ if (source !== undefined && source !== "codex" && source !== "claude" && source !== "opencode" && source !== "pi") {
73
+ throw new Error("--source must be 'codex', 'claude', 'opencode' or 'pi'. Omit it to scan all sources.");
74
74
  }
75
75
  const sinceDays = optionalPositiveInteger(values.flags.get("--since-days"), "--since-days");
76
76
  const all = values.booleans.has("--all");
@@ -151,8 +151,8 @@ export function parseBackfillArgs(args) {
151
151
  });
152
152
  assertNoPositionals(values.positionals, "backfill");
153
153
  const source = values.flags.get("--source");
154
- if (source !== undefined && source !== "codex" && source !== "claude" && source !== "opencode") {
155
- throw new Error("--source must be 'codex', 'claude' or 'opencode'. Omit it to scan all sources.");
154
+ if (source !== undefined && source !== "codex" && source !== "claude" && source !== "opencode" && source !== "pi") {
155
+ throw new Error("--source must be 'codex', 'claude', 'opencode' or 'pi'. Omit it to scan all sources.");
156
156
  }
157
157
  const sinceDays = optionalPositiveInteger(values.flags.get("--since-days"), "--since-days");
158
158
  const all = values.booleans.has("--all");
@@ -146,9 +146,9 @@ const COMMAND_SUMMARY_LINES = [
146
146
  " cockpit correct --claim <claimId> --text \"<what is wrong>\" [--for <person>] [--version <pageId>] [--supersedes <id>] [--dashboard-url <url>] [--json]",
147
147
  " cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|summarize <id>|move <id>|place <id>] [--folder <path>] [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--file <path>] [--name <n>] [--exclude \"<sentence>\"] [--to \"<shelf>\"|--clear-shelf] [--apply] [--yes] [--dashboard-url <url>] [--json]",
148
148
  " cockpit notes [accounts [--show-webhook <account-id>]|connect --provider fellow --workspace <sub> --key-stdin [--name <n>]|connect --provider wisprflow|recordings <note-id>|recording <id>|webhook <account-id> --secret-stdin|detach <account-id>|sync <account-id>] [--json] (the key and the signing secret are read from stdin, never a flag)",
149
- " cockpit backfill (--since-days <n>|--all) [--source codex|claude|opencode] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--lock-wait <seconds>] [--yes] [--workspace <path>] [--json]",
149
+ " cockpit backfill (--since-days <n>|--all) [--source codex|claude|opencode|pi] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--lock-wait <seconds>] [--yes] [--workspace <path>] [--json]",
150
150
  " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
151
- " cockpit sessions [--source codex|claude|opencode] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
151
+ " cockpit sessions [--source codex|claude|opencode|pi] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
152
152
  " cockpit serve [--port <port>] [--workspace <path>]",
153
153
  " cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
154
154
  " cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
@@ -0,0 +1,167 @@
1
+ /** Route Pi's third native format through the existing evidence pack, spool, report queue and durable cursor. */
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, startLocalWorkContext, startLocalWorkContextForAttributedTarget } from "../local-state.js";
5
+ import { writeJsonFileAtomic } from "../local-state-files.js";
6
+ import { reportShelvedSessions } from "../shelf-reports.js";
7
+ import { piCollectionPolicy } from "../pi-policy.js";
8
+ import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET } from "../adapters/raw-evidence.js";
9
+ import { cwdInsideRoots } from "../collection-scope.js";
10
+ import { scoreSignalsAgainstWorktrees, sha256 } from "../adapters/attribution-core.js";
11
+ import { outsideRootsWorkspace, isOutsideRootsWorkspace } from "./backfill-outside-roots.js";
12
+ import { syncLocalAmbientEnvelope } from "../upload.js";
13
+ import { readRawEvidenceCursor, writeRawEvidenceCursor } from "../cursors/raw-evidence-cursor.js";
14
+ import { readPiInventory, acknowledgePiUsage, PI_CURSOR_FILENAME } from "../pi-inventory.js";
15
+ import { queueCodexSessionReport, flushPendingCodexSessionReports } from "../upload-session-reports.js";
16
+ import { recordSourceRetryFailure, clearSourceRetryFailure } from "../spool/local-spool.js";
17
+ import { describeError } from "../health-detail.js";
18
+ /** The all-history inventory is resumable through existing per-object acknowledgments, never a second delivery engine. */
19
+ export async function syncPiSessions(options) {
20
+ const paths = getCollectorRuntimePaths(options.homeDir);
21
+ const now = new Date();
22
+ const summary = { source: "pi", new_since_last_scan: 0, outside_roots: 0, discovered: 0, eligible: 0, delivered: 0, pending: 0, withheld: 0, unique: 0, coverage: "complete", reasons: [] };
23
+ try {
24
+ const policy = await piCollectionPolicy(options);
25
+ if (!policy.enabled) {
26
+ const held = await readPiInventory({ homeDir: options.homeDir, stateDir: paths.state_dir, metadataOnly: true, include: () => false, sinceMs: options.sinceMs });
27
+ summary.discovered = held.discovered;
28
+ summary.unique = held.discovered;
29
+ summary.withheld = held.withheld;
30
+ summary.coverage = held.failures.length || policy.reason === "owner_check_unavailable" ? "partial" : held.absent ? "absent" : "withheld";
31
+ summary.reasons.push(policy.reason, ...held.failures.map(failure => failure.reason));
32
+ if (summary.coverage === "partial")
33
+ await recordSourceRetryFailure(paths, { source: "pi", attemptedAt: now.toISOString(), reason: held.failures[0]?.reason ?? policy.reason });
34
+ console.error("[pi sync] source withheld", JSON.stringify(summary));
35
+ return summary;
36
+ }
37
+ const attributed = (directory) => scoreSignalsAgainstWorktrees({ cwds: [directory], workspaceRoots: [], originUrls: [], branches: [], headShas: [] }, options.worktrees, { collectionRoots: options.collectionRoots });
38
+ const cursor = await readRawEvidenceCursor(paths, { filename: PI_CURSOR_FILENAME, sessionsOnly: true });
39
+ const inventory = await readPiInventory({ homeDir: options.homeDir, stateDir: paths.state_dir, metadataOnly: options.dryRun, sinceMs: options.sinceMs, deliveredSessionIds: new Set(Object.entries(cursor.sessions).filter(([, entry]) => entry.uploaded_object_key).map(([id]) => id)), include: policy.include, countOnly: policy.drawer });
40
+ summary.discovered = inventory.discovered;
41
+ summary.withheld = inventory.withheld;
42
+ summary.eligible = inventory.eligible;
43
+ summary.pending = inventory.eligible - inventory.files.length;
44
+ summary.unique = inventory.discovered;
45
+ summary.reasons.push(...new Set(inventory.failures.map((failure) => failure.reason)), ...new Set(inventory.files.flatMap(file => file.pending_reasons)));
46
+ if (inventory.absent)
47
+ summary.coverage = "absent";
48
+ if (options.dryRun) {
49
+ summary.pending = inventory.eligible;
50
+ if (inventory.failures.length)
51
+ summary.coverage = "partial";
52
+ return summary;
53
+ }
54
+ if (inventory.shelved.length) {
55
+ const [device, config] = await Promise.all([readLocalCollectorSessionFile(paths), readLocalCollectorConfig(paths)]);
56
+ const shelfReport = await reportShelvedSessions({ paths, dashboardUrl: options.dashboardUrl ?? config?.dashboard_url ?? null,
57
+ deviceToken: device?.device_token ?? null, fetchImpl: options.fetchImpl, allow: config?.shelf_metrics_allow ?? [], now,
58
+ shelved: inventory.shelved.map(file => ({ key: `pi:${file.session.id}`, source: "pi", reason: "drawer_shelf", facts: file.facts, recencyMs: file.session.updated_at_ms })) });
59
+ if (shelfReport.failed || shelfReport.not_counted)
60
+ summary.reasons.push("pi_shelf_metrics_pending");
61
+ }
62
+ summary.new_since_last_scan = inventory.files.filter((file) => !cursor.sessions[file.session.id]).length;
63
+ summary.outside_roots = inventory.files.filter((file) => !cwdInsideRoots(file.session.directory, options.collectionRoots)).length;
64
+ const durableSessions = new Set();
65
+ const groups = new Map();
66
+ for (const file of inventory.files) {
67
+ const worktree = attributed(file.session.directory).worktree ?? outsideRootsWorkspace(options.homeDir);
68
+ const group = groups.get(worktree.worktree_fingerprint) ?? { worktree, files: [] };
69
+ group.files.push(file);
70
+ groups.set(worktree.worktree_fingerprint, group);
71
+ }
72
+ const budget = options.budget ?? { remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET };
73
+ for (const { worktree, files } of groups.values()) {
74
+ if (options.deadlineMs && Date.now() >= options.deadlineMs) {
75
+ summary.reasons.push("pi_deadline_deferred");
76
+ summary.pending = summary.eligible - summary.delivered;
77
+ break;
78
+ }
79
+ if (isOutsideRootsWorkspace(worktree))
80
+ await fs.mkdir(worktree.repo_root, { recursive: true, mode: 0o700 });
81
+ const contextOptions = { homeDir: options.homeDir, repoRoot: worktree.repo_root, branch: worktree.branch, leaveActiveContext: true };
82
+ const context = isOutsideRootsWorkspace(worktree) ? await startLocalWorkContextForAttributedTarget(contextOptions, worktree) : await startLocalWorkContext(contextOptions);
83
+ const sync = await syncLocalAmbientEnvelope({ homeDir: options.homeDir, repoRoot: worktree.repo_root, dashboardUrl: options.dashboardUrl,
84
+ rawEvidenceBudget: budget, codexSessionFiles: [], claudeSessionFiles: [], nativeSessionFiles: files.map((file) => ({ local_path: file.local_path, session_id: file.session.id, source: "pi", redaction: file.redaction })), fetch: options.fetchImpl });
85
+ if (sync.status !== "uploaded")
86
+ summary.reasons.push(sync.failure_reason);
87
+ const rows = files.map((file) => {
88
+ const attribution = attributed(file.session.directory);
89
+ const outcome = sync.raw_evidence_outcomes.find((item) => item.kind === "pi_jsonl" && item.codex_session_id === file.session.id);
90
+ const pointer = outcome && (outcome.upload_state === "uploaded" || outcome.upload_state === "reused_existing") ? outcome.raw_evidence_pointer_id : null;
91
+ if (!pointer)
92
+ summary.reasons.push(outcome?.reason ?? "pi_delivery_pending");
93
+ return { source: "pi", codex_session_id: file.session.id, observed_at: now.toISOString(), attribution_state: attribution.state,
94
+ attribution_reason: attribution.reason, attribution_score: attribution.attribution_score, path_score: attribution.path_score, signals: attribution.signals,
95
+ session_file_hash_sha256: file.revision, session_file_byte_size: file.byte_size, session_file_mtime: new Date(file.session.updated_at_ms).toISOString(), session_facts: file.facts,
96
+ repo_fingerprint: attribution.worktree?.repo_fingerprint, worktree_fingerprint: attribution.worktree?.worktree_fingerprint,
97
+ repo_label: attribution.worktree?.repo_label, branch: attribution.worktree?.branch, cwd_basename: path.basename(file.session.directory), cwd_hash: sha256(Buffer.from(file.session.directory)),
98
+ root_scope: cwdInsideRoots(file.session.directory, options.collectionRoots) ? "inside" : "outside",
99
+ raw_evidence_pointer_id: pointer ?? undefined, upload_state: pointer ? "uploaded" : "not_uploaded", upload_reason: pointer ? "durable_evidence_acknowledged" : outcome?.reason ?? "pi_delivery_pending" };
100
+ });
101
+ // Durable metadata queue precedes cursor movement; an unavailable report endpoint cannot age sessions out.
102
+ await queueCodexSessionReport({ homeDir: options.homeDir, dashboardUrl: sync.dashboard_url, generatedAt: now.toISOString(), workContextId: context.work_context_id,
103
+ repoLabel: worktree.repo_label, branch: worktree.branch, repoFingerprint: worktree.repo_fingerprint, repoOriginUrl: worktree.repo_origin_url,
104
+ worktreeLabel: worktree.worktree_label, worktreeFingerprint: worktree.worktree_fingerprint, worktreeIsPrimary: worktree.worktree_is_primary, sessions: rows });
105
+ for (const [index, file] of files.entries()) {
106
+ const row = rows[index];
107
+ const pointer = row.raw_evidence_pointer_id ?? null;
108
+ if (pointer) {
109
+ summary.delivered += 1;
110
+ durableSessions.add(file.session.id);
111
+ }
112
+ else
113
+ summary.pending += 1;
114
+ // Acknowledged bytes can be retained while a partial or racing native observation stays retryable.
115
+ if (file.pending_reasons.some(reason => reason === "pi_source_changed_during_read" || reason === "pi_partial_native_record"))
116
+ continue;
117
+ cursor.sessions[file.session.id] = { file_hash_sha256: file.revision, file_mtime_ms: file.session.updated_at_ms, byte_size: file.byte_size, byte_offset: pointer ? file.byte_size : 0,
118
+ state: row.attribution_state, reason: row.attribution_reason, worktree_fingerprint: row.worktree_fingerprint ?? null, uploaded_object_key: pointer,
119
+ uploaded_at: pointer ? now.toISOString() : null, uploaded_byte_size: pointer ? file.byte_size : null, last_seen_at: now.toISOString() };
120
+ }
121
+ cursor.updated_at = now.toISOString();
122
+ // Metadata acknowledgment below owns cursor promotion.
123
+ }
124
+ const report = await flushPendingCodexSessionReports({ homeDir: options.homeDir, fetch: options.fetchImpl });
125
+ if (report.posted && report.failed_count === 0) {
126
+ await writeRawEvidenceCursor(paths, cursor, { filename: PI_CURSOR_FILENAME, sessionsOnly: true });
127
+ await acknowledgePiUsage(paths.state_dir, durableSessions);
128
+ // The shared pack/spool owns acknowledged bytes now; these are disposable redacted reader derivatives.
129
+ for (const file of inventory.files)
130
+ if (durableSessions.has(file.session.id))
131
+ await fs.unlink(file.local_path).catch(() => undefined);
132
+ }
133
+ if (inventory.files.length && (!report.posted || report.failed_count > 0)) {
134
+ summary.reasons.push(report.reason);
135
+ summary.pending += summary.delivered;
136
+ summary.delivered = 0;
137
+ }
138
+ summary.reasons = [...new Set(summary.reasons)];
139
+ if (summary.pending || summary.reasons.length)
140
+ summary.coverage = "partial";
141
+ if (summary.coverage === "partial")
142
+ await recordSourceRetryFailure(paths, { source: "pi", attemptedAt: now.toISOString(), reason: summary.reasons[0] ?? "pi_delivery_pending" });
143
+ else
144
+ await clearSourceRetryFailure(paths, "pi", now.toISOString());
145
+ }
146
+ catch (error) {
147
+ summary.pending = Math.max(summary.pending, summary.eligible - summary.delivered);
148
+ summary.coverage = "partial";
149
+ summary.reasons.push("pi_sync_failed");
150
+ await recordSourceRetryFailure(paths, { source: "pi", attemptedAt: now.toISOString(), reason: "pi_sync_failed" });
151
+ console.error("[pi sync] pass failed; native sessions remain retryable", JSON.stringify({ reason: "pi_sync_failed", ...describeError(error) }));
152
+ }
153
+ finally {
154
+ if (!options.dryRun) {
155
+ try {
156
+ await writeJsonFileAtomic(path.join(paths.state_dir, "pi-collection-status.json"), { observed_at: now.toISOString(), ...summary });
157
+ }
158
+ catch (error) {
159
+ summary.coverage = "partial";
160
+ summary.reasons.push("pi_coverage_receipt_write_failed");
161
+ console.error("[pi sync] coverage receipt not written", JSON.stringify({ reason: "pi_coverage_receipt_write_failed", ...describeError(error) }));
162
+ }
163
+ }
164
+ }
165
+ console.error("[pi sync] pass finished", JSON.stringify(summary));
166
+ return summary;
167
+ }
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.294");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.295");
25
25
  return 0;
26
26
  }
27
27
 
@@ -174,4 +174,8 @@ export function recordUnexplainedFailure(ledger) {
174
174
  /** Keep native partial outcomes in the same closed failure ledger as legacy sources. */
175
175
  export function recordOpenCodeFailures(ledger, native) {
176
176
  ledger.fail(native.coverage === "partial", "opencode_scan_read_failed", native.reasons[0] ?? "opencode_delivery_pending");
177
+ }
178
+ /** Keep Pi's named partial outcomes inside the single failure vocabulary writer. */
179
+ export function recordPiFailures(ledger, native) {
180
+ ledger.fail(native.coverage === "partial", "pi_scan_read_failed", native.reasons[0] ?? "pi_delivery_pending");
177
181
  }
@@ -1,4 +1,4 @@
1
- import { createSyncFailureLedger, recordOpenCodeFailures } from "./session-sync-failures.js";
1
+ import { createSyncFailureLedger, recordOpenCodeFailures, recordPiFailures } from "./session-sync-failures.js";
2
2
  import { readLocalWorkContextForRepo } from "../local-state.js";
3
3
  /**
4
4
  * One attributed sync pass: scan both session stores, attribute what they hold,
@@ -41,6 +41,7 @@ import { readLocalWorkContextForRepo } from "../local-state.js";
41
41
  */
42
42
  import os from "node:os";
43
43
  import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET } from "../adapters/raw-evidence.js";
44
+ import { syncPiSessions } from "./pi-sync.js";
44
45
  import { syncOpenCodeSessions } from "./opencode-sync.js";
45
46
  import { getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile } from "../local-state.js";
46
47
  import { reportShelvedSessions } from "../shelf-reports.js";
@@ -145,21 +146,23 @@ export async function runAttributedWorktreeSync(requested) {
145
146
  const shelfReports = await sendShelfReports(options, paths, shelfSplit, now);
146
147
  const run = await finishAttributedSync(options, { paths, now, plan, scan, sessions, worktreePass });
147
148
  const opencode = await syncOpenCodeSessions({ homeDir, worktrees: requested.worktrees, collectionRoots: plan.collectionRoots, dashboardUrl: requested.dashboardUrl, fetchImpl: requested.fetchImpl, budget: options.rawEvidenceBudget });
149
+ const pi = await syncPiSessions({ homeDir, worktrees: requested.worktrees, collectionRoots: plan.collectionRoots, dashboardUrl: requested.dashboardUrl, fetchImpl: requested.fetchImpl, budget: options.rawEvidenceBudget });
148
150
  const failures = createSyncFailureLedger();
151
+ recordPiFailures(failures, pi);
149
152
  for (const failure of run.failure_records)
150
153
  failures.add(failure);
151
154
  recordOpenCodeFailures(failures, opencode);
152
155
  return {
153
156
  ...run,
154
- opencode,
155
- summary: { ...run.summary, opencode },
156
- sessions_new_this_tick: run.sessions_new_this_tick + opencode.new_since_last_scan,
157
- sessions_outside_root: run.sessions_outside_root + opencode.outside_roots,
158
- ok: run.ok && opencode.coverage !== "partial",
159
- failure_reasons: [...run.failure_reasons, ...(opencode.coverage === "partial" ? opencode.reasons.length ? opencode.reasons : ["opencode_delivery_pending"] : [])],
157
+ opencode, pi,
158
+ summary: { ...run.summary, opencode, pi },
159
+ sessions_new_this_tick: run.sessions_new_this_tick + opencode.new_since_last_scan + pi.new_since_last_scan,
160
+ sessions_outside_root: run.sessions_outside_root + opencode.outside_roots + pi.outside_roots,
161
+ ok: run.ok && opencode.coverage !== "partial" && pi.coverage !== "partial",
162
+ failure_reasons: [...run.failure_reasons, ...(pi.coverage === "partial" ? pi.reasons : []), ...(opencode.coverage === "partial" ? opencode.reasons.length ? opencode.reasons : ["opencode_delivery_pending"] : [])],
160
163
  failure_records: failures.sortedRecords(),
161
- sessions_observed: run.sessions_observed + opencode.eligible,
162
- sessions_pending_upload: run.sessions_pending_upload + opencode.pending,
164
+ sessions_observed: run.sessions_observed + opencode.eligible + pi.eligible,
165
+ sessions_pending_upload: run.sessions_pending_upload + opencode.pending + pi.pending,
163
166
  sessions_shelved: shelfSplit.shelved.length,
164
167
  packs_refused: worktreePass.packsRefused ?? 0,
165
168
  shelf_reports: shelfReports,
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import os from "node:os";
11
11
  import path from "node:path";
12
+ import { readPiInventory } from "../pi-inventory.js";
12
13
  import { readOpenCodeInventory } from "../opencode-inventory.js";
13
14
  import { scoreSignalsAgainstWorktrees } from "../adapters/attribution-core.js";
14
15
  import { writeLine } from "./cli-io.js";
@@ -41,15 +42,30 @@ export async function runSessions(command, io) {
41
42
  return { source: "opencode", session_id: session.id, state: attributed.state, reason: attributed.reason, attribution_score: attributed.attribution_score,
42
43
  path_score: attributed.path_score, signals: attributed.signals, repo_label: attributed.worktree?.repo_label ?? null };
43
44
  }) } : null;
45
+ const piNative = !command.source || command.source === "pi" ? await readPiInventory({ homeDir,
46
+ stateDir: getCollectorRuntimePaths(homeDir).state_dir, metadataOnly: true,
47
+ sinceMs: window.since_minutes === null ? undefined : now.getTime() - window.since_minutes * 60_000 }) : null;
48
+ const pi = piNative && (!piNative.absent || piNative.failures.length) ? { counts: { observed_count: piNative.discovered, read_failed: piNative.failures.length },
49
+ reasons: [...new Set(piNative.failures.map(failure => failure.reason))], sessions: piNative.sessions.map(session => {
50
+ const attributed = scoreSignalsAgainstWorktrees({ cwds: [session.directory], workspaceRoots: [], originUrls: [], branches: [], headShas: [] }, worktrees);
51
+ return { source: "pi", session_id: session.id, state: attributed.state, reason: attributed.reason, attribution_score: attributed.attribution_score,
52
+ path_score: attributed.path_score, signals: attributed.signals, repo_label: attributed.worktree?.repo_label ?? null };
53
+ }) } : null;
44
54
  // BLI-3483: the scanners have always counted the folders and files they
45
55
  // could not read, and this command has always thrown those counts away
46
56
  // except for one field in `--json`. So "No sessions observed" was printed on
47
57
  // a machine where the scan had been blocked from looking — the operator's
48
58
  // one diagnostic tool answering the question with the failure removed.
49
59
  const readFailures = countReadFailures(codex, claude);
50
- readFailures.total += native?.failures.length ?? 0;
60
+ readFailures.total += (native?.failures.length ?? 0) + (piNative?.failures.length ?? 0);
51
61
  if (command.json) {
52
- return writeSessionJson(io, window, readFailures, codex, claude, codexRows, claudeRows, opencode);
62
+ return writeSessionJson(io, window, readFailures, codex, claude, codexRows, claudeRows, opencode, pi);
63
+ }
64
+ if (pi) {
65
+ for (const row of pi.sessions)
66
+ writeLine(io.stdout, sessionRowLine(row));
67
+ for (const reason of pi.reasons)
68
+ writeLine(io.stdout, `Pi read incomplete: ${reason}`);
53
69
  }
54
70
  if (opencode) {
55
71
  for (const row of opencode.sessions)
@@ -57,7 +73,7 @@ export async function runSessions(command, io) {
57
73
  for (const reason of opencode.reasons)
58
74
  writeLine(io.stdout, `OpenCode read incomplete: ${reason}`);
59
75
  }
60
- return writeSessionText(io, window, readFailures, codexRows, claudeRows, opencode?.sessions.length ?? 0);
76
+ return writeSessionText(io, window, readFailures, codexRows, claudeRows, (opencode?.sessions.length ?? 0) + (pi?.sessions.length ?? 0));
61
77
  }
62
78
  function writeSessionText(io, window, readFailures, codexRows, claudeRows, nativeCount = 0) {
63
79
  writeLine(io.stdout, "TOWER sessions (read-only attribution)");
@@ -84,7 +100,7 @@ function writeSessionText(io, window, readFailures, codexRows, claudeRows, nativ
84
100
  return 0;
85
101
  }
86
102
  async function scanSessionSources(command, homeDir, worktrees, now, window) {
87
- const codex = command.source !== "claude" && command.source !== "opencode"
103
+ const codex = command.source !== "claude" && command.source !== "opencode" && command.source !== "pi"
88
104
  ? await scanAndAttributeCodexSessions({
89
105
  sessionsDirs: defaultCodexSessionDirs(homeDir),
90
106
  worktrees,
@@ -93,7 +109,7 @@ async function scanSessionSources(command, homeDir, worktrees, now, window) {
93
109
  limit: window.limit,
94
110
  })
95
111
  : null;
96
- const claude = command.source !== "codex" && command.source !== "opencode"
112
+ const claude = command.source !== "codex" && command.source !== "opencode" && command.source !== "pi"
97
113
  ? await scanAndAttributeClaudeSessions({
98
114
  projectsDir: path.join(homeDir, ".claude", "projects"),
99
115
  worktrees,
@@ -104,11 +120,12 @@ async function scanSessionSources(command, homeDir, worktrees, now, window) {
104
120
  : null;
105
121
  return { codex, claude };
106
122
  }
107
- function writeSessionJson(io, window, readFailures, codex, claude, codexRows, claudeRows, opencode) {
123
+ function writeSessionJson(io, window, readFailures, codex, claude, codexRows, claudeRows, opencode, pi) {
108
124
  writeLine(io.stdout, JSON.stringify({
109
125
  window,
110
126
  read_failures: readFailures,
111
127
  ...(opencode ? { opencode } : {}),
128
+ ...(pi ? { pi } : {}),
112
129
  ...(codex ? { codex: { counts: codex.counts, sessions: codexRows } } : {}),
113
130
  ...(claude
114
131
  ? {
@@ -28,6 +28,7 @@ import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, } from "../cursors/raw-e
28
28
  import { fetchTranscriptObjects } from "../evidence-transcripts-client.js";
29
29
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, } from "../local-state.js";
30
30
  import { discoverLocalTranscripts } from "./sources-discovery.js";
31
+ import { PI_CURSOR_FILENAME } from "../pi-inventory.js";
31
32
  import { OPENCODE_CURSOR_FILENAME } from "../opencode-inventory.js";
32
33
  import { fileFacts, judgeSource } from "./sources-verdict.js";
33
34
  const VERDICTS = ["stored_full", "stored_slim", "stored_partial", "not_stored"];
@@ -63,6 +64,7 @@ export async function runSourcesCheck(command, io, now = new Date()) {
63
64
  const local = await discoverLocalTranscripts({ homeDir, olderThanMs, skipAll: optOutAll, skipClaude: optOutClaude, skipCodex: optOutCodex, skipOpenCode: !switches.opencode });
64
65
  try {
65
66
  const cursors = {
67
+ pi: await readRawEvidenceCursor(paths, { filename: PI_CURSOR_FILENAME, sessionsOnly: true }),
66
68
  opencode: await readRawEvidenceCursor(paths, { filename: OPENCODE_CURSOR_FILENAME, sessionsOnly: true }),
67
69
  codex: await readRawEvidenceCursor(paths),
68
70
  claude_code: await readRawEvidenceCursor(paths, { filename: CLAUDE_CURSOR_FILENAME, sessionsOnly: true }),
@@ -79,7 +81,7 @@ export async function runSourcesCheck(command, io, now = new Date()) {
79
81
  const inventory = await fetchSourceInventory({ dashboardUrl, deviceToken: session.device_token, fetchImpl: io.fetch });
80
82
  const { rows: _rows, ...inventoryCounts } = inventory;
81
83
  const counts = { missing_row: 0, missing_pointer: 0, stale_revision: 0, unavailable_evidence: 0, explicit_opt_out: 0, unverifiable: 0, revision_verified: 0 };
82
- const optOutSources = optOutAll ? ["codex", "claude_code", "opencode"] : optOutClaude ? ["claude_code"] : [];
84
+ const optOutSources = optOutAll ? ["codex", "claude_code", "opencode", "pi"] : optOutClaude ? ["claude_code"] : [];
83
85
  counts.explicit_opt_out = optOutSources.length;
84
86
  for (const file of local.files) {
85
87
  const finding = await reconcileSource({ file, inventory, remote,
@@ -93,7 +95,7 @@ export async function runSourcesCheck(command, io, now = new Date()) {
93
95
  }
94
96
  report.reconciliation = { counts, inventory: inventoryCounts,
95
97
  scan: { started_at: new Date(startedAt).toISOString(), finished_at: new Date().toISOString(),
96
- roots: ["codex/sessions", "codex/archived_sessions", "claude/projects", "opencode/opencode.db"], older_than_days: command.olderThanDays, opt_out_sources: optOutSources },
98
+ roots: ["codex/sessions", "codex/archived_sessions", "claude/projects", "opencode/opencode.db", "pi/agent/sessions"], older_than_days: command.olderThanDays, opt_out_sources: optOutSources },
97
99
  limitations: ["Snapshot scan, not a live completeness guarantee; absent local sources cannot be discovered.", "Missing historical source identifiers remain unverifiable.", "Pointer metadata does not prove bucket bytes are readable; no storage bodies fetched.", "explicit_opt_out counts sources, not undiscovered files; opted-out stores are not scanned."],
98
100
  };
99
101
  if (inventory.status !== "exhausted" || report.local.unreadable > 0 || counts.unavailable_evidence > 0) {
@@ -25,6 +25,8 @@ import { t3CodexProjectionSessionIds } from "../adapters/t3-projection-source.js
25
25
  */
26
26
  import fs from "node:fs/promises";
27
27
  import { getCollectorRuntimePaths } from "../local-state.js";
28
+ import { piCollectionPolicy } from "../pi-policy.js";
29
+ import { readPiInventory } from "../pi-inventory.js";
28
30
  import { openCodeCollectionPolicy } from "../opencode-policy.js";
29
31
  import { readOpenCodeInventory } from "../opencode-inventory.js";
30
32
  import path from "node:path";
@@ -163,6 +165,25 @@ export async function discoverLocalTranscripts(options) {
163
165
  session_id: file.session.id, session_id_source: "content", key: transcriptFileKey({ source: "opencode", session_id: file.session.id, sidecar: null }) });
164
166
  }
165
167
  }
168
+ if (!options.skipAll) {
169
+ const policy = await piCollectionPolicy({ homeDir: options.homeDir });
170
+ const scratch = policy.enabled ? await fs.mkdtemp(path.join(os.tmpdir(), "tower-pi-reconcile-")) : null;
171
+ const previousCleanup = result.cleanup;
172
+ if (scratch)
173
+ result.cleanup = async () => { await previousCleanup?.(); await fs.rm(scratch, { recursive: true, force: true }); };
174
+ const inventory = policy.enabled ? await readPiInventory({ homeDir: options.homeDir, stateDir: getCollectorRuntimePaths(options.homeDir).state_dir, include: policy.include, readOnlyAccounting: true, exportDirectory: scratch }) : { files: [], failures: [] };
175
+ result.directory_read_failed += inventory.failures.length;
176
+ for (const file of inventory.files) {
177
+ if (file.pending_reasons.length)
178
+ result.file_read_failed++;
179
+ if (options.olderThanMs !== null && file.session.updated_at_ms >= options.olderThanMs) {
180
+ result.newer_count++;
181
+ continue;
182
+ }
183
+ result.files.push({ source: "pi", kind: "pi_main", source_locator: `pi:${file.session.id}`, file_path: file.local_path, byte_size: file.byte_size, mtime_ms: file.session.updated_at_ms,
184
+ session_id: file.session.id, session_id_source: "content", key: transcriptFileKey({ source: "pi", session_id: file.session.id, sidecar: null }) });
185
+ }
186
+ }
166
187
  await groupCodexRevisions(result, options.homeDir);
167
188
  result.files = result.files.filter(f => options.olderThanMs === null || f.mtime_ms < options.olderThanMs);
168
189
  if (result.directory_read_failed > 0 || result.file_read_failed > 0) {
@@ -186,7 +186,7 @@ upload = {}) {
186
186
  if (!buffer)
187
187
  return null;
188
188
  // Compare against what the uploader sends: in drop mode that is the slim form, not the disk bytes.
189
- const uploadText = file.source === "opencode" ? buffer.toString("utf8") : uploadFormOfTranscript(buffer.toString("utf8"), file.source, mode, describedKeys);
189
+ const uploadText = (file.source === "opencode" || file.source === "pi") ? buffer.toString("utf8") : uploadFormOfTranscript(buffer.toString("utf8"), file.source, mode, describedKeys);
190
190
  return redactForUpload(uploadText, buffer);
191
191
  });
192
192
  return sanitized;
@@ -35,6 +35,21 @@ export async function inspectLocalCollectorStatus(options = {}) {
35
35
  if (error.code !== "ENOENT")
36
36
  status.details.push("OpenCode coverage unavailable: opencode_status_unreadable.");
37
37
  }
38
+ try {
39
+ const native = JSON.parse(await readFile(path.join(reading.paths.state_dir, "pi-collection-status.json"), "utf8"));
40
+ if (native.source === "pi" && ["complete", "partial", "absent", "withheld"].includes(native.coverage)
41
+ && [native.discovered, native.eligible, native.delivered, native.pending, native.withheld, native.unique].every(value => typeof value === "number" && Number.isFinite(value) && value >= 0)) {
42
+ const at = typeof native.observed_at === "string" && Number.isFinite(Date.parse(native.observed_at)) ? new Date(native.observed_at).toISOString() : "unknown";
43
+ const reasons = Array.isArray(native.reasons) ? native.reasons.filter((reason) => typeof reason === "string" && /^[a-z0-9_:-]{1,100}$/.test(reason)).join(", ") : "";
44
+ status.details.push(`Pi: ${native.coverage}; discovered ${native.discovered}, eligible ${native.eligible}, delivered ${native.delivered}, pending ${native.pending}, withheld ${native.withheld}, unique ${native.unique}; as of ${at}${reasons ? ` (${reasons})` : ""}.`);
45
+ }
46
+ else
47
+ status.details.push("Pi coverage unavailable: pi_status_invalid.");
48
+ }
49
+ catch (error) {
50
+ if (error.code !== "ENOENT")
51
+ status.details.push("Pi coverage unavailable: pi_status_unreadable.");
52
+ }
38
53
  return status;
39
54
  }
40
55
  function collectorStatusFromReading(reading, now) {
@@ -0,0 +1,238 @@
1
+ /** Pi's shared inventory keeps native records and fragments inside the existing redacted staging path. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { accountPiUsage, piObject, newSessionFactsAccumulator, observeSessionFactsRecord, observeSessionFactsFile, finalizeSessionFacts } from '@bli-cockpit/telemetry-core';
6
+ import { listPiSessions, piSessionRoots, readPiSession, PiReadError } from './adapters/pi-session-reader.js';
7
+ import { piSessionHeader } from './adapters/pi-session-facts.js';
8
+ import { sanitizeTextEvidenceForUpload } from './adapters/raw-evidence-sanitize.js';
9
+ import { openStagingAllowance, roomToStageUpTo, admitToStaging } from './staging-gate.js';
10
+ export const PI_CURSOR_FILENAME = 'pi-raw-evidence.json';
11
+ /** All consumers share roots and bounded keyset discovery; policy is applied before full body reads. */
12
+ export async function readPiInventory(options) {
13
+ const result = { discovered: 0, sessions: [], eligible: 0, withheld: 0, files: [], shelved: [], failures: [], absent: true };
14
+ const sessions = new Map();
15
+ for (const root of options.sessionRoots ?? piSessionRoots(options.homeDir, { agentDir: process.env.PI_CODING_AGENT_DIR, sessionDir: process.env.PI_CODING_AGENT_SESSION_DIR })) {
16
+ let after = '';
17
+ try {
18
+ while (true) {
19
+ const page = await listPiSessions(root, after, 100);
20
+ if (page.state !== 'missing')
21
+ result.absent = false;
22
+ for (const file of page.files) {
23
+ try {
24
+ const stamp = await fs.stat(file.filePath);
25
+ if (options.sinceMs !== undefined && stamp.mtimeMs < options.sinceMs)
26
+ continue;
27
+ let header = null;
28
+ for await (const event of readPiSession(file.filePath)) {
29
+ if (event.kind === 'record') {
30
+ header = piSessionHeader(event.value);
31
+ break;
32
+ }
33
+ }
34
+ const id = header?.id && /^[A-Za-z0-9._-]{1,80}$/.test(header.id) ? header.id : `pi-file-${crypto.createHash('sha256').update(file.filePath).digest('hex').slice(0, 32)}`;
35
+ const existing = sessions.get(id);
36
+ if (existing) {
37
+ if (!existing.filePaths.includes(file.filePath))
38
+ existing.filePaths.push(file.filePath);
39
+ existing.directories[file.filePath] = header?.cwd ?? "";
40
+ existing.updated_at_ms = Math.max(existing.updated_at_ms, stamp.mtimeMs);
41
+ }
42
+ else
43
+ sessions.set(id, { id, directory: header?.cwd ?? '', parentSession: header?.parentSession ?? null, created_at_ms: stamp.birthtimeMs, updated_at_ms: stamp.mtimeMs, filePaths: [file.filePath], directories: { [file.filePath]: header?.cwd ?? "" } });
44
+ }
45
+ catch (error) {
46
+ result.failures.push({ session_id: null, reason: reason(error) });
47
+ }
48
+ }
49
+ if (page.state !== 'more')
50
+ break;
51
+ after = page.next;
52
+ }
53
+ }
54
+ catch (error) {
55
+ result.failures.push({ session_id: null, reason: reason(error) });
56
+ }
57
+ }
58
+ result.sessions = [...sessions.values()];
59
+ result.discovered = sessions.size;
60
+ for (const session of result.sessions.sort((a, b) => Number(options.deliveredSessionIds?.has(a.id) ?? false) - Number(options.deliveredSessionIds?.has(b.id) ?? false) || a.created_at_ms - b.created_at_ms || a.id.localeCompare(b.id))) {
61
+ const eligiblePaths = session.filePaths.filter(filePath => !options.include || options.include({ ...session, directory: session.directories[filePath] ?? session.directory }));
62
+ const included = eligiblePaths.length > 0;
63
+ if (included)
64
+ result.eligible++;
65
+ else
66
+ result.withheld++;
67
+ if (options.metadataOnly || (!included && !options.countOnly?.(session)))
68
+ continue;
69
+ try {
70
+ (included ? result.files : result.shelved).push(await exportPiSession({ ...session, directory: session.directories[(eligiblePaths[0] ?? session.filePaths[0])] ?? session.directory, filePaths: included ? eligiblePaths : session.filePaths }, options, included));
71
+ }
72
+ catch (error) {
73
+ result.failures.push({ session_id: session.id, reason: reason(error) });
74
+ }
75
+ }
76
+ console.error('[pi inventory] observed native source', JSON.stringify({ discovered: result.discovered, returned: result.sessions.length, unique: sessions.size, exported: result.files.length, failed: result.failures.length, absent: result.absent }));
77
+ return result;
78
+ }
79
+ async function exportPiSession(session, options, retain) {
80
+ const directory = options.exportDirectory ?? path.join(options.stateDir, 'pi-transcripts');
81
+ if (retain) {
82
+ // Recompute the current policy-selected, redacted stream before trusting a retained derivative.
83
+ // This pass writes nothing and never buffers the aggregate session.
84
+ const current = await exportPiSession(session, options, false);
85
+ const candidate = path.join(directory, `${session.id}-${current.revision}.jsonl`);
86
+ if (await verifyRetainedExport(candidate, current.revision, current.byte_size)) {
87
+ console.error('[pi inventory] retained export reused', JSON.stringify({ reason: 'pi_retained_export_verified', bytes: current.byte_size }));
88
+ return { ...current, local_path: candidate };
89
+ }
90
+ }
91
+ if (retain)
92
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
93
+ const temporary = path.join(directory, `.export-${crypto.randomUUID()}`), output = retain ? await fs.open(temporary, 'wx', 0o600) : null;
94
+ const allowance = retain ? await openStagingAllowance(options.stateDir, process.env) : undefined;
95
+ const hash = crypto.createHash('sha256'), acc = newSessionFactsAccumulator('pi'), ledger = new Map();
96
+ const pending = new Set(session.id.startsWith('pi-file-') ? ['pi_session_header_identity_unavailable'] : []), seenRevisions = new Set();
97
+ let bytes = 0, lines = 0, secretMatches = 0;
98
+ const rules = new Map();
99
+ const write = async (text) => {
100
+ const masked = sanitizeTextEvidenceForUpload({ text, redactedFields: ['pi_native_record'] });
101
+ if (masked.status === 'redacted') {
102
+ if (masked.completenessLabel === 'redaction_crashed_stubbed')
103
+ throw new PiReadError('pi_redaction_failed');
104
+ secretMatches += masked.redaction.secret_like_match_count;
105
+ for (const rule of masked.redaction.rule_counts) {
106
+ const prior = rules.get(rule.rule_id) ?? { rule_id: rule.rule_id, match_count: 0, redacted_char_count: 0 };
107
+ prior.match_count += rule.match_count;
108
+ prior.redacted_char_count += rule.redacted_char_count;
109
+ rules.set(rule.rule_id, prior);
110
+ }
111
+ }
112
+ hash.update(masked.bytes);
113
+ bytes += masked.bytes.length;
114
+ lines++;
115
+ try {
116
+ observeSessionFactsRecord(acc, JSON.parse(masked.bytes.toString('utf8')));
117
+ }
118
+ catch {
119
+ pending.add('pi_normalization_pending');
120
+ }
121
+ if (output) {
122
+ const deferred = roomToStageUpTo(allowance, bytes);
123
+ if (deferred)
124
+ throw new PiReadError(`pi_staging_deferred_${deferred}`);
125
+ await output.write(masked.bytes);
126
+ }
127
+ };
128
+ try {
129
+ for (const filePath of [...session.filePaths].sort()) {
130
+ // Same-ID divergent files remain separate native segments; no text/title deduplication occurs.
131
+ // Deduplicate only byte-identical copies under the same exact native session ID.
132
+ let observed = null;
133
+ for await (const event of readPiSession(filePath))
134
+ if (event.kind === 'revision')
135
+ observed = event;
136
+ if (observed?.stable && seenRevisions.has(observed.revision))
137
+ continue;
138
+ for await (const event of readPiSession(filePath)) {
139
+ if (event.kind === 'revision') {
140
+ if (!event.stable)
141
+ pending.add('pi_source_changed_during_read');
142
+ seenRevisions.add(event.revision);
143
+ continue;
144
+ }
145
+ if (event.kind === 'partial' || !event.valid) {
146
+ pending.add(event.kind === 'partial' ? 'pi_partial_native_record' : 'pi_malformed_native_record');
147
+ let text, encoding;
148
+ try {
149
+ text = new TextDecoder('utf-8', { fatal: true }).decode(event.raw);
150
+ encoding = 'utf8';
151
+ }
152
+ catch {
153
+ text = event.raw.toString('latin1');
154
+ encoding = 'latin1';
155
+ }
156
+ await write(JSON.stringify({ type: 'pi_raw_fragment', encoding, offset: event.offset, text }) + '\n');
157
+ continue;
158
+ }
159
+ await write(event.raw.toString('utf8'));
160
+ const record = piObject(event.value), message = piObject(record.message);
161
+ if (!['session', 'message', 'model_change', 'thinking_level_change', 'compaction', 'branch_summary', 'custom', 'custom_message', 'label', 'session_info'].includes(String(record.type)))
162
+ pending.add('pi_unknown_native_record');
163
+ if (record.type !== 'session' && typeof record.id !== 'string')
164
+ pending.add('pi_native_entry_identity_missing');
165
+ if (record.type === 'session' && record.version !== 3)
166
+ pending.add('pi_format_version_unverified');
167
+ if (record.type === 'message' && message.role === 'assistant') {
168
+ const decision = accountPiUsage(session.id, record, ledger);
169
+ const identity = decision.identity ? crypto.createHash('sha256').update(decision.identity).digest('hex') : null;
170
+ await write(JSON.stringify({ type: 'pi_usage', status: decision.status === 'known' ? 'known' : decision.status === 'copied' ? 'copied' : 'unknown', identity, model: decision.model, usage: decision.usage, reason: decision.reason }) + '\n');
171
+ }
172
+ if (record.type === 'compaction' || record.type === 'branch_summary' || (message.role !== 'assistant' && message.usage != null))
173
+ await write(JSON.stringify({ type: 'pi_usage', status: 'unknown', reason: 'pi_auxiliary_usage_identity_unverified' }) + '\n');
174
+ }
175
+ }
176
+ await write(JSON.stringify({ type: 'pi_capture', native_id: session.id, native_revisions: [...seenRevisions], pending_reasons: [...pending], accounting_scope: 'pending_operator_evidence_ack' }) + '\n');
177
+ await output?.close();
178
+ const revision = hash.digest('hex'), localPath = retain ? path.join(directory, `${session.id}-${revision}.jsonl`) : '';
179
+ if (retain) {
180
+ const deferred = admitToStaging(allowance, bytes, { kind: 'pi_jsonl' });
181
+ if (deferred)
182
+ throw new PiReadError(`pi_staging_deferred_${deferred}`);
183
+ await fs.rename(temporary, localPath);
184
+ }
185
+ observeSessionFactsFile(acc, { bytes, lines });
186
+ const facts = finalizeSessionFacts(acc);
187
+ return { session, local_path: localPath, byte_size: bytes, revision, facts, pending_reasons: [...pending], ...(secretMatches ? { redaction: { schema_version: 'raw-evidence-redaction.v1', status: 'sanitized', mode: 'deterministic_text_replacement', applied_by: ['local_collector'], rule_counts: [...rules.values()], secret_like_match_count: secretMatches, redacted_fields: ['pi_native_record'], redacted_ranges: [], sanitized_content_hash_sha256: revision, sanitized_byte_size: bytes } } : {}) };
188
+ }
189
+ catch (error) {
190
+ await output?.close().catch(() => undefined);
191
+ if (retain)
192
+ await fs.unlink(temporary).catch(() => undefined);
193
+ throw error;
194
+ }
195
+ }
196
+ function reason(error) { return error instanceof PiReadError ? error.reason : 'pi_inventory_failed'; }
197
+ /** Pi's server claims, not a machine-local ledger, establish acknowledged ownership. */
198
+ export async function acknowledgePiUsage(_stateDir, sessions) { console.error('[pi accounting] metadata acknowledgment received', JSON.stringify({ sessions: sessions.size, reason: 'operator_claims_resolved_by_shared_backend' })); }
199
+ /** A filename is only a candidate: reject links, truncation, corruption and changes during verification. */
200
+ async function verifyRetainedExport(filePath, revision, expectedBytes) {
201
+ let initial;
202
+ try {
203
+ initial = await fs.lstat(filePath);
204
+ }
205
+ catch (error) {
206
+ if (error.code === 'ENOENT')
207
+ return false;
208
+ throw new PiReadError('pi_retained_export_unreadable');
209
+ }
210
+ if (!initial.isFile() || initial.isSymbolicLink() || initial.size !== expectedBytes)
211
+ throw new PiReadError('pi_retained_export_invalid');
212
+ const input = await fs.open(filePath, 'r').catch(() => { throw new PiReadError('pi_retained_export_unreadable'); });
213
+ try {
214
+ const before = await input.stat();
215
+ if (before.dev !== initial.dev || before.ino !== initial.ino || before.size !== expectedBytes)
216
+ throw new PiReadError('pi_retained_export_changed');
217
+ const hash = crypto.createHash('sha256');
218
+ let bytes = 0;
219
+ for await (const chunk of input.createReadStream({ autoClose: false })) {
220
+ hash.update(chunk);
221
+ bytes += chunk.length;
222
+ }
223
+ const after = await input.stat(), named = await fs.lstat(filePath);
224
+ if (named.isSymbolicLink() || named.dev !== before.dev || named.ino !== before.ino || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs)
225
+ throw new PiReadError('pi_retained_export_changed');
226
+ if (bytes !== expectedBytes || hash.digest('hex') !== revision)
227
+ throw new PiReadError('pi_retained_export_invalid');
228
+ return true;
229
+ }
230
+ catch (error) {
231
+ if (error instanceof PiReadError)
232
+ throw error;
233
+ throw new PiReadError('pi_retained_export_unreadable');
234
+ }
235
+ finally {
236
+ await input.close();
237
+ }
238
+ }
@@ -0,0 +1,29 @@
1
+ /** Apply existing collection choices to native directory metadata before exporting Pi evidence. */
2
+ import { getCollectorRuntimePaths, readLocalCollectorConfig } from "./local-state.js";
3
+ import { readSourceSwitches } from "./source-opt-out.js";
4
+ import { settleCollectionScope, sessionInsideRoots } from "./collection-scope.js";
5
+ import { decideShelfMode, sessionShelf } from "./commands/session-sync-shelf.js";
6
+ import { scoreSignalsAgainstWorktrees } from "./adapters/attribution-core.js";
7
+ export async function piCollectionPolicy(options) {
8
+ const paths = getCollectorRuntimePaths(options.homeDir);
9
+ const config = await readLocalCollectorConfig(paths).catch((error) => { if (error.code === "ENOENT")
10
+ return null; throw error; });
11
+ const switches = await readSourceSwitches(config, paths, "pi_inventory", { fetch: options.fetchImpl, dashboardUrl: options.dashboardUrl });
12
+ const shelf = await decideShelfMode({ homeDir: options.homeDir, dashboardUrl: options.dashboardUrl ?? null, fetchImpl: options.fetchImpl ?? globalThis.fetch });
13
+ const scope = await settleCollectionScope(paths.state_dir, config?.collection_scope);
14
+ const roots = options.collectionRoots ?? config?.default_repo_paths ?? [];
15
+ const enabled = switches.pi && !switches.held && shelf.mode !== "hold";
16
+ const reason = switches.held || shelf.mode === "hold" ? "owner_check_unavailable" : !switches.pi ? "raw_evidence_upload_disabled" : "pi_existing_collection_policy";
17
+ const drawer = (session) => {
18
+ const attribution = scoreSignalsAgainstWorktrees({ cwds: [session.directory], workspaceRoots: [], originUrls: [], branches: [], headShas: [] }, options.worktrees ?? [], { collectionRoots: roots });
19
+ return shelf.mode === "founder" && sessionShelf({ ...attribution, local_cwd: session.directory, session_facts: null }, shelf.rules).shelf === "drawer";
20
+ };
21
+ return { enabled, reason, drawer, include: (session) => {
22
+ if (!enabled)
23
+ return false;
24
+ const attribution = scoreSignalsAgainstWorktrees({ cwds: [session.directory], workspaceRoots: [], originUrls: [], branches: [], headShas: [] }, options.worktrees ?? [], { collectionRoots: roots });
25
+ if (scope.scope === "roots_only" && !sessionInsideRoots({ ...attribution, local_cwd: session.directory }, roots))
26
+ return false;
27
+ return !drawer(session);
28
+ } };
29
+ }
@@ -46,7 +46,7 @@ export function resolveSourceSwitches(config, standing) {
46
46
  const labels = configOptOutLabels(config);
47
47
  const verdict = labels.length > 0 ? optOutVerdict(standing) : null;
48
48
  if (verdict && !verdict.obeyed) {
49
- return { opencode: true, codex: true, claude: true, codexReason: null, claudeReason: null, ignored: labels, verdict: verdict.reason, held: false };
49
+ return { opencode: true, pi: true, codex: true, claude: true, codexReason: null, claudeReason: null, ignored: labels, verdict: verdict.reason, held: false };
50
50
  }
51
51
  const all = config?.raw_evidence_upload === "disabled";
52
52
  const codexReason = config?.collect_codex_jsonl === false
@@ -57,6 +57,7 @@ export function resolveSourceSwitches(config, standing) {
57
57
  : all ? ALL_SOURCES_OPT_OUT_REASON : null;
58
58
  return {
59
59
  opencode: !all,
60
+ pi: !all,
60
61
  codex: codexReason === null,
61
62
  claude: claudeReason === null,
62
63
  codexReason,
@@ -100,7 +100,7 @@ export function parseMetadataOnlySessionReportRows(value, fieldPath) {
100
100
  return sessions;
101
101
  }
102
102
  function parseRetrySource(value, fieldPath) {
103
- if (value === "codex" || value === "claude_code" || value === "opencode")
103
+ if (value === "codex" || value === "claude_code" || value === "opencode" || value === "pi")
104
104
  return value;
105
105
  return invalidUploadSpoolState(fieldPath, '"codex" or "claude_code"');
106
106
  }
@@ -37,6 +37,7 @@ export const STUDIES_DIR_SUFFIX = "-studies";
37
37
  * collector name; none does today, and the test pins that.
38
38
  */
39
39
  export const STATE_DIR_RULES = [
40
+ { name: "pi-", match: "prefix", class: "state", bound: "native Pi derivatives and coverage, governed by the existing staging allowance" },
40
41
  { name: "opencode-transcripts", match: "exact", class: "state", bound: "redacted native reader derivatives; existing staging allowance and state ceiling govern every write; current derivatives removed after durable evidence and metadata acknowledgments" },
41
42
  { name: "opencode-", match: "prefix", class: "state", bound: "native identity cursors and metadata-only coverage receipt; overwritten by collection" },
42
43
  { name: "raw-evidence", match: "exact", class: "staging", bound: "staged copies go the tick the server commits them; 256 MiB cap; the staging gate stops new copies at the cap, on a low disk or over the 512 MiB ceiling; uncommitted evidence is never deleted, only counted" },
@@ -69,6 +69,7 @@ export const SYNC_FAILURE_LABELS = [
69
69
  "codex_scan_read_failed",
70
70
  "claude_scan_read_failed",
71
71
  "opencode_scan_read_failed",
72
+ "pi_scan_read_failed",
72
73
  /** Sessions were observed and their metadata report did not reach the door. */
73
74
  "session_report_unposted",
74
75
  /** The gate is real and this file forgot to say why — a bug, made visible. */
@@ -106,6 +107,7 @@ export const SYNC_FAILURE_LABEL_CLASSES = {
106
107
  codex_scan_read_failed: "sync_failed",
107
108
  claude_scan_read_failed: "sync_failed",
108
109
  opencode_scan_read_failed: "sync_failed",
110
+ pi_scan_read_failed: "sync_failed",
109
111
  session_report_unposted: "sync_failed",
110
112
  sync_failed_reason_not_recorded: "sync_failed",
111
113
  };
@@ -9,8 +9,8 @@ import { DELIVERY_BACKOFF_HOLDING_REASON, summarizeStuckEvidence, terminalDelive
9
9
  */
10
10
  export function retrySourcesForFailedSync(options, facts) {
11
11
  const sources = new Set();
12
- if (options.nativeSessionFiles?.length)
13
- sources.add("opencode");
12
+ for (const file of options.nativeSessionFiles ?? [])
13
+ sources.add(file.source);
14
14
  if ((options.codexSessionFiles?.length ?? 0) > 0 ||
15
15
  (options.codexAttributionScan?.directory_read_failed_count ?? 0) > 0 ||
16
16
  (options.codexAttributionScan?.stat_failed_count ?? 0) > 0 ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.294",
3
+ "version": "0.2.295",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,9 +27,9 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.92",
31
- "@bli-cockpit/mcp": "0.1.179",
32
- "@bli-cockpit/telemetry-core": "0.1.98",
30
+ "@bli-cockpit/memory-mcp": "0.1.93",
31
+ "@bli-cockpit/mcp": "0.1.180",
32
+ "@bli-cockpit/telemetry-core": "0.1.99",
33
33
  "unpdf": "1.8.1"
34
34
  },
35
35
  "repository": {