@gajae-code/coding-agent 0.12.19 → 0.12.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +20 -9
  2. package/dist/types/commands/gc.d.ts +4 -0
  3. package/dist/types/commands/sdk.d.ts +66 -0
  4. package/dist/types/config/settings-schema.d.ts +34 -0
  5. package/dist/types/gjc-runtime/gc-runtime.d.ts +97 -1
  6. package/dist/types/modes/acp/acp-agent.d.ts +33 -1
  7. package/dist/types/modes/components/bash-execution.d.ts +7 -0
  8. package/dist/types/modes/components/eval-execution.d.ts +7 -0
  9. package/dist/types/modes/utils/ui-helpers.d.ts +10 -1
  10. package/dist/types/sdk/acp/adapter.d.ts +9 -17
  11. package/dist/types/sdk/acp/index.d.ts +1 -1
  12. package/dist/types/sdk/broker/broker.d.ts +93 -1
  13. package/dist/types/sdk/broker/ensure.d.ts +17 -0
  14. package/dist/types/sdk/broker/index.d.ts +1 -0
  15. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +10 -1
  16. package/dist/types/sdk/broker/lifecycle.d.ts +81 -2
  17. package/dist/types/sdk/broker/startup-budget.d.ts +44 -0
  18. package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
  19. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +11 -1
  20. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -0
  21. package/dist/types/sdk/bus/index.d.ts +20 -9
  22. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -6
  23. package/dist/types/sdk/bus/slack-daemon.d.ts +2 -2
  24. package/dist/types/sdk/host/query/revision-store.d.ts +5 -0
  25. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  26. package/dist/types/sdk/lifecycle-session.d.ts +13 -2
  27. package/dist/types/sdk/prompt-failure.d.ts +13 -0
  28. package/dist/types/sdk/prompt-watchdog.d.ts +82 -0
  29. package/dist/types/sdk/session-reconnect.d.ts +20 -0
  30. package/dist/types/sdk/startup-capability.d.ts +1 -1
  31. package/dist/types/session/agent-session.d.ts +14 -4
  32. package/dist/types/session/blob-store.d.ts +32 -0
  33. package/dist/types/session/internal/managed-session-storage.d.ts +40 -2
  34. package/dist/types/session/internal/session-memory-sidecar.d.ts +881 -0
  35. package/dist/types/session/session-manager.d.ts +301 -27
  36. package/dist/types/session/session-storage.d.ts +170 -0
  37. package/dist/types/task/executor.d.ts +2 -1
  38. package/dist/types/tools/bash.d.ts +1 -0
  39. package/dist/types/tui/output-block.d.ts +6 -0
  40. package/package.json +8 -8
  41. package/src/cli/args.ts +75 -34
  42. package/src/commands/acp.ts +7 -1
  43. package/src/commands/gc.ts +31 -2
  44. package/src/commands/sdk.ts +141 -7
  45. package/src/config/settings-schema.ts +43 -0
  46. package/src/coordinator-mcp/server.ts +6 -1
  47. package/src/export/html/index.ts +83 -39
  48. package/src/gjc-runtime/gc-runtime.ts +1283 -4
  49. package/src/internal-urls/docs-index.generated.ts +1 -1
  50. package/src/internal-urls/local-protocol.ts +14 -0
  51. package/src/main.ts +40 -9
  52. package/src/modes/acp/acp-agent.ts +564 -37
  53. package/src/modes/acp/terminal-auth.ts +2 -2
  54. package/src/modes/components/bash-execution.ts +14 -0
  55. package/src/modes/components/eval-execution.ts +14 -0
  56. package/src/modes/controllers/command-controller.ts +63 -8
  57. package/src/modes/utils/ui-helpers.ts +81 -11
  58. package/src/sdk/acp/adapter.ts +46 -45
  59. package/src/sdk/acp/index.ts +1 -1
  60. package/src/sdk/broker/broker.ts +392 -11
  61. package/src/sdk/broker/ensure.ts +131 -61
  62. package/src/sdk/broker/index.ts +1 -0
  63. package/src/sdk/broker/lifecycle-ledger.ts +59 -5
  64. package/src/sdk/broker/lifecycle.ts +278 -63
  65. package/src/sdk/broker/startup-budget.ts +103 -0
  66. package/src/sdk/bus/chat-daemon-control.ts +9 -3
  67. package/src/sdk/bus/chat-daemon-runtime.ts +565 -29
  68. package/src/sdk/bus/conversation-store.ts +1 -1
  69. package/src/sdk/bus/discord-daemon.ts +2 -1
  70. package/src/sdk/bus/discord-live-provider.ts +34 -6
  71. package/src/sdk/bus/index.ts +118 -20
  72. package/src/sdk/bus/kind-aware-reconciliation.ts +2 -1
  73. package/src/sdk/bus/prompt-reconciliation.ts +4 -9
  74. package/src/sdk/bus/slack-daemon.ts +91 -18
  75. package/src/sdk/cli/session-cli.ts +8 -1
  76. package/src/sdk/host/query/handlers.ts +1 -1
  77. package/src/sdk/host/query/revision-store.ts +48 -12
  78. package/src/sdk/host/session-runtime.test.ts +50 -0
  79. package/src/sdk/host/session-runtime.ts +12 -2
  80. package/src/sdk/lifecycle-session.ts +26 -3
  81. package/src/sdk/mcp/server.ts +6 -1
  82. package/src/sdk/prompt-failure.ts +42 -0
  83. package/src/sdk/prompt-watchdog.ts +162 -0
  84. package/src/sdk/session-reconnect.ts +43 -0
  85. package/src/sdk/startup-capability.ts +13 -4
  86. package/src/session/agent-session.ts +525 -222
  87. package/src/session/blob-store.ts +89 -0
  88. package/src/session/internal/managed-session-scope.ts +36 -26
  89. package/src/session/internal/managed-session-storage.ts +303 -32
  90. package/src/session/internal/session-memory-sidecar.ts +2160 -0
  91. package/src/session/session-manager.ts +7027 -394
  92. package/src/session/session-storage.ts +1134 -11
  93. package/src/task/executor.ts +17 -4
  94. package/src/tools/bash.ts +109 -7
  95. package/src/tools/find.ts +55 -26
  96. package/src/tools/todo-contract.ts +40 -8
  97. package/src/tui/output-block.ts +11 -1
@@ -13,9 +13,24 @@
13
13
  * - Dry-run by default: nothing is deleted unless `--prune`/`--force`.
14
14
  */
15
15
 
16
- import { getAgentDir } from "@gajae-code/utils";
16
+ import type { BigIntStats, Dirent, Stats } from "node:fs";
17
+ import * as fsp from "node:fs/promises";
18
+ import * as os from "node:os";
19
+ import * as path from "node:path";
20
+ import { exactRemoveDirectoryTree, exactUnlink, snapshotDirectoryTree } from "@gajae-code/natives";
21
+ import { getAgentDir, getBlobsDir, getSessionsDir, isEnoent, VERSION } from "@gajae-code/utils";
22
+ import { getDefault } from "../config/settings-schema";
23
+ import { listHarnessRootRegistriesForGc } from "../harness-control-plane/storage";
17
24
  import { SessionIndex } from "../sdk/broker/session-index";
18
25
  import { UnsupportedStateVersionError } from "../sdk/broker/state-version";
26
+ import {
27
+ BLOB_REFERENCE_MAX_LENGTH,
28
+ type CanonicalBlobEntry,
29
+ collectBlobReferences,
30
+ listCanonicalBlobs,
31
+ removeCanonicalBlob,
32
+ } from "../session/blob-store";
33
+ import { FileSessionStorage, probeSessionRetirement, retireSessionTranscript } from "../session/session-storage";
19
34
 
20
35
  import { buildGcReportText } from "./gc-render";
21
36
  import { collectSessionScopeUsage, type GcSessionScopeUsage, shouldReportSessionScope } from "./gc-session-scope";
@@ -155,6 +170,11 @@ export interface GcReport {
155
170
  session_index?: GcSessionIndexHealth;
156
171
  /** Managed-scope capacity, reported only when it is near or past the budget. */
157
172
  session_scope?: GcSessionScopeUsage;
173
+ /**
174
+ * Disk-retention findings. Present only when `--disk` was passed; the
175
+ * PID-liveness axis above is unchanged by its absence or presence.
176
+ */
177
+ disk?: GcDiskReport;
158
178
  }
159
179
 
160
180
  export interface GcRunResult {
@@ -261,6 +281,8 @@ interface ParsedGcArgs {
261
281
  json: boolean;
262
282
  prune: boolean;
263
283
  repairSessionIndex: boolean;
284
+ /** Opt-in second axis: report (and with `--prune`, reclaim) on-disk retention. */
285
+ disk: boolean;
264
286
  help: boolean;
265
287
  }
266
288
 
@@ -270,6 +292,7 @@ function parseGcArgs(argv: string[]): ParsedGcArgs {
270
292
  let json = false;
271
293
  let prune = false;
272
294
  let repairSessionIndex = false;
295
+ let disk = false;
273
296
 
274
297
  let dryRun = false;
275
298
  let help = false;
@@ -286,6 +309,9 @@ function parseGcArgs(argv: string[]): ParsedGcArgs {
286
309
  case "--repair-session-index":
287
310
  repairSessionIndex = true;
288
311
  break;
312
+ case "--disk":
313
+ disk = true;
314
+ break;
289
315
 
290
316
  case "--dry-run":
291
317
  dryRun = true;
@@ -302,7 +328,7 @@ function parseGcArgs(argv: string[]): ParsedGcArgs {
302
328
  if (repairSessionIndex && dryRun) throw new GcUsageError("repair_session_index_cannot_combine_with_dry_run");
303
329
  // Explicit --dry-run always wins over --prune/--force.
304
330
  if (dryRun) prune = false;
305
- return { json, prune, repairSessionIndex, help };
331
+ return { json, prune, repairSessionIndex, disk, help };
306
332
  }
307
333
 
308
334
  /**
@@ -376,10 +402,19 @@ export async function collectGcReport(adapters: GcStoreAdapter[], ctx: GcContext
376
402
  * - prune mode with a failed intended removal => 1
377
403
  * - warnings alone never fail the run
378
404
  * - otherwise => 0
405
+ *
406
+ * The disk axis reuses the same policy against its own errors/failures, and is
407
+ * inert when `--disk` was not passed (`report.disk` is then undefined). A
408
+ * fail-closed KEEP is never a failure — only a hard scan error or a reclaim
409
+ * that was attempted and threw.
379
410
  */
380
411
  export function computeExitCode(report: GcReport): number {
381
412
  if (report.errors.length > 0) return 1;
382
413
  if (!report.dry_run && report.counts.failed > 0) return 1;
414
+ if (report.disk) {
415
+ if (report.disk.errors.length > 0) return 1;
416
+ if (!report.disk.dry_run && report.disk.totals.failed > 0) return 1;
417
+ }
383
418
  return 0;
384
419
  }
385
420
 
@@ -440,6 +475,7 @@ export async function runGjcGcCommand(
440
475
  cwd: string = process.cwd(),
441
476
  env: NodeJS.ProcessEnv = process.env,
442
477
  adapters?: GcStoreAdapter[],
478
+ diskPolicy?: Partial<GcDiskPolicy>,
443
479
  ): Promise<GcRunResult> {
444
480
  let parsed: ParsedGcArgs;
445
481
  try {
@@ -458,6 +494,14 @@ export async function runGjcGcCommand(
458
494
  const report = await collectGcReport(resolvedAdapters, ctx, parsed.prune);
459
495
  report.operation = parsed.repairSessionIndex ? "repair_session_index" : parsed.prune ? "prune" : "dry_run";
460
496
  report.session_index = await collectSessionIndexHealth(parsed.repairSessionIndex, resolveGcAgentDir(env));
497
+ if (parsed.disk) {
498
+ report.disk = await collectGcDiskReport({
499
+ agentDir: resolveGcAgentDir(env),
500
+ env,
501
+ policy: resolveGcDiskPolicy(diskPolicy),
502
+ prune: parsed.prune,
503
+ });
504
+ }
461
505
  const scopeUsage = await collectGcSessionScope(cwd, resolveGcAgentDir(env));
462
506
  if (scopeUsage && shouldReportSessionScope(scopeUsage)) report.session_scope = scopeUsage;
463
507
  const sessionIndexFailed =
@@ -465,7 +509,9 @@ export async function runGjcGcCommand(
465
509
  report.session_index?.status === "unsupported" ||
466
510
  report.session_index?.status === "repair_failed";
467
511
  const status = sessionIndexFailed ? 1 : computeExitCode(report);
468
- const stdout = parsed.json ? `${JSON.stringify(report, null, 2)}\n` : buildGcReportText(report);
512
+ const stdout = parsed.json
513
+ ? `${JSON.stringify(report, null, 2)}\n`
514
+ : `${buildGcReportText(report)}${report.disk ? buildGcDiskReportText(report.disk) : ""}`;
469
515
  return { stdout, stderr: "", status };
470
516
  }
471
517
 
@@ -474,7 +520,7 @@ export function gcHelpText(): string {
474
520
  "gjc gc - garbage-collect stale GJC session/PID records",
475
521
  "",
476
522
  "USAGE",
477
- " $ gjc gc [--prune|--force] [--repair-session-index] [--json]",
523
+ " $ gjc gc [--prune|--force] [--disk] [--repair-session-index] [--json]",
478
524
 
479
525
  "",
480
526
  "FLAGS",
@@ -482,10 +528,16 @@ export function gcHelpText(): string {
482
528
  " --dry-run Force report-only mode (overrides --prune/--force)",
483
529
  " -j, --json Emit machine-readable JSON",
484
530
  " --repair-session-index Explicitly quarantine a corrupt session-index suffix and retain its valid prefix",
531
+ " --disk Also report on-disk retention (sessions, blobs, natives, backups)",
485
532
  "",
486
533
  "Liveness-only: a record is removed only when its owning process is dead",
487
534
  "(ESRCH). Live / permission-denied / unknown processes are always kept.",
488
535
  "",
536
+ "Disk retention (--disk) is a separate, opt-in axis. Without --prune it only",
537
+ "reports reclaimable bytes per surface. Live, referenced, permission-denied or",
538
+ "otherwise ambiguous state is always KEPT and the report says why. Managed",
539
+ "worktrees under ~/.gjc/wt are never touched.",
540
+ "",
489
541
  ].join("\n");
490
542
  }
491
543
 
@@ -513,3 +565,1230 @@ export async function defaultGcAdapters(): Promise<GcStoreAdapter[]> {
513
565
  localRootsGcAdapter,
514
566
  ];
515
567
  }
568
+
569
+ // =============================================================================
570
+ // Disk-retention axis (`gjc gc --disk`)
571
+ // =============================================================================
572
+ //
573
+ // A second, explicitly opt-in axis. The PID-liveness axis above answers "is the
574
+ // owner of this record dead?"; this one answers "are these bytes still reachable
575
+ // from anything live?". Both share the same posture: dry-run by default, and
576
+ // anything live, referenced, permission-denied, unreadable or ambiguous is KEPT
577
+ // with the reason recorded in the report.
578
+ //
579
+ // Deliberately out of scope: `~/.gjc/wt` managed worktrees. Removing a worktree
580
+ // needs evidence-based merge detection, which this axis does not have.
581
+
582
+ /** On-disk surfaces the retention axis can reclaim. */
583
+ export type GcDiskSurface = "sessions" | "blobs" | "natives" | "backups";
584
+
585
+ export const GC_DISK_SURFACES: readonly GcDiskSurface[] = ["sessions", "blobs", "natives", "backups"] as const;
586
+
587
+ export type GcDiskAction = "keep" | "would_reclaim" | "reclaimed" | "reclaim_failed";
588
+
589
+ export interface GcDiskRecord {
590
+ surface: GcDiskSurface;
591
+ /** Session id, blob hash, natives version, or backup entry name. */
592
+ id: string;
593
+ path: string;
594
+ bytes: number;
595
+ age_days: number;
596
+ action: GcDiskAction;
597
+ reason: string;
598
+ error?: string;
599
+ /** Set when `bytes` is a floor because a walk was capped or partially unreadable. */
600
+ partial?: true;
601
+ /** Set when this entry was a reclaim candidate that its surface withheld on incomplete evidence. */
602
+ withheld?: true;
603
+ }
604
+
605
+ /**
606
+ * Why a surface refused to reclaim anything, and how much it withheld to stay
607
+ * fail-closed. Present only when the surface declined.
608
+ */
609
+ export interface GcDiskDeclined {
610
+ reason: string;
611
+ withheld: number;
612
+ withheld_bytes: number;
613
+ }
614
+
615
+ export interface GcDiskSurfaceReport {
616
+ surface: GcDiskSurface;
617
+ root: string;
618
+ scanned: number;
619
+ scanned_bytes: number;
620
+ reclaimable: number;
621
+ reclaimable_bytes: number;
622
+ reclaimed: number;
623
+ reclaimed_bytes: number;
624
+ kept: number;
625
+ kept_bytes: number;
626
+ failed: number;
627
+ /** Set when the surface declined to reclaim anything because its evidence was incomplete. */
628
+ declined?: GcDiskDeclined;
629
+ records: GcDiskRecord[];
630
+ }
631
+
632
+ export interface GcDiskError {
633
+ surface: GcDiskSurface;
634
+ scope: string;
635
+ message: string;
636
+ }
637
+
638
+ /** Retention policy, mirroring the `gc.*` settings one-for-one. */
639
+ export interface GcDiskPolicy {
640
+ sessions_max_age_days: number;
641
+ /** 0 disables the size axis; only the age axis retires transcripts then. */
642
+ sessions_max_total_bytes: number;
643
+ natives_keep_versions: number;
644
+ backups_max_age_days: number;
645
+ }
646
+
647
+ export interface GcDiskReport {
648
+ dry_run: boolean;
649
+ policy: GcDiskPolicy;
650
+ surfaces: Record<GcDiskSurface, GcDiskSurfaceReport>;
651
+ totals: {
652
+ scanned_bytes: number;
653
+ reclaimable_bytes: number;
654
+ reclaimed_bytes: number;
655
+ kept_bytes: number;
656
+ failed: number;
657
+ };
658
+ errors: GcDiskError[];
659
+ }
660
+
661
+ const GC_DISK_DAY_MS = 24 * 60 * 60 * 1000;
662
+
663
+ /**
664
+ * A blob younger than this is never swept: a live session may have written the
665
+ * bytes but not yet appended the entry that references them.
666
+ */
667
+ const GC_DISK_BLOB_GRACE_MS = GC_DISK_DAY_MS;
668
+
669
+ /**
670
+ * How many extra times the blob mark may re-absorb transcripts that appeared or
671
+ * grew while it was running. Ordinary concurrent session activity settles in one
672
+ * extra round; a store that still will not settle is not evidence anyone can
673
+ * sweep on, so the sweep withholds instead.
674
+ */
675
+ const GC_DISK_MARK_REMARK_ROUNDS = 2;
676
+
677
+ /** Bound every recursive size walk so a pathological tree cannot stall `gjc gc`. */
678
+ const GC_DISK_MAX_WALK_ENTRIES = 200_000;
679
+
680
+ /** Cap per-surface record rendering; the JSON output always carries every record. */
681
+ const GC_DISK_MAX_RENDERED_RECORDS = 20;
682
+
683
+ /** Schema-backed defaults, so the CLI and the settings surface cannot drift. */
684
+ export const GC_DISK_POLICY_DEFAULTS: GcDiskPolicy = {
685
+ sessions_max_age_days: getDefault("gc.sessions.maxAgeDays"),
686
+ sessions_max_total_bytes: getDefault("gc.sessions.maxTotalBytes"),
687
+ natives_keep_versions: getDefault("gc.natives.keepVersions"),
688
+ backups_max_age_days: getDefault("gc.backups.maxAgeDays"),
689
+ };
690
+
691
+ export function resolveGcDiskPolicy(overrides: Partial<GcDiskPolicy> = {}): GcDiskPolicy {
692
+ return {
693
+ sessions_max_age_days: overrides.sessions_max_age_days ?? GC_DISK_POLICY_DEFAULTS.sessions_max_age_days,
694
+ sessions_max_total_bytes: overrides.sessions_max_total_bytes ?? GC_DISK_POLICY_DEFAULTS.sessions_max_total_bytes,
695
+ natives_keep_versions: overrides.natives_keep_versions ?? GC_DISK_POLICY_DEFAULTS.natives_keep_versions,
696
+ backups_max_age_days: overrides.backups_max_age_days ?? GC_DISK_POLICY_DEFAULTS.backups_max_age_days,
697
+ };
698
+ }
699
+
700
+ function gcDiskErrorText(error: unknown): string {
701
+ return error instanceof Error ? error.message : String(error);
702
+ }
703
+
704
+ function gcDiskAgeDays(now: number, mtimeMs: number): number {
705
+ return Math.round(((now - mtimeMs) / GC_DISK_DAY_MS) * 100) / 100;
706
+ }
707
+
708
+ function emptyGcDiskSurface(surface: GcDiskSurface, root: string): GcDiskSurfaceReport {
709
+ return {
710
+ surface,
711
+ root,
712
+ scanned: 0,
713
+ scanned_bytes: 0,
714
+ reclaimable: 0,
715
+ reclaimable_bytes: 0,
716
+ reclaimed: 0,
717
+ reclaimed_bytes: 0,
718
+ kept: 0,
719
+ kept_bytes: 0,
720
+ failed: 0,
721
+ records: [],
722
+ };
723
+ }
724
+
725
+ function summarizeGcDiskSurface(report: GcDiskSurfaceReport): void {
726
+ report.scanned = report.records.length;
727
+ report.scanned_bytes = 0;
728
+ report.reclaimable = 0;
729
+ report.reclaimable_bytes = 0;
730
+ report.reclaimed = 0;
731
+ report.reclaimed_bytes = 0;
732
+ report.kept = 0;
733
+ report.kept_bytes = 0;
734
+ report.failed = 0;
735
+ for (const record of report.records) {
736
+ report.scanned_bytes += record.bytes;
737
+ switch (record.action) {
738
+ case "would_reclaim":
739
+ report.reclaimable++;
740
+ report.reclaimable_bytes += record.bytes;
741
+ break;
742
+ case "reclaimed":
743
+ report.reclaimed++;
744
+ report.reclaimed_bytes += record.bytes;
745
+ break;
746
+ case "reclaim_failed":
747
+ report.failed++;
748
+ report.kept_bytes += record.bytes;
749
+ break;
750
+ default:
751
+ report.kept++;
752
+ report.kept_bytes += record.bytes;
753
+ }
754
+ }
755
+ }
756
+
757
+ /**
758
+ * Recursive, bounded byte count. Symlinks are never followed and never counted.
759
+ *
760
+ * `partial` means `bytes` is only a floor. `unreadable` is the narrower, harder
761
+ * fact: some entry in the tree could not be enumerated or stat'd, so the walk is
762
+ * blind to part of what it just measured. A caller about to remove the tree must
763
+ * treat that as incomplete evidence — a recursive remove cannot finish past an
764
+ * unreadable entry, so it would destroy the readable half and leave the rest.
765
+ *
766
+ * An entry that vanished mid-walk (`ENOENT`) is the opposite fact: it is gone,
767
+ * not withheld. A vanished entry cannot block a recursive remove, so it only
768
+ * makes `bytes` a floor and never withholds the tree.
769
+ *
770
+ * The walk is bounded in sizing work, not in reach: past
771
+ * {@link GC_DISK_MAX_WALK_ENTRIES} it stops stat'ing files but keeps opening
772
+ * directories, so a huge but perfectly readable tree stays reclaimable instead
773
+ * of being reported as unreadable.
774
+ */
775
+ async function measureGcDiskTree(root: string): Promise<{ bytes: number; partial: boolean; unreadable: boolean }> {
776
+ let bytes = 0;
777
+ let visited = 0;
778
+ let partial = false;
779
+ let unreadable = false;
780
+ const stack: string[] = [root];
781
+ while (stack.length > 0) {
782
+ const dir = stack.pop()!;
783
+ let entries: Dirent[];
784
+ try {
785
+ entries = await fsp.readdir(dir, { withFileTypes: true });
786
+ } catch (error) {
787
+ if (!isEnoent(error)) {
788
+ partial = true;
789
+ unreadable = true;
790
+ }
791
+ continue;
792
+ }
793
+ for (const entry of entries) {
794
+ visited++;
795
+ const child = path.join(dir, entry.name);
796
+ if (entry.isSymbolicLink()) {
797
+ partial = true;
798
+ continue;
799
+ }
800
+ if (entry.isDirectory()) {
801
+ stack.push(child);
802
+ continue;
803
+ }
804
+ if (!entry.isFile()) continue;
805
+ // The entry cap bounds the per-file `lstat` work, not the enumeration:
806
+ // past it `bytes` is a floor, but every directory is still opened so
807
+ // `unreadable` stays a complete answer for the whole tree.
808
+ if (visited > GC_DISK_MAX_WALK_ENTRIES) {
809
+ partial = true;
810
+ continue;
811
+ }
812
+ try {
813
+ bytes += (await fsp.lstat(child)).size;
814
+ } catch (error) {
815
+ partial = true;
816
+ if (!isEnoent(error)) unreadable = true;
817
+ }
818
+ }
819
+ }
820
+ return { bytes, partial, unreadable };
821
+ }
822
+
823
+ /**
824
+ * Session ids reachable from a live surface. `complete: false` means at least
825
+ * one reference source could not be enumerated, in which case NO session may be
826
+ * retired — an unproven reference is treated as a live one.
827
+ */
828
+ interface GcSessionReferences {
829
+ ids: Set<string>;
830
+ complete: boolean;
831
+ notes: string[];
832
+ }
833
+
834
+ async function collectGcSessionReferences(agentDir: string, env: NodeJS.ProcessEnv): Promise<GcSessionReferences> {
835
+ const ids = new Set<string>();
836
+ const notes: string[] = [];
837
+ let complete = true;
838
+ const incomplete = (note: string): void => {
839
+ complete = false;
840
+ if (!notes.includes(note)) notes.push(note);
841
+ };
842
+
843
+ // 1. Harness root registries and every per-session lease directory beneath
844
+ // their roots. A registry entry or a session directory is a reference even
845
+ // when its lease owner is already dead: the liveness axis reaps those.
846
+ try {
847
+ for (const registry of await listHarnessRootRegistriesForGc(env)) {
848
+ if (registry.error) {
849
+ incomplete("harness_root_registry_unreadable");
850
+ continue;
851
+ }
852
+ if (registry.sessionId) ids.add(registry.sessionId);
853
+ for (const entry of registry.roots) {
854
+ const harnessSessions = path.join(path.resolve(entry.root), "sessions");
855
+ try {
856
+ for (const name of await fsp.readdir(harnessSessions)) ids.add(name);
857
+ } catch (error) {
858
+ if (!isEnoent(error)) incomplete("harness_session_dir_unreadable");
859
+ }
860
+ }
861
+ }
862
+ } catch {
863
+ incomplete("harness_root_registry_scan_failed");
864
+ }
865
+
866
+ // 2. SDK hosts registered in the broker session index.
867
+ const indexDir = path.join(agentDir, "sdk", "sessions");
868
+ const indexFiles = [path.join(indexDir, "index.jsonl"), path.join(indexDir, "index.snapshot.json")];
869
+ let indexPresent = false;
870
+ for (const file of indexFiles) {
871
+ try {
872
+ await fsp.stat(file);
873
+ indexPresent = true;
874
+ } catch (error) {
875
+ if (!isEnoent(error)) incomplete("sdk_session_index_unreadable");
876
+ }
877
+ }
878
+ if (indexPresent) {
879
+ try {
880
+ const index = new SessionIndex(agentDir);
881
+ await index.replay();
882
+ for (const session of index.listSessions().sessions) ids.add(session.sessionId);
883
+ } catch {
884
+ incomplete("sdk_session_index_replay_failed");
885
+ }
886
+ }
887
+
888
+ // 3. `local://` session roots: one directory per session id.
889
+ const localRoots = path.join(env.TMPDIR?.trim() || os.tmpdir(), "gjc-local");
890
+ try {
891
+ for (const name of await fsp.readdir(localRoots)) ids.add(name);
892
+ } catch (error) {
893
+ if (!isEnoent(error)) incomplete("local_root_parent_unreadable");
894
+ }
895
+
896
+ return { ids, complete, notes };
897
+ }
898
+
899
+ /**
900
+ * Whether a reference scan saw every file its conclusions depend on.
901
+ * `complete: false` is a hard veto on reclamation for any surface whose evidence
902
+ * lives in a file the scan could not open.
903
+ */
904
+ interface GcDiskEvidence {
905
+ complete: boolean;
906
+ notes: string[];
907
+ }
908
+
909
+ function markGcDiskEvidenceIncomplete(evidence: GcDiskEvidence, note: string): void {
910
+ evidence.complete = false;
911
+ if (!evidence.notes.includes(note)) evidence.notes.push(note);
912
+ }
913
+
914
+ /** One managed session transcript plus its sibling artifact directory. */
915
+ interface GcDiskTranscript {
916
+ sessionId: string;
917
+ path: string;
918
+ directory: string;
919
+ bytes: number;
920
+ mtimeMs: number;
921
+ partial: boolean;
922
+ }
923
+
924
+ /**
925
+ * Every managed transcript, plus whether that enumeration is complete evidence.
926
+ *
927
+ * A transcript that could not be enumerated or stat'd is absent from
928
+ * `transcripts`, which leaves the session surface fail-closed by construction
929
+ * (an unseen transcript is never retired). It does NOT leave the blob sweep
930
+ * safe: a blob referenced only by an unseen transcript would look unreferenced,
931
+ * so `evidence` is threaded to every surface that marks across all transcripts.
932
+ */
933
+ interface GcDiskTranscriptScan {
934
+ transcripts: GcDiskTranscript[];
935
+ evidence: GcDiskEvidence;
936
+ }
937
+
938
+ /**
939
+ * Walk every managed session transcript under `sessionsRoot` through exactly one
940
+ * enumeration filter. Discovery and the blob sweep's staleness re-check both go
941
+ * through here, so their two views of the store are comparable by construction;
942
+ * anything the walk cannot read degrades `evidence` instead of quietly
943
+ * shrinking the result.
944
+ */
945
+ async function walkGcDiskTranscripts(input: {
946
+ sessionsRoot: string;
947
+ surface: GcDiskSurface;
948
+ evidence: GcDiskEvidence;
949
+ errors: GcDiskError[];
950
+ visit: (transcriptPath: string, directory: string, stat: Stats) => Promise<void> | void;
951
+ }): Promise<void> {
952
+ const { sessionsRoot, surface, evidence, errors, visit } = input;
953
+ let projectDirs: Dirent[];
954
+ try {
955
+ projectDirs = await fsp.readdir(sessionsRoot, { withFileTypes: true });
956
+ } catch (error) {
957
+ // A missing sessions root is complete evidence: there are no transcripts.
958
+ if (isEnoent(error)) return;
959
+ errors.push({ surface, scope: sessionsRoot, message: gcDiskErrorText(error) });
960
+ markGcDiskEvidenceIncomplete(evidence, "sessions_root_unreadable");
961
+ return;
962
+ }
963
+
964
+ for (const projectDir of projectDirs) {
965
+ if (!projectDir.isDirectory() || projectDir.isSymbolicLink()) continue;
966
+ const directory = path.join(sessionsRoot, projectDir.name);
967
+ let files: Dirent[];
968
+ try {
969
+ files = await fsp.readdir(directory, { withFileTypes: true });
970
+ } catch (error) {
971
+ errors.push({ surface, scope: directory, message: gcDiskErrorText(error) });
972
+ markGcDiskEvidenceIncomplete(evidence, "session_project_dir_unreadable");
973
+ continue;
974
+ }
975
+ for (const file of files) {
976
+ if (!file.name.endsWith(".jsonl") || !file.isFile() || file.isSymbolicLink()) continue;
977
+ const transcriptPath = path.join(directory, file.name);
978
+ let stat: Stats;
979
+ try {
980
+ stat = await fsp.lstat(transcriptPath);
981
+ } catch (error) {
982
+ errors.push({ surface, scope: transcriptPath, message: gcDiskErrorText(error) });
983
+ markGcDiskEvidenceIncomplete(evidence, "transcript_unstattable");
984
+ continue;
985
+ }
986
+ await visit(transcriptPath, directory, stat);
987
+ }
988
+ }
989
+ }
990
+
991
+ async function discoverGcDiskTranscripts(sessionsRoot: string, errors: GcDiskError[]): Promise<GcDiskTranscriptScan> {
992
+ const evidence: GcDiskEvidence = { complete: true, notes: [] };
993
+ const transcripts: GcDiskTranscript[] = [];
994
+ await walkGcDiskTranscripts({
995
+ sessionsRoot,
996
+ surface: "sessions",
997
+ evidence,
998
+ errors,
999
+ visit: async (transcriptPath, directory, stat) => {
1000
+ const artifacts = await measureGcDiskTree(transcriptPath.slice(0, -".jsonl".length));
1001
+ transcripts.push({
1002
+ sessionId: path.basename(transcriptPath, ".jsonl"),
1003
+ path: transcriptPath,
1004
+ directory,
1005
+ bytes: stat.size + artifacts.bytes,
1006
+ mtimeMs: stat.mtimeMs,
1007
+ partial: artifacts.partial,
1008
+ });
1009
+ },
1010
+ });
1011
+ return { transcripts, evidence };
1012
+ }
1013
+
1014
+ /**
1015
+ * Classify (and optionally retire) session transcripts. Returns the transcripts
1016
+ * that survived, which is exactly the mark set for the blob sweep.
1017
+ */
1018
+ async function runGcDiskSessions(input: {
1019
+ surface: GcDiskSurfaceReport;
1020
+ transcripts: GcDiskTranscript[];
1021
+ references: GcSessionReferences;
1022
+ policy: GcDiskPolicy;
1023
+ now: number;
1024
+ prune: boolean;
1025
+ }): Promise<GcDiskTranscript[]> {
1026
+ const { surface, transcripts, references, policy, now, prune } = input;
1027
+ const maxAgeMs = policy.sessions_max_age_days * GC_DISK_DAY_MS;
1028
+
1029
+ // The newest transcript in each project directory is the `--continue` resume
1030
+ // target, so it is never a retention candidate regardless of age.
1031
+ const newestPerDirectory = new Map<string, GcDiskTranscript>();
1032
+ for (const transcript of transcripts) {
1033
+ const current = newestPerDirectory.get(transcript.directory);
1034
+ if (!current || transcript.mtimeMs > current.mtimeMs) newestPerDirectory.set(transcript.directory, transcript);
1035
+ }
1036
+
1037
+ interface Classified {
1038
+ transcript: GcDiskTranscript;
1039
+ record: GcDiskRecord;
1040
+ /** Only transcripts kept purely because they are recent may be retired for size. */
1041
+ sizeEligible: boolean;
1042
+ }
1043
+
1044
+ const classified: Classified[] = [];
1045
+ for (const transcript of transcripts) {
1046
+ const record: GcDiskRecord = {
1047
+ surface: "sessions",
1048
+ id: transcript.sessionId,
1049
+ path: transcript.path,
1050
+ bytes: transcript.bytes,
1051
+ age_days: gcDiskAgeDays(now, transcript.mtimeMs),
1052
+ action: "keep",
1053
+ reason: "",
1054
+ ...(transcript.partial ? { partial: true as const } : {}),
1055
+ };
1056
+ let sizeEligible = false;
1057
+ if (!references.complete) {
1058
+ record.reason = `reference_scan_incomplete: ${references.notes.join(", ")}`;
1059
+ } else if (references.ids.has(transcript.sessionId)) {
1060
+ record.reason = "referenced_by_live_surface";
1061
+ } else if (newestPerDirectory.get(transcript.directory) === transcript) {
1062
+ record.reason = "most_recent_resumable_session";
1063
+ } else if (now - transcript.mtimeMs < maxAgeMs) {
1064
+ record.reason = `newer_than_max_age(${policy.sessions_max_age_days}d)`;
1065
+ sizeEligible = true;
1066
+ } else {
1067
+ record.action = "would_reclaim";
1068
+ record.reason = `older_than_max_age(${policy.sessions_max_age_days}d)`;
1069
+ }
1070
+ classified.push({ transcript, record, sizeEligible });
1071
+ surface.records.push(record);
1072
+ }
1073
+
1074
+ // Size axis: when the store would still be over budget after the age pass,
1075
+ // retire the oldest recent-but-unreferenced transcripts until it fits. A
1076
+ // liveness keep is never overridden.
1077
+ if (policy.sessions_max_total_bytes > 0) {
1078
+ let projected = classified.reduce(
1079
+ (sum, item) => (item.record.action === "would_reclaim" ? sum : sum + item.transcript.bytes),
1080
+ 0,
1081
+ );
1082
+ if (projected > policy.sessions_max_total_bytes) {
1083
+ const eligible = classified
1084
+ .filter(item => item.sizeEligible && item.record.action === "keep")
1085
+ .sort((a, b) => a.transcript.mtimeMs - b.transcript.mtimeMs);
1086
+ for (const item of eligible) {
1087
+ if (projected <= policy.sessions_max_total_bytes) break;
1088
+ item.record.action = "would_reclaim";
1089
+ item.record.reason = `over_max_total_bytes(${policy.sessions_max_total_bytes})`;
1090
+ projected -= item.transcript.bytes;
1091
+ }
1092
+ }
1093
+ }
1094
+
1095
+ const storage = new FileSessionStorage();
1096
+ if (!prune) {
1097
+ // Dry run must project the prune verdict, not just the policy verdict: the
1098
+ // retention pass re-checks its own preconditions before it may call the
1099
+ // delete authority, and a candidate that fails them is never removable.
1100
+ for (const item of classified) {
1101
+ if (item.record.action !== "would_reclaim") continue;
1102
+ const probe = probeSessionRetirement(storage, surface.root, item.transcript.path);
1103
+ if (probe.kind === "retirable") continue;
1104
+ item.record.action = "keep";
1105
+ item.record.reason = `retention_declined: ${probe.reason}`;
1106
+ }
1107
+ return classified.filter(item => item.record.action !== "would_reclaim").map(item => item.transcript);
1108
+ }
1109
+
1110
+ // Retirement goes through the identity-bound verified delete authority, which
1111
+ // re-reads and re-verifies the transcript. A declined retirement is a KEEP,
1112
+ // not a failure — the same posture the pid probe takes on EPERM/unknown. A
1113
+ // half-completed one is a failure: bytes are already gone.
1114
+ const survivors: GcDiskTranscript[] = [];
1115
+ for (const item of classified) {
1116
+ if (item.record.action !== "would_reclaim") {
1117
+ survivors.push(item.transcript);
1118
+ continue;
1119
+ }
1120
+ const outcome = await retireSessionTranscript(storage, surface.root, item.transcript.path);
1121
+ if (outcome.kind === "retired") {
1122
+ item.record.action = "reclaimed";
1123
+ continue;
1124
+ }
1125
+ if (outcome.kind === "cleanup_pending") {
1126
+ // The transcript survives but its artifact tree was already detached or
1127
+ // partially removed. Reporting that as a plain keep would hide a
1128
+ // destructive partial failure behind a green exit code.
1129
+ item.record.action = "reclaim_failed";
1130
+ item.record.reason = `retention_incomplete: ${outcome.reason}`;
1131
+ item.record.error = outcome.reason;
1132
+ survivors.push(item.transcript);
1133
+ continue;
1134
+ }
1135
+ item.record.action = "keep";
1136
+ item.record.reason = `retention_declined: ${outcome.reason}`;
1137
+ survivors.push(item.transcript);
1138
+ }
1139
+ return survivors;
1140
+ }
1141
+
1142
+ /** Stream a transcript and collect every blob reference it still holds. */
1143
+ async function markGcDiskBlobReferences(transcriptPath: string, into: Set<string>): Promise<boolean> {
1144
+ try {
1145
+ const decoder = new TextDecoder("utf-8");
1146
+ let carry = "";
1147
+ for await (const chunk of Bun.file(transcriptPath).stream()) {
1148
+ const text = carry + decoder.decode(chunk, { stream: true });
1149
+ collectBlobReferences(text, into);
1150
+ // Retain a reference-length tail so a hash split across chunks is still seen.
1151
+ carry = text.slice(-BLOB_REFERENCE_MAX_LENGTH);
1152
+ }
1153
+ collectBlobReferences(carry + decoder.decode(), into);
1154
+ return true;
1155
+ } catch {
1156
+ return false;
1157
+ }
1158
+ }
1159
+
1160
+ /** What a completed mark read: the exact transcript bytes its references came from. */
1161
+ interface GcDiskMarkedTranscript {
1162
+ size: number;
1163
+ mtimeMs: number;
1164
+ }
1165
+
1166
+ /**
1167
+ * Read one transcript's blob references and bind the result to the bytes that
1168
+ * produced it. An append landing after this lstat can introduce a reference the
1169
+ * pass never saw, which is precisely what the drift check below looks for.
1170
+ */
1171
+ async function markGcDiskTranscript(
1172
+ transcriptPath: string,
1173
+ referenced: Set<string>,
1174
+ marked: Map<string, GcDiskMarkedTranscript>,
1175
+ evidence: GcDiskEvidence,
1176
+ errors: GcDiskError[],
1177
+ ): Promise<void> {
1178
+ if (!(await markGcDiskBlobReferences(transcriptPath, referenced))) {
1179
+ markGcDiskEvidenceIncomplete(evidence, "transcript_unreadable_during_mark");
1180
+ errors.push({ surface: "blobs", scope: transcriptPath, message: "transcript_unreadable_during_mark" });
1181
+ return;
1182
+ }
1183
+ try {
1184
+ const stat = await fsp.lstat(transcriptPath);
1185
+ marked.set(transcriptPath, { size: stat.size, mtimeMs: stat.mtimeMs });
1186
+ } catch (error) {
1187
+ markGcDiskEvidenceIncomplete(evidence, "transcript_unstattable_after_mark");
1188
+ errors.push({ surface: "blobs", scope: transcriptPath, message: gcDiskErrorText(error) });
1189
+ }
1190
+ }
1191
+
1192
+ /**
1193
+ * Transcripts the store holds that nobody has accounted for: created after the
1194
+ * session walk enumerated the store, or written to after their own bytes were
1195
+ * read.
1196
+ *
1197
+ * `accounted` is the session walk's enumeration. A transcript in it that is not
1198
+ * in the mark set is one the session surface decided to retire, and a dry run
1199
+ * leaves those on disk — re-marking them would make the dry run promise fewer
1200
+ * reclaimable blobs than `--prune` actually removes.
1201
+ */
1202
+ async function findGcDiskMarkDrift(input: {
1203
+ sessionsRoot: string;
1204
+ accounted: ReadonlySet<string>;
1205
+ marked: Map<string, GcDiskMarkedTranscript>;
1206
+ evidence: GcDiskEvidence;
1207
+ errors: GcDiskError[];
1208
+ }): Promise<string[]> {
1209
+ const drifted: string[] = [];
1210
+ await walkGcDiskTranscripts({
1211
+ sessionsRoot: input.sessionsRoot,
1212
+ surface: "blobs",
1213
+ evidence: input.evidence,
1214
+ errors: input.errors,
1215
+ visit: (transcriptPath, _directory, stat) => {
1216
+ const mark = input.marked.get(transcriptPath);
1217
+ if (mark) {
1218
+ if (mark.size !== stat.size || mark.mtimeMs !== stat.mtimeMs) drifted.push(transcriptPath);
1219
+ return;
1220
+ }
1221
+ if (!input.accounted.has(transcriptPath)) drifted.push(transcriptPath);
1222
+ },
1223
+ });
1224
+ return drifted;
1225
+ }
1226
+
1227
+ /**
1228
+ * Mark and sweep the content-addressed blob store.
1229
+ *
1230
+ * A blob's only evidence of being referenced lives inside session transcripts,
1231
+ * so the sweep is sound only when every transcript was enumerated AND read.
1232
+ * `discovery` carries that verdict from the session walk; a transcript that
1233
+ * fails to stream here degrades it further. On incomplete evidence the sweep
1234
+ * still reports what it would have reclaimed and reclaims nothing, even under
1235
+ * `--prune`: an unproven reference is treated as a real one.
1236
+ *
1237
+ * The session walk is a snapshot, and a snapshot goes stale: a session started
1238
+ * or appended to while `gjc gc` was measuring the store can reference a blob the
1239
+ * mark never saw. So the mark re-walks the store, absorbs whatever appeared or
1240
+ * grew, and only sweeps once the store stops moving under it. Drift that
1241
+ * outlasts {@link GC_DISK_MARK_REMARK_ROUNDS} is incomplete evidence, not a
1242
+ * licence to delete.
1243
+ */
1244
+ async function runGcDiskBlobs(input: {
1245
+ surface: GcDiskSurfaceReport;
1246
+ sessionsRoot: string;
1247
+ accounted: ReadonlySet<string>;
1248
+ survivors: GcDiskTranscript[];
1249
+ discovery: GcDiskEvidence;
1250
+ now: number;
1251
+ prune: boolean;
1252
+ errors: GcDiskError[];
1253
+ }): Promise<void> {
1254
+ const { surface, sessionsRoot, accounted, survivors, discovery, now, prune, errors } = input;
1255
+ const evidence: GcDiskEvidence = { complete: discovery.complete, notes: [...discovery.notes] };
1256
+ let blobs: CanonicalBlobEntry[];
1257
+ try {
1258
+ blobs = await listCanonicalBlobs(surface.root);
1259
+ } catch (error) {
1260
+ errors.push({ surface: "blobs", scope: surface.root, message: gcDiskErrorText(error) });
1261
+ return;
1262
+ }
1263
+ if (blobs.length === 0) return;
1264
+
1265
+ const referenced = new Set<string>();
1266
+ const marked = new Map<string, GcDiskMarkedTranscript>();
1267
+ let pending = survivors.map(transcript => transcript.path);
1268
+ for (let round = 0; ; round++) {
1269
+ for (const transcriptPath of pending) {
1270
+ await markGcDiskTranscript(transcriptPath, referenced, marked, evidence, errors);
1271
+ }
1272
+ // Already-incomplete evidence withholds the sweep anyway; re-walking would
1273
+ // only re-report the same failure.
1274
+ if (!evidence.complete) break;
1275
+ pending = await findGcDiskMarkDrift({ sessionsRoot, accounted, marked, evidence, errors });
1276
+ if (pending.length === 0 || !evidence.complete) break;
1277
+ if (round === GC_DISK_MARK_REMARK_ROUNDS) {
1278
+ markGcDiskEvidenceIncomplete(evidence, "sessions_changed_during_mark");
1279
+ break;
1280
+ }
1281
+ }
1282
+
1283
+ let withheld = 0;
1284
+ let withheldBytes = 0;
1285
+ for (const blob of blobs) {
1286
+ const record: GcDiskRecord = {
1287
+ surface: "blobs",
1288
+ id: blob.hash,
1289
+ path: blob.path,
1290
+ bytes: blob.bytes,
1291
+ age_days: gcDiskAgeDays(now, blob.mtimeMs),
1292
+ action: "keep",
1293
+ reason: "",
1294
+ };
1295
+ if (referenced.has(blob.hash)) record.reason = "referenced_by_surviving_session";
1296
+ else if (now - blob.mtimeMs < GC_DISK_BLOB_GRACE_MS) record.reason = "within_write_grace_window";
1297
+ else if (!evidence.complete) {
1298
+ record.reason = `withheld_evidence_incomplete: ${evidence.notes.join(", ")}`;
1299
+ record.withheld = true;
1300
+ withheld++;
1301
+ withheldBytes += blob.bytes;
1302
+ } else {
1303
+ record.action = "would_reclaim";
1304
+ record.reason = "unreferenced_by_any_surviving_session";
1305
+ }
1306
+ surface.records.push(record);
1307
+
1308
+ if (!prune || record.action !== "would_reclaim") continue;
1309
+ const removal = await removeCanonicalBlob(blob);
1310
+ if (removal.removed) {
1311
+ record.action = "reclaimed";
1312
+ } else if (removal.failed) {
1313
+ record.action = "reclaim_failed";
1314
+ record.reason = removal.reason;
1315
+ record.error = removal.reason;
1316
+ } else {
1317
+ record.action = "keep";
1318
+ record.reason = removal.reason;
1319
+ }
1320
+ }
1321
+
1322
+ if (!evidence.complete) {
1323
+ surface.declined = {
1324
+ reason: `evidence_incomplete: ${evidence.notes.join(", ")}`,
1325
+ withheld,
1326
+ withheld_bytes: withheldBytes,
1327
+ };
1328
+ }
1329
+ }
1330
+
1331
+ /** Numeric `major.minor.patch` prefix, or undefined when the name is not a version. */
1332
+ function parseGcDiskVersion(value: string): [number, number, number] | undefined {
1333
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(value);
1334
+ if (!match) return undefined;
1335
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
1336
+ }
1337
+
1338
+ function compareGcDiskVersions(a: [number, number, number], b: [number, number, number]): number {
1339
+ for (let index = 0; index < 3; index++) {
1340
+ if (a[index] !== b[index]) return a[index] - b[index];
1341
+ }
1342
+ return 0;
1343
+ }
1344
+
1345
+ async function runGcDiskNatives(input: {
1346
+ surface: GcDiskSurfaceReport;
1347
+ policy: GcDiskPolicy;
1348
+ now: number;
1349
+ prune: boolean;
1350
+ errors: GcDiskError[];
1351
+ runningVersion: string;
1352
+ }): Promise<void> {
1353
+ const { surface, policy, now, prune, errors, runningVersion } = input;
1354
+ let entries: Dirent[];
1355
+ try {
1356
+ entries = await fsp.readdir(surface.root, { withFileTypes: true });
1357
+ } catch (error) {
1358
+ if (!isEnoent(error)) errors.push({ surface: "natives", scope: surface.root, message: gcDiskErrorText(error) });
1359
+ return;
1360
+ }
1361
+
1362
+ const running = parseGcDiskVersion(runningVersion);
1363
+ const versioned = entries
1364
+ .filter(entry => entry.isDirectory() && !entry.isSymbolicLink())
1365
+ .map(entry => ({ name: entry.name, version: parseGcDiskVersion(entry.name) }));
1366
+ const ordered = versioned
1367
+ .filter((entry): entry is { name: string; version: [number, number, number] } => entry.version !== undefined)
1368
+ .sort((a, b) => compareGcDiskVersions(b.version, a.version) || b.name.localeCompare(a.name));
1369
+
1370
+ // Keep the running version, anything newer than it, and the configured number
1371
+ // of immediate predecessors. Everything else is a cached leftover.
1372
+ const keep = new Set<string>();
1373
+ let predecessors = 0;
1374
+ for (const entry of ordered) {
1375
+ if (!running || compareGcDiskVersions(entry.version, running) >= 0) {
1376
+ keep.add(entry.name);
1377
+ continue;
1378
+ }
1379
+ if (predecessors < policy.natives_keep_versions) {
1380
+ keep.add(entry.name);
1381
+ predecessors++;
1382
+ }
1383
+ }
1384
+
1385
+ for (const entry of versioned) {
1386
+ const directory = path.join(surface.root, entry.name);
1387
+ let stat: Stats;
1388
+ try {
1389
+ stat = await fsp.lstat(directory);
1390
+ } catch (error) {
1391
+ errors.push({ surface: "natives", scope: directory, message: gcDiskErrorText(error) });
1392
+ continue;
1393
+ }
1394
+ const usage = await measureGcDiskTree(directory);
1395
+ const record: GcDiskRecord = {
1396
+ surface: "natives",
1397
+ id: entry.name,
1398
+ path: directory,
1399
+ bytes: usage.bytes,
1400
+ age_days: gcDiskAgeDays(now, stat.mtimeMs),
1401
+ action: "keep",
1402
+ reason: "",
1403
+ ...(usage.partial ? { partial: true as const } : {}),
1404
+ };
1405
+ if (!entry.version) record.reason = "unrecognized_version_directory";
1406
+ else if (entry.name === runningVersion) record.reason = "running_version";
1407
+ else if (keep.has(entry.name)) record.reason = `retained_version(keepVersions=${policy.natives_keep_versions})`;
1408
+ else if (usage.unreadable) {
1409
+ // The walk could not read part of the tree this reclaim would remove, so
1410
+ // a recursive remove cannot finish: it would destroy the readable half
1411
+ // and leave the rest. Partial knowledge is never a licence to delete.
1412
+ record.reason = "withheld_evidence_incomplete: tree_unreadable";
1413
+ record.withheld = true;
1414
+ } else {
1415
+ record.action = "would_reclaim";
1416
+ record.reason = `beyond_keep_versions(${policy.natives_keep_versions})`;
1417
+ }
1418
+ surface.records.push(record);
1419
+
1420
+ if (!prune || record.action !== "would_reclaim") continue;
1421
+ const removal = await removeGcDiskEntry(directory, stat);
1422
+ if (removal.removed) record.action = "reclaimed";
1423
+ else if (removal.failed) {
1424
+ record.action = "reclaim_failed";
1425
+ record.reason = removal.reason;
1426
+ record.error = removal.reason;
1427
+ } else {
1428
+ record.action = "keep";
1429
+ record.reason = removal.reason;
1430
+ if (removal.withheld) record.withheld = true;
1431
+ }
1432
+ }
1433
+ }
1434
+
1435
+ /**
1436
+ * Remove one directory/file entry, fail-closed on identity drift. Anything that
1437
+ * changed inode or mtime between classification and removal is left alone.
1438
+ *
1439
+ * Directories use the native exact-tree remover. Its fresh descriptor-relative
1440
+ * snapshot is revalidated by the removal itself before an atomic detach, closing
1441
+ * the gap where a plain recursive `rm` could destroy readable siblings before
1442
+ * discovering that another subtree had become unreadable.
1443
+ */
1444
+ async function removeGcDiskEntry(
1445
+ target: string,
1446
+ expected: Stats,
1447
+ ): Promise<{ removed: true } | { removed: false; reason: string; failed?: true; withheld?: true }> {
1448
+ let current: Stats;
1449
+ try {
1450
+ current = await fsp.lstat(target);
1451
+ } catch (error) {
1452
+ if (isEnoent(error)) return { removed: false, reason: "entry_disappeared" };
1453
+ return { removed: false, reason: `entry_unverifiable: ${gcDiskErrorText(error)}`, failed: true };
1454
+ }
1455
+ if (current.isSymbolicLink()) return { removed: false, reason: "entry_is_symlink" };
1456
+ if (current.ino !== expected.ino || current.dev !== expected.dev) {
1457
+ return { removed: false, reason: "entry_identity_changed" };
1458
+ }
1459
+ if (current.mtimeMs !== expected.mtimeMs) return { removed: false, reason: "entry_changed" };
1460
+
1461
+ if (!current.isDirectory()) {
1462
+ try {
1463
+ await fsp.rm(target, { force: false });
1464
+ return { removed: true };
1465
+ } catch (error) {
1466
+ if (isEnoent(error)) return { removed: false, reason: "entry_disappeared" };
1467
+ return { removed: false, reason: `entry_remove_failed: ${gcDiskErrorText(error)}`, failed: true };
1468
+ }
1469
+ }
1470
+
1471
+ const snapshot = snapshotDirectoryTree(target);
1472
+ if (!snapshot.ok || !snapshot.snapshot) {
1473
+ if (snapshot.code === "not_found") return { removed: false, reason: "entry_disappeared" };
1474
+ if (snapshot.code !== "reparse_point") {
1475
+ return { removed: false, reason: "withheld_evidence_incomplete: tree_unreadable", withheld: true };
1476
+ }
1477
+
1478
+ let identity: BigIntStats;
1479
+ let linkParent: BigIntStats;
1480
+ try {
1481
+ identity = await fsp.lstat(target, { bigint: true });
1482
+ linkParent = await fsp.lstat(path.dirname(target), { bigint: true });
1483
+ } catch (error) {
1484
+ if (isEnoent(error)) return { removed: false, reason: "entry_disappeared" };
1485
+ return { removed: false, reason: `entry_unverifiable: ${gcDiskErrorText(error)}`, failed: true };
1486
+ }
1487
+ const detached = exactUnlink(target, {
1488
+ dev: identity.dev,
1489
+ ino: identity.ino,
1490
+ nlink: identity.nlink,
1491
+ size: identity.size,
1492
+ mtimeNs: identity.mtimeNs,
1493
+ parentDev: linkParent.dev,
1494
+ parentIno: linkParent.ino,
1495
+ directory: true,
1496
+ detachOnly: true,
1497
+ quarantineName: `${path.basename(target)}.removing`,
1498
+ });
1499
+ if ((!detached.ok && detached.code !== "cleanup_pending") || !detached.detachedPath) {
1500
+ return { removed: false, reason: `entry_remove_failed: ${detached.code ?? "unknown"}`, failed: true };
1501
+ }
1502
+ try {
1503
+ await fsp.rm(detached.detachedPath, { recursive: true, force: false });
1504
+ if (detached.retainedPlaceholderPath) await fsp.rmdir(detached.retainedPlaceholderPath);
1505
+ return { removed: true };
1506
+ } catch (error) {
1507
+ if (isEnoent(error)) return { removed: true };
1508
+ return { removed: false, reason: `entry_remove_failed: ${gcDiskErrorText(error)}`, failed: true };
1509
+ }
1510
+ }
1511
+
1512
+ let parent: BigIntStats;
1513
+ try {
1514
+ parent = await fsp.lstat(path.dirname(target), { bigint: true });
1515
+ } catch (error) {
1516
+ if (isEnoent(error)) return { removed: false, reason: "entry_disappeared" };
1517
+ return { removed: false, reason: `entry_unverifiable: ${gcDiskErrorText(error)}`, failed: true };
1518
+ }
1519
+
1520
+ const removal = exactRemoveDirectoryTree(target, snapshot.snapshot, {
1521
+ dev: parent.dev,
1522
+ ino: parent.ino,
1523
+ });
1524
+ if (removal.ok) return { removed: true };
1525
+ if (removal.code === "cleanup_pending" && removal.payloadDurable === true && removal.detachedPath) {
1526
+ try {
1527
+ await fsp.rm(removal.detachedPath, { recursive: true, force: false });
1528
+ return { removed: true };
1529
+ } catch (error) {
1530
+ if (isEnoent(error)) return { removed: true };
1531
+ return { removed: false, reason: `entry_remove_failed: ${gcDiskErrorText(error)}`, failed: true };
1532
+ }
1533
+ }
1534
+ if (removal.code === "not_found") return { removed: false, reason: "entry_disappeared" };
1535
+ if (removal.code === "identity_mismatch" || removal.code === "parent_mismatch") {
1536
+ return { removed: false, reason: "entry_changed" };
1537
+ }
1538
+ const retainedAtOriginal = !removal.detachedPath || path.resolve(removal.detachedPath) === path.resolve(target);
1539
+ if ((removal.code === "permission_denied" || removal.code === "io_error") && retainedAtOriginal) {
1540
+ return { removed: false, reason: "withheld_evidence_incomplete: tree_unreadable", withheld: true };
1541
+ }
1542
+ return {
1543
+ removed: false,
1544
+ reason: `entry_remove_failed: ${removal.code ?? "unknown"}`,
1545
+ failed: true,
1546
+ };
1547
+ }
1548
+
1549
+ async function runGcDiskBackups(input: {
1550
+ surface: GcDiskSurfaceReport;
1551
+ gjcRoot: string;
1552
+ policy: GcDiskPolicy;
1553
+ now: number;
1554
+ prune: boolean;
1555
+ errors: GcDiskError[];
1556
+ }): Promise<void> {
1557
+ const { surface, gjcRoot, policy, now, prune, errors } = input;
1558
+ const maxAgeMs = policy.backups_max_age_days * GC_DISK_DAY_MS;
1559
+ const candidates: Array<{ id: string; path: string }> = [];
1560
+
1561
+ // `~/.gjc/backups/<entry>` — update/restore backup roots.
1562
+ try {
1563
+ for (const entry of await fsp.readdir(surface.root, { withFileTypes: true })) {
1564
+ if (entry.isSymbolicLink()) continue;
1565
+ candidates.push({ id: entry.name, path: path.join(surface.root, entry.name) });
1566
+ }
1567
+ } catch (error) {
1568
+ if (!isEnoent(error)) errors.push({ surface: "backups", scope: surface.root, message: gcDiskErrorText(error) });
1569
+ }
1570
+
1571
+ // `~/.gjc/*.bak` — sibling roots left by update/restore (agent.bak, natives-*.bak).
1572
+ try {
1573
+ for (const entry of await fsp.readdir(gjcRoot, { withFileTypes: true })) {
1574
+ if (!entry.name.endsWith(".bak") || entry.isSymbolicLink()) continue;
1575
+ candidates.push({ id: entry.name, path: path.join(gjcRoot, entry.name) });
1576
+ }
1577
+ } catch (error) {
1578
+ if (!isEnoent(error)) errors.push({ surface: "backups", scope: gjcRoot, message: gcDiskErrorText(error) });
1579
+ }
1580
+
1581
+ for (const candidate of candidates) {
1582
+ let stat: Stats;
1583
+ try {
1584
+ stat = await fsp.lstat(candidate.path);
1585
+ } catch (error) {
1586
+ if (!isEnoent(error)) {
1587
+ errors.push({ surface: "backups", scope: candidate.path, message: gcDiskErrorText(error) });
1588
+ }
1589
+ continue;
1590
+ }
1591
+ if (stat.isSymbolicLink()) continue;
1592
+ const usage = stat.isDirectory()
1593
+ ? await measureGcDiskTree(candidate.path)
1594
+ : { bytes: stat.size, partial: false, unreadable: false };
1595
+ const record: GcDiskRecord = {
1596
+ surface: "backups",
1597
+ id: candidate.id,
1598
+ path: candidate.path,
1599
+ bytes: usage.bytes,
1600
+ age_days: gcDiskAgeDays(now, stat.mtimeMs),
1601
+ action: "keep",
1602
+ reason: "",
1603
+ ...(usage.partial ? { partial: true as const } : {}),
1604
+ };
1605
+ if (now - stat.mtimeMs < maxAgeMs) record.reason = `newer_than_max_age(${policy.backups_max_age_days}d)`;
1606
+ else if (usage.unreadable) {
1607
+ // The walk could not read part of the tree this reclaim would remove, so
1608
+ // a recursive remove cannot finish: it would destroy the readable half
1609
+ // and leave the rest. Partial knowledge is never a licence to delete.
1610
+ record.reason = "withheld_evidence_incomplete: tree_unreadable";
1611
+ record.withheld = true;
1612
+ } else {
1613
+ record.action = "would_reclaim";
1614
+ record.reason = `older_than_max_age(${policy.backups_max_age_days}d)`;
1615
+ }
1616
+ surface.records.push(record);
1617
+
1618
+ if (!prune || record.action !== "would_reclaim") continue;
1619
+ const removal = await removeGcDiskEntry(candidate.path, stat);
1620
+ if (removal.removed) record.action = "reclaimed";
1621
+ else if (removal.failed) {
1622
+ record.action = "reclaim_failed";
1623
+ record.reason = removal.reason;
1624
+ record.error = removal.reason;
1625
+ } else {
1626
+ record.action = "keep";
1627
+ record.reason = removal.reason;
1628
+ if (removal.withheld) record.withheld = true;
1629
+ }
1630
+ }
1631
+ }
1632
+
1633
+ /**
1634
+ * Run the disk-retention axis. Nothing is mutated unless `prune` is true; the
1635
+ * dry-run report projects exactly the same decisions a prune would make.
1636
+ */
1637
+ export async function collectGcDiskReport(input: {
1638
+ agentDir: string;
1639
+ env: NodeJS.ProcessEnv;
1640
+ policy: GcDiskPolicy;
1641
+ prune: boolean;
1642
+ now?: number;
1643
+ runningVersion?: string;
1644
+ }): Promise<GcDiskReport> {
1645
+ const { agentDir, env, policy, prune } = input;
1646
+ const now = input.now ?? Date.now();
1647
+ const gjcRoot = path.dirname(path.resolve(agentDir));
1648
+ const errors: GcDiskError[] = [];
1649
+ const surfaces: Record<GcDiskSurface, GcDiskSurfaceReport> = {
1650
+ sessions: emptyGcDiskSurface("sessions", getSessionsDir(agentDir)),
1651
+ blobs: emptyGcDiskSurface("blobs", getBlobsDir(agentDir)),
1652
+ natives: emptyGcDiskSurface("natives", path.join(gjcRoot, "natives")),
1653
+ backups: emptyGcDiskSurface("backups", path.join(gjcRoot, "backups")),
1654
+ };
1655
+
1656
+ const scan = await discoverGcDiskTranscripts(surfaces.sessions.root, errors);
1657
+ const references = await collectGcSessionReferences(agentDir, env);
1658
+ const survivors = await runGcDiskSessions({
1659
+ surface: surfaces.sessions,
1660
+ transcripts: scan.transcripts,
1661
+ references,
1662
+ policy,
1663
+ now,
1664
+ prune,
1665
+ });
1666
+ await runGcDiskBlobs({
1667
+ surface: surfaces.blobs,
1668
+ sessionsRoot: surfaces.sessions.root,
1669
+ accounted: new Set(scan.transcripts.map(transcript => transcript.path)),
1670
+ survivors,
1671
+ discovery: scan.evidence,
1672
+ now,
1673
+ prune,
1674
+ errors,
1675
+ });
1676
+ await runGcDiskNatives({
1677
+ surface: surfaces.natives,
1678
+ policy,
1679
+ now,
1680
+ prune,
1681
+ errors,
1682
+ runningVersion: input.runningVersion ?? VERSION,
1683
+ });
1684
+ await runGcDiskBackups({ surface: surfaces.backups, gjcRoot, policy, now, prune, errors });
1685
+
1686
+ const totals = { scanned_bytes: 0, reclaimable_bytes: 0, reclaimed_bytes: 0, kept_bytes: 0, failed: 0 };
1687
+ for (const name of GC_DISK_SURFACES) {
1688
+ const surface = surfaces[name];
1689
+ summarizeGcDiskSurface(surface);
1690
+ totals.scanned_bytes += surface.scanned_bytes;
1691
+ totals.reclaimable_bytes += surface.reclaimable_bytes;
1692
+ totals.reclaimed_bytes += surface.reclaimed_bytes;
1693
+ totals.kept_bytes += surface.kept_bytes;
1694
+ totals.failed += surface.failed;
1695
+ }
1696
+
1697
+ return { dry_run: !prune, policy, surfaces, totals, errors };
1698
+ }
1699
+
1700
+ const GC_DISK_SURFACE_HEADINGS: Record<GcDiskSurface, string> = {
1701
+ sessions: "Session transcripts",
1702
+ blobs: "Content-addressed blobs",
1703
+ natives: "Cached native versions",
1704
+ backups: "Update/restore backups",
1705
+ };
1706
+
1707
+ function formatGcDiskBytes(bytes: number): string {
1708
+ if (bytes < 1024) return `${bytes} B`;
1709
+ const units = ["KiB", "MiB", "GiB", "TiB"];
1710
+ let value = bytes / 1024;
1711
+ let unit = 0;
1712
+ while (value >= 1024 && unit < units.length - 1) {
1713
+ value /= 1024;
1714
+ unit++;
1715
+ }
1716
+ return `${value.toFixed(1)} ${units[unit]}`;
1717
+ }
1718
+
1719
+ function gcDiskActionLabel(record: GcDiskRecord): string {
1720
+ switch (record.action) {
1721
+ case "would_reclaim":
1722
+ return "would reclaim";
1723
+ case "reclaimed":
1724
+ return "reclaimed";
1725
+ case "reclaim_failed":
1726
+ return `reclaim failed${record.error ? `: ${record.error}` : ""}`;
1727
+ default:
1728
+ return "keep";
1729
+ }
1730
+ }
1731
+
1732
+ export function buildGcDiskReportText(disk: GcDiskReport): string {
1733
+ const lines: string[] = [];
1734
+ lines.push(
1735
+ disk.dry_run
1736
+ ? "gjc gc --disk — report only (no bytes reclaimed; pass --prune to reclaim)"
1737
+ : "gjc gc --disk --prune — reclaim",
1738
+ );
1739
+ lines.push(
1740
+ ` policy: sessions.maxAgeDays=${disk.policy.sessions_max_age_days} ` +
1741
+ `sessions.maxTotalBytes=${disk.policy.sessions_max_total_bytes} ` +
1742
+ `natives.keepVersions=${disk.policy.natives_keep_versions} ` +
1743
+ `backups.maxAgeDays=${disk.policy.backups_max_age_days}`,
1744
+ );
1745
+ lines.push("");
1746
+
1747
+ for (const name of GC_DISK_SURFACES) {
1748
+ const surface = disk.surfaces[name];
1749
+ lines.push(`${GC_DISK_SURFACE_HEADINGS[name]} (${surface.root})`);
1750
+ lines.push(
1751
+ ` scanned=${surface.scanned} (${formatGcDiskBytes(surface.scanned_bytes)}) ` +
1752
+ (disk.dry_run
1753
+ ? `reclaimable=${surface.reclaimable} (${formatGcDiskBytes(surface.reclaimable_bytes)}) `
1754
+ : `reclaimed=${surface.reclaimed} (${formatGcDiskBytes(surface.reclaimed_bytes)}) failed=${surface.failed} `) +
1755
+ `kept=${surface.kept} (${formatGcDiskBytes(surface.kept_bytes)})`,
1756
+ );
1757
+ if (surface.declined) {
1758
+ lines.push(
1759
+ ` declined: reclaimed nothing — ${surface.declined.reason} ` +
1760
+ `(withheld=${surface.declined.withheld} (${formatGcDiskBytes(surface.declined.withheld_bytes)}))`,
1761
+ );
1762
+ }
1763
+ // Reclaim decisions first: they are what an operator has to audit.
1764
+ const ranked = [...surface.records].sort(
1765
+ (a, b) => Number(a.action === "keep") - Number(b.action === "keep") || b.bytes - a.bytes,
1766
+ );
1767
+ for (const record of ranked.slice(0, GC_DISK_MAX_RENDERED_RECORDS)) {
1768
+ lines.push(
1769
+ ` [${gcDiskActionLabel(record)}] ${record.path} ${formatGcDiskBytes(record.bytes)}` +
1770
+ `${record.partial ? "+" : ""} age=${record.age_days}d — ${record.reason}`,
1771
+ );
1772
+ }
1773
+ if (ranked.length > GC_DISK_MAX_RENDERED_RECORDS) {
1774
+ lines.push(` … ${ranked.length - GC_DISK_MAX_RENDERED_RECORDS} more (use --json for the full list)`);
1775
+ }
1776
+ lines.push("");
1777
+ }
1778
+
1779
+ if (disk.errors.length > 0) {
1780
+ lines.push(`Disk errors (${disk.errors.length})`);
1781
+ for (const error of disk.errors) lines.push(` [${error.surface}/${error.scope}] ${error.message}`);
1782
+ lines.push("");
1783
+ }
1784
+
1785
+ lines.push(
1786
+ `Disk summary: scanned=${formatGcDiskBytes(disk.totals.scanned_bytes)} ` +
1787
+ (disk.dry_run
1788
+ ? `reclaimable=${formatGcDiskBytes(disk.totals.reclaimable_bytes)}`
1789
+ : `reclaimed=${formatGcDiskBytes(disk.totals.reclaimed_bytes)} failed=${disk.totals.failed}`) +
1790
+ ` kept=${formatGcDiskBytes(disk.totals.kept_bytes)}`,
1791
+ );
1792
+ lines.push("");
1793
+ return `${lines.join("\n")}`;
1794
+ }