@bli-cockpit/cli 0.1.9 → 0.1.11

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.
@@ -2,16 +2,14 @@ import { execFile } from "node:child_process";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { createCollectorServer } from "../server.js";
5
- import { autostartStatus, installAutostartAgent, uninstallAutostartAgent, } from "../autostart.js";
6
- import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
7
- import { LocalUploadBlockedError, postCodexSessionReport, syncLocalAmbientEnvelope, } from "../upload.js";
8
- import { scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
9
- import { scanAndAttributeClaudeSessions, } from "../adapters/claude-attribution.js";
10
- import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET, } from "../adapters/raw-evidence.js";
11
- import { CLAUDE_CURSOR_FILENAME, countStaleSessions, emptyRawEvidenceCursorState, readRawEvidenceCursor, recordSessionObservation, writeRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
12
- import { readLocalCollectorConfig } from "../local-state.js";
5
+ import { parseLocalArgs, normalizeUrl } from "./local-args.js";
6
+ import { autostartStatus, installAutostartAgent, uninstallAutostartAgent } from "../autostart.js";
7
+ import { getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext } from "../local-state.js";
8
+ import { scanAndAttributeCodexSessions } from "../adapters/codex-attribution.js";
9
+ import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
13
10
  import { acquireSyncLock } from "../sync-lock.js";
14
- import { discoverGitWorktrees, } from "../repo-identity.js";
11
+ import { discoverGitWorktrees } from "../repo-identity.js";
12
+ import { runAttributedWorktreeSync } from "./session-sync.js";
15
13
  export const rootCommandNames = new Set([
16
14
  "onboard",
17
15
  "install",
@@ -195,314 +193,6 @@ function isLocalHelpRequest(argv) {
195
193
  return false;
196
194
  return argv.length === 2 && (argv[1] === "--help" || argv[1] === "-h");
197
195
  }
198
- function parseLocalArgs(argv) {
199
- const command = argv[0];
200
- switch (command) {
201
- case "onboard":
202
- return parseOnboardArgs(argv.slice(1));
203
- case "install":
204
- return parseInstallArgs(argv.slice(1));
205
- case "login":
206
- case "pair":
207
- return parseLoginArgs(argv.slice(1));
208
- case "logout":
209
- return parseLogoutArgs(argv.slice(1));
210
- case "start":
211
- return parseStartArgs(argv.slice(1));
212
- case "sync":
213
- return parseSyncArgs(argv.slice(1));
214
- case "status":
215
- return parseStatusArgs(argv.slice(1));
216
- case "sessions":
217
- return parseSessionsArgs(argv.slice(1));
218
- case "serve":
219
- return parseServeArgs(argv.slice(1));
220
- case "autostart":
221
- return parseAutostartArgs(argv.slice(1));
222
- default:
223
- throw new Error(`Unknown local command: ${command ?? ""}`);
224
- }
225
- }
226
- function parseOnboardArgs(args) {
227
- const values = parseNamedArgs(args, {
228
- allowedFlags: [
229
- "--home",
230
- "--repo",
231
- "--dashboard-url",
232
- "--email",
233
- "--device-name",
234
- "--ticket",
235
- "--branch",
236
- "--json",
237
- "--poll-interval-ms",
238
- "--timeout-ms",
239
- "--max-depth",
240
- "--max-repos",
241
- ],
242
- valueFlags: [
243
- "--home",
244
- "--repo",
245
- "--dashboard-url",
246
- "--email",
247
- "--device-name",
248
- "--ticket",
249
- "--branch",
250
- "--poll-interval-ms",
251
- "--timeout-ms",
252
- "--max-depth",
253
- "--max-repos",
254
- ],
255
- });
256
- assertNoPositionals(values.positionals, "onboard");
257
- return {
258
- kind: "onboard",
259
- homeDir: optionalNonEmpty(values.flags.get("--home")),
260
- repoRoot: optionalNonEmpty(values.flags.get("--repo")),
261
- dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
262
- claimedOwnerEmail: optionalEmail(values.flags.get("--email")),
263
- deviceName: optionalNonEmpty(values.flags.get("--device-name")),
264
- activeTicketId: optionalNonEmpty(values.flags.get("--ticket")),
265
- branch: optionalNonEmpty(values.flags.get("--branch")),
266
- json: values.booleans.has("--json"),
267
- pollIntervalMs: optionalPositiveInteger(values.flags.get("--poll-interval-ms"), "--poll-interval-ms"),
268
- timeoutMs: optionalPositiveInteger(values.flags.get("--timeout-ms"), "--timeout-ms"),
269
- maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
270
- maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
271
- };
272
- }
273
- function parseInstallArgs(args) {
274
- const values = parseNamedArgs(args, {
275
- allowedFlags: [
276
- "--home",
277
- "--repo",
278
- "--dashboard-url",
279
- "--supabase-url",
280
- "--json",
281
- ],
282
- valueFlags: ["--home", "--repo", "--dashboard-url", "--supabase-url"],
283
- });
284
- assertNoPositionals(values.positionals, "install");
285
- return {
286
- kind: "install",
287
- homeDir: optionalNonEmpty(values.flags.get("--home")),
288
- repoRoot: optionalNonEmpty(values.flags.get("--repo")),
289
- dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
290
- supabaseUrl: optionalNonEmpty(values.flags.get("--supabase-url")),
291
- json: values.booleans.has("--json"),
292
- };
293
- }
294
- function parseLoginArgs(args) {
295
- const values = parseNamedArgs(args, {
296
- allowedFlags: [
297
- "--home",
298
- "--dashboard-url",
299
- "--email",
300
- "--device-name",
301
- "--json",
302
- "--poll-interval-ms",
303
- "--timeout-ms",
304
- ],
305
- valueFlags: [
306
- "--home",
307
- "--dashboard-url",
308
- "--email",
309
- "--device-name",
310
- "--poll-interval-ms",
311
- "--timeout-ms",
312
- ],
313
- });
314
- assertNoPositionals(values.positionals, "login");
315
- return {
316
- kind: "login",
317
- homeDir: optionalNonEmpty(values.flags.get("--home")),
318
- dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
319
- claimedOwnerEmail: optionalEmail(values.flags.get("--email")),
320
- deviceName: optionalNonEmpty(values.flags.get("--device-name")),
321
- json: values.booleans.has("--json"),
322
- pollIntervalMs: optionalPositiveInteger(values.flags.get("--poll-interval-ms"), "--poll-interval-ms"),
323
- timeoutMs: optionalPositiveInteger(values.flags.get("--timeout-ms"), "--timeout-ms"),
324
- };
325
- }
326
- function parseLogoutArgs(args) {
327
- const values = parseNamedArgs(args, {
328
- allowedFlags: ["--home", "--json"],
329
- valueFlags: ["--home"],
330
- });
331
- assertNoPositionals(values.positionals, "logout");
332
- return {
333
- kind: "logout",
334
- homeDir: optionalNonEmpty(values.flags.get("--home")),
335
- json: values.booleans.has("--json"),
336
- };
337
- }
338
- function parseStartArgs(args) {
339
- const values = parseNamedArgs(args, {
340
- allowedFlags: [
341
- "--home",
342
- "--repo",
343
- "--branch",
344
- "--ticket",
345
- "--operator-id",
346
- "--session-id",
347
- "--json",
348
- "--max-depth",
349
- "--max-repos",
350
- ],
351
- valueFlags: [
352
- "--home",
353
- "--repo",
354
- "--branch",
355
- "--ticket",
356
- "--operator-id",
357
- "--session-id",
358
- "--max-depth",
359
- "--max-repos",
360
- ],
361
- });
362
- assertNoPositionals(values.positionals, "start");
363
- return {
364
- kind: "start",
365
- homeDir: optionalNonEmpty(values.flags.get("--home")),
366
- repoRoot: optionalNonEmpty(values.flags.get("--repo")),
367
- branch: optionalNonEmpty(values.flags.get("--branch")),
368
- activeTicketId: optionalNonEmpty(values.flags.get("--ticket")),
369
- operatorId: optionalNonEmpty(values.flags.get("--operator-id")),
370
- sessionId: optionalNonEmpty(values.flags.get("--session-id")),
371
- json: values.booleans.has("--json"),
372
- maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
373
- maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
374
- };
375
- }
376
- function parseSyncArgs(args) {
377
- const values = parseNamedArgs(args, {
378
- allowedFlags: ["--home", "--repo", "--dashboard-url", "--json", "--max-depth", "--max-repos"],
379
- valueFlags: ["--home", "--repo", "--dashboard-url", "--max-depth", "--max-repos"],
380
- });
381
- assertNoPositionals(values.positionals, "sync");
382
- return {
383
- kind: "sync",
384
- homeDir: optionalNonEmpty(values.flags.get("--home")),
385
- repoRoot: optionalNonEmpty(values.flags.get("--repo")),
386
- dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
387
- json: values.booleans.has("--json"),
388
- maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
389
- maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
390
- };
391
- }
392
- function parseStatusArgs(args) {
393
- const values = parseNamedArgs(args, {
394
- allowedFlags: ["--home", "--repo", "--json", "--max-depth", "--max-repos"],
395
- valueFlags: ["--home", "--repo", "--max-depth", "--max-repos"],
396
- });
397
- assertNoPositionals(values.positionals, "status");
398
- return {
399
- kind: "status",
400
- homeDir: optionalNonEmpty(values.flags.get("--home")),
401
- repoRoot: optionalNonEmpty(values.flags.get("--repo")),
402
- json: values.booleans.has("--json"),
403
- maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
404
- maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
405
- };
406
- }
407
- function parseSessionsArgs(args) {
408
- const values = parseNamedArgs(args, {
409
- allowedFlags: ["--home", "--repo", "--source", "--json", "--max-depth", "--max-repos"],
410
- valueFlags: ["--home", "--repo", "--source", "--max-depth", "--max-repos"],
411
- });
412
- assertNoPositionals(values.positionals, "sessions");
413
- const source = values.flags.get("--source");
414
- if (source !== undefined && source !== "codex" && source !== "claude") {
415
- throw new Error("--source must be 'codex' or 'claude'.");
416
- }
417
- return {
418
- kind: "sessions",
419
- homeDir: optionalNonEmpty(values.flags.get("--home")),
420
- repoRoot: optionalNonEmpty(values.flags.get("--repo")),
421
- source,
422
- json: values.booleans.has("--json"),
423
- maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
424
- maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
425
- };
426
- }
427
- function parseServeArgs(args) {
428
- const values = parseNamedArgs(args, {
429
- allowedFlags: ["--home", "--repo", "--port"],
430
- valueFlags: ["--home", "--repo", "--port"],
431
- });
432
- assertNoPositionals(values.positionals, "serve");
433
- const port = Number(values.flags.get("--port") ?? "4174");
434
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
435
- throw new Error("--port must be a TCP port between 1 and 65535.");
436
- }
437
- return {
438
- kind: "serve",
439
- homeDir: optionalNonEmpty(values.flags.get("--home")),
440
- repoRoot: optionalNonEmpty(values.flags.get("--repo")),
441
- port,
442
- };
443
- }
444
- function parseAutostartArgs(args) {
445
- const values = parseNamedArgs(args, {
446
- allowedFlags: [
447
- "--home",
448
- "--repo",
449
- "--dashboard-url",
450
- "--interval-seconds",
451
- "--json",
452
- ],
453
- valueFlags: ["--home", "--repo", "--dashboard-url", "--interval-seconds"],
454
- });
455
- if (values.positionals.length > 1) {
456
- throw new Error("autostart accepts at most one action (install|uninstall|status).");
457
- }
458
- const action = values.positionals[0] ?? "install";
459
- if (action !== "install" && action !== "uninstall" && action !== "status") {
460
- throw new Error("autostart action must be install, uninstall, or status.");
461
- }
462
- return {
463
- kind: "autostart",
464
- action,
465
- homeDir: optionalNonEmpty(values.flags.get("--home")),
466
- repoRoot: optionalNonEmpty(values.flags.get("--repo")),
467
- dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
468
- intervalSeconds: optionalPositiveInteger(values.flags.get("--interval-seconds"), "--interval-seconds") ?? 1800,
469
- json: values.booleans.has("--json"),
470
- };
471
- }
472
- function parseNamedArgs(args, options) {
473
- const allowed = new Set(options.allowedFlags);
474
- const valueFlags = new Set(options.valueFlags);
475
- const flags = new Map();
476
- const booleans = new Set();
477
- const positionals = [];
478
- for (let index = 0; index < args.length; index += 1) {
479
- const arg = args[index] ?? "";
480
- rejectServiceRoleLikeArgument(arg);
481
- if (!arg.startsWith("--")) {
482
- positionals.push(arg);
483
- continue;
484
- }
485
- const [flag, inlineValue] = arg.split("=", 2);
486
- if (!allowed.has(flag))
487
- throw new Error(`Unknown flag: ${flag}`);
488
- if (valueFlags.has(flag)) {
489
- const value = inlineValue ?? args[index + 1];
490
- if (!value || value.startsWith("--")) {
491
- throw new Error(`${flag} requires a value.`);
492
- }
493
- rejectServiceRoleLikeArgument(value);
494
- flags.set(flag, value);
495
- if (inlineValue === undefined)
496
- index += 1;
497
- }
498
- else {
499
- if (inlineValue !== undefined)
500
- throw new Error(`${flag} does not accept a value.`);
501
- booleans.add(flag);
502
- }
503
- }
504
- return { flags, booleans, positionals };
505
- }
506
196
  async function runInstall(command, io) {
507
197
  const result = await installLocalCollector(command);
508
198
  if (command.json) {
@@ -782,497 +472,6 @@ async function readOnboardSessionReuseCandidate(homeDir) {
782
472
  function normalizeUrlForComparison(value) {
783
473
  return value ? normalizeUrl(value) : null;
784
474
  }
785
- const CLAUDE_FIRST_RUN_BACKFILL_MINUTES = 14 * 24 * 60;
786
- const CLAUDE_DAMP_GROWTH_BYTES = 256 * 1024;
787
- const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
788
- /**
789
- * Shared dual-source sync orchestration for single-repo and parent-folder
790
- * modes. Codex AND Claude Code sessions are scanned and attributed once across
791
- * every discovered worktree; each worktree syncs with only its own attributed
792
- * transcripts (codex + claude main + sidecars), and the
793
- * ambiguous/unattributed/skipped remainder is reported with reason labels and a
794
- * `source` discriminator instead of being duplicated into every repo or
795
- * silently dropped. The session row's upload state maps ONLY from the main-file
796
- * outcome (D3); sidecar outcomes aggregate into CLI counts.
797
- */
798
- async function runAttributedWorktreeSync(options) {
799
- const now = new Date();
800
- const homeDir = options.homeDir ?? os.homedir();
801
- const paths = getCollectorRuntimePaths(options.homeDir);
802
- const claudeEnabled = await isClaudeCollectionEnabled(paths);
803
- const codexAttribution = await scanAndAttributeCodexSessions({
804
- sessionsDir: path.join(homeDir, ".codex", "sessions"),
805
- worktrees: options.worktrees,
806
- now,
807
- });
808
- // First run (D B.4 §8): without a Claude cursor yet, widen the window to 14
809
- // days so the first sync captures retroactive history instead of only 24h.
810
- const claudeCursorExists = await fileExists(path.join(paths.cursors_dir, CLAUDE_CURSOR_FILENAME));
811
- const firstRunBackfill = claudeEnabled && !claudeCursorExists;
812
- const claudeAttribution = claudeEnabled
813
- ? await scanAndAttributeClaudeSessions({
814
- projectsDir: path.join(homeDir, ".claude", "projects"),
815
- worktrees: options.worktrees,
816
- now,
817
- sinceMinutes: firstRunBackfill
818
- ? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
819
- : undefined,
820
- })
821
- : emptyClaudeScan();
822
- // Read the Claude cursor up front: damping decisions need prior upload state.
823
- const claudeCursorBefore = claudeEnabled
824
- ? await readRawEvidenceCursor(paths, {
825
- filename: CLAUDE_CURSOR_FILENAME,
826
- }).catch(() => emptyRawEvidenceCursorState())
827
- : emptyRawEvidenceCursorState();
828
- // sessionId -> prior durable pointer for damped sessions (drives the
829
- // growth_damped count + the skip_main decision).
830
- const dampedClaudePointers = new Map();
831
- // sessionId -> prior durable pointer for EVERY already-durable Claude session
832
- // (superset of damped). A session that was durable before but had no fresh
833
- // upload this sync (damped, spooled, budget-deferred) reports reused_existing
834
- // with this pointer instead of not_uploaded, so the store row never flips.
835
- const claudePriorDurablePointers = new Map();
836
- for (const [sessionId, entry] of Object.entries(claudeCursorBefore.sessions)) {
837
- if (entry.uploaded_object_key) {
838
- claudePriorDurablePointers.set(sessionId, entry.uploaded_object_key);
839
- }
840
- }
841
- // One shared budget for the whole sync (D7b is per-sync): a parent-folder
842
- // sync over many worktrees honors a single byte/object cap rather than N×.
843
- const rawEvidenceBudget = {
844
- remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
845
- remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
846
- };
847
- const outcomes = [];
848
- let ok = true;
849
- for (const worktree of options.worktrees) {
850
- let context = null;
851
- if (options.startContexts) {
852
- context = await startLocalWorkContext({
853
- homeDir: options.homeDir,
854
- repoRoot: worktree.repo_root,
855
- branch: options.branch,
856
- activeTicketId: options.activeTicketId,
857
- operatorId: options.operatorId,
858
- sessionId: options.sessionId,
859
- });
860
- }
861
- const claudeSessionFiles = claudeAttribution.results
862
- .filter((result) => result.state === "attributed" &&
863
- result.worktree?.worktree_fingerprint ===
864
- worktree.worktree_fingerprint)
865
- .map((result) => {
866
- const damped = !result.main_file_oversized &&
867
- shouldDampClaudeMain(result, claudeCursorBefore, now);
868
- if (damped) {
869
- dampedClaudePointers.set(result.claude_session_id, claudeCursorBefore.sessions[result.claude_session_id]
870
- ?.uploaded_object_key ?? null);
871
- }
872
- return {
873
- local_path: result.file_path,
874
- claude_session_id: result.claude_session_id,
875
- main_file_oversized: result.main_file_oversized,
876
- skip_main: damped,
877
- sidecar_files: result.sidecar_files
878
- .filter((sidecar) => !sidecar.skipped_reason)
879
- .map((sidecar) => ({ local_path: sidecar.local_path })),
880
- };
881
- });
882
- const syncOptions = {
883
- homeDir: options.homeDir,
884
- repoRoot: worktree.repo_root,
885
- dashboardUrl: options.dashboardUrl,
886
- codexSessionFiles: codexAttribution.results
887
- .filter((result) => result.state === "attributed" &&
888
- result.worktree?.worktree_fingerprint ===
889
- worktree.worktree_fingerprint)
890
- .map((result) => ({
891
- local_path: result.file_path,
892
- codex_session_id: result.codex_session_id,
893
- })),
894
- claudeSessionFiles,
895
- rawEvidenceBudget,
896
- fetch: options.fetchImpl,
897
- };
898
- let sync;
899
- try {
900
- sync = await syncLocalAmbientEnvelope(syncOptions);
901
- }
902
- catch (error) {
903
- // A newly cloned repo has no work context yet. Capture is permissive
904
- // and ticket binding comes later, so start general ambient capture for
905
- // it instead of blocking every other repo's sync until someone runs
906
- // `cockpit start` by hand.
907
- if (error instanceof LocalUploadBlockedError &&
908
- error.blocker === "missing_context") {
909
- context = await startLocalWorkContext({
910
- homeDir: options.homeDir,
911
- repoRoot: worktree.repo_root,
912
- branch: options.branch,
913
- });
914
- sync = await syncLocalAmbientEnvelope(syncOptions);
915
- }
916
- else {
917
- throw error;
918
- }
919
- }
920
- ok = ok && sync.status === "uploaded";
921
- outcomes.push({ worktree, context, sync });
922
- }
923
- const sessions = buildAgentSessionReport({
924
- codexResults: codexAttribution.results,
925
- claudeResults: claudeAttribution.results,
926
- outcomes,
927
- now,
928
- claudePriorDurablePointers,
929
- });
930
- // The sessions cursor is an optimization; a broken local state dir must not
931
- // turn already-completed syncs into a CLI crash.
932
- let codexStaleCount = 0;
933
- let claudeStaleCount = 0;
934
- try {
935
- const codexCursor = await readRawEvidenceCursor(paths);
936
- codexStaleCount = recordSourceObservations({
937
- cursor: codexCursor,
938
- results: codexAttribution.results,
939
- sessions,
940
- source: "codex",
941
- sessionIdOf: (result) => result.codex_session_id,
942
- now,
943
- });
944
- codexCursor.updated_at = now.toISOString();
945
- await writeRawEvidenceCursor(paths, codexCursor);
946
- }
947
- catch {
948
- // Best-effort: stale counts read 0 and observations re-record next sync.
949
- }
950
- if (claudeEnabled) {
951
- try {
952
- claudeStaleCount = recordSourceObservations({
953
- cursor: claudeCursorBefore,
954
- results: claudeAttribution.results,
955
- sessions,
956
- source: "claude_code",
957
- sessionIdOf: (result) => result.claude_session_id,
958
- now,
959
- priorCursor: claudeCursorBefore,
960
- });
961
- claudeCursorBefore.updated_at = now.toISOString();
962
- await writeRawEvidenceCursor(paths, claudeCursorBefore, {
963
- filename: CLAUDE_CURSOR_FILENAME,
964
- sessionsOnly: true,
965
- });
966
- }
967
- catch {
968
- // Best-effort: a broken Claude cursor must not fail the sync.
969
- }
970
- }
971
- const firstUploaded = outcomes.find((outcome) => outcome.sync.status === "uploaded");
972
- const report = firstUploaded
973
- ? await postCodexSessionReport({
974
- homeDir: options.homeDir,
975
- repoRoot: firstUploaded.worktree.repo_root,
976
- dashboardUrl: options.dashboardUrl,
977
- sessions,
978
- fetch: options.fetchImpl,
979
- now,
980
- })
981
- : {
982
- posted: false,
983
- reason: sessions.length === 0 ? "no_sessions_observed" : "no_successful_sync",
984
- };
985
- const summary = buildAgentSessionSummary({
986
- codexAttribution,
987
- claudeAttribution,
988
- outcomes,
989
- codexStaleCount,
990
- claudeStaleCount,
991
- firstRunBackfill,
992
- growthDamped: dampedClaudePointers.size,
993
- report,
994
- });
995
- return { ok, outcomes, codexAttribution, claudeAttribution, summary };
996
- }
997
- const ATTRIBUTION_STATE_RANK = {
998
- attributed: 3,
999
- ambiguous: 2,
1000
- unattributed: 1,
1001
- skipped: 0,
1002
- };
1003
- function normalizeCodexResult(result) {
1004
- return {
1005
- source: "codex",
1006
- session_id: result.codex_session_id,
1007
- state: result.state,
1008
- reason: result.reason,
1009
- signals: result.signals,
1010
- attribution_score: result.attribution_score,
1011
- path_score: result.path_score,
1012
- content_hash_sha256: result.content_hash_sha256,
1013
- byte_size: result.byte_size,
1014
- session_file_mtime: result.session_file_mtime,
1015
- session_file_mtime_ms: result.session_file_mtime_ms,
1016
- worktree: result.worktree,
1017
- cwd_basename: result.cwd_basename,
1018
- cwd_hash: result.cwd_hash,
1019
- };
1020
- }
1021
- function normalizeClaudeResult(result) {
1022
- return {
1023
- source: "claude_code",
1024
- session_id: result.claude_session_id,
1025
- state: result.state,
1026
- reason: result.reason,
1027
- signals: result.signals,
1028
- attribution_score: result.attribution_score,
1029
- path_score: result.path_score,
1030
- content_hash_sha256: result.content_hash_sha256,
1031
- byte_size: result.byte_size,
1032
- session_file_mtime: result.session_file_mtime,
1033
- session_file_mtime_ms: result.session_file_mtime_ms,
1034
- worktree: result.worktree,
1035
- cwd_basename: result.cwd_basename,
1036
- cwd_hash: result.cwd_hash,
1037
- };
1038
- }
1039
- /**
1040
- * Generalizes the per-session report across sources. Dedupe is per
1041
- * `(source, session_id)` so a Codex session and a Claude session that happen to
1042
- * share an id are never collapsed. Upload state maps ONLY from the main-file
1043
- * outcome (kind `codex_jsonl` / `claude_jsonl`); sidecar outcomes never set a
1044
- * session's upload state (D3). Damped Claude sessions report `reused_existing`
1045
- * carrying their prior durable pointer.
1046
- */
1047
- function buildAgentSessionReport(options) {
1048
- const normalized = [
1049
- ...options.codexResults.map(normalizeCodexResult),
1050
- ...options.claudeResults.map(normalizeClaudeResult),
1051
- ];
1052
- const bestByKey = new Map();
1053
- for (const result of normalized) {
1054
- const key = `${result.source}:${result.session_id}`;
1055
- const existing = bestByKey.get(key);
1056
- if (!existing ||
1057
- (ATTRIBUTION_STATE_RANK[result.state] ?? 0) >
1058
- (ATTRIBUTION_STATE_RANK[existing.state] ?? 0) ||
1059
- ((ATTRIBUTION_STATE_RANK[result.state] ?? 0) ===
1060
- (ATTRIBUTION_STATE_RANK[existing.state] ?? 0) &&
1061
- result.session_file_mtime_ms > existing.session_file_mtime_ms)) {
1062
- bestByKey.set(key, result);
1063
- }
1064
- }
1065
- // Main-file outcomes only (D3): a sidecar making it must never mark a session
1066
- // uploaded when the main did not.
1067
- const uploadByKey = new Map();
1068
- for (const outcome of options.outcomes) {
1069
- if (outcome.sync.status !== "uploaded")
1070
- continue;
1071
- for (const upload of outcome.sync.raw_evidence_outcomes) {
1072
- if (!upload.codex_session_id || !upload.raw_evidence_pointer_id)
1073
- continue;
1074
- const source = upload.kind === "claude_jsonl"
1075
- ? "claude_code"
1076
- : upload.kind === "codex_jsonl"
1077
- ? "codex"
1078
- : null;
1079
- if (!source)
1080
- continue; // sidecars and other kinds do not set session state
1081
- uploadByKey.set(`${source}:${upload.codex_session_id}`, upload);
1082
- }
1083
- }
1084
- return [...bestByKey.values()].map((result) => {
1085
- const key = `${result.source}:${result.session_id}`;
1086
- const upload = uploadByKey.get(key);
1087
- // A previously-durable Claude session with no fresh main upload this sync
1088
- // (damped / spooled / budget-deferred) reports reused_existing + its prior
1089
- // pointer rather than not_uploaded, so the store row never flips.
1090
- const priorDurablePointer = result.source === "claude_code"
1091
- ? (options.claudePriorDurablePointers.get(result.session_id) ?? null)
1092
- : null;
1093
- return {
1094
- codex_session_id: result.session_id,
1095
- source: result.source,
1096
- observed_at: options.now.toISOString(),
1097
- attribution_state: result.state,
1098
- attribution_reason: result.reason,
1099
- attribution_score: result.attribution_score,
1100
- path_score: result.path_score,
1101
- signals: result.signals,
1102
- ...(result.content_hash_sha256
1103
- ? { session_file_hash_sha256: result.content_hash_sha256 }
1104
- : {}),
1105
- session_file_byte_size: result.byte_size,
1106
- session_file_mtime: result.session_file_mtime,
1107
- ...(result.worktree
1108
- ? {
1109
- repo_fingerprint: result.worktree.repo_fingerprint,
1110
- worktree_fingerprint: result.worktree.worktree_fingerprint,
1111
- repo_label: result.worktree.repo_label,
1112
- branch: result.worktree.branch,
1113
- }
1114
- : {}),
1115
- ...(result.cwd_basename ? { cwd_basename: result.cwd_basename } : {}),
1116
- ...(result.cwd_hash ? { cwd_hash: result.cwd_hash } : {}),
1117
- ...(upload
1118
- ? {
1119
- raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
1120
- upload_state: upload.upload_state,
1121
- }
1122
- : priorDurablePointer
1123
- ? {
1124
- raw_evidence_pointer_id: priorDurablePointer,
1125
- upload_state: "reused_existing",
1126
- }
1127
- : result.state === "attributed"
1128
- ? { upload_state: "not_uploaded" }
1129
- : {}),
1130
- };
1131
- });
1132
- }
1133
- /**
1134
- * Records per-source session observations into its cursor and returns the stale
1135
- * count. Damped/reused Claude sessions carry forward their prior upload
1136
- * timestamp + byte size so the 6h damping window keeps counting from the real
1137
- * last upload (otherwise a slowly-growing file would never re-upload — D21).
1138
- */
1139
- function recordSourceObservations(options) {
1140
- const seen = new Set(options.results.map((result) => options.sessionIdOf(result)));
1141
- const stale = countStaleSessions(options.cursor, seen);
1142
- for (const result of options.results) {
1143
- const sessionId = options.sessionIdOf(result);
1144
- const reported = options.sessions.find((session) => session.source === options.source &&
1145
- session.codex_session_id === sessionId);
1146
- const uploadedThisSync = reported?.upload_state === "uploaded";
1147
- const durableThisSync = reported?.upload_state === "uploaded" ||
1148
- reported?.upload_state === "reused_existing";
1149
- const prior = options.priorCursor?.sessions[sessionId];
1150
- // D21 / no-flip-flop: a sync that is spooled (offline), budget-deferred, or
1151
- // upload-failed for a session that was ALREADY durable must NOT wipe the
1152
- // prior durable state — otherwise damping is forfeited forever and the
1153
- // store row oscillates uploaded -> not_uploaded hourly. Carry the prior
1154
- // durable pointer/timestamp/size forward unless we durably uploaded anew.
1155
- const uploadedObjectKey = durableThisSync
1156
- ? (reported?.raw_evidence_pointer_id ?? prior?.uploaded_object_key ?? null)
1157
- : (prior?.uploaded_object_key ?? null);
1158
- const uploadedAt = uploadedThisSync
1159
- ? options.now.toISOString()
1160
- : (prior?.uploaded_at ?? (durableThisSync ? options.now.toISOString() : null));
1161
- const uploadedByteSize = uploadedThisSync
1162
- ? result.byte_size
1163
- : (prior?.uploaded_byte_size ??
1164
- (durableThisSync ? result.byte_size : null));
1165
- const entry = {
1166
- file_hash_sha256: result.content_hash_sha256,
1167
- file_mtime_ms: result.session_file_mtime_ms,
1168
- byte_size: result.byte_size,
1169
- // Durable byte offset reflects how many bytes are durable remotely (the
1170
- // last uploaded size), not the current file size.
1171
- byte_offset: uploadedByteSize ?? 0,
1172
- state: result.state,
1173
- reason: result.reason,
1174
- worktree_fingerprint: result.worktree?.worktree_fingerprint ?? null,
1175
- uploaded_object_key: uploadedObjectKey,
1176
- uploaded_at: uploadedAt,
1177
- uploaded_byte_size: uploadedByteSize,
1178
- last_seen_at: options.now.toISOString(),
1179
- };
1180
- recordSessionObservation(options.cursor, sessionId, entry);
1181
- }
1182
- return stale;
1183
- }
1184
- function shouldDampClaudeMain(result, cursor, now) {
1185
- const entry = cursor.sessions[result.claude_session_id];
1186
- if (!entry ||
1187
- !entry.uploaded_object_key ||
1188
- !entry.uploaded_at ||
1189
- entry.uploaded_byte_size == null) {
1190
- return false;
1191
- }
1192
- const grew = result.byte_size > entry.uploaded_byte_size;
1193
- if (!grew)
1194
- return false; // unchanged content reuses via the object cursor
1195
- const growth = result.byte_size - entry.uploaded_byte_size;
1196
- const ageMs = now.getTime() - Date.parse(entry.uploaded_at);
1197
- return (growth <= CLAUDE_DAMP_GROWTH_BYTES &&
1198
- Number.isFinite(ageMs) &&
1199
- ageMs <= CLAUDE_DAMP_MAX_AGE_MS);
1200
- }
1201
- function buildAgentSessionSummary(options) {
1202
- const sidecarOutcomes = options.outcomes.flatMap((outcome) => outcome.sync.raw_evidence_outcomes.filter((upload) => upload.kind === "claude_jsonl_sidecar"));
1203
- const attributedClaude = options.claudeAttribution.results.filter((result) => result.state === "attributed");
1204
- const sidecarsCollected = attributedClaude.reduce((total, result) => total +
1205
- result.sidecar_files.filter((sidecar) => !sidecar.skipped_reason).length, 0);
1206
- const sidecarsSkipped = options.claudeAttribution.results.reduce((total, result) => total +
1207
- result.sidecar_files.filter((sidecar) => sidecar.skipped_reason).length, 0);
1208
- const codex = {
1209
- scanned: options.codexAttribution.scanned_file_count,
1210
- attributed: options.codexAttribution.counts.attributed,
1211
- ambiguous: options.codexAttribution.counts.ambiguous,
1212
- unattributed: options.codexAttribution.counts.unattributed,
1213
- skipped: options.codexAttribution.counts.skipped,
1214
- stale: options.codexStaleCount,
1215
- };
1216
- const claude = {
1217
- scanned: options.claudeAttribution.scanned_session_count,
1218
- attributed: options.claudeAttribution.counts.attributed,
1219
- ambiguous: options.claudeAttribution.counts.ambiguous,
1220
- unattributed: options.claudeAttribution.counts.unattributed,
1221
- skipped: options.claudeAttribution.counts.skipped,
1222
- stale: options.claudeStaleCount,
1223
- sidecars_collected: sidecarsCollected,
1224
- sidecars_uploaded: sidecarOutcomes.filter((upload) => upload.upload_state === "uploaded" ||
1225
- upload.upload_state === "reused_existing").length,
1226
- sidecars_skipped: sidecarsSkipped,
1227
- sidecars_capped: options.claudeAttribution.counts.sidecars_capped,
1228
- sidecars_failed: sidecarOutcomes.filter((upload) => upload.upload_state === "upload_failed").length,
1229
- mains_oversized: options.claudeAttribution.counts.mains_oversized,
1230
- oversized_lines_skipped: options.claudeAttribution.counts.oversized_lines_skipped,
1231
- project_dirs_skipped: options.claudeAttribution.project_dirs_skipped,
1232
- sessions_schema_drift: options.claudeAttribution.counts.sessions_schema_drift,
1233
- growth_damped: options.growthDamped,
1234
- first_run_backfill: options.firstRunBackfill,
1235
- };
1236
- return {
1237
- scanned: codex.scanned,
1238
- attributed: codex.attributed,
1239
- ambiguous: codex.ambiguous,
1240
- unattributed: codex.unattributed,
1241
- skipped: codex.skipped,
1242
- stale: codex.stale,
1243
- report_posted: options.report.posted,
1244
- report_reason: options.report.reason,
1245
- codex,
1246
- claude,
1247
- files_deferred_byte_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_byte_budget, 0),
1248
- files_deferred_object_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_object_budget, 0),
1249
- };
1250
- }
1251
- function emptyClaudeScan() {
1252
- return {
1253
- results: [],
1254
- scanned_session_count: 0,
1255
- project_dirs_skipped: 0,
1256
- counts: {
1257
- attributed: 0,
1258
- ambiguous: 0,
1259
- unattributed: 0,
1260
- skipped: 0,
1261
- mains_oversized: 0,
1262
- oversized_lines_skipped: 0,
1263
- sessions_schema_drift: 0,
1264
- sidecars_capped: 0,
1265
- },
1266
- };
1267
- }
1268
- async function isClaudeCollectionEnabled(paths) {
1269
- const config = await readLocalCollectorConfig(paths).catch(() => null);
1270
- return config?.collect_claude_jsonl !== false;
1271
- }
1272
- async function fileExists(filePath) {
1273
- const { stat } = await import("node:fs/promises");
1274
- return stat(filePath).then(() => true, () => false);
1275
- }
1276
475
  function shortSha(value) {
1277
476
  return value ? value.slice(0, 12) : "unknown";
1278
477
  }
@@ -1828,75 +1027,6 @@ function writeAutostartResult(io, result) {
1828
1027
  return;
1829
1028
  }
1830
1029
  }
1831
- function assertNoPositionals(positionals, command) {
1832
- if (positionals.length > 0) {
1833
- throw new Error(`${command} does not accept positional arguments.`);
1834
- }
1835
- }
1836
- function optionalNonEmpty(value) {
1837
- const trimmed = value?.trim();
1838
- return trimmed ? trimmed : undefined;
1839
- }
1840
- function optionalUrl(value) {
1841
- return value === undefined ? undefined : normalizeUrl(value);
1842
- }
1843
- function optionalEmail(value) {
1844
- const trimmed = value?.trim().toLowerCase();
1845
- if (!trimmed)
1846
- return undefined;
1847
- if (!trimmed.includes("@")) {
1848
- throw new Error("--email must be a valid email address.");
1849
- }
1850
- return trimmed;
1851
- }
1852
- function optionalPositiveInteger(value, flag) {
1853
- if (value === undefined)
1854
- return undefined;
1855
- const parsed = Number(value);
1856
- if (!Number.isInteger(parsed) || parsed < 1) {
1857
- throw new Error(`${flag} must be a positive integer.`);
1858
- }
1859
- return parsed;
1860
- }
1861
- function normalizeUrl(value) {
1862
- const trimmed = value.trim().replace(/\/+$/, "");
1863
- if (!trimmed)
1864
- throw new Error("URL value cannot be empty.");
1865
- return trimmed;
1866
- }
1867
- function rejectServiceRoleLikeArgument(value) {
1868
- if (!looksLikeServiceRoleSecret(value))
1869
- return;
1870
- throw new Error("Service-role credentials are not accepted by local collector commands.");
1871
- }
1872
- function looksLikeServiceRoleSecret(value) {
1873
- if (serviceCredentialNamePattern().test(value))
1874
- return true;
1875
- const parts = value.split(".");
1876
- if (parts.length !== 3)
1877
- return false;
1878
- try {
1879
- const payload = Buffer.from(base64UrlToBase64(parts[1] ?? ""), "base64").toString("utf8");
1880
- return serviceCredentialPayloadPattern().test(payload);
1881
- }
1882
- catch {
1883
- return false;
1884
- }
1885
- }
1886
- function serviceCredentialNamePattern() {
1887
- return new RegExp([
1888
- ["SUPABASE", "SERVICE", "ROLE", "KEY"].join("[_-]?"),
1889
- ["service", "role"].join("[_-]?"),
1890
- ].join("|"), "i");
1891
- }
1892
- function serviceCredentialPayloadPattern() {
1893
- const privilegedRole = ["service", "role"].join("_");
1894
- return new RegExp(`"role"\\s*:\\s*"${privilegedRole}"`);
1895
- }
1896
- function base64UrlToBase64(value) {
1897
- const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
1898
- return `${normalized}${"=".repeat((4 - (normalized.length % 4)) % 4)}`;
1899
- }
1900
1030
  function defaultExec() {
1901
1031
  return (cmd, args) => new Promise((resolve) => {
1902
1032
  execFile(cmd, args, { encoding: "utf8" }, (err, stdout, stderr) => {