@gajae-code/coding-agent 0.15.5 → 0.15.6

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 (155) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/dist/types/async/job-manager.d.ts +83 -0
  3. package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
  4. package/dist/types/cli/model-presets-cli.d.ts +9 -0
  5. package/dist/types/cli/update-cli.d.ts +2 -0
  6. package/dist/types/commands/auth-gateway.d.ts +3 -0
  7. package/dist/types/commands/gc.d.ts +13 -0
  8. package/dist/types/commands/model-presets.d.ts +21 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +96 -0
  10. package/dist/types/config/file-lock.d.ts +49 -2
  11. package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
  12. package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
  13. package/dist/types/config/keybindings.d.ts +3 -2
  14. package/dist/types/config/model-preset-registry.d.ts +368 -0
  15. package/dist/types/config/model-profile-activation.d.ts +39 -0
  16. package/dist/types/config/model-profiles.d.ts +5 -3
  17. package/dist/types/config/model-registry.d.ts +11 -5
  18. package/dist/types/config/settings-schema.d.ts +9 -0
  19. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
  20. package/dist/types/coordinator-mcp/projection-scan.d.ts +46 -0
  21. package/dist/types/extensibility/extensions/types.d.ts +2 -5
  22. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  23. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  24. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  25. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +6 -0
  26. package/dist/types/index.d.ts +1 -0
  27. package/dist/types/main.d.ts +3 -1
  28. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  29. package/dist/types/modes/jobs-observer.d.ts +36 -0
  30. package/dist/types/sdk/broker/broker.d.ts +3 -0
  31. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  32. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  33. package/dist/types/sdk/client/discovery.d.ts +1 -0
  34. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  35. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  36. package/dist/types/sdk/host/session-runtime.d.ts +15 -1
  37. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  38. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  39. package/dist/types/sdk/router/session-router.d.ts +9 -0
  40. package/dist/types/session/account-inventory.d.ts +1 -0
  41. package/dist/types/session/agent-session.d.ts +19 -7
  42. package/dist/types/session/fold-coordinator.d.ts +201 -0
  43. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  44. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  45. package/dist/types/telemetry/control.d.ts +11 -0
  46. package/dist/types/telemetry/events.d.ts +20 -0
  47. package/dist/types/telemetry/index.d.ts +3 -0
  48. package/dist/types/telemetry/transport.d.ts +22 -0
  49. package/dist/types/tools/bash-interactive.d.ts +23 -0
  50. package/dist/types/tools/index.d.ts +6 -5
  51. package/dist/types/web/search/providers/base.d.ts +9 -4
  52. package/dist/types/web/search/providers/xai.d.ts +2 -1
  53. package/dist/types/web/search/types.d.ts +14 -0
  54. package/package.json +10 -7
  55. package/scripts/generate-hotkeys-docs.ts +4 -0
  56. package/scripts/generate-sdk-operation-inventory.ts +3 -3
  57. package/src/async/job-manager.ts +671 -39
  58. package/src/cli/auth-broker-cli.ts +40 -12
  59. package/src/cli/auth-gateway-cli.ts +273 -97
  60. package/src/cli/config-cli.ts +2 -2
  61. package/src/cli/fast-help.ts +3 -0
  62. package/src/cli/model-presets-cli.ts +100 -0
  63. package/src/cli/update-cli.ts +40 -5
  64. package/src/cli.ts +6 -2
  65. package/src/commands/auth-gateway.ts +12 -8
  66. package/src/commands/gc.ts +19 -0
  67. package/src/commands/model-presets.ts +24 -0
  68. package/src/config/autorouting-tier-map.ts +32 -0
  69. package/src/config/file-lock-gc.ts +9 -4
  70. package/src/config/file-lock.ts +775 -59
  71. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  72. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  73. package/src/config/keybindings.ts +6 -2
  74. package/src/config/model-preset-registry.ts +2799 -0
  75. package/src/config/model-profile-activation.ts +115 -28
  76. package/src/config/model-profile-contract.ts +4 -1
  77. package/src/config/model-profiles.ts +51 -9
  78. package/src/config/model-registry.ts +1021 -122
  79. package/src/config/settings-schema.ts +11 -0
  80. package/src/config/settings.ts +6 -1
  81. package/src/coordinator-mcp/model-preset.ts +32 -17
  82. package/src/coordinator-mcp/projection-scan.ts +571 -0
  83. package/src/coordinator-mcp/server.ts +19 -24
  84. package/src/coordinator-mcp/session-reaper.ts +9 -3
  85. package/src/extensibility/extensions/loader.ts +0 -1
  86. package/src/extensibility/extensions/types.ts +2 -3
  87. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  88. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  89. package/src/gjc-runtime/gc-render.ts +29 -0
  90. package/src/gjc-runtime/gc-runtime.ts +174 -11
  91. package/src/gjc-runtime/session-state-lock.ts +1242 -141
  92. package/src/gjc-runtime/session-state-sidecar.ts +76 -3
  93. package/src/index.ts +1 -0
  94. package/src/internal-urls/docs-index.generated.ts +9 -8
  95. package/src/lsp/config.ts +4 -3
  96. package/src/lsp/path-trust.ts +12 -6
  97. package/src/main.ts +9 -2
  98. package/src/modes/acp/acp-agent.ts +11 -5
  99. package/src/modes/components/jobs-overlay-model.ts +99 -16
  100. package/src/modes/components/model-selector.ts +158 -18
  101. package/src/modes/components/status-line/segments.ts +6 -1
  102. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  103. package/src/modes/controllers/input-controller.ts +23 -6
  104. package/src/modes/interactive-mode.ts +14 -1
  105. package/src/modes/jobs-observer.ts +149 -16
  106. package/src/modes/runtime-init.ts +1 -1
  107. package/src/modes/utils/injected-user-submission.ts +15 -1
  108. package/src/prompts/tools/bash.md +2 -2
  109. package/src/prompts/tools/fold-receipt.md +3 -0
  110. package/src/prompts/tools/job.md +1 -1
  111. package/src/sdk/acp/adapter.ts +17 -2
  112. package/src/sdk/broker/broker.ts +48 -21
  113. package/src/sdk/broker/lifecycle.ts +32 -6
  114. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  115. package/src/sdk/bus/chat-daemon-runtime.ts +11 -1
  116. package/src/sdk/bus/kind-aware-reconciliation.ts +36 -6
  117. package/src/sdk/bus/prompt-reconciliation.ts +28 -4
  118. package/src/sdk/bus/slack-conversation.ts +5 -1
  119. package/src/sdk/cli/session-cli.ts +12 -1
  120. package/src/sdk/client/discovery.ts +3 -1
  121. package/src/sdk/host/control/dispatch.ts +3 -0
  122. package/src/sdk/host/model-pin.ts +185 -25
  123. package/src/sdk/host/sdk-run-capability.ts +1 -0
  124. package/src/sdk/host/session-runtime.test.ts +371 -37
  125. package/src/sdk/host/session-runtime.ts +364 -52
  126. package/src/sdk/prompt-deadline-manager.ts +16 -1
  127. package/src/sdk/protocol/adapter-validation.ts +27 -0
  128. package/src/sdk/protocol/operation-inventory.generated.json +2 -2
  129. package/src/sdk/protocol/operation-registry.ts +4 -1
  130. package/src/sdk/router/session-router.ts +228 -96
  131. package/src/sdk/session.ts +83 -12
  132. package/src/session/account-inventory.ts +27 -11
  133. package/src/session/agent-session.ts +678 -103
  134. package/src/session/fold-coordinator.ts +433 -0
  135. package/src/session/sdk-run-capability-internal.ts +15 -0
  136. package/src/session/sdk-run-capability.ts +2 -0
  137. package/src/session/session-manager.ts +5 -4
  138. package/src/setup/provider-presets.json +1 -1
  139. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  140. package/src/task/executor.ts +19 -3
  141. package/src/telemetry/control.ts +26 -0
  142. package/src/telemetry/events.ts +142 -0
  143. package/src/telemetry/index.ts +3 -0
  144. package/src/telemetry/transport.ts +87 -0
  145. package/src/tools/bash-interactive.ts +182 -85
  146. package/src/tools/bash.ts +772 -67
  147. package/src/tools/index.ts +7 -5
  148. package/src/tools/tool-catalog.generated.ts +1 -1
  149. package/src/web/search/provider.ts +120 -11
  150. package/src/web/search/providers/anthropic.ts +89 -17
  151. package/src/web/search/providers/base.ts +9 -4
  152. package/src/web/search/providers/gemini.ts +87 -15
  153. package/src/web/search/providers/openai-compatible.ts +23 -8
  154. package/src/web/search/providers/xai.ts +98 -12
  155. package/src/web/search/types.ts +16 -0
@@ -69,7 +69,7 @@ import {
69
69
  } from "./event-webhook";
70
70
  import {
71
71
  type CoordinatorModelProfileLoader,
72
- loadCoordinatorModelProfiles,
72
+ createCoordinatorModelProfileLoader,
73
73
  resolveCoordinatorMpreset,
74
74
  } from "./model-preset";
75
75
  import {
@@ -83,6 +83,7 @@ import {
83
83
  requireCoordinatorMutation,
84
84
  safeOpenCoordinatorArtifact,
85
85
  } from "./policy";
86
+ import { listCoordinatorJsonFiles } from "./projection-scan";
86
87
  import {
87
88
  answerBindingMatches,
88
89
  buildCoordinatorAskAnswerSchema,
@@ -1497,26 +1498,17 @@ function publicSdkAcknowledgement(result: RuntimePromptAcknowledgement): Record<
1497
1498
  }
1498
1499
 
1499
1500
  async function listJsonFiles(dir: string): Promise<unknown[]> {
1500
- try {
1501
- const entries = await fs.readdir(dir);
1502
- const values = await Promise.all(
1503
- entries
1504
- .filter(entry => entry.endsWith(".json") && !entry.startsWith(".gjc-"))
1505
- .map(async entry => {
1506
- try {
1507
- return await readJsonFile(path.join(dir, entry));
1508
- } catch (error) {
1509
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
1510
- logger.warn("Coordinator projection read failed", { path: entry, error: String(error) });
1511
- throw error;
1512
- }
1513
- }),
1514
- );
1515
- return values.filter(value => value !== null);
1516
- } catch (error) {
1517
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
1518
- throw error;
1501
+ const scan = await listCoordinatorJsonFiles(dir);
1502
+ if (scan.capped) throw new Error("coordinator_projection_scan_incomplete");
1503
+ if (scan.skippedEmpty > 0 || scan.skippedDebris > 0) {
1504
+ logger.warn("Coordinator projection scan skipped debris", {
1505
+ dir,
1506
+ parsed: scan.parsed,
1507
+ skippedDebris: scan.skippedDebris,
1508
+ skippedEmpty: scan.skippedEmpty,
1509
+ });
1519
1510
  }
1511
+ return scan.values;
1520
1512
  }
1521
1513
 
1522
1514
  const COORDINATOR_STATUS_EVENT_LIMIT = 100;
@@ -3260,7 +3252,7 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
3260
3252
  await routerReady;
3261
3253
  }
3262
3254
  const platform = options.platform ?? process.platform;
3263
- const loadModelProfiles = services.resolveModelProfiles ?? loadCoordinatorModelProfiles;
3255
+ const loadModelProfiles = services.resolveModelProfiles ?? createCoordinatorModelProfileLoader(routerAgentDir);
3264
3256
  // Every authoritative projection is scoped by the collision-resistant namespace identity.
3265
3257
  const namespaceDir = coordinatorNamespacePath(config);
3266
3258
  // The prior human-readable directory is migration input only; it is never authority.
@@ -8412,6 +8404,9 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
8412
8404
  }
8413
8405
  if (name === "gjc_coordinator_start_session") {
8414
8406
  requireCoordinatorMutation(config, "sessions", args);
8407
+ const suppliedPrompt = Object.hasOwn(args, "prompt");
8408
+ if (suppliedPrompt && (typeof args.prompt !== "string" || args.prompt.trim().length === 0))
8409
+ return { ok: false, error: { code: "invalid_input", message: "Prompt must not be empty." } };
8415
8410
  const idempotencyKey = requiredIdempotencyKey(args);
8416
8411
  const cwd = await canonicalBrokerWorkspace(await assertCoordinatorWorkdir(config, args.cwd));
8417
8412
  const mpresetResolution = await resolveCoordinatorMpreset(args.mpreset, loadModelProfiles);
@@ -8434,7 +8429,7 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
8434
8429
  error: { code: "unknown_model", message: modelResolution.error },
8435
8430
  };
8436
8431
  }
8437
- const prompt = typeof args.prompt === "string" && args.prompt.length > 0 ? args.prompt : null;
8432
+ const prompt = suppliedPrompt ? (args.prompt as string) : null;
8438
8433
  /**
8439
8434
  * A prepared session is deliberately not ready for input: its readiness
8440
8435
  * is withheld until an operator-supplied thread is bound and activation
@@ -9079,10 +9074,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
9079
9074
  }
9080
9075
  if (name === "gjc_coordinator_send_prompt") {
9081
9076
  requireCoordinatorMutation(config, "sessions", args);
9077
+ if (typeof args.prompt !== "string" || args.prompt.trim().length === 0)
9078
+ return { ok: false, error: { code: "invalid_input", message: "Prompt must not be empty." } };
9082
9079
  const idempotencyKey = requiredIdempotencyKey(args);
9083
9080
  const sessionId = safeExternalId("session", args.session_id);
9084
- if (typeof args.prompt !== "string" || args.prompt.length === 0)
9085
- return { ok: false, error: { code: "invalid_input", message: "prompt is required" } };
9086
9081
  const prompt = args.prompt;
9087
9082
  return await withToolIdempotency(
9088
9083
  name,
@@ -100,9 +100,15 @@ export function createSessionReaper(deps: SessionReaperDeps, policy: SessionReap
100
100
  function schedule(gen: number): void {
101
101
  timer = setTimeout(() => {
102
102
  if (gen !== generation) return; // stale timer from a prior start()
103
- void sweepOnce().finally(() => {
104
- if (gen === generation) schedule(gen);
105
- });
103
+ void sweepOnce()
104
+ .catch(error => {
105
+ // A refused sweep (e.g. a fail-closed capped projection scan) must not
106
+ // become an unhandled rejection; log and keep the schedule alive.
107
+ logger.warn(`session-reaper: sweep refused: ${error instanceof Error ? error.message : String(error)}`);
108
+ })
109
+ .finally(() => {
110
+ if (gen === generation) schedule(gen);
111
+ });
106
112
  }, sweepIntervalMs);
107
113
  // The reaper must never keep the coordinator process alive by itself.
108
114
  (timer as { unref?: () => void }).unref?.();
@@ -345,7 +345,6 @@ class ConcreteExtensionAPI implements ExtensionAPI {
345
345
  onQueuedPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
346
346
  onDispatchDisposition?: (promotion: { startsOwnRun: boolean }) => void;
347
347
  preflightSignal?: AbortSignal;
348
- sdkRunToken?: string;
349
348
  },
350
349
  ): Promise<void> {
351
350
  return Promise.resolve(this.runtime.sendUserMessage(content, options));
@@ -536,7 +536,7 @@ export interface ExtensionContext {
536
536
  onPreflightAcceptCommit?: () => void | Promise<void>;
537
537
  onSkillPrepared?: (meta: { name: string; path: string; lineCount?: number; cleanedArgs?: string }) => void;
538
538
  preflightSignal?: AbortSignal;
539
- sdkRunToken?: string;
539
+ sdkRunCapability?: unknown;
540
540
  },
541
541
  ): Promise<unknown>;
542
542
  setPlanMode?(on: boolean): unknown;
@@ -1316,7 +1316,6 @@ export interface ExtensionAPI {
1316
1316
  onDispatchDisposition?: (promotion: { startsOwnRun: boolean }) => void;
1317
1317
  preflightSignal?: AbortSignal;
1318
1318
  /** Internal SDK correlation owner for an exact queued follow-up. */
1319
- sdkRunToken?: string;
1320
1319
  },
1321
1320
  ): Promise<void>;
1322
1321
 
@@ -1546,7 +1545,6 @@ export type SendUserMessageHandler = (
1546
1545
  onQueuedPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
1547
1546
  preflightSignal?: AbortSignal;
1548
1547
  /** Internal SDK correlation owner for an exact queued follow-up. */
1549
- sdkRunToken?: string;
1550
1548
  },
1551
1549
  ) => void | Promise<void>;
1552
1550
 
@@ -1708,6 +1706,7 @@ export interface ExtensionContextActions {
1708
1706
  onPreflightAcceptCommit?: () => void | Promise<void>;
1709
1707
  onSkillPrepared?: (meta: { name: string; path: string; lineCount?: number; cleanedArgs?: string }) => void;
1710
1708
  preflightSignal?: AbortSignal;
1709
+ sdkRunCapability?: unknown;
1711
1710
  },
1712
1711
  ) => Promise<unknown>;
1713
1712
  setPlanMode?: (on: boolean) => unknown;
@@ -453,6 +453,7 @@ export async function buildPluginMcpConfigs(input: { cwd: string }): Promise<{
453
453
  command: cfg.command,
454
454
  args: cfg.args,
455
455
  cwd: cfg.cwd ? nodePath.resolve(entry.pluginRoot, cfg.cwd) : entry.pluginRoot,
456
+ timeout: 5_000,
456
457
  // Third-party plugin MCP processes must not inherit host secrets;
457
458
  // only a minimal OS allowlist (PATH/HOME/temp/locale) is provided.
458
459
  noInheritEnv: true,
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Official GC of already-quarantined empty `.gjc-delete-*` receipts.
3
+ * Roots are operator operands — never hardcoded host paths.
4
+ */
5
+
6
+ import { randomUUID } from "node:crypto";
7
+ import type { BigIntStats } from "node:fs";
8
+ import * as fs from "node:fs/promises";
9
+ import * as path from "node:path";
10
+ import { exactUnlinkDirect, type NativeExactUnlinkResult } from "@gajae-code/natives";
11
+
12
+ export const EMPTY_DELETE_PREFIX = ".gjc-delete-";
13
+ const EMPTY_DELETE_RECEIPT_PATTERN =
14
+ /^\.gjc-delete-session-state-lock-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.json$/u;
15
+ const EMPTY_FILE_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
16
+
17
+ export interface EmptyDeleteIdentity {
18
+ dev: bigint;
19
+ ino: bigint;
20
+ nlink: bigint;
21
+ size: bigint;
22
+ mtimeNs: bigint;
23
+ parentDev: bigint;
24
+ parentIno: bigint;
25
+ sha256: string;
26
+ }
27
+
28
+ export interface EmptyDeleteGcRecord {
29
+ root: string;
30
+ path: string;
31
+ action: "would_remove" | "removed" | "kept" | "skipped";
32
+ reason: string;
33
+ identity?: EmptyDeleteIdentity;
34
+ /** A dry-run candidate is an observation, never deletion authority. */
35
+ observationOnly?: boolean;
36
+ /** Retained-object paths a native direct unlink left behind; operator-recoverable only there. */
37
+ retainedPaths?: { detached?: string; successor?: string; placeholder?: string; unknown?: string };
38
+ }
39
+
40
+ export interface EmptyDeleteGcReport {
41
+ dry_run: boolean;
42
+ roots: string[];
43
+ records: EmptyDeleteGcRecord[];
44
+ would_remove: number;
45
+ removed: number;
46
+ kept: number;
47
+ skipped: number;
48
+ errors: string[];
49
+ }
50
+
51
+ export interface EmptyDeleteGcOptions {
52
+ roots: string[];
53
+ prune: boolean;
54
+ }
55
+
56
+ export interface EmptyDeleteGcDependencies {
57
+ /** Internal seam for deterministic collect-to-prune race tests. */
58
+ collect?: (root: string) => Promise<EmptyDeleteGcRecord[]>;
59
+ exactUnlinkDirect?: typeof exactUnlinkDirect;
60
+ }
61
+
62
+ function isUnsafeName(name: string): boolean {
63
+ return name.includes("/") || name.includes("\0") || name === "." || name === "..";
64
+ }
65
+
66
+ function identityOf(stat: BigIntStats): Omit<EmptyDeleteIdentity, "parentDev" | "parentIno" | "sha256"> {
67
+ return {
68
+ dev: stat.dev,
69
+ ino: stat.ino,
70
+ nlink: stat.nlink,
71
+ size: stat.size,
72
+ mtimeNs: stat.mtimeNs,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Extract the operator-recoverable retained paths a native direct unlink can leave behind.
78
+ * Any of them means debris still exists outside the receipt namespace, so the caller
79
+ * records them and fails the run instead of reporting a clean prune.
80
+ */
81
+ function retainedPathsOf(
82
+ result: NativeExactUnlinkResult,
83
+ ): { detached?: string; successor?: string; placeholder?: string; unknown?: string } | undefined {
84
+ if (
85
+ result.detachedPath === undefined &&
86
+ result.retainedSuccessorPath === undefined &&
87
+ result.retainedPlaceholderPath === undefined &&
88
+ result.retainedUnknownPath === undefined
89
+ )
90
+ return undefined;
91
+ return {
92
+ detached: result.detachedPath,
93
+ successor: result.retainedSuccessorPath,
94
+ placeholder: result.retainedPlaceholderPath,
95
+ unknown: result.retainedUnknownPath,
96
+ };
97
+ }
98
+
99
+ function isEmptyDeleteReceiptName(name: string): boolean {
100
+ return EMPTY_DELETE_RECEIPT_PATTERN.test(name);
101
+ }
102
+
103
+ function sameRootIdentity(left: BigIntStats, right: BigIntStats): boolean {
104
+ return left.isDirectory() && right.isDirectory() && left.dev === right.dev && left.ino === right.ino;
105
+ }
106
+
107
+ function rootRaceRecord(root: string): EmptyDeleteGcRecord[] {
108
+ return [{ root, path: root, action: "skipped", reason: "root_race" }];
109
+ }
110
+
111
+ export async function collectEmptyDeleteReceipts(
112
+ root: string,
113
+ deps: { lstat?: (file: string, options: { bigint: true }) => Promise<BigIntStats> } = {},
114
+ ): Promise<EmptyDeleteGcRecord[]> {
115
+ const lstatFile = deps.lstat ?? ((file: string) => fs.lstat(file, { bigint: true }));
116
+ const records: EmptyDeleteGcRecord[] = [];
117
+ const resolvedRoot = path.resolve(root);
118
+ let rootStat: BigIntStats;
119
+ try {
120
+ rootStat = await lstatFile(resolvedRoot, { bigint: true });
121
+ } catch (error) {
122
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
123
+ return [{ root, path: root, action: "skipped", reason: "missing_root" }];
124
+ }
125
+ throw error;
126
+ }
127
+ if (rootStat.isSymbolicLink()) {
128
+ return [{ root, path: root, action: "skipped", reason: "symlink_root" }];
129
+ }
130
+ if (!rootStat.isDirectory()) {
131
+ return [{ root, path: root, action: "skipped", reason: "not_directory" }];
132
+ }
133
+ let names: string[];
134
+ try {
135
+ names = await fs.readdir(resolvedRoot);
136
+ } catch (error) {
137
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
138
+ return rootRaceRecord(root);
139
+ }
140
+ throw error;
141
+ }
142
+ // Enumeration is the first operation that can observe a replacement root. Do not
143
+ // attribute any names from that enumeration until the original root identity is
144
+ // proved to still own the pathname.
145
+ try {
146
+ const enumeratedRoot = await lstatFile(resolvedRoot, { bigint: true });
147
+ if (!sameRootIdentity(rootStat, enumeratedRoot)) return rootRaceRecord(root);
148
+ } catch (error) {
149
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return rootRaceRecord(root);
150
+ throw error;
151
+ }
152
+ for (const name of names) {
153
+ if (isUnsafeName(name) || !isEmptyDeleteReceiptName(name)) continue;
154
+ const file = path.join(resolvedRoot, name);
155
+ let stat: BigIntStats;
156
+ try {
157
+ stat = await lstatFile(file, { bigint: true });
158
+ } catch (error) {
159
+ // ENOENT is an ordinary race with concurrent cleanup, but silently dropping the
160
+ // entry would shrink the report without trace; record it as a raced candidate so
161
+ // discovery stays fail-closed. Any other stat failure is a hard discovery error.
162
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
163
+ records.push({ root, path: file, action: "skipped", reason: "raced" });
164
+ continue;
165
+ }
166
+ throw error;
167
+ }
168
+ if (stat.isSymbolicLink()) {
169
+ records.push({ root, path: file, action: "kept", reason: "symlink" });
170
+ continue;
171
+ }
172
+ if (!stat.isFile()) {
173
+ records.push({ root, path: file, action: "kept", reason: "not_regular" });
174
+ continue;
175
+ }
176
+ if (stat.size !== 0n) {
177
+ records.push({ root, path: file, action: "kept", reason: "non_empty" });
178
+ continue;
179
+ }
180
+ if (stat.nlink !== 1n) {
181
+ records.push({ root, path: file, action: "kept", reason: "nlink" });
182
+ continue;
183
+ }
184
+ records.push({
185
+ root,
186
+ path: file,
187
+ action: "would_remove",
188
+ reason: "empty_delete_receipt",
189
+ identity: {
190
+ ...identityOf(stat),
191
+ parentDev: rootStat.dev,
192
+ parentIno: rootStat.ino,
193
+ sha256: EMPTY_FILE_SHA256,
194
+ },
195
+ });
196
+ }
197
+ // The candidate lstat calls above are pathname operations. Revalidate once more
198
+ // before returning so a replaced root can never leak its external candidate paths
199
+ // (or their identities) into a GC report.
200
+ try {
201
+ const finalRoot = await lstatFile(resolvedRoot, { bigint: true });
202
+ if (!sameRootIdentity(rootStat, finalRoot)) return rootRaceRecord(root);
203
+ } catch (error) {
204
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return rootRaceRecord(root);
205
+ throw error;
206
+ }
207
+ return records;
208
+ }
209
+
210
+ export async function runEmptyDeleteGc(
211
+ options: EmptyDeleteGcOptions,
212
+ deps: EmptyDeleteGcDependencies = {},
213
+ ): Promise<EmptyDeleteGcReport> {
214
+ const report: EmptyDeleteGcReport = {
215
+ dry_run: !options.prune,
216
+ roots: options.roots,
217
+ records: [],
218
+ would_remove: 0,
219
+ removed: 0,
220
+ kept: 0,
221
+ skipped: 0,
222
+ errors: [],
223
+ };
224
+ const collect = deps.collect ?? collectEmptyDeleteReceipts;
225
+ const unlink = deps.exactUnlinkDirect ?? exactUnlinkDirect;
226
+ for (const root of options.roots) {
227
+ let records: EmptyDeleteGcRecord[];
228
+ try {
229
+ records = await collect(root);
230
+ } catch (error) {
231
+ report.errors.push(`${root}: ${error instanceof Error ? error.message : String(error)}`);
232
+ continue;
233
+ }
234
+ for (const record of records) {
235
+ if (record.action === "would_remove" && !options.prune) record.observationOnly = true;
236
+ if (
237
+ record.action === "skipped" &&
238
+ (record.reason === "raced" ||
239
+ record.reason === "root_race" ||
240
+ (record.path === root &&
241
+ (record.reason === "missing_root" ||
242
+ record.reason === "symlink_root" ||
243
+ record.reason === "not_directory")))
244
+ ) {
245
+ report.errors.push(`${record.path}: ${record.reason}`);
246
+ }
247
+ if (record.action === "would_remove" && options.prune) {
248
+ try {
249
+ if (!record.identity) {
250
+ record.action = "kept";
251
+ record.reason = "identity_missing";
252
+ } else {
253
+ const result = unlink(record.path, {
254
+ ...record.identity,
255
+ quarantineName: `.gjc-delete-gc-${randomUUID()}.json`,
256
+ });
257
+ const retainedPaths = retainedPathsOf(result);
258
+ if (result.ok) {
259
+ record.action = "removed";
260
+ } else if (result.code === "not_found") {
261
+ record.action = "skipped";
262
+ record.reason = "gone";
263
+ } else if (result.code === "identity_mismatch") {
264
+ record.action = "kept";
265
+ record.reason = "identity_drift";
266
+ } else {
267
+ record.action = "kept";
268
+ record.reason = `unlink_failed:${result.code ?? "unknown"}`;
269
+ report.errors.push(`${record.path}: ${record.reason}`);
270
+ }
271
+ // Objects the native layer retained (post-detach failure or a successor at
272
+ // the private quarantine name) are recoverable only at those paths; surface
273
+ // them and fail the run instead of silently stranding debris.
274
+ if (retainedPaths !== undefined) {
275
+ record.retainedPaths = retainedPaths;
276
+ record.reason = result.ok ? `retained:${result.code ?? "cleanup_pending"}` : record.reason;
277
+ report.errors.push(
278
+ `${record.path}: retained debris requires operator recovery: ${JSON.stringify(retainedPaths)}`,
279
+ );
280
+ }
281
+ }
282
+ } catch (error) {
283
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
284
+ record.action = "skipped";
285
+ record.reason = "gone";
286
+ } else {
287
+ record.action = "kept";
288
+ record.reason = `unlink_failed:${error instanceof Error ? error.message : String(error)}`;
289
+ report.errors.push(`${record.path}: ${record.reason}`);
290
+ }
291
+ }
292
+ }
293
+ report.records.push(record);
294
+ if (record.action === "would_remove") report.would_remove += 1;
295
+ else if (record.action === "removed") report.removed += 1;
296
+ else if (record.action === "skipped") report.skipped += 1;
297
+ else report.kept += 1;
298
+ }
299
+ }
300
+ return report;
301
+ }
@@ -88,6 +88,35 @@ export function buildGcReportText(report: GcReport): string {
88
88
  lines.push("");
89
89
  }
90
90
 
91
+ if (report.empty_delete_receipts) {
92
+ const empty = report.empty_delete_receipts;
93
+ lines.push(`Empty .gjc-delete receipts (${empty.records.length})`);
94
+ if (empty.records.length === 0) {
95
+ lines.push(" (none)");
96
+ } else {
97
+ for (const record of empty.records) {
98
+ const action = record.action === "would_remove" ? "would remove" : record.action;
99
+ lines.push(` [${action}] ${record.path} :: ${record.reason}`);
100
+ if (record.retainedPaths) {
101
+ const retained = [
102
+ ["detached", record.retainedPaths.detached],
103
+ ["successor", record.retainedPaths.successor],
104
+ ["placeholder", record.retainedPaths.placeholder],
105
+ ["unknown", record.retainedPaths.unknown],
106
+ ].filter((entry): entry is [string, string] => entry[1] !== undefined);
107
+ if (retained.length > 0)
108
+ lines.push(` retained: ${retained.map(([kind, p]) => `${kind}=${p}`).join(" ")}`);
109
+ }
110
+ }
111
+ }
112
+ for (const error of empty.errors) lines.push(` [error] ${error}`);
113
+ lines.push(
114
+ ` Summary: roots=${empty.roots.length} would_remove=${empty.would_remove} removed=${empty.removed} ` +
115
+ `kept=${empty.kept} skipped=${empty.skipped} errors=${empty.errors.length}`,
116
+ );
117
+ lines.push("");
118
+ }
119
+
91
120
  if (report.warnings.length > 0) {
92
121
  lines.push(`Warnings (${report.warnings.length})`);
93
122
  for (const warning of report.warnings) lines.push(` [${warning.store}/${warning.scope}] ${warning.message}`);