@gajae-code/coding-agent 0.18.12 → 0.19.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 (305) hide show
  1. package/CHANGELOG.md +467 -0
  2. package/dist/types/capability/rule.d.ts +7 -2
  3. package/dist/types/cli/append-system-prompt.d.ts +6 -0
  4. package/dist/types/cli/args.d.ts +7 -2
  5. package/dist/types/cli/customize-doctor-precedence.d.ts +16 -0
  6. package/dist/types/cli/customize-doctor.d.ts +4 -3
  7. package/dist/types/cli/public-command-errors.d.ts +1 -0
  8. package/dist/types/cli/root-flags.d.ts +0 -12
  9. package/dist/types/cli/update-cli.d.ts +7 -2
  10. package/dist/types/cli-main.d.ts +0 -6
  11. package/dist/types/commands/launch.d.ts +0 -6
  12. package/dist/types/commands/migrate.d.ts +1 -1
  13. package/dist/types/commands/sdk.d.ts +8 -0
  14. package/dist/types/commit/map-reduce/index.d.ts +4 -4
  15. package/dist/types/config/autorouting-tier-map.d.ts +23 -1
  16. package/dist/types/config/config-hot-reload.d.ts +5 -0
  17. package/dist/types/config/file-lock.d.ts +44 -2
  18. package/dist/types/config/model-profile-activation.d.ts +19 -5
  19. package/dist/types/config/model-registry.d.ts +16 -3
  20. package/dist/types/config/model-resolver.d.ts +5 -4
  21. package/dist/types/config/settings-schema.d.ts +11 -1
  22. package/dist/types/coordinator-mcp/server.d.ts +4 -0
  23. package/dist/types/customization/import.d.ts +2 -0
  24. package/dist/types/customization/types.d.ts +1 -1
  25. package/dist/types/dap/session.d.ts +11 -2
  26. package/dist/types/discovery/helpers.d.ts +7 -2
  27. package/dist/types/edit/index.d.ts +5 -0
  28. package/dist/types/edit/modes/patch.d.ts +9 -0
  29. package/dist/types/eval/py/runtime.d.ts +9 -0
  30. package/dist/types/export/ttsr.d.ts +19 -0
  31. package/dist/types/extensibility/extensions/types.d.ts +5 -1
  32. package/dist/types/extensibility/slash-commands.d.ts +1 -0
  33. package/dist/types/gjc-runtime/ralplan-current-final.d.ts +54 -0
  34. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -0
  35. package/dist/types/gjc-runtime/tmux-sessions.d.ts +24 -0
  36. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +6 -0
  37. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  38. package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
  39. package/dist/types/hindsight/content.d.ts +5 -6
  40. package/dist/types/internal-urls/types.d.ts +2 -0
  41. package/dist/types/lsp/clients/swiftlint-client.d.ts +0 -4
  42. package/dist/types/main.d.ts +23 -18
  43. package/dist/types/migrate/action-planner.d.ts +10 -1
  44. package/dist/types/migrate/adapters/codex.d.ts +1 -1
  45. package/dist/types/migrate/adapters/index.d.ts +35 -3
  46. package/dist/types/migrate/hook-module.d.ts +23 -0
  47. package/dist/types/migrate/report.d.ts +2 -1
  48. package/dist/types/migrate/types.d.ts +69 -2
  49. package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
  50. package/dist/types/modes/components/model-selector.d.ts +43 -0
  51. package/dist/types/modes/prompt-suggestion-controller.d.ts +1 -1
  52. package/dist/types/modes/utils/host-status.d.ts +15 -11
  53. package/dist/types/runtime-mcp/project-host-values.d.ts +11 -5
  54. package/dist/types/sdk/broker/broker-exit.d.ts +1 -1
  55. package/dist/types/sdk/broker/broker.d.ts +22 -1
  56. package/dist/types/sdk/broker/ensure.d.ts +5 -2
  57. package/dist/types/sdk/broker/hop.d.ts +9 -0
  58. package/dist/types/sdk/broker/lifecycle.d.ts +22 -0
  59. package/dist/types/sdk/broker/session-index.d.ts +2 -0
  60. package/dist/types/sdk/broker/transport.d.ts +2 -0
  61. package/dist/types/sdk/bus/index.d.ts +21 -0
  62. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -1
  63. package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
  64. package/dist/types/sdk/bus/threaded-render.d.ts +2 -1
  65. package/dist/types/sdk/host/control/operations.d.ts +5 -1
  66. package/dist/types/sdk/host/events.d.ts +4 -0
  67. package/dist/types/sdk/host/host.d.ts +2 -1
  68. package/dist/types/sdk/host/prompt-image-upload.d.ts +43 -0
  69. package/dist/types/sdk/host/session-runtime.d.ts +8 -8
  70. package/dist/types/sdk/host/surface-policy.d.ts +2 -1
  71. package/dist/types/sdk/lifecycle/service.d.ts +12 -0
  72. package/dist/types/sdk/prompt-status.d.ts +11 -4
  73. package/dist/types/session/agent-session.d.ts +50 -8
  74. package/dist/types/session/session-manager.d.ts +16 -0
  75. package/dist/types/skill-state/workflow-mutation-guard.d.ts +13 -1
  76. package/dist/types/slash-commands/helpers/mcp.d.ts +5 -0
  77. package/dist/types/ssh/ssh-executor.d.ts +8 -1
  78. package/dist/types/system-prompt.d.ts +3 -2
  79. package/dist/types/task/role-body.d.ts +11 -0
  80. package/dist/types/tools/awaiting-input.d.ts +28 -0
  81. package/dist/types/tools/bash-allowed-prefixes.d.ts +6 -0
  82. package/dist/types/tools/bash-skill-urls.d.ts +4 -0
  83. package/dist/types/tools/bash.d.ts +7 -0
  84. package/dist/types/tools/descriptors.d.ts +13 -2
  85. package/dist/types/tools/fetch.d.ts +6 -0
  86. package/dist/types/tools/irc.d.ts +5 -3
  87. package/dist/types/tools/tmux-self-injection-guard.d.ts +2 -0
  88. package/dist/types/tools/ultragoal-ask-guard.d.ts +1 -1
  89. package/dist/types/tools/write.d.ts +2 -0
  90. package/dist/types/utils/file-mentions.d.ts +5 -0
  91. package/dist/types/utils/image-limits.d.ts +7 -0
  92. package/dist/types/utils/image-loading.d.ts +0 -1
  93. package/dist/types/utils/open.d.ts +0 -1
  94. package/dist/types/utils/pasted-image-loading.d.ts +1 -5
  95. package/dist/types/utils/prompt-suggestion.d.ts +2 -2
  96. package/dist/types/utils/shell-snapshot.d.ts +2 -0
  97. package/dist/types/utils/title-generator.d.ts +13 -3
  98. package/dist/types/web/search/providers/codex.d.ts +6 -7
  99. package/package.json +9 -8
  100. package/scripts/acp-conformance-agent.ts +12 -2
  101. package/scripts/generate-sdk-operation-inventory.ts +16 -0
  102. package/src/capability/fs.ts +31 -3
  103. package/src/capability/rule.ts +11 -2
  104. package/src/cli/append-system-prompt.ts +12 -0
  105. package/src/cli/args.ts +11 -7
  106. package/src/cli/customize-doctor-precedence.ts +31 -0
  107. package/src/cli/customize-doctor.ts +49 -39
  108. package/src/cli/fast-help.ts +0 -2
  109. package/src/cli/migrate-cli.ts +14 -3
  110. package/src/cli/public-command-errors.ts +2 -0
  111. package/src/cli/root-flags.ts +0 -2
  112. package/src/cli/update-cli.ts +247 -6
  113. package/src/commands/harness.ts +25 -15
  114. package/src/commands/migrate.ts +3 -3
  115. package/src/commands/sdk.ts +81 -11
  116. package/src/commit/agentic/agent.ts +6 -6
  117. package/src/commit/analysis/conventional.ts +14 -5
  118. package/src/commit/map-reduce/index.ts +7 -7
  119. package/src/commit/pipeline.ts +12 -12
  120. package/src/config/autorouting-tier-map.ts +20 -1
  121. package/src/config/config-file.ts +106 -3
  122. package/src/config/config-hot-reload.ts +8 -0
  123. package/src/config/file-lock.ts +1431 -304
  124. package/src/config/keybindings.ts +154 -7
  125. package/src/config/model-profile-activation.ts +177 -39
  126. package/src/config/model-registry.ts +380 -55
  127. package/src/config/model-resolver.ts +49 -21
  128. package/src/config/settings-schema.ts +13 -1
  129. package/src/config/settings.ts +2 -0
  130. package/src/coordinator-mcp/server.ts +37 -2
  131. package/src/crash/sanitize.ts +3 -1
  132. package/src/customization/import.ts +276 -7
  133. package/src/customization/inventory.ts +24 -8
  134. package/src/customization/types.ts +1 -0
  135. package/src/dap/client.ts +3 -2
  136. package/src/dap/session.ts +161 -36
  137. package/src/debug/crash-diagnostics.ts +87 -5
  138. package/src/debug/report-bundle.ts +71 -2
  139. package/src/discovery/builtin.ts +4 -3
  140. package/src/discovery/helpers.ts +24 -14
  141. package/src/edit/index.ts +191 -5
  142. package/src/edit/modes/patch.ts +27 -0
  143. package/src/eval/py/runtime.ts +44 -4
  144. package/src/exec/non-interactive-env.ts +0 -1
  145. package/src/export/html/index.ts +60 -20
  146. package/src/export/ttsr.ts +176 -1
  147. package/src/extensibility/extensions/loader.ts +2 -0
  148. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +63 -1
  149. package/src/extensibility/extensions/types.ts +5 -1
  150. package/src/extensibility/hooks/loader.ts +10 -2
  151. package/src/extensibility/plugins/marketplace/manager.ts +36 -10
  152. package/src/extensibility/slash-commands.ts +5 -0
  153. package/src/gjc-runtime/gc-runtime.ts +4 -1
  154. package/src/gjc-runtime/launch-tmux.ts +23 -13
  155. package/src/gjc-runtime/ralplan-current-final.ts +294 -0
  156. package/src/gjc-runtime/ralplan-runtime.ts +95 -1
  157. package/src/gjc-runtime/state-writer.ts +109 -5
  158. package/src/gjc-runtime/tmux-sessions.ts +93 -13
  159. package/src/gjc-runtime/ultragoal-change-set.ts +90 -11
  160. package/src/gjc-runtime/ultragoal-guard.ts +9 -1
  161. package/src/gjc-runtime/ultragoal-runtime.ts +15 -5
  162. package/src/harness-control-plane/finalize.ts +11 -0
  163. package/src/harness-control-plane/receipt-spool.ts +105 -28
  164. package/src/harness-control-plane/storage.ts +124 -6
  165. package/src/hindsight/content.ts +116 -16
  166. package/src/hindsight/mental-models.ts +22 -10
  167. package/src/hindsight/state.ts +7 -4
  168. package/src/hooks/native-skill-hook.ts +3 -1
  169. package/src/internal-urls/artifact-protocol.ts +3 -1
  170. package/src/internal-urls/docs-index.generated.ts +14 -14
  171. package/src/internal-urls/local-protocol.ts +28 -3
  172. package/src/internal-urls/types.ts +2 -0
  173. package/src/lsp/client.ts +2 -1
  174. package/src/lsp/clients/biome-client.ts +2 -0
  175. package/src/lsp/clients/swiftlint-client.ts +2 -0
  176. package/src/main.ts +330 -92
  177. package/src/memories/index.ts +25 -2
  178. package/src/migrate/action-planner.ts +517 -2
  179. package/src/migrate/adapters/claude-code.ts +141 -3
  180. package/src/migrate/adapters/codex.ts +57 -5
  181. package/src/migrate/adapters/index.ts +240 -5
  182. package/src/migrate/adapters/opencode.ts +4 -0
  183. package/src/migrate/executor.ts +141 -3
  184. package/src/migrate/hook-module.ts +193 -0
  185. package/src/migrate/report.ts +45 -1
  186. package/src/migrate/types.ts +73 -3
  187. package/src/modes/acp/acp-agent.ts +10 -1
  188. package/src/modes/components/agent-dashboard.ts +20 -4
  189. package/src/modes/components/customization/customization-dashboard.ts +7 -0
  190. package/src/modes/components/customization/import-wizard.ts +29 -3
  191. package/src/modes/components/model-selector.ts +157 -103
  192. package/src/modes/components/welcome.ts +3 -1
  193. package/src/modes/controllers/command-controller.ts +271 -16
  194. package/src/modes/controllers/event-controller.ts +1 -1
  195. package/src/modes/controllers/extension-ui-controller.ts +31 -1
  196. package/src/modes/controllers/goal-mode-controller.ts +79 -10
  197. package/src/modes/controllers/input-controller.ts +2 -0
  198. package/src/modes/controllers/plan-mode-controller.ts +49 -4
  199. package/src/modes/controllers/runtime-mcp-command-controller.ts +7 -6
  200. package/src/modes/controllers/selector-controller.ts +14 -1
  201. package/src/modes/interactive-mode.ts +55 -4
  202. package/src/modes/prompt-suggestion-controller.ts +1 -1
  203. package/src/modes/runtime-init.ts +5 -1
  204. package/src/modes/utils/host-status.ts +21 -10
  205. package/src/prompts/agent-fragments/browser-aside.md +2 -2
  206. package/src/prompts/system/irc-incoming.md +3 -1
  207. package/src/prompts/tools/irc.md +1 -1
  208. package/src/runtime-mcp/manager.ts +8 -3
  209. package/src/runtime-mcp/project-host-values.ts +12 -8
  210. package/src/runtime-mcp/tool-bridge.ts +68 -4
  211. package/src/runtime-mcp/transports/stdio.ts +2 -1
  212. package/src/sdk/broker/broker-exit.ts +2 -0
  213. package/src/sdk/broker/broker.ts +143 -4
  214. package/src/sdk/broker/ensure.ts +74 -6
  215. package/src/sdk/broker/hop.ts +13 -1
  216. package/src/sdk/broker/lifecycle-ledger.ts +3 -0
  217. package/src/sdk/broker/lifecycle.ts +102 -6
  218. package/src/sdk/broker/session-index.ts +29 -4
  219. package/src/sdk/broker/transport.ts +17 -1
  220. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  221. package/src/sdk/bus/index.ts +408 -14
  222. package/src/sdk/bus/session-control.ts +2 -0
  223. package/src/sdk/bus/slack-daemon.ts +105 -9
  224. package/src/sdk/bus/telegram-daemon-contract.ts +7 -1
  225. package/src/sdk/bus/telegram-daemon.ts +10 -2
  226. package/src/sdk/bus/threaded-render.ts +20 -1
  227. package/src/sdk/cli/session-cli.ts +2 -0
  228. package/src/sdk/host/control/dispatch.ts +13 -1
  229. package/src/sdk/host/control/operations.ts +10 -1
  230. package/src/sdk/host/events.ts +67 -4
  231. package/src/sdk/host/host.ts +8 -2
  232. package/src/sdk/host/prompt-image-upload.ts +438 -0
  233. package/src/sdk/host/sdk-surface-parity.test.ts +15 -0
  234. package/src/sdk/host/session-runtime.test.ts +1537 -24
  235. package/src/sdk/host/session-runtime.ts +332 -36
  236. package/src/sdk/host/surface-policy.ts +4 -0
  237. package/src/sdk/host/websocket-transport.lifecycle.test.ts +90 -2
  238. package/src/sdk/host/websocket-transport.ts +19 -7
  239. package/src/sdk/lifecycle/service.ts +14 -1
  240. package/src/sdk/prompt-status.ts +6 -4
  241. package/src/sdk/protocol/adapter-validation.ts +28 -1
  242. package/src/sdk/protocol/operation-inventory.generated.json +160 -0
  243. package/src/sdk/protocol/operation-registry.ts +33 -3
  244. package/src/sdk/session.ts +123 -36
  245. package/src/session/agent-session.ts +1614 -329
  246. package/src/session/history-storage.ts +182 -12
  247. package/src/session/internal/managed-session-scope.ts +47 -18
  248. package/src/session/internal/managed-session-storage.ts +71 -1
  249. package/src/session/session-manager.ts +90 -5
  250. package/src/session-import/redact.ts +53 -2
  251. package/src/skill-state/workflow-mutation-guard.ts +1498 -18
  252. package/src/slash-commands/builtin-registry.ts +6 -3
  253. package/src/slash-commands/helpers/mcp.ts +11 -3
  254. package/src/ssh/ssh-executor.ts +9 -2
  255. package/src/stt/recorder.ts +26 -9
  256. package/src/system-prompt.ts +61 -16
  257. package/src/task/executor.ts +12 -8
  258. package/src/task/role-body.ts +84 -0
  259. package/src/task/token-log-mode.test.ts +162 -0
  260. package/src/task/token-log.ts +91 -2
  261. package/src/tools/archive-reader.ts +650 -37
  262. package/src/tools/ask.ts +1 -0
  263. package/src/tools/ast-grep.ts +7 -2
  264. package/src/tools/auto-generated-guard.ts +12 -3
  265. package/src/tools/awaiting-input.ts +114 -0
  266. package/src/tools/bash-allowed-prefixes.ts +61 -1
  267. package/src/tools/bash-skill-urls.ts +58 -5
  268. package/src/tools/bash.ts +99 -26
  269. package/src/tools/browser/tab-worker.ts +39 -13
  270. package/src/tools/browser.ts +17 -7
  271. package/src/tools/cron.ts +10 -1
  272. package/src/tools/debug.ts +9 -2
  273. package/src/tools/descriptors.test.ts +4 -4
  274. package/src/tools/descriptors.ts +16 -4
  275. package/src/tools/eval.ts +15 -0
  276. package/src/tools/fetch.ts +90 -2
  277. package/src/tools/gh.ts +27 -12
  278. package/src/tools/image-gen.ts +20 -6
  279. package/src/tools/irc.ts +5 -3
  280. package/src/tools/monitor.ts +9 -1
  281. package/src/tools/output-meta.ts +51 -3
  282. package/src/tools/read.ts +9 -2
  283. package/src/tools/recipe/runner.ts +23 -12
  284. package/src/tools/render-utils.ts +2 -2
  285. package/src/tools/search.ts +3 -2
  286. package/src/tools/ssh.ts +274 -8
  287. package/src/tools/subagent.ts +10 -4
  288. package/src/tools/tmux-self-injection-guard.ts +3301 -122
  289. package/src/tools/tool-catalog.generated.ts +1 -1
  290. package/src/tools/ultragoal-ask-guard.ts +13 -1
  291. package/src/tools/write.ts +7 -0
  292. package/src/tui/tree-list.ts +15 -1
  293. package/src/utils/commit-message-generator.ts +3 -2
  294. package/src/utils/external-editor.ts +21 -7
  295. package/src/utils/file-mentions.ts +46 -6
  296. package/src/utils/git.ts +38 -7
  297. package/src/utils/image-limits.ts +8 -0
  298. package/src/utils/image-loading.ts +1 -1
  299. package/src/utils/open.ts +73 -4
  300. package/src/utils/pasted-image-loading.ts +9 -8
  301. package/src/utils/prompt-suggestion.ts +5 -4
  302. package/src/utils/shell-snapshot.ts +9 -1
  303. package/src/utils/title-generator.ts +65 -20
  304. package/src/web/search/index.ts +2 -1
  305. package/src/web/search/providers/codex.ts +29 -32
@@ -4,6 +4,8 @@ import * as fsSync from "node:fs";
4
4
  import * as fs from "node:fs/promises";
5
5
  import * as path from "node:path";
6
6
  import type { Process } from "@gajae-code/natives";
7
+ import { startupProjectEnvSnapshot } from "@gajae-code/utils/env";
8
+ import { canonicalEnvKey, type ProjectEnvSnapshot, projectEnvSnapshot } from "@gajae-code/utils/env-file";
7
9
  import { nativeProcessBindings } from "@gajae-code/utils/native-process";
8
10
  import { managedSecurityFailureClassification } from "../session/internal/managed-session-storage";
9
11
  import { readLinuxProcStartTime, readLinuxProcStartTimeSync } from "./linux-proc";
@@ -23,6 +25,7 @@ import {
23
25
  GJC_TMUX_OWNER_SERVER_KEY_ENV,
24
26
  GJC_TMUX_OWNER_STATE_DIR_ENV,
25
27
  } from "./session-state-sidecar";
28
+ import { trustedCoordinatorEnv } from "./sidecar-bootstrap-env";
26
29
  import {
27
30
  assertGjcTmuxMutationAuthoritySync,
28
31
  bindGjcTmuxProviderAuthority,
@@ -247,6 +250,81 @@ export function __setMutationServerProofForTests(
247
250
  mutationServerProofTestDependency = dependency;
248
251
  }
249
252
 
253
+ const TMUX_LAUNCH_IDENTITY_ENV_NAMES = [
254
+ "GJC_SESSION_ID",
255
+ GJC_COORDINATOR_SESSION_ID_ENV,
256
+ GJC_COORDINATOR_SESSION_STATE_FILE_ENV,
257
+ "GJC_TMUX_SESSION",
258
+ ] as const;
259
+
260
+ /**
261
+ * Drop project-dotenv copies of the tmux session name, coordinator id, and
262
+ * state-file path. A pin is kept only when every snapshot accepts it, so a
263
+ * declaration Bun loaded before `chdir` is refused even if the launch cwd no
264
+ * longer contains that file. Acceptance uses the shared helper, but the
265
+ * original string stays: callers trim at use, and a later check must see the
266
+ * same value that was accepted.
267
+ */
268
+ export function trustedTmuxLaunchIdentityEnv(
269
+ env: NodeJS.ProcessEnv,
270
+ snapshots: readonly ProjectEnvSnapshot[],
271
+ ): NodeJS.ProcessEnv {
272
+ const denied: string[] = [];
273
+ for (const name of TMUX_LAUNCH_IDENTITY_ENV_NAMES) {
274
+ if (!env[name]) continue;
275
+ for (const snapshot of snapshots) {
276
+ if (trustedCoordinatorEnv(name, env, snapshot) === undefined) {
277
+ denied.push(name);
278
+ break;
279
+ }
280
+ }
281
+ }
282
+ if (denied.length === 0) return env;
283
+ const next: NodeJS.ProcessEnv = { ...env };
284
+ for (const name of denied) delete next[name];
285
+ return next;
286
+ }
287
+
288
+ export function tmuxLaunchIdentityEnv(env: NodeJS.ProcessEnv, cwd: string): NodeJS.ProcessEnv {
289
+ return trustedTmuxLaunchIdentityEnv(env, [projectEnvSnapshot(cwd), startupProjectEnvSnapshot()]);
290
+ }
291
+
292
+ /**
293
+ * Session id the launched child must see. A trusted pin is passed through.
294
+ * A rejected project pin is replaced by the same fallback the launch plan uses
295
+ * for the session root, which also stops Bun from reloading that dotenv value
296
+ * (`GJC_TMUX_LAUNCHED=1` skips the planner filter). An omitted id stays omitted.
297
+ */
298
+ export function resolvedChildGjcSessionId(input: {
299
+ trusted: string | undefined;
300
+ inherited: string | undefined;
301
+ projectDeclared: boolean;
302
+ fallback: string;
303
+ }): string | undefined {
304
+ const trusted = input.trusted?.trim();
305
+ if (trusted) return trusted;
306
+ if (!input.inherited?.trim() && !input.projectDeclared) return undefined;
307
+ const fallback = input.fallback.trim();
308
+ return fallback.length > 0 ? fallback : undefined;
309
+ }
310
+
311
+ export function childGjcSessionIdAssignment(
312
+ env: NodeJS.ProcessEnv,
313
+ identityEnv: NodeJS.ProcessEnv,
314
+ cwd: string,
315
+ fallbackSessionId: string,
316
+ ): Record<string, string> {
317
+ const snapshot = projectEnvSnapshot(cwd);
318
+ const key = canonicalEnvKey("GJC_SESSION_ID");
319
+ const value = resolvedChildGjcSessionId({
320
+ trusted: identityEnv.GJC_SESSION_ID,
321
+ inherited: env.GJC_SESSION_ID,
322
+ projectDeclared: snapshot.values[key] !== undefined || snapshot.dynamic.has(key),
323
+ fallback: fallbackSessionId,
324
+ });
325
+ return value === undefined ? {} : { GJC_SESSION_ID: value };
326
+ }
327
+
250
328
  function psmuxAuthorityFromEnv(env: NodeJS.ProcessEnv): ProviderAuthority | null {
251
329
  const stateDir = env[GJC_TMUX_OWNER_STATE_DIR_ENV]?.trim();
252
330
  const sessionId = env[GJC_COORDINATOR_SESSION_ID_ENV]?.trim();
@@ -515,11 +593,14 @@ function canonicalProviderStateDirs(cwd: string): string[] {
515
593
  }
516
594
 
517
595
  function psmuxAuthorityEnvironments(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv[] {
518
- const explicitAuthority = psmuxAuthorityFromEnv(env);
596
+ const identityEnv = tmuxLaunchIdentityEnv(env, process.cwd());
597
+ const explicitAuthority = psmuxAuthorityFromEnv(identityEnv);
519
598
  if (explicitAuthority) return [environmentForProviderAuthority(env, explicitAuthority)];
520
599
  const explicitStateDir =
521
- env[GJC_TMUX_OWNER_STATE_DIR_ENV]?.trim() ??
522
- (env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV] ? path.dirname(env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]) : "");
600
+ identityEnv[GJC_TMUX_OWNER_STATE_DIR_ENV]?.trim() ??
601
+ (identityEnv[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]
602
+ ? path.dirname(identityEnv[GJC_COORDINATOR_SESSION_STATE_FILE_ENV])
603
+ : "");
523
604
  const ambient = resolveGjcTmuxBinary({ env });
524
605
  if (gjcTmuxAuthorityPlatform() === "win32") {
525
606
  const ambientAvailable = path.isAbsolute(ambient.command)
@@ -669,17 +750,15 @@ export function createGjcTmuxSession(
669
750
  if (launch && !validManagedLaunchSpec(launch, platform)) throw new Error("gjc_tmux_managed_launch_spec_invalid");
670
751
  const provider = resolveGjcTmuxProviderContext({ env, platform });
671
752
  const tmuxCommand = provider.command;
672
- const launchEnvironment = launch ? { ...env, GJC_TMUX_SESSION: launch.childSessionId } : env;
673
- const sessionName = buildGjcTmuxSessionName(launchEnvironment);
674
753
  const cwd = launch?.cwd ?? process.cwd();
675
- const sessionId = launch?.childSessionId ?? (env[GJC_COORDINATOR_SESSION_ID_ENV]?.trim() || sessionName);
754
+ const identityEnv = tmuxLaunchIdentityEnv(env, cwd);
755
+ const launchEnvironment = launch ? { ...identityEnv, GJC_TMUX_SESSION: launch.childSessionId } : identityEnv;
756
+ const sessionName = buildGjcTmuxSessionName(launchEnvironment);
757
+ const sessionId = launch?.childSessionId ?? (identityEnv[GJC_COORDINATOR_SESSION_ID_ENV]?.trim() || sessionName);
758
+ const filteredGjcSessionId = identityEnv.GJC_SESSION_ID?.trim() || sessionId;
676
759
  const stateFile =
677
- (!launch ? env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim() : undefined) ||
678
- tmuxRuntimeSessionPath(
679
- cwd,
680
- launch?.childSessionId ?? (env.GJC_SESSION_ID?.trim() || sessionId),
681
- buildGjcTmuxSessionSlug(sessionName),
682
- );
760
+ (!launch ? identityEnv[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim() : undefined) ||
761
+ tmuxRuntimeSessionPath(cwd, launch?.childSessionId ?? filteredGjcSessionId, buildGjcTmuxSessionSlug(sessionName));
683
762
  const stateDir = (platform === "win32" ? path.win32 : path).dirname(stateFile);
684
763
  const generation = crypto.randomUUID();
685
764
  const authority = bindGjcTmuxProviderAuthority(provider, { stateDir, sessionId, generation });
@@ -690,6 +769,7 @@ export function createGjcTmuxSession(
690
769
  [GJC_TMUX_OWNER_SERVER_KEY_ENV]: tmuxCommand,
691
770
  [GJC_COORDINATOR_SESSION_ID_ENV]: sessionId,
692
771
  [GJC_COORDINATOR_SESSION_STATE_FILE_ENV]: stateFile,
772
+ ...childGjcSessionIdAssignment(env, identityEnv, cwd, filteredGjcSessionId),
693
773
  ...(launch
694
774
  ? {
695
775
  [MANAGED_OWNER_RUN_ID_ENV]: crypto.randomUUID(),
@@ -699,7 +779,7 @@ export function createGjcTmuxSession(
699
779
  : {}),
700
780
  };
701
781
  const childEnvironment: Record<string, string> = { ...(launch?.env ?? {}), ...managedEnvironment };
702
- const executionEnv = { ...env, ...managedEnvironment };
782
+ const executionEnv = { ...identityEnv, ...managedEnvironment };
703
783
  const shellQuote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`;
704
784
  const command =
705
785
  platform === "win32"
@@ -128,15 +128,30 @@ async function hashAddedFiles(cwd: string, paths: readonly UltragoalChangeSetPat
128
128
 
129
129
  export async function spawnText(
130
130
  command: string[],
131
- options: { cwd: string; timeoutMs?: number },
131
+ options: { cwd: string; timeoutMs?: number; stdin?: string },
132
132
  ): Promise<{ ok: boolean; stdout: string; stderr: string }> {
133
133
  try {
134
- const proc = Bun.spawn(command, { cwd: options.cwd, stdout: "pipe", stderr: "pipe" });
134
+ const proc = Bun.spawn(command, { cwd: options.cwd, stdin: "pipe", stdout: "pipe", stderr: "pipe" });
135
135
  const timeout = setTimeout(() => proc.kill(), options.timeoutMs ?? 5000);
136
+ // Drain both pipes while the stdin write runs: a child that never reads
137
+ // stdin blocks the write, and a child that fills stdout before draining
138
+ // stdin blocks the read.
139
+ const stdinWrite = (async () => {
140
+ const stdin = proc.stdin;
141
+ if (!stdin || options.stdin === undefined) return;
142
+ try {
143
+ stdin.write(options.stdin);
144
+ await stdin.end();
145
+ } catch {
146
+ // The child closed stdin or the timer killed it; the exit code
147
+ // already reports the failure.
148
+ }
149
+ })();
136
150
  const [stdoutBytes, stderrBytes, exitCode] = await Promise.all([
137
151
  new Response(proc.stdout).arrayBuffer(),
138
152
  new Response(proc.stderr).arrayBuffer(),
139
153
  proc.exited,
154
+ stdinWrite,
140
155
  ]);
141
156
  clearTimeout(timeout);
142
157
  let stdout: string;
@@ -152,6 +167,37 @@ export async function spawnText(
152
167
  }
153
168
  }
154
169
 
170
+ /**
171
+ * Derive the empty tree object ID for the given repository, which depends on the
172
+ * repository's object format (SHA-1 vs SHA-256). A repository with no commits has
173
+ * no refs and no merge base, so the empty tree is the only authoritative baseline:
174
+ * every tracked, staged, and untracked file is an addition on top of it.
175
+ *
176
+ * @param cwd The repository root directory
177
+ * @returns The empty tree object ID (format-specific), or the SHA-1 ID if derivation fails
178
+ */
179
+ async function getEmptyTreeId(cwd: string): Promise<string> {
180
+ // Compute the empty tree object ID by hashing an empty tree.
181
+ // This respects the repository's object format (SHA-1 or SHA-256).
182
+ const result = await spawnText(["git", "hash-object", "-t", "tree", "--stdin"], {
183
+ cwd,
184
+ timeoutMs: 3000,
185
+ stdin: "", // Empty input for empty tree
186
+ });
187
+ if (result.ok && result.stdout.trim()) {
188
+ return result.stdout.trim();
189
+ }
190
+ // Fallback to SHA-1 empty tree ID if derivation fails
191
+ // (e.g., git command not available, permission denied, etc.)
192
+ return "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
193
+ }
194
+
195
+ /**
196
+ * The canonical empty tree object for SHA-1 repositories. Use getEmptyTreeId()
197
+ * to derive the format-specific ID for a given repository.
198
+ */
199
+ export const GIT_EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
200
+
155
201
  export async function resolveGitBase(cwd: string, branch?: string): Promise<string> {
156
202
  if (branch) {
157
203
  const exists = await spawnText(["git", "rev-parse", "--verify", branch], { cwd, timeoutMs: 3000 });
@@ -163,11 +209,31 @@ export async function resolveGitBase(cwd: string, branch?: string): Promise<stri
163
209
  // and mis-attributes other people's changes to this story (e.g. falsely
164
210
  // tripping change-scoped gates). Among existing candidates, pick the one
165
211
  // whose merge-base with HEAD is closest to HEAD (fewest commits ahead).
166
- const candidates = ["origin/dev", "dev", "origin/main", "origin/master", "main", "master"];
212
+ const candidates = [
213
+ "origin/dev",
214
+ "dev",
215
+ "origin/develop",
216
+ "develop",
217
+ "origin/main",
218
+ "origin/master",
219
+ "main",
220
+ "master",
221
+ ];
222
+ const currentBranch = await spawnText(["git", "symbolic-ref", "--quiet", "HEAD"], { cwd, timeoutMs: 3000 });
223
+ const currentRef = currentBranch.ok ? currentBranch.stdout.trim() : undefined;
167
224
  let best: { ref: string; ahead: number } | undefined;
225
+
168
226
  for (const candidate of candidates) {
169
- const exists = await spawnText(["git", "rev-parse", "--verify", candidate], { cwd, timeoutMs: 3000 });
227
+ const exists = await spawnText(["git", "rev-parse", "--symbolic-full-name", "--verify", candidate], {
228
+ cwd,
229
+ timeoutMs: 3000,
230
+ });
170
231
  if (!exists.ok) continue;
232
+ // Exclude only the checked-out local ref, not distinct refs at the same
233
+ // commit or remote-tracking refs with the same branch name. Comparing
234
+ // the checked-out ref to HEAD hides committed work as a complete empty
235
+ // capture; without an independent baseline, resolution must fail closed.
236
+ if (currentRef && exists.stdout.trim() === currentRef) continue;
171
237
  const mergeBase = await spawnText(["git", "merge-base", "HEAD", candidate], { cwd, timeoutMs: 3000 });
172
238
  if (!mergeBase.ok || !mergeBase.stdout.trim()) continue;
173
239
  const count = await spawnText(["git", "rev-list", "--count", `${mergeBase.stdout.trim()}..HEAD`], {
@@ -285,16 +351,26 @@ export async function computeCheckpointChangeSet(cwd: string): Promise<Ultragoal
285
351
  }
286
352
  const captureWitness = await repositoryStateWitness(cwd);
287
353
  await repositoryStateWitnessTestHook?.("after-initial", cwd);
288
- const baseRef = await resolveGitBase(cwd);
354
+ // A freshly initialized repository has no commits, so `HEAD`, every branch
355
+ // candidate, and any merge base are unresolvable. The empty tree is the only
356
+ // authoritative baseline there; refusing outright would strand a run whose
357
+ // implementation is entirely untracked. Everything on disk then counts as an
358
+ // addition, which is hashed like any other added file and never committed.
359
+ const headPresent = await spawnText(["git", "rev-parse", "--verify", "HEAD"], { cwd, timeoutMs: 3000 });
360
+ const unborn = !headPresent.ok;
361
+ const baseRef = unborn ? await getEmptyTreeId(cwd) : await resolveGitBase(cwd);
289
362
  const base = baseRef;
290
- const mergeBase = await spawnText(["git", "merge-base", "HEAD", baseRef], { cwd, timeoutMs: 3000 });
363
+ const mergeBase = unborn
364
+ ? { ok: false, stdout: "", stderr: "repository has no commits" }
365
+ : await spawnText(["git", "merge-base", "HEAD", baseRef], { cwd, timeoutMs: 3000 });
366
+ const diffRange = unborn ? baseRef : `${base}...HEAD`;
291
367
  const [committed, unstaged, staged, untracked, stat, committedDiff, unstagedDiff, stagedDiff] = await Promise.all([
292
- spawnText(["git", "diff", "--name-status", "-z", `${base}...HEAD`], { cwd, timeoutMs: 5000 }),
368
+ spawnText(["git", "diff", "--name-status", "-z", unborn ? baseRef : diffRange], { cwd, timeoutMs: 5000 }),
293
369
  spawnText(["git", "diff", "--name-status", "-z"], { cwd, timeoutMs: 5000 }),
294
370
  spawnText(["git", "diff", "--cached", "--name-status", "-z"], { cwd, timeoutMs: 5000 }),
295
371
  spawnText(["git", "ls-files", "--others", "--exclude-standard", "-z"], { cwd, timeoutMs: 5000 }),
296
- spawnText(["git", "diff", "--stat", `${base}...HEAD`], { cwd, timeoutMs: 5000 }),
297
- spawnText(["git", "diff", `${base}...HEAD`], { cwd, timeoutMs: 5000 }),
372
+ spawnText(["git", "diff", "--stat", diffRange], { cwd, timeoutMs: 5000 }),
373
+ spawnText(["git", "diff", diffRange], { cwd, timeoutMs: 5000 }),
298
374
  spawnText(["git", "diff"], { cwd, timeoutMs: 5000 }),
299
375
  spawnText(["git", "diff", "--cached"], { cwd, timeoutMs: 5000 }),
300
376
  ]);
@@ -382,7 +458,10 @@ async function repositoryStateWitness(cwd: string): Promise<string | undefined>
382
458
  spawnText(["git", "ls-files", "-z"], { cwd, timeoutMs: 5000 }),
383
459
  spawnText(["git", "ls-files", "--others", "--exclude-standard", "-z"], { cwd, timeoutMs: 5000 }),
384
460
  ]);
385
- if (!head.ok || !status.ok || !tracked.ok || !untracked.ok) return undefined;
461
+ // An unborn repository has no HEAD yet; the remaining reads still describe its
462
+ // full state, so witness it as an empty head rather than failing the capture.
463
+ if (!status.ok || !tracked.ok || !untracked.ok) return undefined;
464
+ const headToken = head.ok ? head.stdout.trim() : "unborn";
386
465
  const paths = [
387
466
  ...new Set([
388
467
  ...parseGitUntrackedPaths(tracked.stdout).map(row => row.path),
@@ -417,7 +496,7 @@ async function repositoryStateWitness(cwd: string): Promise<string | undefined>
417
496
  } catch {
418
497
  return undefined;
419
498
  }
420
- return `${head.stdout.trim()}\u0000${status.stdout}\u0000sha256:${content.digest("hex")}`;
499
+ return `${headToken}\u0000${status.stdout}\u0000sha256:${content.digest("hex")}`;
421
500
  }
422
501
 
423
502
  export function parseUnifiedDiffPaths(diff: string): UltragoalChangeSetPath[] {
@@ -25,6 +25,7 @@ import {
25
25
  type UltragoalPlan,
26
26
  type UltragoalReceiptKind,
27
27
  } from "./ultragoal-runtime";
28
+ import { readUltragoalSuccessionFence } from "./ultragoal-succession";
28
29
 
29
30
  export type UltragoalGuardState =
30
31
  | "inactive"
@@ -823,7 +824,14 @@ async function consumeUltragoalNudge(input: {
823
824
  if (!sessionId) return { nudged: false };
824
825
  const plan = await readUltragoalPlan(input.cwd, sessionId);
825
826
  if (!plan) return { nudged: false };
826
- const target = selectUltragoalNudgeTarget(plan, { currentGoalObjective: input.currentGoal?.objective });
827
+ const fence = await readUltragoalSuccessionFence(input.cwd, sessionId);
828
+ // A goal handed off to a successor run is not this run's resolvable work. Skip
829
+ // fenced goals while selecting, so a retained source-owned goal can still receive
830
+ // the nudge (and keep aggregate drop guarded) without spending budget on the handoff.
831
+ const target = selectUltragoalNudgeTarget(plan, {
832
+ currentGoalObjective: input.currentGoal?.objective,
833
+ excludedGoalIds: fence?.selectedGoalIds,
834
+ });
827
835
  if (!target) return { nudged: false };
828
836
  const { budget } = await resolveUltragoalNudgeBudget(input.cwd, input.agentDir);
829
837
  const outcome = await recordUltragoalNudgeIfBudgetRemaining({
@@ -82,6 +82,7 @@ import {
82
82
  import {
83
83
  assertUltragoalAdoptionPublished,
84
84
  assertUltragoalGoalNotFenced,
85
+ readUltragoalSuccessionFence,
85
86
  runUltragoalSuccessionCommand,
86
87
  } from "./ultragoal-succession";
87
88
  import {
@@ -472,22 +473,27 @@ export async function resolveUltragoalNudgeBudget(
472
473
  */
473
474
  export function selectUltragoalNudgeTarget(
474
475
  plan: UltragoalPlan,
475
- options: { currentGoalObjective?: string; retryFailed?: boolean } = {},
476
+ options: { currentGoalObjective?: string; retryFailed?: boolean; excludedGoalIds?: readonly string[] } = {},
476
477
  ): UltragoalNudgeTarget | null {
477
478
  const objective = options.currentGoalObjective?.trim();
479
+ const isExcluded = (goalId: string): boolean => options.excludedGoalIds?.includes(goalId) ?? false;
478
480
  if (objective) {
479
481
  const matched = plan.goals.find(
480
- goal => goal.objective.trim() === objective && SCHEDULABLE_STATUSES.has(goal.status),
482
+ goal => goal.objective.trim() === objective && SCHEDULABLE_STATUSES.has(goal.status) && !isExcluded(goal.id),
481
483
  );
482
484
  if (matched) return { goalId: matched.id, targetKind: "story" };
483
485
  }
484
- const next = chooseNextGoal(plan, options.retryFailed === true);
486
+ const schedulableGoals = options.excludedGoalIds?.length
487
+ ? plan.goals.filter(goal => !isExcluded(goal.id))
488
+ : plan.goals;
489
+ const next = selectNextSchedulableGoal(schedulableGoals, options.retryFailed === true);
485
490
  if (next) return { goalId: next.id, targetKind: "story" };
486
491
  const completion = getUltragoalRunCompletionState(plan, { retryFailed: options.retryFailed });
487
492
  if (completion.needsFinalAggregateReceipt) {
488
493
  const required = requiredUltragoalGoals(plan);
489
494
  const finalGoal = required.at(-1);
490
- if (finalGoal) return { goalId: finalGoal.id, targetKind: "final_aggregate_receipt" };
495
+ if (finalGoal && !isExcluded(finalGoal.id))
496
+ return { goalId: finalGoal.id, targetKind: "final_aggregate_receipt" };
491
497
  }
492
498
  return null;
493
499
  }
@@ -1125,7 +1131,11 @@ export async function getUltragoalStatus(
1125
1131
  else if (counts.active > 0) status = "active";
1126
1132
  else if (counts.failed > 0) status = "failed";
1127
1133
  else if (counts.blocked > 0 || counts.review_blocked > 0) status = "blocked";
1128
- const nudgeTarget = selectUltragoalNudgeTarget(plan, { currentGoalObjective: currentGoal?.objective });
1134
+ const fence = await readUltragoalSuccessionFence(cwd, resolvedSessionId);
1135
+ const nudgeTarget = selectUltragoalNudgeTarget(plan, {
1136
+ currentGoalObjective: currentGoal?.objective,
1137
+ excludedGoalIds: fence?.selectedGoalIds,
1138
+ });
1129
1139
  let nudgeFields: Partial<UltragoalStatusSummary> = {};
1130
1140
  if (nudgeTarget) {
1131
1141
  const { budget } = await resolveUltragoalNudgeBudget(cwd, agentDir);
@@ -14,6 +14,7 @@ import { randomBytes } from "node:crypto";
14
14
  import { readdirSync, readFileSync } from "node:fs";
15
15
  import { readFile } from "node:fs/promises";
16
16
  import { type LinuxProcPidProbeResult, probeLinuxProcPidSync } from "../gjc-runtime/linux-proc";
17
+ import { checkTmuxSelfInjection } from "../tools/tmux-self-injection-guard";
17
18
  import {
18
19
  buildReceipt,
19
20
  type CompletionEvidence,
@@ -558,6 +559,16 @@ export function defaultFinalizeChecks(
558
559
  return {
559
560
  async runValidation(spec, signal) {
560
561
  if (signal?.aborted) throw new ValidationObservationUncertainError(spec.command, workspace, signal.reason);
562
+ // Same preflight as BashTool: refuse before spawn so a plan command cannot type into this pane.
563
+ const injection = await checkTmuxSelfInjection(spec.command, {
564
+ env: process.env,
565
+ cwd: workspace,
566
+ executionEnv: process.env,
567
+ });
568
+ if (signal?.aborted) throw new ValidationObservationUncertainError(spec.command, workspace, signal.reason);
569
+ if (injection.block) {
570
+ return { exactCommand: spec.command, cwd: workspace, exitStatus: 1, pass: false };
571
+ }
561
572
  let proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
562
573
  try {
563
574
  proc = Bun.spawn(["bash", "-lc", spec.command], {
@@ -1,6 +1,8 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { constants as fsConstants } from "node:fs";
2
3
  import * as fs from "node:fs/promises";
3
4
  import * as path from "node:path";
5
+ import { canonicalEnvKey, loadedProjectEnvSnapshot } from "@gajae-code/utils/dirs";
4
6
  import { withFileLock } from "../config/file-lock";
5
7
  import type { ReceiptEnvelope } from "./receipts";
6
8
 
@@ -21,6 +23,42 @@ export interface ReceiptSpoolAppendResult {
21
23
  const receiptSpoolDirStorage = new AsyncLocalStorage<string | undefined>();
22
24
  const spoolQueues = new Map<string, Promise<void>>();
23
25
  const noop = (): void => undefined;
26
+ const NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0;
27
+
28
+ /** True only when an exception's `code` is `ENOENT`. Other codes propagate. */
29
+ export function isEnoent(error: unknown): boolean {
30
+ return (error as NodeJS.ErrnoException).code === "ENOENT";
31
+ }
32
+
33
+ /**
34
+ * Bun copies the cwd dotenv into `process.env` before module init. The startup
35
+ * project snapshot is the declaration set trusted directory overrides use: a
36
+ * path that resolves like that declaration, or a dynamic declaration, is not a
37
+ * spool directory. A shell or `--receipt-spool-dir` value that resolves
38
+ * somewhere else is kept.
39
+ */
40
+ function projectDeclaredReceiptSpoolDir(raw: string): boolean {
41
+ const snapshot = loadedProjectEnvSnapshot();
42
+ const key = canonicalEnvKey(RECEIPT_SPOOL_DIR_ENV);
43
+ const projectValue = snapshot.values[key];
44
+ if (projectValue === undefined) return false;
45
+ if (snapshot.dynamic.has(key)) return true;
46
+ const declared = projectValue.trim();
47
+ const candidate = raw.trim();
48
+ if (!declared || !candidate) return declared === candidate;
49
+ return path.resolve(declared) === path.resolve(candidate);
50
+ }
51
+
52
+ function adoptReceiptSpoolDir(raw: string | undefined): string | undefined {
53
+ if (raw === undefined || projectDeclaredReceiptSpoolDir(raw)) return undefined;
54
+ const trimmed = raw.trim();
55
+ return trimmed ? path.resolve(trimmed) : undefined;
56
+ }
57
+
58
+ /** A spool directory the startup project snapshot does not declare. */
59
+ export function trustedReceiptSpoolDir(raw: string | undefined): string | undefined {
60
+ return adoptReceiptSpoolDir(raw);
61
+ }
24
62
 
25
63
  interface ReceiptSpoolCursorCache {
26
64
  mtimeMs: number;
@@ -44,10 +82,15 @@ export function resetReceiptSpoolCursorCacheForTesting(): void {
44
82
 
45
83
  async function spoolStat(spoolFile: string): Promise<{ mtimeMs: number; size: number } | undefined> {
46
84
  try {
47
- const stats = await fs.stat(spoolFile);
48
- return { mtimeMs: stats.mtimeMs, size: stats.size };
85
+ const handle = await fs.open(spoolFile, fsConstants.O_RDONLY | NOFOLLOW);
86
+ try {
87
+ const stats = await handle.stat();
88
+ return { mtimeMs: stats.mtimeMs, size: stats.size };
89
+ } finally {
90
+ await handle.close();
91
+ }
49
92
  } catch (error) {
50
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
93
+ if (isEnoent(error)) return undefined;
51
94
  throw error;
52
95
  }
53
96
  }
@@ -64,15 +107,17 @@ async function cacheHighestCursor(spoolFile: string, highest: bigint): Promise<v
64
107
  export async function withReceiptSpoolDir<T>(spoolDir: string, fn: () => Promise<T>): Promise<T> {
65
108
  const trimmed = spoolDir.trim();
66
109
  if (!trimmed) throw new Error("receipt_spool_dir_empty");
67
- const resolved = path.resolve(trimmed);
110
+ const resolved = adoptReceiptSpoolDir(trimmed);
111
+ // Owner input repeats process.env. A project-declared directory must not
112
+ // become the active spool via that copy.
113
+ if (!resolved) return fn();
68
114
  return receiptSpoolDirStorage.run(resolved, fn);
69
115
  }
70
116
 
71
117
  export function resolveReceiptSpoolDir(env: NodeJS.ProcessEnv = process.env): string | undefined {
72
118
  const active = receiptSpoolDirStorage.getStore();
73
119
  if (active !== undefined) return active;
74
- const raw = env[RECEIPT_SPOOL_DIR_ENV]?.trim();
75
- return raw ? path.resolve(raw) : undefined;
120
+ return adoptReceiptSpoolDir(env[RECEIPT_SPOOL_DIR_ENV]);
76
121
  }
77
122
 
78
123
  export function receiptSpoolPath(spoolDir: string): string {
@@ -95,30 +140,56 @@ export function formatReceiptSpoolCursor(cursor: bigint): string {
95
140
 
96
141
  export async function readHighestReceiptSpoolCursor(spoolDir: string): Promise<bigint> {
97
142
  const spoolFile = receiptSpoolPath(spoolDir);
98
- const revision = await spoolStat(spoolFile);
99
- if (!revision) {
100
- cursorCache.delete(spoolFile);
101
- return 0n;
143
+ let handle: fs.FileHandle;
144
+ try {
145
+ handle = await fs.open(spoolFile, fsConstants.O_RDONLY | NOFOLLOW);
146
+ } catch (error) {
147
+ if (isEnoent(error)) {
148
+ cursorCache.delete(spoolFile);
149
+ return 0n;
150
+ }
151
+ throw error;
102
152
  }
103
- const cached = cursorCache.get(spoolFile);
104
- if (cached && sameSpoolRevision(cached, revision)) return cached.highest;
105
-
106
- fullScanCount++;
107
- const raw = await fs.readFile(spoolFile, "utf8");
108
- let highest = 0n;
109
- for (const line of raw.split("\n")) {
110
- const trimmed = line.trim();
111
- if (!trimmed) continue;
112
- try {
113
- const parsed = JSON.parse(trimmed) as { cursor?: unknown };
114
- const cursor = parseCursor(parsed.cursor);
115
- if (cursor !== undefined && cursor > highest) highest = cursor;
116
- } catch {
117
- // A crash may leave a torn tail; a full scan ignores it and re-establishes the cache.
153
+ try {
154
+ const stats = await handle.stat();
155
+ const revision = { mtimeMs: stats.mtimeMs, size: stats.size };
156
+ const cached = cursorCache.get(spoolFile);
157
+ if (cached && sameSpoolRevision(cached, revision)) return cached.highest;
158
+
159
+ fullScanCount++;
160
+ const raw = await handle.readFile({ encoding: "utf8" });
161
+ let highest = 0n;
162
+ for (const line of raw.split("\n")) {
163
+ const trimmed = line.trim();
164
+ if (!trimmed) continue;
165
+ try {
166
+ const parsed = JSON.parse(trimmed) as { cursor?: unknown };
167
+ const cursor = parseCursor(parsed.cursor);
168
+ if (cursor !== undefined && cursor > highest) highest = cursor;
169
+ } catch {
170
+ // A crash may leave a torn tail; a full scan ignores it and re-establishes the cache.
171
+ }
118
172
  }
173
+ cursorCache.set(spoolFile, { ...revision, highest });
174
+ return highest;
175
+ } finally {
176
+ await handle.close();
177
+ }
178
+ }
179
+
180
+ async function tightenSpoolDir(dir: string): Promise<void> {
181
+ if (process.platform === "win32") return;
182
+ let handle: fs.FileHandle;
183
+ try {
184
+ handle = await fs.open(dir, fsConstants.O_RDONLY | (fsConstants.O_DIRECTORY ?? 0) | NOFOLLOW);
185
+ } catch {
186
+ return;
187
+ }
188
+ try {
189
+ await handle.chmod(0o700);
190
+ } finally {
191
+ await handle.close();
119
192
  }
120
- cursorCache.set(spoolFile, { ...revision, highest });
121
- return highest;
122
193
  }
123
194
 
124
195
  async function enqueueSpoolAppend<T>(spoolFile: string, task: () => Promise<T>): Promise<T> {
@@ -142,13 +213,19 @@ export async function appendReceiptToSpool(
142
213
  const spoolFile = receiptSpoolPath(resolvedDir);
143
214
  return enqueueSpoolAppend(spoolFile, async () => {
144
215
  await fs.mkdir(resolvedDir, { recursive: true, mode: 0o700 });
216
+ await tightenSpoolDir(resolvedDir);
145
217
  return withFileLock(
146
218
  spoolFile,
147
219
  async () => {
148
220
  const cursor = formatReceiptSpoolCursor((await readHighestReceiptSpoolCursor(resolvedDir)) + 1n);
149
221
  const record: ReceiptSpoolRecord = { cursor, envelope };
150
- const handle = await fs.open(spoolFile, "a", 0o600);
222
+ const handle = await fs.open(
223
+ spoolFile,
224
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_APPEND | NOFOLLOW,
225
+ 0o600,
226
+ );
151
227
  try {
228
+ if (process.platform !== "win32") await handle.chmod(0o600);
152
229
  await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8");
153
230
  await handle.sync();
154
231
  } finally {