@gajae-code/coding-agent 0.15.5 → 0.16.0

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 (272) hide show
  1. package/CHANGELOG.md +85 -1
  2. package/dist/types/async/job-manager.d.ts +83 -0
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
  5. package/dist/types/cli/model-presets-cli.d.ts +9 -0
  6. package/dist/types/cli/root-flags.d.ts +14 -0
  7. package/dist/types/cli/update-cli.d.ts +15 -0
  8. package/dist/types/cli.d.ts +7 -0
  9. package/dist/types/commands/auth-gateway.d.ts +3 -0
  10. package/dist/types/commands/gc.d.ts +13 -0
  11. package/dist/types/commands/launch.d.ts +10 -0
  12. package/dist/types/commands/model-presets.d.ts +21 -0
  13. package/dist/types/config/autorouting-tier-map.d.ts +120 -0
  14. package/dist/types/config/file-lock.d.ts +54 -2
  15. package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
  16. package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
  17. package/dist/types/config/keybindings.d.ts +3 -2
  18. package/dist/types/config/model-preset-registry.d.ts +368 -0
  19. package/dist/types/config/model-profile-activation.d.ts +39 -0
  20. package/dist/types/config/model-profiles.d.ts +5 -3
  21. package/dist/types/config/model-registry.d.ts +17 -5
  22. package/dist/types/config/settings-schema.d.ts +19 -1
  23. package/dist/types/config/settings.d.ts +2 -0
  24. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
  25. package/dist/types/coordinator-mcp/projection-scan.d.ts +54 -0
  26. package/dist/types/crash/record-loader.d.ts +15 -0
  27. package/dist/types/extensibility/extensions/types.d.ts +2 -5
  28. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  29. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  30. package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
  31. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
  32. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  33. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +72 -1
  34. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
  35. package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
  36. package/dist/types/index.d.ts +1 -0
  37. package/dist/types/main.d.ts +3 -1
  38. package/dist/types/master-mode/context.d.ts +21 -0
  39. package/dist/types/master-mode/first-request.d.ts +34 -0
  40. package/dist/types/master-mode/snapshot.d.ts +32 -0
  41. package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
  42. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  43. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  44. package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
  45. package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
  46. package/dist/types/modes/components/status-line/segments.d.ts +10 -0
  47. package/dist/types/modes/jobs-observer.d.ts +36 -0
  48. package/dist/types/modes/print-mode.d.ts +17 -0
  49. package/dist/types/modes/ui-language.d.ts +7 -1
  50. package/dist/types/sdk/broker/broker.d.ts +80 -1
  51. package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
  52. package/dist/types/sdk/broker/index.d.ts +3 -0
  53. package/dist/types/sdk/broker/lifecycle.d.ts +26 -0
  54. package/dist/types/sdk/broker/master-capability.d.ts +10 -0
  55. package/dist/types/sdk/broker/session-index.d.ts +40 -11
  56. package/dist/types/sdk/broker/session-scope.d.ts +72 -0
  57. package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
  58. package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
  59. package/dist/types/sdk/broker/state-version.d.ts +4 -1
  60. package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
  61. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
  62. package/dist/types/sdk/bus/index.d.ts +6 -0
  63. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  64. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  65. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  66. package/dist/types/sdk/cli/index.d.ts +1 -0
  67. package/dist/types/sdk/cli/master-cli.d.ts +63 -0
  68. package/dist/types/sdk/cli/rows.d.ts +4 -3
  69. package/dist/types/sdk/cli/session-cli.d.ts +18 -2
  70. package/dist/types/sdk/client/client.d.ts +3 -2
  71. package/dist/types/sdk/client/discovery.d.ts +1 -0
  72. package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
  73. package/dist/types/sdk/host/control/index.d.ts +1 -1
  74. package/dist/types/sdk/host/control/operations.d.ts +2 -0
  75. package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
  76. package/dist/types/sdk/host/host.d.ts +10 -0
  77. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  78. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  79. package/dist/types/sdk/host/session-runtime.d.ts +67 -1
  80. package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
  81. package/dist/types/sdk/lifecycle/service.d.ts +15 -3
  82. package/dist/types/sdk/lifecycle-session.d.ts +2 -0
  83. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  84. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  85. package/dist/types/sdk/router/session-router.d.ts +11 -0
  86. package/dist/types/sdk/session.d.ts +16 -0
  87. package/dist/types/sdk/startup-capability.d.ts +2 -1
  88. package/dist/types/session/account-inventory.d.ts +1 -0
  89. package/dist/types/session/agent-session.d.ts +24 -7
  90. package/dist/types/session/credential-store-errors.d.ts +1 -0
  91. package/dist/types/session/fold-coordinator.d.ts +201 -0
  92. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  93. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  94. package/dist/types/session/session-manager.d.ts +65 -0
  95. package/dist/types/session/yield-queue.d.ts +5 -4
  96. package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
  97. package/dist/types/telemetry/control.d.ts +11 -0
  98. package/dist/types/telemetry/events.d.ts +20 -0
  99. package/dist/types/telemetry/index.d.ts +3 -0
  100. package/dist/types/telemetry/transport.d.ts +22 -0
  101. package/dist/types/tools/bash-interactive.d.ts +23 -0
  102. package/dist/types/tools/bash.d.ts +4 -0
  103. package/dist/types/tools/gh.d.ts +8 -0
  104. package/dist/types/tools/index.d.ts +10 -5
  105. package/dist/types/web/search/providers/base.d.ts +9 -4
  106. package/dist/types/web/search/providers/xai.d.ts +2 -1
  107. package/dist/types/web/search/types.d.ts +14 -0
  108. package/package.json +10 -7
  109. package/scripts/generate-hotkeys-docs.ts +4 -0
  110. package/scripts/generate-sdk-operation-inventory.ts +9 -3
  111. package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
  112. package/src/async/job-manager.ts +671 -39
  113. package/src/cli/accounts-cli.ts +9 -5
  114. package/src/cli/args.ts +17 -0
  115. package/src/cli/auth-broker-cli.ts +40 -12
  116. package/src/cli/auth-gateway-cli.ts +273 -97
  117. package/src/cli/config-cli.ts +2 -2
  118. package/src/cli/fast-help.ts +4 -0
  119. package/src/cli/model-presets-cli.ts +100 -0
  120. package/src/cli/root-flags.ts +5 -0
  121. package/src/cli/update-cli.ts +117 -16
  122. package/src/cli.ts +6 -2
  123. package/src/commands/auth-gateway.ts +12 -8
  124. package/src/commands/gc.ts +19 -0
  125. package/src/commands/launch.ts +86 -18
  126. package/src/commands/model-presets.ts +24 -0
  127. package/src/commands/sdk.ts +113 -21
  128. package/src/config/autorouting-tier-map.ts +44 -0
  129. package/src/config/file-lock-gc.ts +9 -4
  130. package/src/config/file-lock.ts +890 -99
  131. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  132. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  133. package/src/config/keybindings.ts +6 -2
  134. package/src/config/model-preset-registry.ts +2795 -0
  135. package/src/config/model-profile-activation.ts +115 -28
  136. package/src/config/model-profile-contract.ts +4 -1
  137. package/src/config/model-profiles.ts +56 -14
  138. package/src/config/model-registry.ts +1044 -122
  139. package/src/config/model-resolver.ts +5 -0
  140. package/src/config/settings-schema.ts +19 -1
  141. package/src/config/settings.ts +11 -1
  142. package/src/coordinator-mcp/model-preset.ts +32 -17
  143. package/src/coordinator-mcp/projection-scan.ts +592 -0
  144. package/src/coordinator-mcp/question-state.ts +9 -11
  145. package/src/coordinator-mcp/server.ts +111 -42
  146. package/src/coordinator-mcp/session-reaper.ts +9 -3
  147. package/src/crash/index-store.ts +2 -0
  148. package/src/crash/record-loader.ts +10 -0
  149. package/src/extensibility/extensions/loader.ts +0 -1
  150. package/src/extensibility/extensions/types.ts +2 -3
  151. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  152. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  153. package/src/gjc-runtime/gc-render.ts +29 -0
  154. package/src/gjc-runtime/gc-runtime.ts +174 -11
  155. package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
  156. package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
  157. package/src/gjc-runtime/session-state-lock.ts +1321 -147
  158. package/src/gjc-runtime/session-state-sidecar.ts +1004 -25
  159. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
  160. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -2
  161. package/src/gjc-runtime/tmux-sessions.ts +252 -13
  162. package/src/hooks/skill-state.ts +6 -0
  163. package/src/index.ts +1 -0
  164. package/src/internal-urls/docs-index.generated.ts +14 -13
  165. package/src/internal-urls/issue-pr-protocol.ts +4 -2
  166. package/src/lsp/config.ts +4 -3
  167. package/src/lsp/path-trust.ts +12 -6
  168. package/src/main.ts +66 -4
  169. package/src/master-mode/context.ts +50 -0
  170. package/src/master-mode/first-request.ts +65 -0
  171. package/src/master-mode/snapshot.ts +205 -0
  172. package/src/modes/acp/acp-agent.ts +616 -161
  173. package/src/modes/components/footer.ts +4 -2
  174. package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
  175. package/src/modes/components/jobs-overlay-model.ts +99 -16
  176. package/src/modes/components/model-selector.ts +158 -18
  177. package/src/modes/components/oauth-selector.ts +6 -1
  178. package/src/modes/components/settings-selector.ts +1 -0
  179. package/src/modes/components/status-line/model-name.ts +53 -0
  180. package/src/modes/components/status-line/priority-row.ts +107 -0
  181. package/src/modes/components/status-line/segments.ts +29 -10
  182. package/src/modes/components/tool-status-header.ts +151 -15
  183. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  184. package/src/modes/controllers/input-controller.ts +51 -6
  185. package/src/modes/controllers/selector-controller.ts +191 -96
  186. package/src/modes/interactive-mode.ts +14 -1
  187. package/src/modes/jobs-observer.ts +149 -16
  188. package/src/modes/print-mode.ts +64 -0
  189. package/src/modes/runtime-init.ts +1 -1
  190. package/src/modes/ui-language.ts +49 -2
  191. package/src/modes/utils/injected-user-submission.ts +15 -1
  192. package/src/prompts/system/master-mode.md +28 -0
  193. package/src/prompts/tools/bash.md +2 -2
  194. package/src/prompts/tools/fold-receipt.md +3 -0
  195. package/src/prompts/tools/job.md +1 -1
  196. package/src/sdk/acp/adapter.ts +17 -2
  197. package/src/sdk/broker/broker.ts +1671 -34
  198. package/src/sdk/broker/endpoint-authority.ts +86 -0
  199. package/src/sdk/broker/ensure.ts +5 -0
  200. package/src/sdk/broker/index.ts +3 -0
  201. package/src/sdk/broker/lifecycle.ts +217 -46
  202. package/src/sdk/broker/master-capability.ts +232 -0
  203. package/src/sdk/broker/session-index.ts +121 -17
  204. package/src/sdk/broker/session-scope.ts +272 -0
  205. package/src/sdk/broker/spawn-authority.ts +1031 -0
  206. package/src/sdk/broker/spawn-substrate.ts +548 -0
  207. package/src/sdk/broker/state-version.ts +26 -14
  208. package/src/sdk/broker/transport.ts +1 -0
  209. package/src/sdk/broker/worktree-occupancy.ts +90 -0
  210. package/src/sdk/bus/chat-daemon-control.ts +5 -2
  211. package/src/sdk/bus/chat-daemon-runtime.ts +46 -5
  212. package/src/sdk/bus/index.ts +51 -4
  213. package/src/sdk/bus/kind-aware-reconciliation.ts +36 -6
  214. package/src/sdk/bus/prompt-reconciliation.ts +28 -4
  215. package/src/sdk/bus/slack-conversation.ts +5 -1
  216. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  217. package/src/sdk/cli/index.ts +1 -0
  218. package/src/sdk/cli/master-cli.ts +259 -0
  219. package/src/sdk/cli/rows.ts +11 -8
  220. package/src/sdk/cli/session-cli.ts +276 -6
  221. package/src/sdk/client/client.ts +14 -8
  222. package/src/sdk/client/discovery.ts +3 -1
  223. package/src/sdk/host/control/dispatch.ts +101 -37
  224. package/src/sdk/host/control/index.ts +2 -0
  225. package/src/sdk/host/control/operations.ts +2 -0
  226. package/src/sdk/host/control/runtime-gate.ts +203 -11
  227. package/src/sdk/host/host.ts +27 -2
  228. package/src/sdk/host/model-pin.ts +185 -25
  229. package/src/sdk/host/sdk-run-capability.ts +1 -0
  230. package/src/sdk/host/session-runtime.test.ts +581 -43
  231. package/src/sdk/host/session-runtime.ts +655 -101
  232. package/src/sdk/lifecycle/broker-client.ts +28 -0
  233. package/src/sdk/lifecycle/service.ts +279 -20
  234. package/src/sdk/lifecycle-session.ts +10 -2
  235. package/src/sdk/prompt-deadline-manager.ts +16 -1
  236. package/src/sdk/protocol/adapter-validation.ts +27 -0
  237. package/src/sdk/protocol/operation-inventory.generated.json +53 -2
  238. package/src/sdk/protocol/operation-registry.ts +17 -4
  239. package/src/sdk/router/session-router.ts +294 -154
  240. package/src/sdk/session.ts +440 -167
  241. package/src/sdk/startup-capability.ts +1 -0
  242. package/src/session/account-inventory.ts +27 -11
  243. package/src/session/agent-session.ts +1292 -180
  244. package/src/session/blob-store.ts +15 -5
  245. package/src/session/credential-store-errors.ts +2 -0
  246. package/src/session/fold-coordinator.ts +433 -0
  247. package/src/session/internal/managed-session-storage.ts +153 -78
  248. package/src/session/sdk-run-capability-internal.ts +15 -0
  249. package/src/session/sdk-run-capability.ts +2 -0
  250. package/src/session/session-manager.ts +342 -28
  251. package/src/session/yield-queue.ts +24 -10
  252. package/src/setup/frictionless-onboarding.ts +2 -0
  253. package/src/setup/provider-presets.json +1 -1
  254. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  255. package/src/task/executor.ts +19 -3
  256. package/src/telemetry/control.ts +26 -0
  257. package/src/telemetry/events.ts +1268 -0
  258. package/src/telemetry/index.ts +3 -0
  259. package/src/telemetry/transport.ts +87 -0
  260. package/src/tools/bash-interactive.ts +182 -85
  261. package/src/tools/bash.ts +1028 -109
  262. package/src/tools/gh.ts +104 -48
  263. package/src/tools/github-cache.ts +2 -2
  264. package/src/tools/index.ts +11 -5
  265. package/src/tools/tool-catalog.generated.ts +1 -1
  266. package/src/web/search/provider.ts +120 -11
  267. package/src/web/search/providers/anthropic.ts +89 -17
  268. package/src/web/search/providers/base.ts +9 -4
  269. package/src/web/search/providers/gemini.ts +87 -15
  270. package/src/web/search/providers/openai-compatible.ts +23 -8
  271. package/src/web/search/providers/xai.ts +98 -12
  272. package/src/web/search/types.ts +16 -0
@@ -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}`);
@@ -31,7 +31,12 @@ import {
31
31
  removeCanonicalBlob,
32
32
  } from "../session/blob-store";
33
33
  import { FileSessionStorage, probeSessionRetirement, retireSessionTranscript } from "../session/session-storage";
34
-
34
+ import {
35
+ collectEmptyDeleteReceipts,
36
+ type EmptyDeleteGcRecord,
37
+ type EmptyDeleteGcReport,
38
+ runEmptyDeleteGc,
39
+ } from "./empty-delete-gc";
35
40
  import { buildGcReportText } from "./gc-render";
36
41
  import { collectSessionScopeUsage, type GcSessionScopeUsage, shouldReportSessionScope } from "./gc-session-scope";
37
42
 
@@ -168,6 +173,7 @@ export interface GcReport {
168
173
  * PID-liveness axis above is unchanged by its absence or presence.
169
174
  */
170
175
  disk?: GcDiskReport;
176
+ empty_delete_receipts?: EmptyDeleteGcReport;
171
177
  }
172
178
 
173
179
  export interface GcRunResult {
@@ -274,9 +280,11 @@ interface ParsedGcArgs {
274
280
  json: boolean;
275
281
  prune: boolean;
276
282
  repairSessionIndex: boolean;
277
- /** Opt-in second axis: report (and with `--prune`, reclaim) on-disk retention. */
278
283
  disk: boolean;
279
284
  help: boolean;
285
+ emptyDeleteReceipts: boolean;
286
+ emptyDeleteRoots: string[];
287
+ emptyDeleteManifests: string[];
280
288
  }
281
289
 
282
290
  class GcUsageError extends Error {}
@@ -286,10 +294,13 @@ function parseGcArgs(argv: string[]): ParsedGcArgs {
286
294
  let prune = false;
287
295
  let repairSessionIndex = false;
288
296
  let disk = false;
289
-
290
297
  let dryRun = false;
291
298
  let help = false;
292
- for (const arg of argv) {
299
+ let emptyDeleteReceipts = false;
300
+ const emptyDeleteRoots: string[] = [];
301
+ const emptyDeleteManifests: string[] = [];
302
+ for (let i = 0; i < argv.length; i++) {
303
+ const arg = argv[i]!;
293
304
  switch (arg) {
294
305
  case "--json":
295
306
  case "-j":
@@ -305,7 +316,6 @@ function parseGcArgs(argv: string[]): ParsedGcArgs {
305
316
  case "--disk":
306
317
  disk = true;
307
318
  break;
308
-
309
319
  case "--dry-run":
310
320
  dryRun = true;
311
321
  break;
@@ -313,15 +323,34 @@ function parseGcArgs(argv: string[]): ParsedGcArgs {
313
323
  case "-h":
314
324
  help = true;
315
325
  break;
326
+ case "--empty-delete-receipts":
327
+ emptyDeleteReceipts = true;
328
+ break;
329
+ case "--root": {
330
+ const value = argv[++i];
331
+ // A following option token is a missing operand, not a root path. Any
332
+ // dash-prefixed value is rejected (short flags like -j included); spell a
333
+ // genuinely dash-prefixed path as ./-name so it is unambiguous.
334
+ if (!value || value.startsWith("-")) throw new GcUsageError("missing_root");
335
+ emptyDeleteRoots.push(value);
336
+ break;
337
+ }
338
+ case "--manifest": {
339
+ const value = argv[++i];
340
+ if (!value || value.startsWith("-")) throw new GcUsageError("missing_manifest");
341
+ emptyDeleteManifests.push(value);
342
+ break;
343
+ }
316
344
  default:
317
345
  throw new GcUsageError(`unknown_flag:${arg}`);
318
346
  }
319
347
  }
320
348
  if (repairSessionIndex && prune) throw new GcUsageError("repair_session_index_cannot_combine_with_prune");
349
+ if (!emptyDeleteReceipts && (emptyDeleteRoots.length > 0 || emptyDeleteManifests.length > 0))
350
+ throw new GcUsageError("empty_delete_operands_require_feature_flag");
321
351
  if (repairSessionIndex && dryRun) throw new GcUsageError("repair_session_index_cannot_combine_with_dry_run");
322
- // Explicit --dry-run always wins over --prune/--force.
323
352
  if (dryRun) prune = false;
324
- return { json, prune, repairSessionIndex, disk, help };
353
+ return { json, prune, repairSessionIndex, disk, help, emptyDeleteReceipts, emptyDeleteRoots, emptyDeleteManifests };
325
354
  }
326
355
 
327
356
  /**
@@ -404,6 +433,7 @@ export async function collectGcReport(adapters: GcStoreAdapter[], ctx: GcContext
404
433
  export function computeExitCode(report: GcReport): number {
405
434
  if (report.errors.length > 0) return 1;
406
435
  if (!report.dry_run && report.counts.failed > 0) return 1;
436
+ if (report.empty_delete_receipts?.errors.length) return 1;
407
437
  if (report.disk) {
408
438
  if (report.disk.errors.length > 0) return 1;
409
439
  if (!report.disk.dry_run && report.disk.totals.failed > 0) return 1;
@@ -415,6 +445,63 @@ function resolveGcAgentDir(env: NodeJS.ProcessEnv): string {
415
445
  return env.GJC_CODING_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim() || getAgentDir();
416
446
  }
417
447
 
448
+ /**
449
+ * Validate operator-supplied empty-delete roots before any ordinary GC mutation.
450
+ *
451
+ * These roots are explicit operands, so a missing, symlink, non-directory, or
452
+ * unreadable root is a usage failure rather than a partial GC report. The
453
+ * collector repeats its own race-safe checks after this preflight.
454
+ */
455
+ async function preflightEmptyDeleteRoot(root: string): Promise<void> {
456
+ const resolvedRoot = path.resolve(root);
457
+ let stat: Stats;
458
+ try {
459
+ stat = await fsp.lstat(resolvedRoot);
460
+ } catch (error) {
461
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
462
+ throw new GcUsageError(`${root}: missing_root`);
463
+ }
464
+ throw new GcUsageError(`${root}: ${error instanceof Error ? error.message : String(error)}`);
465
+ }
466
+ if (stat.isSymbolicLink()) throw new GcUsageError(`${root}: symlink_root`);
467
+ if (!stat.isDirectory()) throw new GcUsageError(`${root}: not_directory`);
468
+ try {
469
+ await fsp.readdir(resolvedRoot);
470
+ } catch (error) {
471
+ throw new GcUsageError(`${root}: ${error instanceof Error ? error.message : String(error)}`);
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Collect every explicit empty-delete root once before starting any destructive
477
+ * operation. The second pass reuses those identities so a later root race cannot
478
+ * leave an earlier root pruned before the race is discovered.
479
+ */
480
+ async function collectEmptyDeleteReceiptsForGc(
481
+ roots: string[],
482
+ ): Promise<{ report: EmptyDeleteGcReport; records: Map<string, EmptyDeleteGcRecord[]> }> {
483
+ const collected = new Map<string, EmptyDeleteGcRecord[]>();
484
+ const collect = async (root: string): Promise<EmptyDeleteGcRecord[]> => {
485
+ const records = await collectEmptyDeleteReceipts(root);
486
+ collected.set(root, records);
487
+ return records.map(record => ({
488
+ ...record,
489
+ identity: record.identity ? { ...record.identity } : undefined,
490
+ retainedPaths: record.retainedPaths ? { ...record.retainedPaths } : undefined,
491
+ }));
492
+ };
493
+ const report = await runEmptyDeleteGc({ roots, prune: false }, { collect });
494
+ return { report, records: collected };
495
+ }
496
+
497
+ function cloneEmptyDeleteRecords(records: EmptyDeleteGcRecord[]): EmptyDeleteGcRecord[] {
498
+ return records.map(record => ({
499
+ ...record,
500
+ identity: record.identity ? { ...record.identity } : undefined,
501
+ retainedPaths: record.retainedPaths ? { ...record.retainedPaths } : undefined,
502
+ }));
503
+ }
504
+
418
505
  /**
419
506
  * Locate and measure the managed scope for `cwd`.
420
507
  *
@@ -482,9 +569,77 @@ export async function runGjcGcCommand(
482
569
  return { stdout: gcHelpText(), stderr: "", status: 0 };
483
570
  }
484
571
 
572
+ // Resolve and validate every empty-delete operand BEFORE any store collection or
573
+ // prune can mutate: a malformed manifest or missing operand must fail with status 2
574
+ // while every candidate is still untouched.
575
+ let emptyDeleteRoots: string[] = [];
576
+ if (parsed.emptyDeleteReceipts) {
577
+ emptyDeleteRoots = [...parsed.emptyDeleteRoots];
578
+ if (emptyDeleteRoots.some(root => root.includes("\0"))) {
579
+ return { stdout: "", stderr: "gjc gc: root_invalid\n", status: 2 };
580
+ }
581
+ // Every supplied manifest is validated, not just the last: a malformed earlier
582
+ // manifest must fail the run before any store collection or prune can mutate.
583
+ for (const manifestPath of parsed.emptyDeleteManifests) {
584
+ let parsedManifest: unknown;
585
+ try {
586
+ const raw = await fsp.readFile(manifestPath, "utf8");
587
+ parsedManifest = JSON.parse(raw);
588
+ } catch (error) {
589
+ const message = error instanceof Error ? error.message : String(error);
590
+ return { stdout: "", stderr: `gjc gc: manifest_invalid: ${message}\n`, status: 2 };
591
+ }
592
+ if (typeof parsedManifest !== "object" || parsedManifest === null || Array.isArray(parsedManifest)) {
593
+ return { stdout: "", stderr: "gjc gc: manifest_shape_invalid\n", status: 2 };
594
+ }
595
+ const manifestRoots = (parsedManifest as { roots?: unknown }).roots;
596
+ if (!Array.isArray(manifestRoots)) {
597
+ return { stdout: "", stderr: "gjc gc: manifest_roots_required\n", status: 2 };
598
+ }
599
+ for (const root of manifestRoots) {
600
+ if (typeof root !== "string" || root.length === 0 || root.includes("\0")) {
601
+ return { stdout: "", stderr: "gjc gc: manifest_root_invalid\n", status: 2 };
602
+ }
603
+ emptyDeleteRoots.push(root);
604
+ }
605
+ }
606
+ if (emptyDeleteRoots.length === 0) {
607
+ return { stdout: "", stderr: "gjc gc: empty_delete_receipts_requires_root_or_manifest\n", status: 2 };
608
+ }
609
+ try {
610
+ for (const root of emptyDeleteRoots) await preflightEmptyDeleteRoot(root);
611
+ } catch (error) {
612
+ const message = error instanceof GcUsageError ? error.message : String(error);
613
+ return { stdout: "", stderr: `gjc gc: ${message}\n`, status: 2 };
614
+ }
615
+ }
616
+ let emptyDeleteReceipts: EmptyDeleteGcReport | undefined;
617
+ let emptyDeleteRecords: Map<string, EmptyDeleteGcRecord[]> | undefined;
618
+ if (parsed.emptyDeleteReceipts) {
619
+ // Discovery is deliberately read-only and covers every root before either the
620
+ // empty-delete unlink or an ordinary --prune adapter can mutate anything.
621
+ const collected = await collectEmptyDeleteReceiptsForGc(emptyDeleteRoots);
622
+ emptyDeleteReceipts = collected.report;
623
+ emptyDeleteRecords = collected.records;
624
+ if (parsed.prune && emptyDeleteReceipts.errors.length === 0) {
625
+ emptyDeleteReceipts = await runEmptyDeleteGc(
626
+ { roots: emptyDeleteRoots, prune: true },
627
+ {
628
+ collect: async root => {
629
+ const records = emptyDeleteRecords?.get(root);
630
+ if (!records) throw new Error(`${root}: empty_delete_collection_missing`);
631
+ return cloneEmptyDeleteRecords(records);
632
+ },
633
+ },
634
+ );
635
+ }
636
+ }
485
637
  const resolvedAdapters = adapters ?? (await defaultGcAdapters());
486
638
  const ctx: GcContext = { probe: gcPidProbe, force: parsed.prune, env, cwd };
487
- const report = await collectGcReport(resolvedAdapters, ctx, parsed.prune);
639
+ // A failed explicit empty-delete discovery or cleanup is a safety gate: report
640
+ // ordinary candidates, but never perform their destructive prune in that run.
641
+ const ordinaryPrune = parsed.prune && (emptyDeleteReceipts?.errors.length ?? 0) === 0;
642
+ const report = await collectGcReport(resolvedAdapters, ctx, ordinaryPrune);
488
643
  report.operation = parsed.repairSessionIndex ? "repair_session_index" : parsed.prune ? "prune" : "dry_run";
489
644
  report.session_index = await collectSessionIndexHealth(parsed.repairSessionIndex, resolveGcAgentDir(env));
490
645
  if (parsed.disk) {
@@ -492,9 +647,10 @@ export async function runGjcGcCommand(
492
647
  agentDir: resolveGcAgentDir(env),
493
648
  env,
494
649
  policy: resolveGcDiskPolicy(diskPolicy),
495
- prune: parsed.prune,
650
+ prune: ordinaryPrune,
496
651
  });
497
652
  }
653
+ if (emptyDeleteReceipts) report.empty_delete_receipts = emptyDeleteReceipts;
498
654
  const scopeUsage = await collectGcSessionScope(cwd, resolveGcAgentDir(env));
499
655
  if (scopeUsage && shouldReportSessionScope(scopeUsage)) report.session_scope = scopeUsage;
500
656
  const sessionIndexFailed =
@@ -503,7 +659,7 @@ export async function runGjcGcCommand(
503
659
  report.session_index?.status === "repair_failed";
504
660
  const status = sessionIndexFailed ? 1 : computeExitCode(report);
505
661
  const stdout = parsed.json
506
- ? `${JSON.stringify(report, null, 2)}\n`
662
+ ? `${JSON.stringify(report, (_key, value) => (typeof value === "bigint" ? value.toString() : value), 2)}\n`
507
663
  : `${buildGcReportText(report)}${report.disk ? buildGcDiskReportText(report.disk) : ""}`;
508
664
  return { stdout, stderr: "", status };
509
665
  }
@@ -513,7 +669,7 @@ export function gcHelpText(): string {
513
669
  "gjc gc - garbage-collect stale GJC session/PID records",
514
670
  "",
515
671
  "USAGE",
516
- " $ gjc gc [--prune|--force] [--disk] [--repair-session-index] [--json]",
672
+ " $ gjc gc [--prune|--force] [--disk] [--repair-session-index] [--empty-delete-receipts --root <dir>] [--json]",
517
673
 
518
674
  "",
519
675
  "FLAGS",
@@ -522,6 +678,13 @@ export function gcHelpText(): string {
522
678
  " -j, --json Emit machine-readable JSON",
523
679
  " --repair-session-index Explicitly quarantine a corrupt session-index suffix and retain its valid prefix",
524
680
  " --disk Also report on-disk retention (sessions, blobs, artifacts, natives, backups)",
681
+ " --empty-delete-receipts Report/prune empty .gjc-delete-* under --root / --manifest",
682
+ " --root <dir> Operand root for --empty-delete-receipts (repeatable)",
683
+ ' --manifest <file> JSON {"roots":[...]} for --empty-delete-receipts (repeatable)',
684
+ "",
685
+ " Operand values starting with '-' are rejected as missing operands; spell a",
686
+ " dash-prefixed path as ./-name. --root/--manifest without --empty-delete-receipts",
687
+ " is a usage error, and every supplied manifest is validated before any prune.",
525
688
  "",
526
689
  "Liveness-only: a record is removed only when its owning process is dead",
527
690
  "(ESRCH). Live / permission-denied / unknown processes are always kept.",