@bli-cockpit/cli 0.2.28 → 0.2.29

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,52 +1,58 @@
1
- import { execFile } from "node:child_process";
2
- import { readdir, readFile, stat } from "node:fs/promises";
3
- import os from "node:os";
1
+ /**
2
+ * The `cockpit` subcommands and the router that picks one.
3
+ *
4
+ * Read `runLocalCockpitCli` below as the table of contents: one case per
5
+ * command, each delegating to a `run<Command>` function. The commands that
6
+ * grew their own supporting cast now live beside this file and are re-exported
7
+ * here so `./local.js` stays the single published entry point (BLI-3104):
8
+ *
9
+ * local-help.ts what --help prints, and the recognised command names
10
+ * cli-io.ts stdout/stderr/stdin plumbing and the production CliIo
11
+ * install-receipts.ts the named step results this machine reports back
12
+ * local-auth.ts which email, the OTP exchange, pairing with fallback
13
+ * collection-roots.ts which folders may be collected, saved and read back
14
+ * local-discovery.ts finding git worktrees inside those roots
15
+ * collection-report.ts how one collection run reads back to a person
16
+ * install-update.ts install, update, self-update, release
17
+ * status.ts `cockpit status`
18
+ * sessions.ts `cockpit sessions`
19
+ *
20
+ * What stays here is orchestration: onboard, login/logout/start, the sync tick,
21
+ * analyze, serve, autostart and agent-rules.
22
+ */
4
23
  import path from "node:path";
24
+ import { bufferedWritable, defaultExec, defaultIo, errorMessage, parseCapturedJson, replayCaptured, writeLine, } from "./cli-io.js";
25
+ import { isLocalHelpRequest, localCommandHelp } from "./local-help.js";
26
+ import { addInstallEvent, classifySyncHealthError, redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
27
+ import { canReuseOnboardSession, pairLocalCollectorWithAuthFallback, readOnboardSessionReuseCandidate, requestPairingAccessToken, requestPairingAccessTokenDetailed, resolveInteractiveLoginEmail, resolveOnboardEmail, } from "./local-auth.js";
28
+ import { collectionRootConsentAliases, persistOnboardingRootConfig, resolveOnboardingRootsForCommand, } from "./collection-roots.js";
29
+ import { discoverCommandWorktrees, rememberDiscoveryLimits } from "./local-discovery.js";
30
+ import { attributedSyncRunStatus, cursorStatusLine, displayTicketId, rawEvidenceSyncLine, shortSha, worktreeSyncRow, writeAgentSessionSummary, } from "./collection-report.js";
31
+ import { runInstall, runRelease, runSelfUpdate, runUpdate, SelfUpdateError, } from "./install-update.js";
32
+ import { runStatus } from "./status.js";
33
+ import { runSessions } from "./sessions.js";
5
34
  import { createCollectorServer } from "../server.js";
6
35
  import { inspectAgentRules, installAgentRules, uninstallAgentRules, } from "../agent-rules.js";
7
36
  import { backfillRetryCommand, runBackfill, runBackfillCommand, } from "./backfill.js";
8
37
  import { runDoctor } from "./doctor.js";
9
38
  import { inspectBackfillLock } from "../backfill-lock.js";
10
- import { maskLocalIdentifiers, redactedHealthDetail, } from "../health-detail.js";
11
39
  import { parseLocalArgs, normalizeUrl } from "./local-args.js";
12
40
  import { autostartStatus, installAutostartAgent, uninstallAutostartAgent, } from "../autostart.js";
13
- import { DEFAULT_DASHBOARD_URL, ensureLocalCollectorConfig, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
14
- import { redactSecretLikeContent, } from "@bli-cockpit/telemetry-core";
15
- import { CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT, CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
16
- import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
17
- import { backfillCompletionCovers, emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
41
+ import { DEFAULT_DASHBOARD_URL, ensureLocalCollectorConfig, getCollectorRuntimePaths, inspectLocalCollectorStatus, logoutLocalCollector, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, startLocalWorkContext, } from "../local-state.js";
18
42
  import { acquireSyncLock } from "../sync-lock.js";
19
- import { collectionRootPathAliases, discoverGitWorktreesInRootsWithStatus, } from "../repo-identity.js";
20
- import { resolveDiscoveryLimits, saveDiscoveryLimits, } from "../discovery-limits.js";
21
- import { runAttributedWorktreeSync, matchesLiveSyncWorktree, } from "./session-sync.js";
22
- import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, normalizeRootsDetailed, resolveOnboardingRoots, rootRejectionExplanation, } from "../onboarding-roots.js";
43
+ import { runAttributedWorktreeSync, } from "./session-sync.js";
44
+ import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, } from "../onboarding-roots.js";
23
45
  import { rawEvidenceDedupSummary, rawEvidenceGcSummary, runRawEvidenceLocalGc, sweepDuplicateStagedRawEvidence, } from "../raw-evidence-gc.js";
24
46
  import { envWithNodeRuntimeOnPath, runScheduledSelfUpdate, } from "../scheduled-self-update.js";
25
47
  import { runAutostartSelfHeal, } from "../autostart-self-heal.js";
26
- import { enqueueInstallEventEntry, readPendingInstallEventEntries, recordInstallEventAttemptFailure, removeInstallEventEntry, } from "../spool/install-event-outbox.js";
27
- import { createCapturedExecRunner, createInteractiveExecRunner, } from "../process-runner.js";
28
48
  import { normalizeCollectionRoots } from "../root-normalization.js";
29
- export const rootCommandNames = new Set([
30
- "onboard",
31
- "update",
32
- "upgrade",
33
- "do-everything",
34
- "fix",
35
- "install",
36
- "login",
37
- "pair",
38
- "logout",
39
- "start",
40
- "sync",
41
- "analyze",
42
- "backfill",
43
- "status",
44
- "sessions",
45
- "serve",
46
- "autostart",
47
- "agent-rules",
48
- "release",
49
- ]);
49
+ // `./local.js` is the published entry point for this command surface: the
50
+ // public CLI's generated root, commands/root.ts, doctor.ts and the test suite
51
+ // all import from here. Splitting the file must not move a name off it.
52
+ export { rootCommandNames, localCommandHelp } from "./local-help.js";
53
+ export { classifySyncHealthError, redactedSyncErrorDetail, reportInstallEventsBestEffort, SYNC_ERROR_DETAIL_MAX_CHARS, } from "./install-receipts.js";
54
+ export { assertCollectionRootPersisted } from "./collection-roots.js";
55
+ export { runSelfUpdate, SelfUpdateError, } from "./install-update.js";
50
56
  export async function runLocalCockpitCli(argv, io = defaultIo()) {
51
57
  if (isLocalHelpRequest(argv)) {
52
58
  writeLine(io.stdout, localCommandHelp(argv[0]));
@@ -112,553 +118,6 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
112
118
  return 1;
113
119
  }
114
120
  }
115
- export function localCommandHelp(command) {
116
- if (command)
117
- return localSubcommandHelp(command);
118
- return [
119
- " cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--branch <name>] [--no-auth] [--max-depth <n>] [--max-repos <n>] [--json]",
120
- " cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--no-auth] [--json]",
121
- " cockpit upgrade [same flags as update]",
122
- " cockpit do-everything [--workspace <path>] [--dashboard-url <url>] [--update-tag <tag>] [--dry-run] [--json]",
123
- " cockpit fix [same flags as do-everything]",
124
- " cockpit install [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--json]",
125
- " cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
126
- " cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
127
- " cockpit logout",
128
- " cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
129
- " cockpit sync [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
130
- " cockpit analyze [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
131
- " cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
132
- " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
133
- " cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
134
- " cockpit serve [--port <port>] [--workspace <path>]",
135
- " cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
136
- " cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
137
- " cockpit release [--dry-run] [--skip-checks] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
138
- "",
139
- `Default dashboard: ${DEFAULT_DASHBOARD_URL}. Omit --dashboard-url for normal production use; pass it only for staging/custom dashboards or to force a different pairing.`,
140
- ].join("\n");
141
- }
142
- function localSubcommandHelp(command) {
143
- const helpByCommand = new Map([
144
- [
145
- "onboard",
146
- [
147
- "Usage: cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--branch <name>] [--no-auth] [--max-depth <n>] [--max-repos <n>] [--json]",
148
- "",
149
- "Installs, pairs, starts work context(s), syncs once, completes all-history",
150
- "Codex and Claude backfill for the saved roots, and then prints readiness proof.",
151
- "If --workspace is a parent folder, scans child git repos/worktrees and rolls them up by repo.",
152
- "`--repo <path>` remains supported as a backward-compatible alias.",
153
- `Omit --dashboard-url for normal production setup (${DEFAULT_DASHBOARD_URL}).`,
154
- "Pass --dashboard-url only for staging/custom dashboards or to force a different pairing.",
155
- "Run with no flags in a terminal and it prompts for the dashboard email and OTP code; pass --email to skip the email prompt. Use --no-auth to force the manual approval fallback.",
156
- "Interactive runs also offer to add Cockpit ticket-binding rules to AGENTS.md and CLAUDE.md after readiness proof.",
157
- ],
158
- ],
159
- [
160
- "install",
161
- [
162
- "Usage: cockpit install [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--json]",
163
- "",
164
- "Writes local collector config. Pair with `cockpit login`, then run `cockpit start` when work begins.",
165
- "`--repo <path>` remains supported as a backward-compatible alias.",
166
- "Omit --dashboard-url for the production dashboard; pass it only for staging/custom dashboards.",
167
- ],
168
- ],
169
- [
170
- "update",
171
- [
172
- "Usage: cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--no-auth] [--json]",
173
- "",
174
- "Updates the global public CLI from npm, then reruns `cockpit onboard`",
175
- "with the same setup flags so pairing, saved roots, all-history backfill,",
176
- "agent rules, autostart, and the initial sync are refreshed in one command.",
177
- "`cockpit upgrade` is an alias.",
178
- ],
179
- ],
180
- [
181
- "upgrade",
182
- [
183
- "Usage: cockpit upgrade [same flags as cockpit update]",
184
- "",
185
- "Alias for `cockpit update`.",
186
- ],
187
- ],
188
- [
189
- "do-everything",
190
- [
191
- "Usage: cockpit do-everything [--workspace <path>] [--dashboard-url <url>] [--update-tag <tag>] [--dry-run] [--json]",
192
- "",
193
- "Converges a blank, existing, or reused intern machine: latest CLI, interactive auth and collection-root recovery when needed, saved roots, autostart, all-history backfill, raw-evidence GC, and sync freshness.",
194
- "`cockpit fix` is an alias.",
195
- "Maintainer canary: use `--update-tag next` so self-update and re-exec stay on the prerelease candidate.",
196
- "--dry-run prints the checks and would-fix steps without writing config, plists, cursors, or install telemetry.",
197
- ],
198
- ],
199
- [
200
- "fix",
201
- [
202
- "Usage: cockpit fix [same flags as cockpit do-everything]",
203
- "",
204
- "Alias for `cockpit do-everything`.",
205
- ],
206
- ],
207
- [
208
- "login",
209
- [
210
- "Usage: cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
211
- "",
212
- "Signs in with an email OTP when interactive, starts dashboard device pairing, and stores the approved local session.",
213
- "Omit --dashboard-url for the production dashboard; pass it only for staging/custom dashboards.",
214
- ],
215
- ],
216
- [
217
- "pair",
218
- [
219
- "Usage: cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
220
- "",
221
- "Alias for `cockpit login`.",
222
- ],
223
- ],
224
- ["logout", ["Usage: cockpit logout [--json]", "", "Removes the local device session."]],
225
- [
226
- "start",
227
- [
228
- "Usage: cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--topic-summary <summary>] [--intent <intent>] [--phase <phase>] [--intent-confidence <0..1>] [--workspace <path>] [--branch <name>] [--json]",
229
- "",
230
- "Starts local ambient capture. Parent folders start each child git worktree.",
231
- "Add --ticket only when the work already has a visible ticket; omit it to preserve an existing binding.",
232
- "Use --clear-ticket to intentionally return the context to general ambient capture.",
233
- "Use --topic/--intent/--phase for planning, discovery, and learning work that has no ticket yet.",
234
- "Supported intents: implementation, bug_fix, root_cause_analysis, planning, discovery, review, testing, documentation, release, learning, coordination, maintenance, analysis, unknown, other.",
235
- "Supported phases: planning, discovery, implementation, debugging, review, testing, documentation, release, handoff, analysis, unknown, other.",
236
- "`--repo <path>` remains supported as a backward-compatible alias; use --workspace in agent guidance.",
237
- ],
238
- ],
239
- [
240
- "sync",
241
- [
242
- "Usage: cockpit sync [--workspace <path>] [--dashboard-url <url>] [--json]",
243
- "",
244
- "Uploads latest local ambient envelope(s), or spools safe retries if blocked.",
245
- "Omit --dashboard-url for normal production sync; pass it only for staging/custom dashboards or forced re-pairing.",
246
- "`--repo <path>` remains supported as a backward-compatible alias.",
247
- "Parent folders sync each child git worktree; Codex AND Claude Code JSONL",
248
- "transcripts (and Claude subagent sidecars) are attributed to repos",
249
- "deterministically and ambiguous transcripts are retained as unattributed",
250
- "instead of being duplicated across repos. Use `cockpit sessions` to see why",
251
- "a session is or is not collected.",
252
- "Newly discovered repos get a general ambient work context automatically.",
253
- "Discovery scans 3 folder levels and up to 50 repos by default; tune with",
254
- "--max-depth and --max-repos.",
255
- "Also self-updates the CLI from npm latest once per day, strictly after",
256
- "collection finishes; set COCKPIT_DISABLE_AUTO_UPDATE=1 to freeze the",
257
- "installed version during incident triage.",
258
- ],
259
- ],
260
- [
261
- "analyze",
262
- [
263
- "Usage: cockpit analyze [--workspace <path>] [--dashboard-url <url>] [--json]",
264
- "",
265
- "Uploads the latest local ambient evidence, then queues one analysis job.",
266
- "The command returns after the batch is queued; view status and results in My Work.",
267
- "Omit --dashboard-url for production; pass it only for staging/custom dashboards.",
268
- "`--repo <path>` remains supported as a backward-compatible alias.",
269
- ],
270
- ],
271
- [
272
- "backfill",
273
- [
274
- "Usage: cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
275
- "",
276
- "Backfills historical Codex and Claude Code session evidence using the saved collection roots from local config when --workspace is omitted.",
277
- "Omit --source to scan both sources; there is no --source all literal.",
278
- "The non-all window is capped at the collector session paired_at timestamp.",
279
- "--all requires a dry-run review and TTY confirmation; pass --yes for headless agent runs.",
280
- "Repo discovery scans 3 folder levels and up to 50 repos by default.",
281
- "Raise --max-depth or --max-repos when a partial result reports a discovery cap.",
282
- "--dry-run validates the paired collector and dashboard reachability, prints the same summary tables, and writes nothing.",
283
- ],
284
- ],
285
- [
286
- "status",
287
- [
288
- "Usage: cockpit status [--workspace <path>] [--json]",
289
- "",
290
- "Prints install, pairing, active work, upload, and retry state.",
291
- "`--repo <path>` remains supported as a backward-compatible alias.",
292
- ],
293
- ],
294
- [
295
- "sessions",
296
- [
297
- "Usage: cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--json]",
298
- "",
299
- "Read-only: re-runs Codex + Claude session attribution and prints each",
300
- "session's id, source, state, reason, scores, signals, and per-sidecar",
301
- "skip reasons. No upload, no cursor writes. Answers \"why is session X",
302
- "missing?\" locally — counts and labels only, never paths or content.",
303
- "--since-days uses the same paired_at cap as `cockpit backfill`; --all scans the full local history.",
304
- "`--repo <path>` remains supported as a backward-compatible alias.",
305
- ],
306
- ],
307
- [
308
- "serve",
309
- [
310
- "Usage: cockpit serve [--port <port>] [--workspace <path>]",
311
- "",
312
- "Starts the local collector HTTP status server.",
313
- "`--repo <path>` remains supported as a backward-compatible alias.",
314
- ],
315
- ],
316
- [
317
- "autostart",
318
- [
319
- "Usage: cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
320
- "",
321
- "Installs background sync through launchd on Apple Silicon macOS or a",
322
- "per-user Task Scheduler task on native Windows.",
323
- "Runs `cockpit sync` every 15 min by default and survives reboots;",
324
- "the Windows task runs while the user is signed in.",
325
- "Action defaults to `install`. `--workspace` is the parent work folder to sync.",
326
- "`--repo <path>` remains supported as a backward-compatible alias.",
327
- "Omit --dashboard-url for production; pass it only for staging/custom dashboards.",
328
- "See docs/runbooks/cockpit-launchd-sync.md and",
329
- "docs/runbooks/cockpit-windows-task-scheduler-sync.md.",
330
- ],
331
- ],
332
- [
333
- "agent-rules",
334
- [
335
- "Usage: cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
336
- "",
337
- "Installs a managed Cockpit Ticket Binding block into ~/.codex/AGENTS.md",
338
- "and ~/.claude/CLAUDE.md by default. Pass --host to manage only one.",
339
- "The block is scoped to --workspace, or the current directory when omitted,",
340
- "so Codex and Claude only run Cockpit ticket binding inside that onboarded folder.",
341
- "Action defaults to `install`.",
342
- ],
343
- ],
344
- [
345
- "release",
346
- [
347
- "Usage: cockpit release [--dry-run] [--skip-checks] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
348
- "",
349
- "Maintainer-only helper. Run from inside the bli-cockpit repo checkout.",
350
- "Requires a clean `main` branch and runs `git pull --ff-only` before publishing.",
351
- "Delegates to `npm run publish:public -- ...` so public packages are",
352
- "built, checked, and published in the safe telemetry-core then CLI order.",
353
- ],
354
- ],
355
- ]);
356
- return (helpByCommand.get(command) ?? [localCommandHelp()]).join("\n");
357
- }
358
- function isLocalHelpRequest(argv) {
359
- const command = argv[0];
360
- if (!command || !rootCommandNames.has(command))
361
- return false;
362
- return argv.length === 2 && (argv[1] === "--help" || argv[1] === "-h");
363
- }
364
- async function runInstall(command, io) {
365
- const installEvents = [];
366
- const finish = async (code) => {
367
- await reportInstallEventsBestEffort({
368
- homeDir: command.homeDir,
369
- dashboardUrl: command.dashboardUrl,
370
- command: "install",
371
- events: installEvents,
372
- json: command.json,
373
- io,
374
- });
375
- return code;
376
- };
377
- const resolved = resolveInstallCommandRoots(command);
378
- if (resolved.homeRootOptIn) {
379
- addInstallEvent(installEvents, "home_root_optin", "ok");
380
- }
381
- const result = await installLocalCollector(resolved.command);
382
- // Same invariant as the onboarding path: never report a successful install
383
- // over a config that saved no usable collection root.
384
- await assertCollectionRootPersisted(resolved.command.homeDir);
385
- addInstallEvent(installEvents, "install", "ok");
386
- if (command.json) {
387
- writeLine(io.stdout, JSON.stringify(result, null, 2));
388
- return finish(0);
389
- }
390
- writeLine(io.stdout, "Cockpit local collector installed.");
391
- writeLine(io.stdout, `Config: ${result.paths.config_file}`);
392
- writeLine(io.stdout, `Session: ${result.paths.session_file}`);
393
- writeLine(io.stdout, "Auth: missing; upload stays local-only until pairing/login.");
394
- writeLine(io.stdout, "Next: run `cockpit login`, then `cockpit start` inside the repo; add `--ticket <id>` only when ticket work begins.");
395
- return finish(0);
396
- }
397
- function resolveInstallCommandRoots(command) {
398
- const detailed = normalizeRootsDetailed([command.repoRoot ?? process.cwd()], {
399
- homeDir: command.homeDir,
400
- allowHomeRoot: command.allowHomeRoot,
401
- });
402
- if (detailed.roots.length > 0) {
403
- const root = detailed.roots[0];
404
- return {
405
- command: {
406
- ...command,
407
- repoRoot: root,
408
- },
409
- homeRootOptIn: Boolean(command.allowHomeRoot) &&
410
- path.resolve(root) === path.resolve(command.homeDir ?? os.homedir()),
411
- };
412
- }
413
- if (detailed.rejected.length > 0) {
414
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${rootRejectionExplanation(detailed.rejected[0], command)}`);
415
- }
416
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${missingCollectionRootMessage(command)}`);
417
- }
418
- async function runUpdate(command, io) {
419
- const installEvents = [];
420
- const finish = async (code) => {
421
- await reportInstallEventsBestEffort({
422
- homeDir: command.homeDir,
423
- dashboardUrl: command.dashboardUrl,
424
- command: "update",
425
- events: installEvents,
426
- json: command.json,
427
- io,
428
- });
429
- return code;
430
- };
431
- const exec = io.exec ?? defaultExec();
432
- try {
433
- await runSelfUpdate(io, { json: command.json });
434
- }
435
- catch (error) {
436
- if (!(error instanceof SelfUpdateError))
437
- throw error;
438
- addInstallEvent(installEvents, "npm_install", "fail", error.eacces
439
- ? "npm_install_eacces"
440
- : "npm_install_failed");
441
- if (command.json) {
442
- writeLine(io.stdout, JSON.stringify({
443
- status: "blocked",
444
- step: "npm_install",
445
- command: `npm ${selfUpdateInstallArgs().join(" ")}`,
446
- exit_code: error.result.code,
447
- }, null, 2));
448
- }
449
- else {
450
- writeLine(io.stderr, "BLOCKED: npm install failed; Cockpit CLI was not refreshed.");
451
- if (error.eacces) {
452
- writeLine(io.stderr, "Fix Homebrew npm ownership once: sudo chown -R $(whoami) /opt/homebrew/lib/node_modules/@bli-cockpit /opt/homebrew/bin/cockpit");
453
- writeLine(io.stderr, "Do not use `sudo npm i -g`; it makes the ownership problem come back.");
454
- }
455
- }
456
- return finish(error.result.code || 1);
457
- }
458
- addInstallEvent(installEvents, "npm_install", "ok");
459
- if (!command.json) {
460
- writeLine(io.stdout, "Cockpit CLI updated. Rechecking onboarding...");
461
- }
462
- const onboard = await exec("cockpit", [
463
- "onboard",
464
- ...updateOnboardArgs(command),
465
- ]);
466
- writeExecOutput(io, onboard, { stdout: true, stderr: true });
467
- addInstallEvent(installEvents, "onboard_rerun", onboard.code === 0 ? "ok" : "fail", onboard.code === 0 ? undefined : updateOnboardFailureCode(onboard));
468
- return finish(onboard.code);
469
- }
470
- export class SelfUpdateError extends Error {
471
- result;
472
- eacces;
473
- constructor(result) {
474
- super("npm install failed; Cockpit CLI was not refreshed.");
475
- this.name = "SelfUpdateError";
476
- this.result = result;
477
- this.eacces = isNpmEaccesFailure(result.stderr);
478
- }
479
- }
480
- export async function runSelfUpdate(io, options = {}) {
481
- const exec = io.exec ?? defaultExec();
482
- const tag = options.tag ?? "latest";
483
- if (!options.json) {
484
- writeLine(io.stdout, `Updating Cockpit CLI from npm (${tag})...`);
485
- }
486
- const install = await exec("npm", selfUpdateInstallArgs(tag));
487
- writeExecOutput(io, install, { stdout: !options.json, stderr: true });
488
- if (install.code !== 0) {
489
- throw new SelfUpdateError(install);
490
- }
491
- return { updated: true, version: LOCAL_COLLECTOR_VERSION };
492
- }
493
- function selfUpdateInstallArgs(tag = "latest") {
494
- return [
495
- "install",
496
- "-g",
497
- `@bli-cockpit/cli@${tag}`,
498
- "--prefer-online",
499
- ];
500
- }
501
- async function runRelease(command, io) {
502
- const releaseRoot = await findPublicReleaseRoot(process.cwd());
503
- if (!releaseRoot) {
504
- writeLine(io.stderr, "cockpit release must be run inside the bli-cockpit repo checkout (missing publish:public script).");
505
- return 1;
506
- }
507
- const exec = io.exec ?? defaultExec();
508
- const gitReady = await prepareReleaseMainBranch(releaseRoot, exec, io);
509
- if (!gitReady)
510
- return 1;
511
- writeLine(io.stdout, "Running Cockpit public package release...");
512
- const npmArgs = ["--prefix", releaseRoot, "run", "publish:public"];
513
- if (command.args.length > 0)
514
- npmArgs.push("--", ...command.args);
515
- const releaseExec = io.interactiveExec ?? defaultInteractiveExec();
516
- const result = await releaseExec("npm", npmArgs);
517
- writeExecOutput(io, result, { stdout: true, stderr: true });
518
- return result.code;
519
- }
520
- async function prepareReleaseMainBranch(releaseRoot, exec, io) {
521
- const branch = await exec("git", [
522
- "-C",
523
- releaseRoot,
524
- "rev-parse",
525
- "--abbrev-ref",
526
- "HEAD",
527
- ]);
528
- writeExecOutput(io, branch, { stdout: false, stderr: true });
529
- if (branch.code !== 0) {
530
- writeLine(io.stderr, "BLOCKED: cockpit release could not read the current git branch.");
531
- return false;
532
- }
533
- const currentBranch = branch.stdout.trim();
534
- if (currentBranch !== "main") {
535
- writeLine(io.stderr, `BLOCKED: cockpit release only publishes from main. Current branch is ${currentBranch || "unknown"}.`);
536
- writeLine(io.stderr, "Merge the release changes, switch to main, then rerun `cockpit release`.");
537
- return false;
538
- }
539
- const status = await exec("git", [
540
- "-C",
541
- releaseRoot,
542
- "status",
543
- "--porcelain",
544
- ]);
545
- writeExecOutput(io, status, { stdout: false, stderr: true });
546
- if (status.code !== 0) {
547
- writeLine(io.stderr, "BLOCKED: cockpit release could not inspect git status.");
548
- return false;
549
- }
550
- if (status.stdout.trim()) {
551
- writeLine(io.stderr, "BLOCKED: cockpit release requires a clean main checkout.");
552
- writeLine(io.stderr, "Commit or discard local changes, then rerun `cockpit release`.");
553
- return false;
554
- }
555
- writeLine(io.stdout, "Syncing main with git pull --ff-only...");
556
- const pull = await exec("git", ["-C", releaseRoot, "pull", "--ff-only"]);
557
- writeExecOutput(io, pull, { stdout: true, stderr: true });
558
- if (pull.code !== 0) {
559
- writeLine(io.stderr, "BLOCKED: git pull --ff-only failed; main is not safely current.");
560
- return false;
561
- }
562
- return true;
563
- }
564
- async function findPublicReleaseRoot(startDir) {
565
- let current = path.resolve(startDir);
566
- while (true) {
567
- const packageJsonPath = path.join(current, "package.json");
568
- try {
569
- const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
570
- if (packageJson.scripts?.["publish:public"] !== undefined)
571
- return current;
572
- }
573
- catch {
574
- // Keep walking: nested packages may be missing package.json or have one
575
- // without the release script.
576
- }
577
- const parent = path.dirname(current);
578
- if (parent === current)
579
- return null;
580
- current = parent;
581
- }
582
- }
583
- function updateOnboardArgs(command) {
584
- const args = [];
585
- if (command.homeDir)
586
- args.push("--home", command.homeDir);
587
- for (const root of updateCollectionRoots(command)) {
588
- args.push("--workspace", root);
589
- }
590
- if (command.dashboardUrlExplicit) {
591
- args.push("--dashboard-url", command.dashboardUrl);
592
- }
593
- if (command.claimedOwnerEmail)
594
- args.push("--email", command.claimedOwnerEmail);
595
- if (command.noAuth)
596
- args.push("--no-auth");
597
- if (command.deviceName)
598
- args.push("--device-name", command.deviceName);
599
- if (command.activeTicketId)
600
- args.push("--ticket", command.activeTicketId);
601
- if (command.branch)
602
- args.push("--branch", command.branch);
603
- if (command.pollIntervalMs !== undefined) {
604
- args.push("--poll-interval-ms", String(command.pollIntervalMs));
605
- }
606
- if (command.timeoutMs !== undefined) {
607
- args.push("--timeout-ms", String(command.timeoutMs));
608
- }
609
- if (command.maxDepth !== undefined)
610
- args.push("--max-depth", String(command.maxDepth));
611
- if (command.maxRepos !== undefined)
612
- args.push("--max-repos", String(command.maxRepos));
613
- if (command.allowHomeRoot)
614
- args.push("--allow-home-root");
615
- if (command.json)
616
- args.push("--json");
617
- return args;
618
- }
619
- function isNpmEaccesFailure(stderr) {
620
- return /EACCES|permission denied/i.test(stderr);
621
- }
622
- function updateOnboardFailureCode(result) {
623
- const output = `${result.stdout}\n${result.stderr}`;
624
- if (output.includes(COLLECTION_ROOT_REQUIRED))
625
- return COLLECTION_ROOT_REQUIRED;
626
- if (/pairing|approval|device_pairing/i.test(output))
627
- return "pairing_timeout";
628
- if (/sync_blocked|spooled|upload failed|network_or_ingest/i.test(output)) {
629
- return "sync_blocked";
630
- }
631
- return "onboard_rerun_failed";
632
- }
633
- function updateCollectionRoots(command) {
634
- const roots = command.collectionRoots?.length
635
- ? command.collectionRoots
636
- : command.repoRoot
637
- ? [command.repoRoot]
638
- : [];
639
- const seen = new Set();
640
- const deduped = [];
641
- for (const root of roots) {
642
- if (seen.has(root))
643
- continue;
644
- seen.add(root);
645
- deduped.push(root);
646
- }
647
- return deduped;
648
- }
649
- function addInstallEvent(events, step, status, errorCode,
650
- // BLI-2542: the bucket alone cannot be acted on. Callers that hold the reason
651
- // pass it; it is redacted at this boundary, not at the call site.
652
- errorMessage) {
653
- const detail = errorMessage ? redactedHealthDetail(errorMessage) : "";
654
- const code = errorCode ? sanitizeInstallErrorCode(errorCode) : undefined;
655
- events.push({
656
- step,
657
- status,
658
- ...(code ? { error_code: code } : {}),
659
- ...(detail && detail !== code ? { error_detail: detail } : {}),
660
- });
661
- }
662
121
  function addOnboardFailureEvent(events, blocker) {
663
122
  const step = onboardFailureStep(blocker, events);
664
123
  const existing = events.find((event) => event.step === step && event.status === "fail");
@@ -703,395 +162,6 @@ function onboardFailureStep(blocker, events) {
703
162
  const lastIncomplete = ["install", "auth", "pair", "work_context", "sync"].find((step) => !events.some((event) => event.step === step));
704
163
  return lastIncomplete ?? "sync";
705
164
  }
706
- function sanitizeInstallErrorCode(value) {
707
- const normalized = value
708
- .trim()
709
- .toLowerCase()
710
- .replace(/[^a-z0-9_]+/gu, "_")
711
- .replace(/^_+|_+$/gu, "")
712
- .slice(0, 120);
713
- return normalized || "unknown";
714
- }
715
- /**
716
- * Posts pending install events. Also the collector's only per-tick listening
717
- * post: the response carries the server-published `min_cli_version` floor
718
- * (BLI-2678), so the last one observed is returned for the scheduled
719
- * self-update step to act on. Every early-out returns null — no receipt, no
720
- * floor.
721
- */
722
- export async function reportInstallEventsBestEffort(options) {
723
- if (options.events.length === 0)
724
- return null;
725
- const paths = getCollectorRuntimePaths(options.homeDir);
726
- try {
727
- await enqueueInstallEventEntry(paths, {
728
- dashboardUrl: options.dashboardUrl,
729
- cliVersion: LOCAL_COLLECTOR_VERSION,
730
- command: options.command,
731
- osPlatform: os.platform(),
732
- events: options.events.map((event) => ({
733
- step: event.step.trim().slice(0, 120),
734
- status: event.status,
735
- ...(event.error_code
736
- ? { error_code: sanitizeInstallErrorCode(event.error_code) }
737
- : {}),
738
- // Already redacted and capped at the point it was produced; bounded
739
- // again here because this mapping is what the server contract sees.
740
- ...(event.error_detail
741
- ? {
742
- error_detail: event.error_detail
743
- .trim()
744
- .slice(0, SYNC_ERROR_DETAIL_MAX_CHARS),
745
- }
746
- : {}),
747
- ...(event.at ? { at: event.at } : {}),
748
- })),
749
- });
750
- }
751
- catch {
752
- if (options.json) {
753
- writeLine(options.io.stderr, "Install event outbox unavailable: local_write_failed");
754
- }
755
- return null;
756
- }
757
- const session = await readLocalCollectorSessionFile(paths).catch(() => null);
758
- if (!session ||
759
- session.session_state !== "valid" ||
760
- typeof session.device_token !== "string" ||
761
- !session.device_token) {
762
- return null;
763
- }
764
- const pending = (await readPendingInstallEventEntries(paths)).slice(0, 20);
765
- const failures = [];
766
- let observedMinCliVersion = null;
767
- for (let offset = 0; offset < pending.length; offset += 5) {
768
- await Promise.all(pending.slice(offset, offset + 5).map(async (entry) => {
769
- const controller = new AbortController();
770
- const timeout = setTimeout(() => controller.abort(), 5_000);
771
- try {
772
- const response = await options.io.fetch(`${entry.dashboard_url}/api/ambient/install-events`, {
773
- method: "POST",
774
- headers: {
775
- "Content-Type": "application/json",
776
- Authorization: `Bearer ${session.device_token}`,
777
- },
778
- body: JSON.stringify({
779
- cli_version: entry.cli_version,
780
- command: entry.command,
781
- os_platform: entry.os_platform,
782
- events: entry.events,
783
- }),
784
- signal: controller.signal,
785
- });
786
- if (!response.ok) {
787
- throw new Error(`http_${response.status}`);
788
- }
789
- const receipt = (await response
790
- .json()
791
- .catch(() => null));
792
- if (typeof receipt?.min_cli_version === "string" &&
793
- receipt.min_cli_version.trim()) {
794
- observedMinCliVersion = receipt.min_cli_version.trim();
795
- }
796
- await removeInstallEventEntry(paths, entry.outbox_id);
797
- }
798
- catch (error) {
799
- const failureReason = classifyInstallTelemetryError(error);
800
- failures.push(failureReason);
801
- await recordInstallEventAttemptFailure(paths, entry, {
802
- attemptedAt: new Date().toISOString(),
803
- failureReason,
804
- }).catch(() => undefined);
805
- }
806
- finally {
807
- clearTimeout(timeout);
808
- }
809
- }));
810
- }
811
- if (options.json && failures.length > 0) {
812
- writeLine(options.io.stderr, `Install event telemetry queued for retry: ${[...new Set(failures)].join(",")}`);
813
- }
814
- return observedMinCliVersion;
815
- }
816
- function classifyInstallTelemetryError(error) {
817
- if (error instanceof Error && error.name === "AbortError") {
818
- return "timeout";
819
- }
820
- const message = errorMessage(error);
821
- const status = message.match(/http_(\d{3})/i)?.[1];
822
- if (status)
823
- return `http_${status}`;
824
- if (/fetch|network|ENOTFOUND|ECONNREFUSED/i.test(message))
825
- return "network";
826
- return "failed";
827
- }
828
- function writeExecOutput(io, result, options) {
829
- if (options.stdout)
830
- writeRaw(io.stdout, result.stdout);
831
- if (options.stderr)
832
- writeRaw(io.stderr, result.stderr);
833
- }
834
- function writeRaw(stream, text) {
835
- if (!text)
836
- return;
837
- stream.write(text);
838
- if (!text.endsWith("\n"))
839
- stream.write("\n");
840
- }
841
- function isInteractiveStdin(io) {
842
- return Boolean(io.stdin.isTTY);
843
- }
844
- /**
845
- * Reads one line from stdin after writing a prompt. Shared by the onboard email
846
- * prompt and the autostart prompt; callers gate on `isInteractiveStdin` first so
847
- * headless / piped / spawned runs never block on input.
848
- */
849
- async function readLine(io, prompt) {
850
- io.stdout.write(prompt);
851
- io.stdin.setEncoding("utf8");
852
- return new Promise((resolve) => {
853
- const onData = (chunk) => {
854
- io.stdin.removeListener("data", onData);
855
- io.stdin.pause();
856
- resolve(chunk);
857
- };
858
- io.stdin.resume();
859
- io.stdin.on("data", onData);
860
- });
861
- }
862
- /**
863
- * Asks for the dashboard email so `cockpit onboard` (no flags) need not force a
864
- * `--email`. Only called when stdin is a TTY and not in --json mode. An empty
865
- * answer or a non-email skips rather than failing, matching `optionalEmail`'s
866
- * leniency (the approving admin's account then owns the device).
867
- */
868
- async function promptOnboardEmail(io) {
869
- const raw = await readLine(io, "What's your @buildlaunchiterate.ca email? (press enter to skip): ");
870
- const answer = raw.trim().toLowerCase();
871
- if (!answer)
872
- return undefined;
873
- if (!answer.includes("@")) {
874
- writeLine(io.stderr, `"${answer}" is not an email; continuing without one — the approving admin's account will own this device.`);
875
- return undefined;
876
- }
877
- return answer;
878
- }
879
- function onboardingRootPrompt(io) {
880
- return {
881
- confirm: async (message) => yesByDefault(await readLine(io, message)),
882
- input: (message) => readLine(io, message),
883
- message: (message) => writeLine(io.stdout, message),
884
- };
885
- }
886
- function yesByDefault(raw) {
887
- const answer = raw.trim().split(/\s+/u)[0]?.toLowerCase() ?? "";
888
- return answer !== "n" && answer !== "no";
889
- }
890
- const OTP_CODE_PROMPT = [
891
- "Email code needed:",
892
- " Check your latest Cockpit email for a 6- to 10-digit code.",
893
- "What you can do:",
894
- " 1) Paste the code here.",
895
- " 2) No code yet: wait for the resend window, then rerun this command.",
896
- " 3) Can't use email: rerun with --no-auth for manual approval.",
897
- "Code: ",
898
- ].join("\n");
899
- const OTP_INVALID_MESSAGE = [
900
- "Email code was not accepted:",
901
- " Cockpit expects digits only, length 6 to 10.",
902
- "What you can do:",
903
- " 1) Rerun and paste the latest email code.",
904
- " 2) Use manual approval: rerun this command with --no-auth --email <you@buildlaunchiterate.ca>",
905
- ].join("\n");
906
- async function resolveOnboardEmail(command, roots, config, io) {
907
- if (command.claimedOwnerEmail)
908
- return command.claimedOwnerEmail;
909
- const inferred = await inferOnboardEmail(command.homeDir, roots, config);
910
- const interactive = !command.json && isInteractiveStdin(io);
911
- if (!inferred) {
912
- return interactive ? promptOnboardEmail(io) : undefined;
913
- }
914
- if (inferred.source === "session") {
915
- if (!command.json)
916
- writeLine(io.stdout, `Dashboard email: ${inferred.email}`);
917
- return inferred.email;
918
- }
919
- if (!interactive)
920
- return inferred.email;
921
- const answer = (await readLine(io, `Use ${inferred.email} for Cockpit pairing? [Y/n] `)).trim().toLowerCase();
922
- const typedEmail = answer.split(/\s+/u).find((part) => part.includes("@"));
923
- if (typedEmail)
924
- return typedEmail;
925
- if (yesByDefault(answer)) {
926
- return inferred.email;
927
- }
928
- return promptOnboardEmail(io);
929
- }
930
- async function resolveInteractiveLoginEmail(command, io) {
931
- if (command.claimedOwnerEmail)
932
- return command.claimedOwnerEmail;
933
- if (command.noAuth || command.json || !isInteractiveStdin(io)) {
934
- return undefined;
935
- }
936
- return promptOnboardEmail(io);
937
- }
938
- async function requestPairingAccessToken(input, io) {
939
- return (await requestPairingAccessTokenDetailed(input, io)).accessToken;
940
- }
941
- async function requestPairingAccessTokenDetailed(input, io) {
942
- if (!input.email ||
943
- input.noAuth ||
944
- input.json ||
945
- !isInteractiveStdin(io)) {
946
- return {
947
- status: "skipped",
948
- errorCode: authSkippedReason(input, io),
949
- };
950
- }
951
- try {
952
- const fetchImpl = io.fetch;
953
- writeLine(io.stdout, `Signing in as ${input.email}.`);
954
- const start = await postOtpStart(fetchImpl, input.dashboardUrl, input.email);
955
- const resendAfter = typeof start.resend_after_seconds === "number"
956
- ? start.resend_after_seconds
957
- : 60;
958
- writeLine(io.stdout, `Code sent; valid 1h, resend in ${resendAfter}s by rerunning this command.`);
959
- const code = (await readLine(io, OTP_CODE_PROMPT)).trim();
960
- if (!/^\d{6,10}$/.test(code)) {
961
- throw new Error(OTP_INVALID_MESSAGE);
962
- }
963
- const verified = await postOtpVerify(fetchImpl, input.dashboardUrl, input.email, code);
964
- if (typeof verified.access_token !== "string" || !verified.access_token) {
965
- throw new Error("OTP verified but dashboard returned no access token.");
966
- }
967
- writeLine(io.stdout, `Signed in as ${input.email}.`);
968
- return { status: "ok", accessToken: verified.access_token };
969
- }
970
- catch (error) {
971
- writeLine(io.stderr, `Auth step skipped: ${errorMessage(error)}`);
972
- writeLine(io.stderr, "Continuing with manual dashboard approval.");
973
- return { status: "fail", errorCode: classifyAuthError(error) };
974
- }
975
- }
976
- function authSkippedReason(input, io) {
977
- if (!input.email)
978
- return "email_missing";
979
- if (input.noAuth)
980
- return "no_auth";
981
- if (input.json)
982
- return "json_mode";
983
- if (!isInteractiveStdin(io))
984
- return "non_interactive";
985
- return "auth_skipped";
986
- }
987
- function classifyAuthError(error) {
988
- const message = errorMessage(error);
989
- if (/\b(otp|code|digit|invalid)\b/i.test(message))
990
- return "otp_invalid";
991
- if (/fetch|network|ENOTFOUND|ECONNREFUSED|HTTP/i.test(message)) {
992
- return "network_or_auth";
993
- }
994
- return "auth_failed";
995
- }
996
- async function pairLocalCollectorWithAuthFallback(options, io) {
997
- try {
998
- return await pairLocalCollector(options);
999
- }
1000
- catch (error) {
1001
- if (!options.pairingAccessToken || !isPairingAuthFailure(error)) {
1002
- throw error;
1003
- }
1004
- writeLine(io.stderr, `Authenticated pairing failed: ${errorMessage(error)}`);
1005
- writeLine(io.stderr, "Continuing with manual dashboard approval.");
1006
- return pairLocalCollector({
1007
- ...options,
1008
- pairingAccessToken: undefined,
1009
- });
1010
- }
1011
- }
1012
- function isPairingAuthFailure(error) {
1013
- return /\b(auth|authorization|bearer|token|jwt|otp)\b/i.test(errorMessage(error));
1014
- }
1015
- async function postOtpStart(fetchImpl, dashboardUrl, email) {
1016
- const response = await fetchImpl(`${dashboardUrl}/api/auth/otp/start`, {
1017
- method: "POST",
1018
- headers: { "Content-Type": "application/json" },
1019
- body: JSON.stringify({ email }),
1020
- });
1021
- const parsed = await readJsonResponse(response);
1022
- if (!response.ok) {
1023
- throw new Error(responseErrorMessage(parsed, "OTP start failed"));
1024
- }
1025
- return parsed;
1026
- }
1027
- async function postOtpVerify(fetchImpl, dashboardUrl, email, code) {
1028
- const response = await fetchImpl(`${dashboardUrl}/api/auth/otp/verify`, {
1029
- method: "POST",
1030
- headers: { "Content-Type": "application/json" },
1031
- body: JSON.stringify({ email, code }),
1032
- });
1033
- const parsed = await readJsonResponse(response);
1034
- if (!response.ok) {
1035
- throw new Error(responseErrorMessage(parsed, "OTP verify failed"));
1036
- }
1037
- return parsed;
1038
- }
1039
- async function readJsonResponse(response) {
1040
- const text = await response.text();
1041
- if (!text)
1042
- return null;
1043
- try {
1044
- return JSON.parse(text);
1045
- }
1046
- catch {
1047
- return text;
1048
- }
1049
- }
1050
- function responseErrorMessage(parsed, fallback) {
1051
- if (parsed && typeof parsed === "object") {
1052
- const record = parsed;
1053
- if (typeof record["message"] === "string" && record["message"].trim()) {
1054
- return record["message"];
1055
- }
1056
- if (typeof record["error"] === "string" && record["error"].trim()) {
1057
- return record["error"];
1058
- }
1059
- }
1060
- return fallback;
1061
- }
1062
- async function inferOnboardEmail(homeDir, roots, config) {
1063
- const session = await readOnboardSessionReuseCandidate(homeDir).catch(() => null);
1064
- const sessionEmail = normalizeEmailForComparison(session?.email);
1065
- if (session?.session_state === "valid" && sessionEmail) {
1066
- return { email: sessionEmail, source: "session" };
1067
- }
1068
- const configEmail = normalizeEmailForComparison(config?.claimed_owner_email);
1069
- if (configEmail)
1070
- return { email: configEmail, source: "config" };
1071
- const gitEmail = await inferUniqueGitEmail(roots);
1072
- return gitEmail ? { email: gitEmail, source: "git" } : null;
1073
- }
1074
- async function inferUniqueGitEmail(roots) {
1075
- const emails = new Set();
1076
- for (const root of roots) {
1077
- const email = await readGitConfigEmail(root);
1078
- if (email)
1079
- emails.add(email);
1080
- }
1081
- return emails.size === 1 ? [...emails][0] ?? null : null;
1082
- }
1083
- async function readGitConfigEmail(root) {
1084
- return new Promise((resolve) => {
1085
- execFile("git", ["config", "user.email"], { cwd: root, encoding: "utf8" }, (error, stdout) => {
1086
- if (error) {
1087
- resolve(null);
1088
- return;
1089
- }
1090
- const email = normalizeEmailForComparison(stdout);
1091
- resolve(email?.includes("@") ? email : null);
1092
- });
1093
- });
1094
- }
1095
165
  /** Refreshes the host autostart backend after successful onboarding. */
1096
166
  async function refreshOnboardAutostart(command, roots, io) {
1097
167
  if (!io.exec)
@@ -1199,6 +269,25 @@ function onboardBackfillPayload(outcome) {
1199
269
  retry_command: outcome.retryCommand,
1200
270
  });
1201
271
  }
272
+ /**
273
+ * The blocked-backfill result, identical in both onboarding arms. `extra`
274
+ * carries the arm-specific keys and is spread where those keys appeared before,
275
+ * so the `--json` key order is unchanged.
276
+ */
277
+ function writeOnboardBackfillBlockedResult(io, options) {
278
+ if (!options.json) {
279
+ writeOnboardBackfillBlocker(io, options.backfill);
280
+ return;
281
+ }
282
+ writeLine(io.stdout, JSON.stringify({
283
+ ...options.base,
284
+ ...options.extra,
285
+ backfill: onboardBackfillPayload(options.backfill),
286
+ blocker: "backfill_incomplete",
287
+ backfill_failure_reason: options.backfill.failureReason,
288
+ next_step: options.backfill.retryCommand,
289
+ }, null, 2));
290
+ }
1202
291
  function writeOnboardBackfillBlocker(io, outcome) {
1203
292
  writeLine(io.stderr, "BLOCKED: all-history Codex and Claude backfill is incomplete for the approved collection roots.");
1204
293
  writeLine(io.stderr, `Failure: ${outcome.failureReason ?? `backfill_${outcome.status}`}`);
@@ -1216,125 +305,80 @@ function backgroundSyncLine(result) {
1216
305
  }
1217
306
  return result.status;
1218
307
  }
1219
- async function resolveOnboardingRootsForCommand(command, io) {
1220
- const paths = getCollectorRuntimePaths(command.homeDir);
1221
- const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
1222
- const interactive = !command.json && isInteractiveStdin(io);
1223
- const rootsResult = await resolveOnboardingRoots({
308
+ function writeOnboardBanner(command, io) {
309
+ writeLine(io.stdout, "Cockpit harvest onboarding");
310
+ writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}`);
311
+ writeLine(io.stdout, `Ticket: ${command.activeTicketId ?? "general ambient"}`);
312
+ }
313
+ /**
314
+ * Step 2 of onboarding: reuse the device session when it already belongs to
315
+ * this owner on this dashboard, otherwise sign in and pair — logging the old
316
+ * session out first when it belongs to someone else.
317
+ *
318
+ * Returns null when an existing session was reused, which is why the caller's
319
+ * `pair` stays null and the readiness proof falls back to the status file.
320
+ */
321
+ async function pairForOnboarding(command, input, installEvents, io) {
322
+ const installedStatus = await inspectLocalCollectorStatus({
1224
323
  homeDir: command.homeDir,
1225
- explicitRoots: command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []),
1226
- config: existingConfig,
1227
- interactive,
1228
- allowHomeRoot: command.allowHomeRoot,
1229
- prompt: interactive ? onboardingRootPrompt(io) : undefined,
324
+ repoRoot: input.primaryRoot,
325
+ branch: command.branch,
1230
326
  });
1231
- const collectionRoots = rootsResult.roots;
1232
- const primaryRoot = collectionRoots[0];
1233
- if (!primaryRoot) {
1234
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: no collection root confirmed.`);
1235
- }
1236
- const replaceRepoRoots = rootsResult.source === "prompt" &&
1237
- !command.collectionRoots?.length &&
1238
- (existingConfig?.default_repo_paths.length ?? 0) > 0;
1239
- return {
1240
- existingConfig,
1241
- rootsResult,
1242
- collectionRoots,
1243
- primaryRoot,
1244
- replaceRepoRoots,
1245
- };
1246
- }
1247
- async function persistOnboardingRootConfig(command, resolution) {
1248
- const result = await installLocalCollector({
327
+ const installedSession = await readOnboardSessionReuseCandidate(command.homeDir);
328
+ const canReuseInstalledSession = canReuseOnboardSession(installedSession, input.claimedOwnerEmail, command.dashboardUrl);
329
+ if (installedStatus.session_state === "valid" && canReuseInstalledSession) {
330
+ addInstallEvent(installEvents, "auth", "skipped", "existing_session");
331
+ addInstallEvent(installEvents, "pair", "skipped", "existing_session");
332
+ if (!command.json) {
333
+ writeLine(io.stdout, "2/5 Existing valid device session found; pairing skipped.");
334
+ }
335
+ return null;
336
+ }
337
+ if (installedStatus.session_state === "valid") {
338
+ await logoutLocalCollector({ homeDir: command.homeDir });
339
+ if (!command.json) {
340
+ writeLine(io.stdout, "2/5 Existing valid device session does not match requested owner or dashboard; pairing again.");
341
+ }
342
+ }
343
+ const authResult = await requestPairingAccessTokenDetailed({
344
+ dashboardUrl: command.dashboardUrl,
345
+ email: input.claimedOwnerEmail,
346
+ noAuth: command.noAuth,
347
+ json: command.json,
348
+ }, io);
349
+ addInstallEvent(installEvents, "auth", authResult.status, authResult.errorCode);
350
+ const pair = await pairLocalCollectorWithAuthFallback({
1249
351
  homeDir: command.homeDir,
1250
- repoRoot: resolution.primaryRoot,
1251
- repoRoots: resolution.collectionRoots,
1252
- replaceRepoRoots: resolution.replaceRepoRoots,
1253
352
  dashboardUrl: command.dashboardUrl,
353
+ claimedOwnerEmail: input.claimedOwnerEmail,
1254
354
  deviceName: command.deviceName,
1255
- });
1256
- await assertCollectionRootPersisted(command.homeDir);
1257
- return result;
355
+ pollIntervalMs: command.pollIntervalMs,
356
+ timeoutMs: command.timeoutMs,
357
+ pairingAccessToken: authResult.accessToken,
358
+ fetch: io.fetch,
359
+ onPairStarted: command.json
360
+ ? undefined
361
+ : (request) => writePairingInstructions(io, request),
362
+ }, io);
363
+ addInstallEvent(installEvents, "pair", "ok");
364
+ if (!command.json) {
365
+ writeLine(io.stdout, "2/5 Device paired.");
366
+ writeLine(io.stdout, `User: ${pair.session.email ?? pair.session.auth_subject_id}`);
367
+ writeLine(io.stdout, `Device: ${pair.session.device_name ?? pair.session.device_id ?? "unknown"}`);
368
+ }
369
+ return pair;
1258
370
  }
1259
371
  /**
1260
- * Setup does not get to claim success on its own say-so.
1261
- *
1262
- * Onboarding used to write the config and report success without ever reading
1263
- * it back. Savina's onboard did exactly that, persisted nothing, and every
1264
- * scheduled sync afterwards threw `collection_root_required` into a log nobody
1265
- * reads — twelve consecutive failures, six days at 3 uploaded of 195, found
1266
- * only by hand-querying the database. BLI-1986 fixed one path into that state;
1267
- * this closes the state itself.
1268
- *
1269
- * So we read the config back through the SAME resolution the scheduled sync
1270
- * will use, and fail here — in front of a human who can still fix it — rather
1271
- * than silently handing back a machine that will never collect.
372
+ * The last two setup steps of a successful onboard, in the order both arms run
373
+ * them. `ok` is false only when a supported scheduler was asked to load
374
+ * recurring collection and refused an unsupported host is not a failure.
1272
375
  */
1273
- export async function assertCollectionRootPersisted(homeDir) {
1274
- const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
1275
- const saved = normalizeCollectionRoots(config?.default_repo_paths ?? []);
1276
- if (saved.length === 0) {
1277
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${collectionRootNotPersistedMessage(homeDir)}`);
1278
- }
1279
- // Present in the file is not the same as usable. A root that no longer
1280
- // exists on disk resolves to nothing at sync time, which is the same silent
1281
- // dead end arriving one step later.
1282
- const usable = [];
1283
- for (const root of saved) {
1284
- if (await directoryExists(root))
1285
- usable.push(root);
1286
- }
1287
- if (usable.length === 0) {
1288
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${collectionRootMissingOnDiskMessage(saved, homeDir)}`);
1289
- }
1290
- return usable;
1291
- }
1292
- // Placeholders like <path-to-your-work-folder> make a person stop and think.
1293
- // These print real, paste-able commands with this machine's actual paths in
1294
- // them, so the fix is a copy away rather than a puzzle.
1295
- function collectionRootNotPersistedMessage(homeDir) {
1296
- const home = path.resolve(homeDir ?? os.homedir());
1297
- return [
1298
- "Setup finished without saving a collection root, so this machine would never collect anything.",
1299
- "Nothing was saved, so nothing is broken — setup just did not finish.",
1300
- "",
1301
- "Fix it by running ONE of these:",
1302
- "",
1303
- " # Sync everything on this machine (what most people want on a work laptop)",
1304
- " cockpit do-everything --allow-home-root",
1305
- "",
1306
- " # Or sync one folder — replace the path with where your projects live",
1307
- ` cockpit do-everything --workspace ${path.join(home, "BLI")}`,
1308
- "",
1309
- " # Or answer the folder question interactively",
1310
- " cockpit do-everything",
1311
- "",
1312
- "Then check it worked:",
1313
- " cockpit status",
1314
- ].join("\n");
1315
- }
1316
- function collectionRootMissingOnDiskMessage(saved, homeDir) {
1317
- const home = path.resolve(homeDir ?? os.homedir());
1318
- return [
1319
- "Cockpit is set up to collect from a folder that is not on this machine:",
1320
- ...saved.map((root) => ` ${root}`),
1321
- "",
1322
- "That usually means the folder was renamed, moved, or deleted since setup.",
1323
- "",
1324
- "Fix it by running ONE of these:",
1325
- "",
1326
- " # Point Cockpit at where your projects actually live now",
1327
- ` cockpit do-everything --workspace ${path.join(home, "BLI")}`,
1328
- "",
1329
- " # Or sync everything on this machine and stop worrying about the path",
1330
- " cockpit do-everything --allow-home-root",
1331
- "",
1332
- "Not sure where your projects are? This lists the folders Cockpit can see:",
1333
- " cockpit status",
1334
- ].join("\n");
1335
- }
1336
- async function directoryExists(dir) {
1337
- return stat(dir).then((stats) => stats.isDirectory(), () => false);
376
+ async function completeOnboardingSetup(resolvedCommand, collectionRoots, installEvents, io) {
377
+ const agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
378
+ addInstallEvent(installEvents, "agent_rules", "ok");
379
+ const autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
380
+ addAutostartInstallEvent(installEvents, autostart);
381
+ return { agentRules, autostart, ok: !onboardAutostartFailed(autostart) };
1338
382
  }
1339
383
  async function runDoctorLogin(command, io) {
1340
384
  return runLogin({
@@ -1383,11 +427,8 @@ async function runOnboard(command, io) {
1383
427
  let agentRules = null;
1384
428
  let autostart = null;
1385
429
  try {
1386
- if (!command.json) {
1387
- writeLine(io.stdout, "Cockpit harvest onboarding");
1388
- writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}`);
1389
- writeLine(io.stdout, `Ticket: ${command.activeTicketId ?? "general ambient"}`);
1390
- }
430
+ if (!command.json)
431
+ writeOnboardBanner(command, io);
1391
432
  const resolvedRoots = await resolveOnboardingRootsForCommand(command, io);
1392
433
  const existingConfig = resolvedRoots.existingConfig;
1393
434
  rootsResult = resolvedRoots.rootsResult;
@@ -1412,54 +453,7 @@ async function runOnboard(command, io) {
1412
453
  writeLine(io.stdout, "1/5 Installed local collector.");
1413
454
  writeLine(io.stdout, `Config: ${install.paths.config_file}`);
1414
455
  }
1415
- const installedStatus = await inspectLocalCollectorStatus({
1416
- homeDir: command.homeDir,
1417
- repoRoot: primaryRoot,
1418
- branch: command.branch,
1419
- });
1420
- const installedSession = await readOnboardSessionReuseCandidate(command.homeDir);
1421
- const canReuseInstalledSession = canReuseOnboardSession(installedSession, claimedOwnerEmail, command.dashboardUrl);
1422
- if (installedStatus.session_state === "valid" && canReuseInstalledSession) {
1423
- addInstallEvent(installEvents, "auth", "skipped", "existing_session");
1424
- addInstallEvent(installEvents, "pair", "skipped", "existing_session");
1425
- if (!command.json) {
1426
- writeLine(io.stdout, "2/5 Existing valid device session found; pairing skipped.");
1427
- }
1428
- }
1429
- else {
1430
- if (installedStatus.session_state === "valid" && !canReuseInstalledSession) {
1431
- await logoutLocalCollector({ homeDir: command.homeDir });
1432
- if (!command.json) {
1433
- writeLine(io.stdout, "2/5 Existing valid device session does not match requested owner or dashboard; pairing again.");
1434
- }
1435
- }
1436
- const authResult = await requestPairingAccessTokenDetailed({
1437
- dashboardUrl: command.dashboardUrl,
1438
- email: claimedOwnerEmail,
1439
- noAuth: command.noAuth,
1440
- json: command.json,
1441
- }, io);
1442
- addInstallEvent(installEvents, "auth", authResult.status, authResult.errorCode);
1443
- pair = await pairLocalCollectorWithAuthFallback({
1444
- homeDir: command.homeDir,
1445
- dashboardUrl: command.dashboardUrl,
1446
- claimedOwnerEmail,
1447
- deviceName: command.deviceName,
1448
- pollIntervalMs: command.pollIntervalMs,
1449
- timeoutMs: command.timeoutMs,
1450
- pairingAccessToken: authResult.accessToken,
1451
- fetch: io.fetch,
1452
- onPairStarted: command.json
1453
- ? undefined
1454
- : (request) => writePairingInstructions(io, request),
1455
- }, io);
1456
- addInstallEvent(installEvents, "pair", "ok");
1457
- if (!command.json) {
1458
- writeLine(io.stdout, "2/5 Device paired.");
1459
- writeLine(io.stdout, `User: ${pair.session.email ?? pair.session.auth_subject_id}`);
1460
- writeLine(io.stdout, `Device: ${pair.session.device_name ?? pair.session.device_id ?? "unknown"}`);
1461
- }
1462
- }
456
+ pair = await pairForOnboarding(command, { primaryRoot, claimedOwnerEmail }, installEvents, io);
1463
457
  const worktrees = await discoverCommandWorktrees(collectionRoots, {
1464
458
  maxDepth: command.maxDepth,
1465
459
  maxRepos: command.maxRepos,
@@ -1491,30 +485,26 @@ async function runOnboard(command, io) {
1491
485
  ? undefined
1492
486
  : backfill.failureReason ?? "backfill_incomplete");
1493
487
  if (!completedBackfill) {
1494
- if (command.json) {
1495
- writeLine(io.stdout, JSON.stringify({
488
+ writeOnboardBackfillBlockedResult(io, {
489
+ json: command.json,
490
+ base: {
1496
491
  ...onboardResult("blocked", resolvedCommand, install, pair, null, null),
1497
492
  collection_roots: collectionRoots,
1498
493
  root_resolution: rootsResult,
494
+ },
495
+ extra: {
1499
496
  mode: "multi_repo",
1500
497
  repos: multi.results,
1501
498
  codex_sessions: multi.codex_sessions,
1502
- backfill: onboardBackfillPayload(backfill),
1503
- blocker: "backfill_incomplete",
1504
- backfill_failure_reason: backfill.failureReason,
1505
- next_step: backfill.retryCommand,
1506
- }, null, 2));
1507
- }
1508
- else {
1509
- writeOnboardBackfillBlocker(io, backfill);
1510
- }
499
+ },
500
+ backfill,
501
+ });
1511
502
  return finish(1);
1512
503
  }
1513
- agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
1514
- addInstallEvent(installEvents, "agent_rules", "ok");
1515
- autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
1516
- addAutostartInstallEvent(installEvents, autostart);
1517
- const onboardOk = !onboardAutostartFailed(autostart);
504
+ const multiSetup = await completeOnboardingSetup(resolvedCommand, collectionRoots, installEvents, io);
505
+ agentRules = multiSetup.agentRules;
506
+ autostart = multiSetup.autostart;
507
+ const onboardOk = multiSetup.ok;
1518
508
  if (command.json) {
1519
509
  writeLine(io.stdout, JSON.stringify({
1520
510
  ...onboardResult(onboardOk ? "pass" : "blocked", resolvedCommand, install, pair, null, null),
@@ -1585,14 +575,14 @@ async function runOnboard(command, io) {
1585
575
  });
1586
576
  if (!run.ok || sync.status !== "uploaded") {
1587
577
  addInstallEvent(installEvents, "sync", "fail", "sync_blocked");
1588
- const runStatus = attributedSyncRunStatus(run);
578
+ const collectionRunStatus = attributedSyncRunStatus(run);
1589
579
  if (command.json) {
1590
580
  writeLine(io.stdout, JSON.stringify({
1591
581
  ...onboardResult("blocked", resolvedCommand, install, pair, sync, status),
1592
582
  collection_roots: collectionRoots,
1593
583
  root_resolution: rootsResult,
1594
584
  codex_sessions: run.summary,
1595
- collection_status: runStatus,
585
+ collection_status: collectionRunStatus,
1596
586
  }, null, 2));
1597
587
  }
1598
588
  else {
@@ -1601,7 +591,7 @@ async function runOnboard(command, io) {
1601
591
  ? sync.retry_command
1602
592
  : `cockpit sync --workspace ${JSON.stringify(worktreeRoot)}`;
1603
593
  writeLine(io.stderr, "BLOCKED: initial collection is incomplete; retry until every eligible session has a durable receipt or explicit terminal reason.");
1604
- writeLine(io.stderr, `Failure: ${syncFailureReason ?? (run.summary.report_posted ? runStatus : run.summary.report_reason)}`);
594
+ writeLine(io.stderr, `Failure: ${syncFailureReason ?? (run.summary.report_posted ? collectionRunStatus : run.summary.report_reason)}`);
1605
595
  writeAgentSessionSummary(io, run.summary);
1606
596
  writeLine(io.stderr, `Retry: ${retryCommand}`);
1607
597
  }
@@ -1614,29 +604,23 @@ async function runOnboard(command, io) {
1614
604
  ? undefined
1615
605
  : backfill.failureReason ?? "backfill_incomplete");
1616
606
  if (!completedBackfill) {
1617
- if (command.json) {
1618
- writeLine(io.stdout, JSON.stringify({
607
+ writeOnboardBackfillBlockedResult(io, {
608
+ json: command.json,
609
+ base: {
1619
610
  ...onboardResult("blocked", resolvedCommand, install, pair, sync, status),
1620
611
  collection_roots: collectionRoots,
1621
612
  root_resolution: rootsResult,
1622
- codex_sessions: run.summary,
1623
- backfill: onboardBackfillPayload(backfill),
1624
- blocker: "backfill_incomplete",
1625
- backfill_failure_reason: backfill.failureReason,
1626
- next_step: backfill.retryCommand,
1627
- }, null, 2));
1628
- }
1629
- else {
1630
- writeOnboardBackfillBlocker(io, backfill);
1631
- }
613
+ },
614
+ extra: { codex_sessions: run.summary },
615
+ backfill,
616
+ });
1632
617
  return finish(1);
1633
618
  }
1634
619
  if (command.json) {
1635
- agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
1636
- addInstallEvent(installEvents, "agent_rules", "ok");
1637
- autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
1638
- addAutostartInstallEvent(installEvents, autostart);
1639
- const onboardOk = !onboardAutostartFailed(autostart);
620
+ const jsonSetup = await completeOnboardingSetup(resolvedCommand, collectionRoots, installEvents, io);
621
+ agentRules = jsonSetup.agentRules;
622
+ autostart = jsonSetup.autostart;
623
+ const onboardOk = jsonSetup.ok;
1640
624
  writeLine(io.stdout, JSON.stringify({
1641
625
  ...onboardResult(onboardOk ? "pass" : "blocked", resolvedCommand, install, pair, sync, status),
1642
626
  collection_roots: collectionRoots,
@@ -1665,11 +649,10 @@ async function runOnboard(command, io) {
1665
649
  writeLine(io.stdout, "5/5 Status ready.");
1666
650
  writeLine(io.stdout, `Upload state: ${status.upload_state}`);
1667
651
  writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
1668
- agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
1669
- addInstallEvent(installEvents, "agent_rules", "ok");
1670
- autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
1671
- addAutostartInstallEvent(installEvents, autostart);
1672
- if (onboardAutostartFailed(autostart)) {
652
+ const setup = await completeOnboardingSetup(resolvedCommand, collectionRoots, installEvents, io);
653
+ agentRules = setup.agentRules;
654
+ autostart = setup.autostart;
655
+ if (!setup.ok) {
1673
656
  writeOnboardAutostartBlocker(io, autostart);
1674
657
  return finish(1);
1675
658
  }
@@ -1715,212 +698,6 @@ async function runOnboard(command, io) {
1715
698
  return finish(1);
1716
699
  }
1717
700
  }
1718
- function canReuseOnboardSession(session, claimedOwnerEmail, dashboardUrl) {
1719
- if (session.session_state !== "valid")
1720
- return false;
1721
- const expectedEmail = normalizeEmailForComparison(claimedOwnerEmail);
1722
- if (expectedEmail && normalizeEmailForComparison(session.email) !== expectedEmail) {
1723
- return false;
1724
- }
1725
- const expectedDashboardUrl = normalizeUrlForComparison(dashboardUrl);
1726
- if (expectedDashboardUrl &&
1727
- normalizeUrlForComparison(session.dashboard_url) !== expectedDashboardUrl) {
1728
- return false;
1729
- }
1730
- return true;
1731
- }
1732
- function normalizeEmailForComparison(value) {
1733
- const normalized = value?.trim().toLowerCase();
1734
- return normalized ? normalized : null;
1735
- }
1736
- async function readOnboardSessionReuseCandidate(homeDir) {
1737
- const paths = getCollectorRuntimePaths(homeDir);
1738
- try {
1739
- return await readLocalCollectorSessionFile(paths);
1740
- }
1741
- catch {
1742
- return readLocalSessionReference(paths);
1743
- }
1744
- }
1745
- function normalizeUrlForComparison(value) {
1746
- return value ? normalizeUrl(value) : null;
1747
- }
1748
- function shortSha(value) {
1749
- return value ? value.slice(0, 12) : "unknown";
1750
- }
1751
- function sourceFunnelLine(label, counts) {
1752
- const readFailures = counts.read_failures > 0 ? `, read_failures ${counts.read_failures}` : "";
1753
- return `${label} sessions: attributed ${counts.attributed}, fallback ${counts.attributed_fallback}, ambiguous ${counts.ambiguous}, unattributed ${counts.unattributed}, skipped ${counts.skipped}, stale ${counts.stale}${readFailures}`;
1754
- }
1755
- function attributionReportLine(summary) {
1756
- return summary.report_posted
1757
- ? "Attribution report: recorded"
1758
- : `Attribution report: skipped (${summary.report_reason})`;
1759
- }
1760
- /**
1761
- * One funnel line per source, an anomaly diagnostics line only when something
1762
- * is nonzero (clean syncs stay one line per source), and the report line.
1763
- * Counts only — project-dir slugs encode full local paths and never print
1764
- * (B.4 §5).
1765
- */
1766
- function writeAgentSessionSummary(io, summary) {
1767
- writeLine(io.stdout, sourceFunnelLine("Codex", summary.codex));
1768
- writeLine(io.stdout, sourceFunnelLine("Claude", summary.claude));
1769
- const claudeDiagnostics = claudeDiagnosticsLine(summary);
1770
- if (claudeDiagnostics)
1771
- writeLine(io.stdout, claudeDiagnostics);
1772
- writeLine(io.stdout, attributionReportLine(summary));
1773
- }
1774
- function claudeDiagnosticsLine(summary) {
1775
- // Anomaly-only (D34): sidecars_collected/uploaded are normal-operation
1776
- // counters and must NOT trigger this line, or a healthy orchestrated sync
1777
- // prints it 48×/day in launchd logs. Clean syncs stay one line per source.
1778
- const claude = summary.claude;
1779
- const parts = [];
1780
- if (claude.sidecars_skipped)
1781
- parts.push(`sidecars_skipped ${claude.sidecars_skipped}`);
1782
- if (claude.sidecars_capped)
1783
- parts.push(`sidecars_capped ${claude.sidecars_capped}`);
1784
- if (claude.sidecars_failed)
1785
- parts.push(`sidecars_failed ${claude.sidecars_failed}`);
1786
- if (claude.mains_oversized)
1787
- parts.push(`mains_oversized ${claude.mains_oversized}`);
1788
- if (claude.oversized_lines_skipped)
1789
- parts.push(`oversized_lines_skipped ${claude.oversized_lines_skipped}`);
1790
- if (claude.project_dirs_skipped)
1791
- parts.push(`project_dirs_skipped ${claude.project_dirs_skipped}`);
1792
- if (claude.sessions_schema_drift)
1793
- parts.push(`schema_drift ${claude.sessions_schema_drift}`);
1794
- if (claude.growth_damped)
1795
- parts.push(`growth_damped ${claude.growth_damped}`);
1796
- if (claude.first_run_backfill)
1797
- parts.push("first_run_backfill");
1798
- if (summary.files_deferred_byte_budget)
1799
- parts.push(`deferred_byte_budget ${summary.files_deferred_byte_budget}`);
1800
- if (summary.files_deferred_object_budget)
1801
- parts.push(`deferred_object_budget ${summary.files_deferred_object_budget}`);
1802
- return parts.length > 0 ? `Claude diagnostics: ${parts.join(", ")}` : null;
1803
- }
1804
- function rawEvidenceSyncLine(sync) {
1805
- const failures = sync.raw_evidence_failure_reasons.length > 0
1806
- ? ` failures: ${sync.raw_evidence_failure_reasons.join(",")}`
1807
- : "";
1808
- const retries = sync.raw_evidence_retry_reasons.length > 0
1809
- ? ` retry_required: ${sync.raw_evidence_retry_reasons.join(",")}`
1810
- : "";
1811
- // A held object and a nine-day-old first failure both belong on this line.
1812
- // Neither used to appear anywhere, which is how a 1,030-attempt loop stayed
1813
- // invisible (BLI-3066).
1814
- const held = sync.raw_evidence_delivery_held_count > 0
1815
- ? ` held: ${sync.raw_evidence_delivery_held_count}`
1816
- : "";
1817
- const stuck = sync.raw_evidence_stuck_object_count > 0
1818
- ? ` stuck: ${sync.raw_evidence_stuck_object_count} (worst ${sync.raw_evidence_max_delivery_attempts} attempt(s) since ${sync.raw_evidence_oldest_delivery_failure_at ?? "unknown"})`
1819
- : "";
1820
- return `Raw evidence: uploaded ${sync.raw_evidence_uploaded_object_count} object(s) in ${sync.raw_evidence_uploaded_chunk_count} chunk(s), reused ${sync.raw_evidence_reused_count}, failed ${sync.raw_evidence_failed_count}${held}${stuck}${failures}${retries}`;
1821
- }
1822
- function cursorStatusLine(sync) {
1823
- return `Cursor: ${sync.cursor_tracked_object_count} durable object(s) tracked`;
1824
- }
1825
- const ALL_SESSION_SCAN_WINDOW_MINUTES = 20 * 365 * 24 * 60;
1826
- const SESSION_SCAN_OVERRIDE_LIMIT = 10_000;
1827
- async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
1828
- // What the operator typed this run, else what they typed some previous run,
1829
- // else the built-in defaults (BLI-2362).
1830
- const limits = await resolveDiscoveryLimits(discovery, discovery.homeDir);
1831
- const maxWorktrees = limits.maxRepos;
1832
- const roots = Array.isArray(repoRoot)
1833
- ? repoRoot
1834
- : [repoRoot ?? process.cwd()];
1835
- const result = await discoverGitWorktreesInRootsWithStatus(roots, {
1836
- maxDepth: limits.maxDepth,
1837
- maxWorktrees,
1838
- });
1839
- if (io && result.unreadable_dirs.length > 0) {
1840
- // Never silently dropped: anything under these folders is missing from the
1841
- // scan, so say so even when the run otherwise succeeds.
1842
- writeLine(io.stderr, unreadableDirectoriesMessage(result.unreadable_dirs));
1843
- }
1844
- const worktrees = result.worktrees;
1845
- if (!result.complete) {
1846
- // Sync fails closed here ON PURPOSE, and that is not the bug. Advancing a
1847
- // cursor after a partial scan would mark the run as covering repos it
1848
- // never saw, permanently skipping their sessions — backfill can tolerate
1849
- // partial only because it keeps per-scope completion markers, and sync
1850
- // does not. The bug (BLI-2362) was that the refusal named no roots and
1851
- // gave no runnable command, so a big workspace just stayed red forever.
1852
- const message = incompleteDiscoveryMessage({
1853
- result,
1854
- roots,
1855
- maxDepth: limits.maxDepth,
1856
- maxRepos: maxWorktrees,
1857
- found: worktrees.length,
1858
- });
1859
- if (io)
1860
- writeLine(io.stderr, message);
1861
- throw new Error(message);
1862
- }
1863
- if (worktrees.length === 0 && !discovery.allowEmpty) {
1864
- throw new Error("No git repos found. Run from a git repo, or from a parent folder containing git repos.");
1865
- }
1866
- return worktrees;
1867
- }
1868
- /**
1869
- * Persists `--max-depth` / `--max-repos` when a command carried them, so the
1870
- * number survives into the background sync and the doctor's own sync — neither
1871
- * of which has anywhere to type one (BLI-2362). Best-effort: failing to record
1872
- * a preference must never fail the command the operator actually asked for.
1873
- */
1874
- async function rememberDiscoveryLimits(command) {
1875
- const limits = command;
1876
- if (limits.maxDepth === undefined && limits.maxRepos === undefined)
1877
- return;
1878
- await saveDiscoveryLimits({ maxDepth: limits.maxDepth, maxRepos: limits.maxRepos }, limits.homeDir).catch(() => undefined);
1879
- }
1880
- /**
1881
- * Says which folders could not be opened, and therefore what the scan could not
1882
- * see. Reported without failing the run — an unreadable folder cannot be fixed
1883
- * by retrying, so blocking on one would strand the machine (BLI-2362).
1884
- */
1885
- function unreadableDirectoriesMessage(unreadable) {
1886
- return [
1887
- `WARNING: ${unreadable.length} folder(s) could not be opened, so anything inside them was not scanned:`,
1888
- ...unreadable.map((dir) => ` ${dir.path} (${dir.code})`),
1889
- "Collection continued for everything else. If a repo is missing from Cockpit,",
1890
- "check the permissions on the folders above.",
1891
- ].join("\n");
1892
- }
1893
- /**
1894
- * Names the roots that could not be covered and hands back a command that
1895
- * actually fixes it, with this machine's numbers already filled in.
1896
- */
1897
- function incompleteDiscoveryMessage(input) {
1898
- const { result, roots, maxDepth, maxRepos, found } = input;
1899
- const blocked = result.incomplete_roots.length > 0 ? result.incomplete_roots : roots;
1900
- const hitRepoCap = result.incomplete_reasons.includes("max_worktrees_reached");
1901
- const nextDepth = maxDepth + 3;
1902
- const nextRepos = Math.max(maxRepos * 2, found + 50);
1903
- const retry = [
1904
- "cockpit do-everything",
1905
- ...roots.map((root) => `--workspace ${root}`),
1906
- `--max-depth ${hitRepoCap ? maxDepth : nextDepth}`,
1907
- `--max-repos ${nextRepos}`,
1908
- ].join(" ");
1909
- return [
1910
- `Cockpit could not finish scanning for repos, so it stopped instead of collecting a partial picture (${result.incomplete_reasons.join(", ")}).`,
1911
- "It stops rather than continuing because a partial scan would mark these repos as already checked and skip them from now on.",
1912
- "",
1913
- "Could not fully scan:",
1914
- ...blocked.map((root) => ` ${root}`),
1915
- "",
1916
- `Found ${found} repo(s) before stopping, with --max-depth ${maxDepth} and --max-repos ${maxRepos}.`,
1917
- "",
1918
- "Run this to raise the limits and try again:",
1919
- ` ${retry}`,
1920
- "",
1921
- "If that still stops, the folder is deeper or larger than expected — raise the numbers again, or point --workspace at the specific project folders instead of a parent.",
1922
- ].join("\n");
1923
- }
1924
701
  async function runMultiRepoOnboard(command, io, worktrees) {
1925
702
  if (!command.json) {
1926
703
  writeLine(io.stdout, `3/5 Parent folder mode: discovered ${worktrees.length} git worktree(s).`);
@@ -1938,7 +715,7 @@ async function runMultiRepoOnboard(command, io, worktrees) {
1938
715
  fetchImpl: io.fetch,
1939
716
  });
1940
717
  const results = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
1941
- const runStatus = attributedSyncRunStatus(run);
718
+ const collectionRunStatus = attributedSyncRunStatus(run);
1942
719
  if (!command.json) {
1943
720
  for (const [index, outcome] of run.outcomes.entries()) {
1944
721
  const row = results[index];
@@ -1954,42 +731,12 @@ async function runMultiRepoOnboard(command, io, worktrees) {
1954
731
  writeLine(io.stdout, "Live sync receipts complete; verifying all-history Codex and Claude backfill.");
1955
732
  }
1956
733
  else {
1957
- writeLine(io.stderr, `BLOCKED: Cockpit collection is ${runStatus}; retry until every eligible session has a durable receipt or explicit terminal reason.`);
734
+ writeLine(io.stderr, `BLOCKED: Cockpit collection is ${collectionRunStatus}; retry until every eligible session has a durable receipt or explicit terminal reason.`);
1958
735
  }
1959
736
  writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
1960
737
  }
1961
738
  return { ok: run.ok, results, codex_sessions: run.summary };
1962
739
  }
1963
- function worktreeSyncRow(outcome, run) {
1964
- const { worktree, context, sync } = outcome;
1965
- const matchesWorktree = (result) => matchesLiveSyncWorktree(result, worktree);
1966
- const codexSessionCount = run.codexAttribution.results.filter(matchesWorktree).length;
1967
- const claudeSessionCount = run.claudeAttribution.results.filter(matchesWorktree).length;
1968
- const attributedSessionCount = codexSessionCount + claudeSessionCount;
1969
- return {
1970
- repo_label: context?.repo_label ?? worktree.repo_label,
1971
- repo_fingerprint: context?.repo_fingerprint ?? worktree.repo_fingerprint,
1972
- worktree_label: context?.worktree_label ?? worktree.worktree_label,
1973
- worktree_fingerprint: context?.worktree_fingerprint ?? worktree.worktree_fingerprint,
1974
- branch: context?.branch ?? worktree.branch,
1975
- head_sha: sync.head_sha ?? worktree.head_sha,
1976
- work_context_id: context?.work_context_id ?? sync.work_context_id,
1977
- upload_status: sync.status,
1978
- raw_evidence_file_count: sync.raw_evidence_file_count,
1979
- raw_evidence_uploaded_object_count: sync.raw_evidence_uploaded_object_count,
1980
- raw_evidence_uploaded_chunk_count: sync.raw_evidence_uploaded_chunk_count,
1981
- raw_evidence_reused_count: sync.raw_evidence_reused_count,
1982
- raw_evidence_failed_count: sync.raw_evidence_failed_count,
1983
- raw_evidence_failure_reasons: sync.raw_evidence_failure_reasons,
1984
- raw_evidence_retry_required: sync.raw_evidence_retry_required,
1985
- raw_evidence_retry_reasons: sync.raw_evidence_retry_reasons,
1986
- attributed_session_count: attributedSessionCount,
1987
- codex_session_count: codexSessionCount,
1988
- claude_session_count: claudeSessionCount,
1989
- cursor_tracked_object_count: sync.cursor_tracked_object_count,
1990
- failure_reason: sync.status === "spooled" ? sync.failure_reason : null,
1991
- };
1992
- }
1993
740
  async function runLogin(command, io) {
1994
741
  // Standalone `cockpit login` must work on a fresh machine: bootstrap a
1995
742
  // minimal rootless config when onboard/install has not run yet, instead of
@@ -2442,46 +1189,6 @@ async function runSyncWithHealthReceipt(command, io) {
2442
1189
  await lock.handle.release();
2443
1190
  }
2444
1191
  }
2445
- export function classifySyncHealthError(error) {
2446
- const message = errorMessage(error);
2447
- if (/auth|token|session|unauthorized|forbidden|401|403/iu.test(message)) {
2448
- return "auth_failed";
2449
- }
2450
- if (/fetch|network|enotfound|econnrefused|timeout/iu.test(message)) {
2451
- return "network_failed";
2452
- }
2453
- // Anchored on the code the collector actually throws rather than on loose
2454
- // vocabulary. The old test matched /collection.root|workspace|repo|worktree/
2455
- // against the message, so any failure that merely mentioned a repo was filed
2456
- // as a collection-root failure and the real reason was lost (BLI-2492).
2457
- if (message.includes(COLLECTION_ROOT_REQUIRED) ||
2458
- /collection root/iu.test(message)) {
2459
- return "collection_root_failed";
2460
- }
2461
- return "sync_failed";
2462
- }
2463
- // The bucket above is for aggregation. This is the reason — the actual message,
2464
- // redacted on the machine that produced it, before it ever leaves.
2465
- //
2466
- // Error text can carry absolute paths and, on some auth failures, token-shaped
2467
- // fragments. It goes through the same deterministic redaction the collector
2468
- // already applies to evidence, and is capped so one pathological stack trace
2469
- // cannot dominate a health receipt.
2470
- export const SYNC_ERROR_DETAIL_MAX_CHARS = 600;
2471
- export function redactedSyncErrorDetail(error) {
2472
- const message = errorMessage(error).replace(/\s+/gu, " ").trim();
2473
- const { text } = redactSecretLikeContent(message, {
2474
- appliedBy: "local_collector",
2475
- });
2476
- // BLI-2542: the comment above always said this text can carry absolute paths,
2477
- // and until now nothing removed them — secret redaction matches token shapes,
2478
- // not filesystem paths. Same masking the doctor receipts use, so one boundary
2479
- // rule covers every health receipt.
2480
- const masked = maskLocalIdentifiers(text);
2481
- return masked.length > SYNC_ERROR_DETAIL_MAX_CHARS
2482
- ? `${masked.slice(0, SYNC_ERROR_DETAIL_MAX_CHARS - 1)}…`
2483
- : masked;
2484
- }
2485
1192
  /**
2486
1193
  * Turn a finished run into an exit code and the reasons behind it.
2487
1194
  *
@@ -2522,12 +1229,12 @@ async function runSyncLocked(command, io) {
2522
1229
  });
2523
1230
  if (run.outcomes.length > 1) {
2524
1231
  const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
2525
- const runStatus = attributedSyncRunStatus(run);
1232
+ const collectionRunStatus = attributedSyncRunStatus(run);
2526
1233
  const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
2527
1234
  if (command.json) {
2528
1235
  writeLine(io.stdout, JSON.stringify({
2529
1236
  mode: "multi_repo",
2530
- status: runStatus,
1237
+ status: collectionRunStatus,
2531
1238
  collection_complete: run.ok,
2532
1239
  results: run.outcomes.map((outcome) => outcome.sync),
2533
1240
  repos: rows,
@@ -2537,7 +1244,7 @@ async function runSyncLocked(command, io) {
2537
1244
  }, null, 2));
2538
1245
  return syncResult(run);
2539
1246
  }
2540
- writeLine(run.ok ? io.stdout : io.stderr, `Cockpit parent sync ${runStatus} ${run.outcomes.filter((outcome) => outcome.sync.status === "uploaded").length}/${run.outcomes.length} worktree(s).`);
1247
+ writeLine(run.ok ? io.stdout : io.stderr, `Cockpit parent sync ${collectionRunStatus} ${run.outcomes.filter((outcome) => outcome.sync.status === "uploaded").length}/${run.outcomes.length} worktree(s).`);
2541
1248
  for (const outcome of run.outcomes) {
2542
1249
  const { worktree, sync } = outcome;
2543
1250
  const uploaded = sync.status === "uploaded";
@@ -2556,12 +1263,12 @@ async function runSyncLocked(command, io) {
2556
1263
  // fleet machine with a single empty root and taught people to ignore
2557
1264
  // sync_failed (BLI-2722). A genuinely broken run still fails via run.ok.
2558
1265
  if (run.outcomes.length === 0) {
2559
- const runStatus = attributedSyncRunStatus(run);
1266
+ const collectionRunStatus = attributedSyncRunStatus(run);
2560
1267
  const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
2561
1268
  if (command.json) {
2562
1269
  writeLine(io.stdout, JSON.stringify({
2563
1270
  mode: "no_worktrees",
2564
- status: runStatus,
1271
+ status: collectionRunStatus,
2565
1272
  collection_complete: run.ok,
2566
1273
  codex_sessions: run.summary,
2567
1274
  raw_evidence_gc: gc,
@@ -2569,7 +1276,7 @@ async function runSyncLocked(command, io) {
2569
1276
  }, null, 2));
2570
1277
  return syncResult(run);
2571
1278
  }
2572
- writeLine(run.ok ? io.stdout : io.stderr, `Cockpit sync ${runStatus}: no git worktrees under this root; session scan ran.`);
1279
+ writeLine(run.ok ? io.stdout : io.stderr, `Cockpit sync ${collectionRunStatus}: no git worktrees under this root; session scan ran.`);
2573
1280
  writeAgentSessionSummary(io, run.summary);
2574
1281
  if (gc && !gc.skipped)
2575
1282
  writeLine(io.stdout, rawEvidenceGcSummary(gc));
@@ -2579,12 +1286,12 @@ async function runSyncLocked(command, io) {
2579
1286
  if (!result) {
2580
1287
  throw new Error("Sync produced no result for the repo worktree.");
2581
1288
  }
2582
- const runStatus = attributedSyncRunStatus(run);
1289
+ const collectionRunStatus = attributedSyncRunStatus(run);
2583
1290
  const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
2584
1291
  if (command.json) {
2585
1292
  writeLine(io.stdout, JSON.stringify({
2586
1293
  ...result,
2587
- status: runStatus,
1294
+ status: collectionRunStatus,
2588
1295
  collection_complete: run.ok,
2589
1296
  codex_sessions: run.summary,
2590
1297
  raw_evidence_gc: gc,
@@ -2630,20 +1337,6 @@ async function resolveSyncCollectionRoots(command) {
2630
1337
  }
2631
1338
  throw new Error(`${COLLECTION_ROOT_REQUIRED}: no explicit or saved collection root is available.`);
2632
1339
  }
2633
- async function collectionRootConsentAliases(roots) {
2634
- // Keep both spellings when an existing root is reached through a filesystem
2635
- // alias such as macOS `/var` -> `/private/var`. Deleted child paths cannot be
2636
- // realpathed later, so either transcript spelling must remain inside the same
2637
- // operator-approved physical root.
2638
- return normalizeCollectionRoots(await collectionRootPathAliases(roots));
2639
- }
2640
- function attributedSyncRunStatus(run) {
2641
- if (run.ok)
2642
- return "uploaded";
2643
- return run.outcomes.every((outcome) => outcome.sync.status === "uploaded")
2644
- ? "partial"
2645
- : "spooled";
2646
- }
2647
1340
  async function runAnalyze(command, io) {
2648
1341
  const syncStdout = [];
2649
1342
  const syncStderr = [];
@@ -2746,372 +1439,15 @@ async function readAnalyzeApiResponse(response) {
2746
1439
  return {};
2747
1440
  }
2748
1441
  }
2749
- function parseCapturedJson(chunks) {
2750
- const text = chunks.join("").trim();
2751
- if (!text)
2752
- return null;
2753
- try {
2754
- return JSON.parse(text);
2755
- }
2756
- catch {
2757
- return text;
2758
- }
2759
- }
2760
1442
  function isUploadedSyncResult(value) {
2761
1443
  return Boolean(value
2762
1444
  && typeof value === "object"
2763
1445
  && !Array.isArray(value)
2764
1446
  && value["status"] === "uploaded");
2765
1447
  }
2766
- function bufferedWritable(chunks) {
2767
- return {
2768
- write(chunk) {
2769
- chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
2770
- return true;
2771
- },
2772
- };
2773
- }
2774
- function replayCaptured(stream, chunks) {
2775
- for (const chunk of chunks)
2776
- stream.write(chunk);
2777
- }
2778
1448
  async function runSyncRawEvidenceGc(command, io) {
2779
1449
  return runRawEvidenceLocalGc(getCollectorRuntimePaths(command.homeDir), io.env);
2780
1450
  }
2781
- async function runStatus(command, io) {
2782
- const backfillCursor = await inspectBackfillCursor(command.homeDir);
2783
- const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos, homeDir: command.homeDir }, io);
2784
- if (worktrees.length > 1) {
2785
- const statuses = await Promise.all(worktrees.map(async (worktree) => ({
2786
- ...(await inspectLocalCollectorStatus({
2787
- homeDir: command.homeDir,
2788
- repoRoot: worktree.repo_root,
2789
- })),
2790
- head_sha: worktree.head_sha,
2791
- })));
2792
- if (command.json) {
2793
- writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", statuses, backfill_cursor: backfillCursor }, null, 2));
2794
- return 0;
2795
- }
2796
- writeLine(io.stdout, "Cockpit parent status");
2797
- writeLine(io.stdout, `backfill: ${backfillCursorLine(backfillCursor)}`);
2798
- for (const status of statuses) {
2799
- writeLine(io.stdout, `- ${status.repo_label ?? status.repo}/${status.worktree_label ?? "worktree"} · ${status.branch} · head:${shortSha(status.head_sha)} · ${status.upload_state}`);
2800
- }
2801
- return 0;
2802
- }
2803
- const status = await inspectLocalCollectorStatus(command);
2804
- if (command.json) {
2805
- writeLine(io.stdout, JSON.stringify({ ...status, backfill_cursor: backfillCursor }, null, 2));
2806
- return 0;
2807
- }
2808
- writeLine(io.stdout, "Cockpit local status");
2809
- writeLine(io.stdout, `installed: ${status.installed}`);
2810
- writeLine(io.stdout, `session_state: ${status.session_state}`);
2811
- writeLine(io.stdout, `repo: ${status.repo}`);
2812
- writeLine(io.stdout, `branch: ${status.branch}`);
2813
- writeLine(io.stdout, `ticket: ${displayTicketId(status.active_ticket_id)}`);
2814
- writeLine(io.stdout, `work: ${displayWorkLabel(status)}`);
2815
- writeLine(io.stdout, `collector_freshness: ${status.collector_freshness}`);
2816
- writeLine(io.stdout, `collector_version: ${status.collector_version}`);
2817
- writeLine(io.stdout, `upload_state: ${status.upload_state}`);
2818
- writeLine(io.stdout, `last_upload_attempt: ${status.last_upload_attempt_at ?? "never"}`);
2819
- writeLine(io.stdout, `last_upload_success: ${status.last_upload_success_at ?? "never"}`);
2820
- writeLine(io.stdout, `last_upload_failure: ${status.last_upload_failure_reason ?? "none"}`);
2821
- writeLine(io.stdout, `pending_uploads: ${status.pending_upload_count}`);
2822
- writeLine(io.stdout, `pending_health_receipts: ${status.pending_health_receipt_count}`);
2823
- writeLine(io.stdout, `last_health_receipt_failure: ${status.last_health_receipt_failure_reason ?? "none"}`);
2824
- writeLine(io.stdout, `backfill: ${backfillCursorLine(backfillCursor)}`);
2825
- writeLine(io.stdout, `stuck_evidence: ${stuckEvidenceLine(status)}`);
2826
- for (const detail of status.details)
2827
- writeLine(io.stdout, `- ${detail}`);
2828
- return 0;
2829
- }
2830
- /**
2831
- * One line that cannot say "fine" while an object has never been accepted.
2832
- * Named reasons, worst attempt count, and the date it started.
2833
- */
2834
- function stuckEvidenceLine(status) {
2835
- if (status.stuck_evidence_object_count === 0)
2836
- return "none";
2837
- return `${status.stuck_evidence_object_count} object(s), ${status.stuck_evidence_held_count} held, worst ${status.stuck_evidence_max_attempts} attempt(s) since ${status.stuck_evidence_oldest_failure_at ?? "unknown"} (${status.stuck_evidence_reasons.join(",") || "unknown"})`;
2838
- }
2839
- function displayTicketId(ticketId) {
2840
- return ticketId ?? "general ambient";
2841
- }
2842
- function displayWorkLabel(status) {
2843
- if (status.work_label && status.work_id)
2844
- return `${status.work_label} (${status.work_id})`;
2845
- return status.work_label ?? status.work_id ?? "no active work context";
2846
- }
2847
- async function inspectBackfillCursor(homeDir) {
2848
- const paths = getCollectorRuntimePaths(homeDir);
2849
- const roots = await currentBackfillRoots(homeDir);
2850
- const marker = await readBackfillCompletionMarker(paths);
2851
- if (backfillCompletionCovers(marker, roots, ["codex", "claude_code"])) {
2852
- return {
2853
- state: "done",
2854
- remaining_count: 0,
2855
- updated_at: marker?.cursor.updated_at ?? null,
2856
- completed_at: marker?.completed_at ?? null,
2857
- sources: summarizeBackfillCursorSources(marker?.cursor ?? emptyBackfillCursorState()),
2858
- };
2859
- }
2860
- const cursor = (prepareBackfillCursorForScope(await readBackfillCursor(paths), roots, ["codex", "claude_code"])).cursor;
2861
- if (!cursor.updated_at) {
2862
- return {
2863
- state: "never_run",
2864
- remaining_count: 0,
2865
- updated_at: null,
2866
- completed_at: null,
2867
- sources: summarizeBackfillCursorSources(cursor),
2868
- };
2869
- }
2870
- return {
2871
- state: "remaining",
2872
- remaining_count: await countRemainingBackfillSessionFiles(homeDir ?? os.homedir(), cursor),
2873
- updated_at: cursor.updated_at,
2874
- completed_at: null,
2875
- sources: summarizeBackfillCursorSources(cursor),
2876
- };
2877
- }
2878
- async function currentBackfillRoots(homeDir) {
2879
- const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
2880
- return normalizeCollectionRoots(config?.default_repo_paths ?? []);
2881
- }
2882
- function summarizeBackfillCursorSources(cursor) {
2883
- return {
2884
- codex: summarizeBackfillSource(cursor.sources.codex),
2885
- claude_code: summarizeBackfillSource(cursor.sources.claude_code),
2886
- };
2887
- }
2888
- function summarizeBackfillSource(source) {
2889
- return {
2890
- oldest_mtime_processed: source.oldest_mtime_processed,
2891
- observed_count: Object.values(source.state_counts).reduce((total, count) => total + count, 0),
2892
- state_counts: source.state_counts,
2893
- reason_counts: source.reason_counts,
2894
- };
2895
- }
2896
- function backfillCursorLine(status) {
2897
- switch (status.state) {
2898
- case "done":
2899
- return `done (${status.completed_at ?? "completion marker present"})`;
2900
- case "never_run":
2901
- return "never run";
2902
- case "remaining":
2903
- return `${status.remaining_count} remaining`;
2904
- }
2905
- }
2906
- async function countRemainingBackfillSessionFiles(homeDir, cursor) {
2907
- const codex = await countJsonlFilesBeforeCursor(defaultCodexSessionDirs(homeDir), cursor.sources.codex.oldest_mtime_ms_processed);
2908
- const claude = await countClaudeMainFilesBeforeCursor(path.join(homeDir, ".claude", "projects"), cursor.sources.claude_code.oldest_mtime_ms_processed);
2909
- return codex + claude;
2910
- }
2911
- async function countJsonlFilesBeforeCursor(roots, oldestProcessedMs) {
2912
- let count = 0;
2913
- await walkFiles(roots, async (filePath, entryName) => {
2914
- if (!entryName.endsWith(".jsonl"))
2915
- return;
2916
- const info = await stat(filePath).catch(() => null);
2917
- if (!info?.isFile())
2918
- return;
2919
- if (oldestProcessedMs === null || info.mtimeMs < oldestProcessedMs)
2920
- count += 1;
2921
- });
2922
- return count;
2923
- }
2924
- async function countClaudeMainFilesBeforeCursor(projectsDir, oldestProcessedMs) {
2925
- let count = 0;
2926
- await walkFiles([projectsDir], async (filePath, entryName) => {
2927
- if (!entryName.endsWith(".jsonl"))
2928
- return;
2929
- if (filePath.includes(`${path.sep}subagents${path.sep}`))
2930
- return;
2931
- const info = await stat(filePath).catch(() => null);
2932
- if (!info?.isFile())
2933
- return;
2934
- if (oldestProcessedMs === null || info.mtimeMs < oldestProcessedMs)
2935
- count += 1;
2936
- });
2937
- return count;
2938
- }
2939
- async function walkFiles(roots, onFile, shouldStop = () => false) {
2940
- const stack = [...roots];
2941
- while (stack.length > 0 && !shouldStop()) {
2942
- const current = stack.pop();
2943
- if (!current)
2944
- continue;
2945
- let entries;
2946
- try {
2947
- entries = await readdir(current, { withFileTypes: true });
2948
- }
2949
- catch {
2950
- continue;
2951
- }
2952
- for (const entry of entries) {
2953
- if (shouldStop())
2954
- return;
2955
- const full = path.join(current, entry.name);
2956
- if (entry.isDirectory()) {
2957
- stack.push(full);
2958
- }
2959
- else if (entry.isFile()) {
2960
- await onFile(full, entry.name);
2961
- }
2962
- }
2963
- }
2964
- }
2965
- async function sessionsScanWindow(command, now) {
2966
- if (command.all) {
2967
- return {
2968
- mode: "all",
2969
- since_days: null,
2970
- started_at: new Date(now.getTime() - ALL_SESSION_SCAN_WINDOW_MINUTES * 60_000)
2971
- .toISOString(),
2972
- paired_at: await readPairedAt(command.homeDir),
2973
- since_minutes: ALL_SESSION_SCAN_WINDOW_MINUTES,
2974
- limit: SESSION_SCAN_OVERRIDE_LIMIT,
2975
- };
2976
- }
2977
- if (command.sinceDays !== undefined) {
2978
- const requestedMs = now.getTime() - command.sinceDays * 24 * 60 * 60_000;
2979
- const pairedAt = await readPairedAt(command.homeDir);
2980
- const pairedMs = pairedAt ? Date.parse(pairedAt) : Number.NaN;
2981
- const startedAtMs = Number.isFinite(pairedMs)
2982
- ? Math.max(requestedMs, pairedMs)
2983
- : requestedMs;
2984
- return {
2985
- mode: "since_days",
2986
- since_days: command.sinceDays,
2987
- started_at: new Date(startedAtMs).toISOString(),
2988
- paired_at: pairedAt,
2989
- since_minutes: Math.max(1, Math.ceil((now.getTime() - startedAtMs) / 60_000)),
2990
- limit: SESSION_SCAN_OVERRIDE_LIMIT,
2991
- };
2992
- }
2993
- return {
2994
- mode: "default",
2995
- since_days: null,
2996
- started_at: null,
2997
- paired_at: await readPairedAt(command.homeDir),
2998
- since_minutes: null,
2999
- limit: CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT,
3000
- };
3001
- }
3002
- async function readPairedAt(homeDir) {
3003
- const session = await readLocalCollectorSessionFile(getCollectorRuntimePaths(homeDir)).catch(() => null);
3004
- return typeof session?.paired_at === "string" ? session.paired_at : null;
3005
- }
3006
- function sessionsWindowLine(window) {
3007
- if (window.mode === "all")
3008
- return "all local history";
3009
- if (window.mode === "since_days") {
3010
- return `since ${window.started_at} (${window.since_days} day request, paired_at cap ${window.paired_at ?? "unavailable"})`;
3011
- }
3012
- return "default scan window";
3013
- }
3014
- /**
3015
- * Read-only diagnostic: re-runs attribution (no upload, no cursor writes) and
3016
- * prints why each session is or is not collected. Per-session reasons otherwise
3017
- * live only in a service-role table with no UI, so this is the operator's local
3018
- * answer to "why is session X missing?" (B.4 §6). Counts and labels only — the
3019
- * project-dir slug encodes a local path and is never printed.
3020
- */
3021
- async function runSessions(command, io) {
3022
- const now = new Date();
3023
- const homeDir = command.homeDir ?? os.homedir();
3024
- const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos, homeDir: command.homeDir }, io);
3025
- const window = await sessionsScanWindow(command, now);
3026
- const wantCodex = command.source !== "claude";
3027
- const wantClaude = command.source !== "codex";
3028
- const codex = wantCodex
3029
- ? await scanAndAttributeCodexSessions({
3030
- sessionsDirs: defaultCodexSessionDirs(homeDir),
3031
- worktrees,
3032
- now,
3033
- sinceMinutes: window.since_minutes ?? CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES,
3034
- limit: window.limit,
3035
- })
3036
- : null;
3037
- const claude = wantClaude
3038
- ? await scanAndAttributeClaudeSessions({
3039
- projectsDir: path.join(homeDir, ".claude", "projects"),
3040
- worktrees,
3041
- now,
3042
- sinceMinutes: window.since_minutes ?? undefined,
3043
- limit: window.mode === "default" ? undefined : window.limit,
3044
- })
3045
- : null;
3046
- // Safe output contract (B.4 §6): id, state, reason, scores, signals, sidecar
3047
- // skip reasons, plus the repo_label basename. Branch is intentionally omitted
3048
- // — branch names can carry operator-authored task/customer text.
3049
- const codexRows = (codex?.results ?? []).map((result) => ({
3050
- source: "codex",
3051
- session_id: result.codex_session_id,
3052
- state: result.state,
3053
- reason: result.reason,
3054
- attribution_score: result.attribution_score,
3055
- path_score: result.path_score,
3056
- signals: result.signals,
3057
- repo_label: result.worktree?.repo_label ?? null,
3058
- }));
3059
- const claudeRows = (claude?.results ?? []).map((result) => ({
3060
- source: "claude_code",
3061
- session_id: result.claude_session_id,
3062
- state: result.state,
3063
- reason: result.reason,
3064
- attribution_score: result.attribution_score,
3065
- path_score: result.path_score,
3066
- signals: result.signals,
3067
- repo_label: result.worktree?.repo_label ?? null,
3068
- main_file_oversized: result.main_file_oversized,
3069
- sidecar_skips: result.sidecar_files
3070
- .filter((sidecar) => sidecar.skipped_reason)
3071
- .map((sidecar) => ({
3072
- file_name: sidecar.file_name,
3073
- reason: sidecar.skipped_reason,
3074
- })),
3075
- }));
3076
- if (command.json) {
3077
- writeLine(io.stdout, JSON.stringify({
3078
- window,
3079
- ...(codex
3080
- ? { codex: { counts: codex.counts, sessions: codexRows } }
3081
- : {}),
3082
- ...(claude
3083
- ? {
3084
- claude: {
3085
- counts: claude.counts,
3086
- project_dirs_skipped: claude.project_dirs_skipped,
3087
- sessions: claudeRows,
3088
- },
3089
- }
3090
- : {}),
3091
- }, null, 2));
3092
- return 0;
3093
- }
3094
- writeLine(io.stdout, "Cockpit sessions (read-only attribution)");
3095
- writeLine(io.stdout, `window: ${sessionsWindowLine(window)}`);
3096
- for (const row of codexRows) {
3097
- writeLine(io.stdout, sessionRowLine(row));
3098
- }
3099
- for (const row of claudeRows) {
3100
- writeLine(io.stdout, sessionRowLine(row));
3101
- for (const sidecar of row.sidecar_skips) {
3102
- writeLine(io.stdout, ` sidecar ${sidecar.file_name}: ${sidecar.reason}`);
3103
- }
3104
- }
3105
- if (codexRows.length === 0 && claudeRows.length === 0) {
3106
- writeLine(io.stdout, "No sessions observed in the scan window.");
3107
- }
3108
- return 0;
3109
- }
3110
- function sessionRowLine(row) {
3111
- const repo = row.repo_label ? ` repo:${row.repo_label}` : "";
3112
- const signals = row.signals.length > 0 ? ` signals:${row.signals.join("|")}` : "";
3113
- return `- [${row.source}] ${row.session_id} ${row.state} (${row.reason}) score:${row.attribution_score} path:${row.path_score}${repo}${signals}`;
3114
- }
3115
1451
  async function runServe(command, io) {
3116
1452
  const server = createCollectorServer(command);
3117
1453
  await new Promise((resolve) => {
@@ -3255,30 +1591,4 @@ function autostartLocationLine(result) {
3255
1591
  return result.task_name
3256
1592
  ? `Task: ${result.task_name}`
3257
1593
  : `Plist: ${result.plist_path}`;
3258
- }
3259
- function defaultExec() {
3260
- return createCapturedExecRunner();
3261
- }
3262
- function defaultInteractiveExec() {
3263
- return createInteractiveExecRunner();
3264
- }
3265
- function defaultIo() {
3266
- if (!globalThis.fetch) {
3267
- throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
3268
- }
3269
- return {
3270
- stdin: process.stdin,
3271
- stdout: process.stdout,
3272
- stderr: process.stderr,
3273
- env: process.env,
3274
- fetch: globalThis.fetch.bind(globalThis),
3275
- exec: defaultExec(),
3276
- interactiveExec: defaultInteractiveExec(),
3277
- };
3278
- }
3279
- function writeLine(stream, text) {
3280
- stream.write(`${text}\n`);
3281
- }
3282
- function errorMessage(error) {
3283
- return error instanceof Error ? error.message : String(error);
3284
1594
  }