@gajae-code/coding-agent 0.16.7 → 0.17.1

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 (321) hide show
  1. package/CHANGELOG.md +180 -1
  2. package/dist/types/capability/fs.d.ts +37 -4
  3. package/dist/types/capability/index.d.ts +19 -0
  4. package/dist/types/capability/types.d.ts +9 -0
  5. package/dist/types/cli/doctor/args.d.ts +36 -0
  6. package/dist/types/cli/doctor/checks.d.ts +18 -0
  7. package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
  8. package/dist/types/cli/doctor/context.d.ts +89 -0
  9. package/dist/types/cli/doctor/files.d.ts +27 -0
  10. package/dist/types/cli/doctor/ids.d.ts +20 -0
  11. package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
  12. package/dist/types/cli/doctor/journal.d.ts +43 -0
  13. package/dist/types/cli/doctor/managed-link.d.ts +39 -0
  14. package/dist/types/cli/doctor/native-probe.d.ts +2 -0
  15. package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
  16. package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
  17. package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
  18. package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
  19. package/dist/types/cli/doctor/probe-client.d.ts +9 -0
  20. package/dist/types/cli/doctor/probe-types.d.ts +18 -0
  21. package/dist/types/cli/doctor/probe.d.ts +3 -0
  22. package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
  23. package/dist/types/cli/doctor/repairs.d.ts +28 -0
  24. package/dist/types/cli/doctor/report.d.ts +27 -0
  25. package/dist/types/cli/doctor/runner.d.ts +4 -0
  26. package/dist/types/cli/doctor/safety.d.ts +10 -0
  27. package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
  28. package/dist/types/cli/doctor/service-targets.d.ts +41 -0
  29. package/dist/types/cli/doctor/types.d.ts +145 -0
  30. package/dist/types/cli/doctor-cli.d.ts +7 -0
  31. package/dist/types/cli/doctor-supervisor.d.ts +121 -0
  32. package/dist/types/cli/doctor-worker.d.ts +29 -0
  33. package/dist/types/cli/install-activation.d.ts +158 -0
  34. package/dist/types/cli/macos-community-app.d.ts +41 -0
  35. package/dist/types/cli/mcp-cli.d.ts +0 -11
  36. package/dist/types/cli/update-cli.d.ts +41 -4
  37. package/dist/types/cli-main.d.ts +174 -0
  38. package/dist/types/cli.d.ts +1 -171
  39. package/dist/types/commands/doctor.d.ts +7 -0
  40. package/dist/types/commands/sdk.d.ts +17 -8
  41. package/dist/types/config/autorouting-tier-map.d.ts +19 -0
  42. package/dist/types/config/file-lock.d.ts +27 -3
  43. package/dist/types/config/model-preset-registry.d.ts +1 -0
  44. package/dist/types/config/model-registry.d.ts +12 -5
  45. package/dist/types/config/model-resolver.d.ts +16 -3
  46. package/dist/types/config/models-config-schema.d.ts +2 -0
  47. package/dist/types/config/settings-schema.d.ts +1 -1
  48. package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
  49. package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
  50. package/dist/types/cursor.d.ts +9 -9
  51. package/dist/types/discovery/agents-md.d.ts +6 -1
  52. package/dist/types/discovery/helpers.d.ts +36 -7
  53. package/dist/types/discovery/index.d.ts +1 -1
  54. package/dist/types/discovery/mcp-json.d.ts +2 -0
  55. package/dist/types/extensibility/extensions/loader.d.ts +22 -1
  56. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
  57. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
  58. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  59. package/dist/types/extensibility/plugins/manager.d.ts +33 -4
  60. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
  61. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
  62. package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
  63. package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
  64. package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
  65. package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
  66. package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
  67. package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
  68. package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
  69. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
  70. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
  71. package/dist/types/main.d.ts +19 -1
  72. package/dist/types/modes/acp/acp-agent.d.ts +40 -0
  73. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  74. package/dist/types/modes/components/model-selector.d.ts +2 -0
  75. package/dist/types/modes/interactive-mode.d.ts +1 -1
  76. package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
  77. package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
  78. package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
  79. package/dist/types/runtime-mcp/types.d.ts +2 -2
  80. package/dist/types/sdk/acp/adapter.d.ts +5 -1
  81. package/dist/types/sdk/broker/broker.d.ts +51 -0
  82. package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
  83. package/dist/types/sdk/broker/discovery.d.ts +38 -0
  84. package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
  85. package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
  86. package/dist/types/sdk/broker/ensure.d.ts +14 -0
  87. package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
  88. package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
  89. package/dist/types/sdk/broker/runtime.d.ts +26 -0
  90. package/dist/types/sdk/broker/session-index.d.ts +13 -4
  91. package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
  92. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
  93. package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
  94. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
  95. package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
  96. package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
  97. package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
  98. package/dist/types/sdk/bus/index.d.ts +2 -0
  99. package/dist/types/sdk/bus/notification-service.d.ts +3 -0
  100. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
  101. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  102. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
  103. package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
  104. package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
  105. package/dist/types/sdk/cli/session-cli.d.ts +1 -0
  106. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  107. package/dist/types/sdk/host/surface-policy.d.ts +3 -1
  108. package/dist/types/sdk/lifecycle/service.d.ts +44 -10
  109. package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
  110. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
  111. package/dist/types/sdk/prompt-failure.d.ts +47 -0
  112. package/dist/types/sdk/prompt-status.d.ts +19 -0
  113. package/dist/types/sdk/router/session-router.d.ts +3 -4
  114. package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
  115. package/dist/types/sdk/session-authority.d.ts +9 -0
  116. package/dist/types/sdk/session.d.ts +3 -1
  117. package/dist/types/session/agent-session.d.ts +1 -1
  118. package/dist/types/session/blob-store.d.ts +1 -1
  119. package/dist/types/session/fallback-chain-controller.d.ts +3 -6
  120. package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
  121. package/dist/types/session/primary-control-surface.d.ts +4 -0
  122. package/dist/types/session/session-manager.d.ts +6 -0
  123. package/dist/types/system-prompt.d.ts +6 -0
  124. package/dist/types/tools/ask.d.ts +25 -0
  125. package/dist/types/tools/image-gen.d.ts +0 -4
  126. package/dist/types/tools/skill-discovery.d.ts +7 -5
  127. package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
  128. package/dist/types/utils/changelog.d.ts +0 -9
  129. package/dist/types/utils/mupdf-wasm.d.ts +1 -0
  130. package/package.json +9 -8
  131. package/scripts/compile-args.ts +8 -4
  132. package/src/capability/fs.ts +395 -17
  133. package/src/capability/index.ts +273 -10
  134. package/src/capability/types.ts +9 -0
  135. package/src/cli/customize-doctor.ts +18 -12
  136. package/src/cli/doctor/args.ts +236 -0
  137. package/src/cli/doctor/checks.ts +431 -0
  138. package/src/cli/doctor/config-repairs.ts +517 -0
  139. package/src/cli/doctor/context.ts +149 -0
  140. package/src/cli/doctor/files.ts +149 -0
  141. package/src/cli/doctor/ids.ts +115 -0
  142. package/src/cli/doctor/install-repairs.ts +335 -0
  143. package/src/cli/doctor/journal.ts +423 -0
  144. package/src/cli/doctor/managed-link.ts +514 -0
  145. package/src/cli/doctor/native-probe.ts +13 -0
  146. package/src/cli/doctor/permission-repairs.ts +242 -0
  147. package/src/cli/doctor/plugin-quarantine.ts +377 -0
  148. package/src/cli/doctor/plugin-restore.ts +59 -0
  149. package/src/cli/doctor/plugin-targets.ts +373 -0
  150. package/src/cli/doctor/probe-client.ts +195 -0
  151. package/src/cli/doctor/probe-types.ts +21 -0
  152. package/src/cli/doctor/probe.ts +42 -0
  153. package/src/cli/doctor/projection-probe.ts +249 -0
  154. package/src/cli/doctor/repairs.ts +1157 -0
  155. package/src/cli/doctor/report.ts +176 -0
  156. package/src/cli/doctor/runner.ts +342 -0
  157. package/src/cli/doctor/safety.ts +40 -0
  158. package/src/cli/doctor/service-repairs.ts +452 -0
  159. package/src/cli/doctor/service-targets.ts +274 -0
  160. package/src/cli/doctor/types.ts +164 -0
  161. package/src/cli/doctor-cli.ts +62 -0
  162. package/src/cli/doctor-supervisor.ts +571 -0
  163. package/src/cli/doctor-worker.ts +181 -0
  164. package/src/cli/install-activation.ts +666 -0
  165. package/src/cli/macos-community-app.ts +1449 -0
  166. package/src/cli/mcp-cli.ts +11 -21
  167. package/src/cli/nofile-limit.ts +5 -1
  168. package/src/cli/plugin-cli.ts +6 -1
  169. package/src/cli/update-cli.ts +362 -264
  170. package/src/cli-main.ts +517 -0
  171. package/src/cli.ts +64 -433
  172. package/src/commands/autoresearch.ts +11 -5
  173. package/src/commands/completion.ts +1 -1
  174. package/src/commands/doctor.ts +13 -0
  175. package/src/commands/sdk.ts +58 -10
  176. package/src/commands/ultragoal.ts +11 -5
  177. package/src/config/autorouting-tier-map.ts +7 -0
  178. package/src/config/file-lock.ts +736 -71
  179. package/src/config/mcp-schema.json +3 -3
  180. package/src/config/model-preset-registry.ts +1 -0
  181. package/src/config/model-registry.ts +80 -27
  182. package/src/config/model-resolver.ts +33 -4
  183. package/src/config/models-config-schema.ts +2 -0
  184. package/src/config/settings-schema.ts +6 -2
  185. package/src/coordinator-mcp/question-gate-codec.ts +1 -0
  186. package/src/coordinator-mcp/server.ts +534 -51
  187. package/src/coordinator-mcp/session-reaper.ts +64 -8
  188. package/src/cursor.ts +223 -53
  189. package/src/discovery/agents-md.ts +74 -8
  190. package/src/discovery/agents.ts +32 -7
  191. package/src/discovery/builtin.ts +184 -75
  192. package/src/discovery/claude-plugins.ts +85 -17
  193. package/src/discovery/cline.ts +38 -10
  194. package/src/discovery/cursor.ts +19 -6
  195. package/src/discovery/gemini.ts +62 -22
  196. package/src/discovery/github.ts +13 -3
  197. package/src/discovery/helpers.ts +483 -89
  198. package/src/discovery/index.ts +1 -0
  199. package/src/discovery/mcp-json.ts +36 -10
  200. package/src/discovery/opencode.ts +36 -19
  201. package/src/discovery/ssh.ts +11 -8
  202. package/src/discovery/windsurf.ts +12 -3
  203. package/src/exec/bash-executor.ts +10 -3
  204. package/src/exec/isolated-shell.ts +15 -2
  205. package/src/extensibility/extensions/loader.ts +88 -26
  206. package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
  207. package/src/extensibility/gjc-plugins/paths.ts +4 -1
  208. package/src/extensibility/gjc-plugins/types.ts +27 -0
  209. package/src/extensibility/plugins/manager.ts +200 -22
  210. package/src/extensibility/plugins/marketplace/cache.ts +55 -2
  211. package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
  212. package/src/extensibility/plugins/marketplace/registry.ts +153 -0
  213. package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
  214. package/src/extensibility/runtime-skill-discovery.ts +61 -9
  215. package/src/extensibility/skill-management.ts +7 -1
  216. package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
  217. package/src/gjc-runtime/launch-worktree.test.ts +248 -0
  218. package/src/gjc-runtime/launch-worktree.ts +275 -38
  219. package/src/gjc-runtime/repository-binding.ts +26 -1
  220. package/src/gjc-runtime/session-layout.ts +10 -0
  221. package/src/gjc-runtime/session-state-lock.ts +330 -23
  222. package/src/gjc-runtime/session-state-sidecar.ts +328 -80
  223. package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
  224. package/src/harness-control-plane/session-lease.ts +2 -2
  225. package/src/internal-urls/docs-index.generated.ts +28 -26
  226. package/src/main.ts +150 -29
  227. package/src/memories/index.ts +24 -2
  228. package/src/modes/acp/acp-agent.ts +522 -68
  229. package/src/modes/components/agent-dashboard.ts +3 -1
  230. package/src/modes/components/model-selector.ts +28 -19
  231. package/src/modes/components/plugin-settings.ts +2 -1
  232. package/src/modes/controllers/selector-controller.ts +15 -9
  233. package/src/modes/interactive-mode.ts +21 -3
  234. package/src/prompts/system/system-prompt.md +5 -1
  235. package/src/prompts/tools/browser.md +1 -1
  236. package/src/prompts/tools/move-session.md +4 -0
  237. package/src/prompts/tools/read.md +2 -1
  238. package/src/prompts/tools/skill-discovery.md +6 -5
  239. package/src/prompts/tools/task.md +1 -0
  240. package/src/runtime-mcp/config-writer.ts +228 -0
  241. package/src/runtime-mcp/oauth-discovery.ts +114 -14
  242. package/src/runtime-mcp/policy-json-edit.ts +129 -0
  243. package/src/runtime-mcp/startup-policy.ts +14 -0
  244. package/src/runtime-mcp/types.ts +2 -2
  245. package/src/sdk/acp/adapter.ts +27 -12
  246. package/src/sdk/broker/broker.ts +273 -44
  247. package/src/sdk/broker/daemon-entry.ts +119 -0
  248. package/src/sdk/broker/discovery.ts +173 -20
  249. package/src/sdk/broker/doctor-restart.ts +206 -0
  250. package/src/sdk/broker/endpoint-authority.ts +36 -0
  251. package/src/sdk/broker/ensure.ts +49 -14
  252. package/src/sdk/broker/lifecycle.ts +174 -42
  253. package/src/sdk/broker/process-incarnation.ts +86 -2
  254. package/src/sdk/broker/runtime.ts +145 -21
  255. package/src/sdk/broker/session-index.ts +136 -22
  256. package/src/sdk/broker/spawn-substrate.ts +4 -1
  257. package/src/sdk/broker/transport.ts +5 -0
  258. package/src/sdk/broker/worktree-occupancy.ts +21 -1
  259. package/src/sdk/bus/chat-daemon-cli.ts +63 -0
  260. package/src/sdk/bus/chat-daemon-control.ts +260 -64
  261. package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
  262. package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
  263. package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
  264. package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
  265. package/src/sdk/bus/index.ts +252 -51
  266. package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
  267. package/src/sdk/bus/notification-service.ts +21 -3
  268. package/src/sdk/bus/reconciliation-store.ts +17 -11
  269. package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
  270. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  271. package/src/sdk/bus/telegram-daemon-control.ts +47 -0
  272. package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
  273. package/src/sdk/bus/telegram-daemon.ts +178 -13
  274. package/src/sdk/cli/session-cli.ts +100 -12
  275. package/src/sdk/host/host.ts +2 -7
  276. package/src/sdk/host/session-runtime.test.ts +329 -8
  277. package/src/sdk/host/session-runtime.ts +202 -50
  278. package/src/sdk/host/surface-policy.ts +8 -1
  279. package/src/sdk/lifecycle/service.ts +199 -22
  280. package/src/sdk/mcp/server.ts +1 -1
  281. package/src/sdk/prompt-deadline-lease.ts +12 -4
  282. package/src/sdk/prompt-deadline-manager.ts +5 -7
  283. package/src/sdk/prompt-failure.ts +148 -0
  284. package/src/sdk/prompt-status.ts +30 -1
  285. package/src/sdk/prompt-watchdog.ts +11 -3
  286. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  287. package/src/sdk/protocol/operation-registry.ts +20 -8
  288. package/src/sdk/router/session-router.ts +8 -3
  289. package/src/sdk/service-artifact-paths.ts +26 -0
  290. package/src/sdk/session-authority.ts +10 -0
  291. package/src/sdk/session.ts +361 -194
  292. package/src/session/agent-session.ts +398 -93
  293. package/src/session/blob-store.ts +9 -9
  294. package/src/session/fallback-chain-controller.ts +3 -7
  295. package/src/session/internal/managed-session-scope.ts +445 -29
  296. package/src/session/internal/managed-session-storage.ts +38 -6
  297. package/src/session/primary-control-surface.ts +12 -0
  298. package/src/session/session-manager.ts +11 -3
  299. package/src/session/session-storage.ts +1 -1
  300. package/src/session-import/codex.ts +6 -1
  301. package/src/session-import/redact.ts +7 -0
  302. package/src/skill-state/workflow-mutation-guard.ts +645 -39
  303. package/src/slash-commands/builtin-registry.ts +7 -3
  304. package/src/system-prompt.ts +8 -0
  305. package/src/task/executor.ts +36 -3
  306. package/src/task/types.ts +27 -5
  307. package/src/tools/ask.ts +114 -34
  308. package/src/tools/browser/actions.ts +2 -2
  309. package/src/tools/browser/tab-worker.ts +11 -8
  310. package/src/tools/descriptors.ts +6 -1
  311. package/src/tools/fetch.ts +7 -2
  312. package/src/tools/image-gen.ts +33 -34
  313. package/src/tools/read.ts +78 -32
  314. package/src/tools/skill-discovery.ts +17 -7
  315. package/src/tools/tool-catalog.generated.ts +8 -8
  316. package/src/tools/ultragoal-ask-guard.ts +16 -26
  317. package/src/utils/changelog.ts +7 -1
  318. package/src/utils/git.ts +3 -1
  319. package/src/utils/markit.ts +24 -2
  320. package/src/utils/mupdf-wasm.ts +26 -0
  321. package/src/web/search/providers/openai-compatible.ts +191 -14
@@ -0,0 +1,248 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import type { EvacuationHandle, GjcLaunchWorktreePlan } from "./launch-worktree";
6
+ import {
7
+ acquireTargetLock,
8
+ commitPreWorktreeRestore,
9
+ ensureLaunchWorktree,
10
+ evacuatePreWorktreeTarget,
11
+ isReplaceableWorktreeTarget,
12
+ restorePreWorktreeGjc,
13
+ } from "./launch-worktree";
14
+
15
+ const tempDirs: string[] = [];
16
+
17
+ function makeTempDir(prefix: string): string {
18
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
19
+ tempDirs.push(dir);
20
+ return dir;
21
+ }
22
+
23
+ function git(cwd: string, args: string[]): void {
24
+ const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
25
+ if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString().trim()}`);
26
+ }
27
+
28
+ function initRepo(prefix: string): string {
29
+ const repo = makeTempDir(prefix);
30
+ git(repo, ["init", "-q"]);
31
+ git(repo, ["config", "user.email", "test@example.com"]);
32
+ git(repo, ["config", "user.name", "Test"]);
33
+ fs.writeFileSync(path.join(repo, "README.md"), "seed");
34
+ git(repo, ["add", "."]);
35
+ git(repo, ["commit", "-q", "-m", "init"]);
36
+ return repo;
37
+ }
38
+
39
+ function countRegisteredWorktrees(repo: string): number {
40
+ const raw = Bun.spawnSync(["git", "worktree", "list", "--porcelain"], { cwd: repo }).stdout.toString();
41
+ return raw.split(/\r?\n/).filter(line => line.startsWith("worktree ")).length;
42
+ }
43
+
44
+ afterEach(() => {
45
+ for (const dir of tempDirs.splice(0)) {
46
+ fs.rmSync(dir, { recursive: true, force: true });
47
+ }
48
+ });
49
+
50
+ describe("isReplaceableWorktreeTarget", () => {
51
+ test("accepts a real directory holding only .gjc", () => {
52
+ const root = makeTempDir("gjc-replaceable-");
53
+ const target = path.join(root, "wt");
54
+ fs.mkdirSync(path.join(target, ".gjc"), { recursive: true });
55
+ expect(isReplaceableWorktreeTarget(target)).toBe(true);
56
+ });
57
+
58
+ test("rejects other content", () => {
59
+ const root = makeTempDir("gjc-replaceable-");
60
+ const target = path.join(root, "wt");
61
+ fs.mkdirSync(target, { recursive: true });
62
+ fs.writeFileSync(path.join(target, "file.txt"), "x");
63
+ expect(isReplaceableWorktreeTarget(target)).toBe(false);
64
+ });
65
+
66
+ test("rejects a symlinked target even when the resolved dir holds only .gjc", () => {
67
+ const root = makeTempDir("gjc-replaceable-");
68
+ const real = path.join(root, "real");
69
+ fs.mkdirSync(path.join(real, ".gjc"), { recursive: true });
70
+ const link = path.join(root, "link");
71
+ fs.symlinkSync(real, link, "dir");
72
+ expect(isReplaceableWorktreeTarget(link)).toBe(false);
73
+ });
74
+ });
75
+
76
+ describe("evacuatePreWorktreeTarget", () => {
77
+ test("rejects a symlinked .gjc before evacuation", () => {
78
+ const root = makeTempDir("gjc-evacuate-");
79
+ const target = path.join(root, "wt");
80
+ fs.mkdirSync(target, { recursive: true });
81
+ const outside = path.join(root, "outside");
82
+ fs.mkdirSync(outside, { recursive: true });
83
+ fs.symlinkSync(outside, path.join(target, ".gjc"), "dir");
84
+
85
+ expect(() => evacuatePreWorktreeTarget(target)).toThrow(/worktree_path_conflict/);
86
+ // The symlink target must be left untouched — evacuation must not follow it.
87
+ expect(fs.existsSync(outside)).toBe(true);
88
+ expect(fs.existsSync(path.join(root, ".gjc-pre-wt-wt"))).toBe(false);
89
+ });
90
+ });
91
+
92
+ describe("restorePreWorktreeGjc", () => {
93
+ test("rejects a symlinked overlay destination", () => {
94
+ const root = makeTempDir("gjc-restore-");
95
+ const worktree = path.join(root, "wt");
96
+ fs.mkdirSync(worktree, { recursive: true });
97
+ const outside = path.join(root, "outside");
98
+ fs.mkdirSync(outside, { recursive: true });
99
+ // A tracked `.gjc` symlink that `git worktree add` would have checked out.
100
+ fs.symlinkSync(outside, path.join(worktree, ".gjc"), "dir");
101
+
102
+ const stash = path.join(root, ".gjc-pre-wt-wt");
103
+ fs.mkdirSync(stash, { recursive: true });
104
+ fs.writeFileSync(path.join(stash, "seed.txt"), "seed");
105
+
106
+ expect(() => restorePreWorktreeGjc(stash, worktree)).toThrow(/worktree_path_conflict/);
107
+ // The overlay must not have leaked into the symlink target.
108
+ expect(fs.existsSync(path.join(outside, "seed.txt"))).toBe(false);
109
+ });
110
+ });
111
+
112
+ describe("commitPreWorktreeRestore", () => {
113
+ test("rolls back the created worktree, preserves the stash, and refuses to report success", () => {
114
+ const repo = initRepo("gjc-tx-repo-");
115
+ const bucket = makeTempDir("gjc-tx-bucket-");
116
+ const worktreePath = path.join(bucket, "wt");
117
+ git(repo, ["worktree", "add", "-q", "--detach", worktreePath, "HEAD"]);
118
+ expect(fs.existsSync(path.join(worktreePath, ".git"))).toBe(true);
119
+ expect(countRegisteredWorktrees(repo)).toBe(2);
120
+
121
+ // A stash that the (failing) restore left behind for recovery.
122
+ const stashPath = path.join(bucket, ".gjc-pre-wt-wt");
123
+ fs.mkdirSync(stashPath, { recursive: true });
124
+ fs.writeFileSync(path.join(stashPath, "launcher.txt"), "config");
125
+
126
+ const plan: GjcLaunchWorktreePlan = {
127
+ enabled: true,
128
+ repoRoot: repo,
129
+ worktreePath,
130
+ detached: true,
131
+ baseRef: "HEAD",
132
+ branchName: null,
133
+ };
134
+ const handle: EvacuationHandle = {
135
+ restore: () => {
136
+ throw new Error("overlay exploded");
137
+ },
138
+ stashPath,
139
+ };
140
+
141
+ let message = "";
142
+ try {
143
+ commitPreWorktreeRestore(handle, plan);
144
+ throw new Error("expected commitPreWorktreeRestore to throw");
145
+ } catch (error) {
146
+ message = error instanceof Error ? error.message : String(error);
147
+ }
148
+ expect(message).toMatch(/worktree_gjc_restore_failed/);
149
+ // Stash location is surfaced for manual recovery.
150
+ expect(message).toContain(stashPath);
151
+
152
+ // The worktree was rolled back: its directory is gone and Git no longer registers it.
153
+ expect(fs.existsSync(worktreePath)).toBe(false);
154
+ expect(countRegisteredWorktrees(repo)).toBe(1);
155
+ // The stash survives so the pre-seeded launcher config can be recovered.
156
+ expect(fs.existsSync(path.join(stashPath, "launcher.txt"))).toBe(true);
157
+
158
+ // A subsequent retry starts from a clean slate.
159
+ git(repo, ["worktree", "add", "-q", "--detach", worktreePath, "HEAD"]);
160
+ expect(fs.existsSync(path.join(worktreePath, ".git"))).toBe(true);
161
+ });
162
+
163
+ test("removes the stash and reports success when restore completes", () => {
164
+ const repo = initRepo("gjc-tx-repo-");
165
+ const bucket = makeTempDir("gjc-tx-bucket-");
166
+ const worktreePath = path.join(bucket, "wt");
167
+ git(repo, ["worktree", "add", "-q", "--detach", worktreePath, "HEAD"]);
168
+
169
+ const stashPath = path.join(bucket, ".gjc-pre-wt-wt");
170
+ fs.mkdirSync(stashPath, { recursive: true });
171
+ fs.writeFileSync(path.join(stashPath, "launcher.txt"), "config");
172
+
173
+ const plan: GjcLaunchWorktreePlan = {
174
+ enabled: true,
175
+ repoRoot: repo,
176
+ worktreePath,
177
+ detached: true,
178
+ baseRef: "HEAD",
179
+ branchName: null,
180
+ };
181
+ const handle: EvacuationHandle = {
182
+ restore: () => restorePreWorktreeGjc(stashPath, worktreePath),
183
+ stashPath,
184
+ };
185
+
186
+ expect(() => commitPreWorktreeRestore(handle, plan)).not.toThrow();
187
+ // Overlay landed inside the worktree and the stash was consumed.
188
+ expect(fs.existsSync(path.join(worktreePath, ".gjc", "launcher.txt"))).toBe(true);
189
+ expect(fs.existsSync(stashPath)).toBe(false);
190
+ expect(countRegisteredWorktrees(repo)).toBe(2);
191
+ });
192
+ });
193
+
194
+ describe("acquireTargetLock", () => {
195
+ test("a second concurrent acquire fails while the first holds the lock", () => {
196
+ const bucket = makeTempDir("gjc-lock-");
197
+ const worktreePath = path.join(bucket, "wt");
198
+ fs.mkdirSync(path.join(worktreePath, ".gjc"), { recursive: true });
199
+
200
+ const release = acquireTargetLock(worktreePath);
201
+ expect(() => acquireTargetLock(worktreePath)).toThrow(/worktree_target_locked/);
202
+ // The contended target is left intact — the loser touched nothing.
203
+ expect(fs.readdirSync(worktreePath)).toEqual([".gjc"]);
204
+
205
+ release();
206
+ // After release the lockfile is gone and the lock is reusable.
207
+ expect(fs.existsSync(path.join(bucket, ".gjc-lock-wt"))).toBe(false);
208
+ acquireTargetLock(worktreePath)();
209
+ });
210
+
211
+ test("release is idempotent", () => {
212
+ const bucket = makeTempDir("gjc-lock-");
213
+ const worktreePath = path.join(bucket, "wt");
214
+ fs.mkdirSync(worktreePath, { recursive: true });
215
+ const release = acquireTargetLock(worktreePath);
216
+ release();
217
+ expect(() => release()).not.toThrow();
218
+ });
219
+
220
+ test("ensureLaunchWorktree refuses a target already locked by another launcher", () => {
221
+ const repo = initRepo("gjc-lock-repo-");
222
+ const bucket = makeTempDir("gjc-lock-bucket-");
223
+ const worktreePath = path.join(bucket, "wt");
224
+ const plan: GjcLaunchWorktreePlan = {
225
+ enabled: true,
226
+ repoRoot: repo,
227
+ worktreePath,
228
+ detached: true,
229
+ baseRef: "HEAD",
230
+ branchName: null,
231
+ };
232
+
233
+ const release = acquireTargetLock(worktreePath);
234
+ try {
235
+ expect(() => ensureLaunchWorktree(plan)).toThrow(/worktree_target_locked/);
236
+ // The losing launcher never created the worktree.
237
+ expect(fs.existsSync(worktreePath)).toBe(false);
238
+ expect(countRegisteredWorktrees(repo)).toBe(1);
239
+ } finally {
240
+ release();
241
+ }
242
+
243
+ // Once the lock is released the same launch succeeds cleanly.
244
+ const result = ensureLaunchWorktree(plan);
245
+ expect(result.enabled).toBe(true);
246
+ expect(fs.existsSync(path.join(worktreePath, ".git"))).toBe(true);
247
+ });
248
+ });
@@ -510,6 +510,221 @@ export function planLaunchWorktree(
510
510
  return { enabled: true, repoRoot, worktreePath, detached: mode.detached, baseRef, branchName };
511
511
  }
512
512
 
513
+ /**
514
+ * Config subdirectory a launcher (e.g. paseo, #5570) may seed into the worktree target
515
+ * before handing the path to gjc. `git worktree add` refuses any non-empty target, so
516
+ * this artifact is evacuated and restored around the add rather than treated as a conflict.
517
+ */
518
+ const PRE_WORKTREE_GJC_DIR = ".gjc";
519
+
520
+ /**
521
+ * Assert that `target` resolves to a location contained within `parent`.
522
+ *
523
+ * Both paths are resolved through {@link fs.realpathSync} so a symlink anywhere along the
524
+ * chain cannot smuggle a write outside the worktree bucket. Throws `worktree_path_conflict`
525
+ * when the resolved target escapes the resolved parent.
526
+ */
527
+ function assertRealpathContained(target: string, parent: string): void {
528
+ const realParent = fs.realpathSync(parent);
529
+ const realTarget = fs.realpathSync(target);
530
+ if (realTarget === realParent || realTarget.startsWith(realParent + path.sep)) return;
531
+ throw new Error(`worktree_path_conflict:${target}`);
532
+ }
533
+
534
+ /**
535
+ * Whether an existing worktree target can be materialized in place. An empty directory,
536
+ * or one holding nothing but a pre-seeded {@link PRE_WORKTREE_GJC_DIR}, is safe to replace;
537
+ * anything else is a genuine `worktree_path_conflict`. A missing path is trivially usable.
538
+ *
539
+ * A symlinked target is never replaceable: evacuation and overlay writes would follow the
540
+ * link out of the worktree bucket, so it is reported as a conflict instead.
541
+ */
542
+ export function isReplaceableWorktreeTarget(worktreePath: string): boolean {
543
+ let stat: fs.Stats;
544
+ try {
545
+ stat = fs.lstatSync(worktreePath);
546
+ } catch (error) {
547
+ if (fileSystemErrorCode(error) === "ENOENT") return true;
548
+ throw error;
549
+ }
550
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return false;
551
+ const entries = fs.readdirSync(worktreePath);
552
+ return entries.every(entry => entry === PRE_WORKTREE_GJC_DIR);
553
+ }
554
+
555
+ /**
556
+ * Restore a stashed pre-seeded `.gjc/` into `worktreePath`.
557
+ *
558
+ * `git worktree add` materializes any tracked `.gjc/**` content (e.g. `.gjc/qa/**`) into the
559
+ * fresh worktree before this runs, so a bare rename would collide with EEXIST. When the target
560
+ * already exists, the stashed launcher config is overlaid onto it — stash entries win on name
561
+ * collision, tracked-only entries survive — and the stash is removed. When it does not exist
562
+ * (the untracked case, or a rolled-back add that never checked out), the stash is renamed in.
563
+ */
564
+ export function restorePreWorktreeGjc(gjcStash: string, worktreePath: string): void {
565
+ fs.mkdirSync(worktreePath, { recursive: true });
566
+ const target = path.join(worktreePath, PRE_WORKTREE_GJC_DIR);
567
+ let targetStat: fs.Stats | null;
568
+ try {
569
+ targetStat = fs.lstatSync(target);
570
+ } catch (error) {
571
+ if (fileSystemErrorCode(error) === "ENOENT") targetStat = null;
572
+ else throw error;
573
+ }
574
+ if (targetStat) {
575
+ // `git worktree add` may have checked out a tracked `.gjc` symlink pointing outside the
576
+ // worktree; a recursive cpSync into it would redirect the overlay off-tree. Reject it and
577
+ // require the resolved destination to stay inside the worktree.
578
+ if (targetStat.isSymbolicLink()) throw new Error(`worktree_path_conflict:${target}`);
579
+ assertRealpathContained(target, worktreePath);
580
+ fs.cpSync(gjcStash, target, { recursive: true, force: true });
581
+ fs.rmSync(gjcStash, { recursive: true, force: true });
582
+ return;
583
+ }
584
+ fs.renameSync(gjcStash, target);
585
+ }
586
+
587
+ /**
588
+ * Handle returned by {@link evacuatePreWorktreeTarget}: a hook that restores the pre-seeded
589
+ * `.gjc/` into the freshly created worktree, plus the sibling stash path it was moved to
590
+ * (`null` when there was nothing to preserve). The stash path lets a failed restore report
591
+ * where the pre-seeded config can be recovered by hand.
592
+ */
593
+ export interface EvacuationHandle {
594
+ restore: () => void;
595
+ stashPath: string | null;
596
+ }
597
+
598
+ /**
599
+ * Clear a {@link isReplaceableWorktreeTarget replaceable} target so `git worktree add` can
600
+ * create it, moving any pre-seeded `.gjc/` to a sibling stash. Returns a hook that restores
601
+ * `.gjc/` into the freshly created worktree; a no-op when there was nothing to preserve.
602
+ */
603
+ export function evacuatePreWorktreeTarget(worktreePath: string): EvacuationHandle {
604
+ if (!fs.existsSync(worktreePath)) return { restore: () => {}, stashPath: null };
605
+ const gjcSource = path.join(worktreePath, PRE_WORKTREE_GJC_DIR);
606
+ let gjcStat: fs.Stats | null;
607
+ try {
608
+ gjcStat = fs.lstatSync(gjcSource);
609
+ } catch (error) {
610
+ if (fileSystemErrorCode(error) === "ENOENT") gjcStat = null;
611
+ else throw error;
612
+ }
613
+ if (!gjcStat) {
614
+ fs.rmdirSync(worktreePath);
615
+ return { restore: () => {}, stashPath: null };
616
+ }
617
+ // A symlinked `.gjc` would let the rename below (and the later overlay) escape the worktree
618
+ // bucket. Refuse it as a path conflict rather than following the link off-tree.
619
+ if (gjcStat.isSymbolicLink()) throw new Error(`worktree_path_conflict:${gjcSource}`);
620
+ assertRealpathContained(gjcSource, worktreePath);
621
+ const gjcStash = path.join(path.dirname(worktreePath), `.gjc-pre-wt-${path.basename(worktreePath)}`);
622
+ // A lingering stash means a prior evacuation was interrupted before its restore ran, or the
623
+ // path holds unrelated user data. Either way it is not ours to destroy: fail closed so the
624
+ // operator can inspect and remove it, rather than overwriting it before the worktree add.
625
+ if (fs.existsSync(gjcStash)) {
626
+ throw new Error(`stash path already occupied: ${gjcStash} — remove it manually before retrying`);
627
+ }
628
+ fs.renameSync(gjcSource, gjcStash);
629
+ fs.rmdirSync(worktreePath);
630
+ return { restore: () => restorePreWorktreeGjc(gjcStash, worktreePath), stashPath: gjcStash };
631
+ }
632
+
633
+ /** Best-effort restore that never masks the failure that triggered it. */
634
+ function tryRestore(restore: () => void): void {
635
+ try {
636
+ restore();
637
+ } catch {
638
+ // The original worktree-add failure is the actionable one; leave restore best-effort.
639
+ }
640
+ }
641
+
642
+ /** Best-effort removal of a worktree GJC just created, used when a later step must roll it back. */
643
+ function rollbackCreatedWorktree(plan: GjcLaunchWorktreePlan): void {
644
+ try {
645
+ runGit(plan.repoRoot, ["worktree", "remove", "--force", plan.worktreePath]);
646
+ } catch {
647
+ try {
648
+ runGit(plan.repoRoot, ["worktree", "prune"]);
649
+ } catch {
650
+ // Best-effort rollback; the restore failure below is the actionable error.
651
+ }
652
+ }
653
+ }
654
+
655
+ /**
656
+ * Finalize the pre-seeded `.gjc/` overlay after a successful `git worktree add`, transactionally.
657
+ *
658
+ * `git worktree add` has already registered and materialized the worktree by the time this runs,
659
+ * so a restore/overlay failure cannot simply propagate: the half-prepared worktree would be
660
+ * reused verbatim on the next launch (never restored, stash stranded). On failure this rolls the
661
+ * newly created worktree back so a retry starts clean, preserves the stash for manual recovery,
662
+ * and throws — success is never reported when restoration did not complete.
663
+ */
664
+ export function commitPreWorktreeRestore(handle: EvacuationHandle, plan: GjcLaunchWorktreePlan): void {
665
+ try {
666
+ handle.restore();
667
+ } catch (error) {
668
+ rollbackCreatedWorktree(plan);
669
+ const detail = error instanceof Error ? error.message : String(error);
670
+ const recovery = handle.stashPath
671
+ ? `The stashed launcher .gjc was preserved at ${handle.stashPath} for manual recovery.`
672
+ : "No launcher .gjc stash was created.";
673
+ throw new Error(
674
+ [
675
+ `worktree_gjc_restore_failed:${plan.worktreePath}`,
676
+ `GJC created the worktree but could not restore the pre-seeded .gjc overlay: ${detail}`,
677
+ recovery,
678
+ "The newly created worktree was rolled back; resolve the obstruction and relaunch.",
679
+ ].join("\n"),
680
+ );
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Acquire a per-target filesystem lock so the replaceability check, evacuation, `git worktree
686
+ * add`, and restore run as one critical section. Without it a second launcher racing on the same
687
+ * target could add files between the scan and the evacuation, or move `.gjc` first and strand the
688
+ * partially modified target. The lock is an atomic `O_EXCL` lockfile beside the target (inside the
689
+ * git-ignored bucket); a concurrent holder fails fast with `worktree_target_locked`.
690
+ */
691
+ export function acquireTargetLock(worktreePath: string): () => void {
692
+ const lockPath = path.join(path.dirname(worktreePath), `.gjc-lock-${path.basename(worktreePath)}`);
693
+ try {
694
+ fs.closeSync(fs.openSync(lockPath, "wx"));
695
+ } catch (error) {
696
+ if (fileSystemErrorCode(error) === "EEXIST") {
697
+ throw new Error(
698
+ `worktree_target_locked:${worktreePath} — another launch is preparing this worktree (lock: ${lockPath})`,
699
+ );
700
+ }
701
+ throw error;
702
+ }
703
+ let released = false;
704
+ return () => {
705
+ if (released) return;
706
+ released = true;
707
+ try {
708
+ fs.rmSync(lockPath, { force: true });
709
+ } catch {
710
+ // Best-effort release; a stranded lockfile is surfaced as worktree_target_locked next time.
711
+ }
712
+ };
713
+ }
714
+
715
+ function buildWorktreeAddArgs(plan: GjcLaunchWorktreePlan, branchAlreadyExisted: boolean): string[] {
716
+ const args = ["worktree", "add"];
717
+ if (plan.detached) args.push("--detach", plan.worktreePath, plan.baseRef);
718
+ else if (branchAlreadyExisted) args.push(plan.worktreePath, plan.branchName ?? "");
719
+ else args.push("-b", plan.branchName ?? "", plan.worktreePath, plan.baseRef);
720
+ return args;
721
+ }
722
+
723
+ function classifyWorktreeAddFailure(plan: GjcLaunchWorktreePlan, args: string[], stderr: string): Error {
724
+ if (plan.branchName && BRANCH_IN_USE_PATTERN.test(stderr)) return new Error(`branch_in_use:${plan.branchName}`);
725
+ return new Error(stderr || `worktree_add_failed:${args.join(" ")}`);
726
+ }
727
+
513
728
  export function ensureLaunchWorktree(
514
729
  plan: GjcLaunchWorktreePlan | { enabled: false },
515
730
  options?: LaunchWorktreeAbortOptions,
@@ -573,32 +788,43 @@ function ensureLaunchWorktreeSync(
573
788
  };
574
789
  }
575
790
 
576
- if (fs.existsSync(plan.worktreePath)) throw new Error(`worktree_path_conflict:${plan.worktreePath}`);
791
+ if (!isReplaceableWorktreeTarget(plan.worktreePath)) throw new Error(`worktree_path_conflict:${plan.worktreePath}`);
577
792
  if (plan.branchName && hasBranchInUse(allWorktrees, plan.branchName, plan.worktreePath)) {
578
793
  throw new Error(`branch_in_use:${plan.branchName}`);
579
794
  }
580
795
 
581
796
  ensureBucketDirUsable(path.dirname(plan.worktreePath));
582
- const branchAlreadyExisted = plan.branchName ? branchExists(plan.repoRoot, plan.branchName) : false;
583
- const args = ["worktree", "add"];
584
- if (plan.detached) args.push("--detach", plan.worktreePath, plan.baseRef);
585
- else if (branchAlreadyExisted) args.push(plan.worktreePath, plan.branchName ?? "");
586
- else args.push("-b", plan.branchName ?? "", plan.worktreePath, plan.baseRef);
797
+ const releaseLock = acquireTargetLock(plan.worktreePath);
798
+ try {
799
+ // Re-check under the lock: a concurrent launcher may have populated the target between the
800
+ // scan above and now. From here the check, evacuation, add, and restore are serialized.
801
+ if (!isReplaceableWorktreeTarget(plan.worktreePath)) {
802
+ throw new Error(`worktree_path_conflict:${plan.worktreePath}`);
803
+ }
804
+ const branchAlreadyExisted = plan.branchName ? branchExists(plan.repoRoot, plan.branchName) : false;
805
+ const args = buildWorktreeAddArgs(plan, branchAlreadyExisted);
806
+ const evacuation = evacuatePreWorktreeTarget(plan.worktreePath);
807
+ try {
808
+ const result = Bun.spawnSync(["git", ...args], { cwd: plan.repoRoot, stdout: "pipe", stderr: "pipe" });
809
+ if (result.exitCode !== 0) {
810
+ throw classifyWorktreeAddFailure(plan, args, sanitizeWorktreeDiagnostic(result.stderr.toString().trim()));
811
+ }
812
+ } catch (error) {
813
+ tryRestore(evacuation.restore);
814
+ throw error;
815
+ }
816
+ commitPreWorktreeRestore(evacuation, plan);
587
817
 
588
- const result = Bun.spawnSync(["git", ...args], { cwd: plan.repoRoot, stdout: "pipe", stderr: "pipe" });
589
- if (result.exitCode !== 0) {
590
- const stderr = sanitizeWorktreeDiagnostic(result.stderr.toString().trim());
591
- if (plan.branchName && BRANCH_IN_USE_PATTERN.test(stderr)) throw new Error(`branch_in_use:${plan.branchName}`);
592
- throw new Error(stderr || `worktree_add_failed:${args.join(" ")}`);
818
+ return {
819
+ ...plan,
820
+ worktreePath: path.resolve(plan.worktreePath),
821
+ created: true,
822
+ reused: false,
823
+ createdBranch: Boolean(plan.branchName && !branchAlreadyExisted),
824
+ };
825
+ } finally {
826
+ releaseLock();
593
827
  }
594
-
595
- return {
596
- ...plan,
597
- worktreePath: path.resolve(plan.worktreePath),
598
- created: true,
599
- reused: false,
600
- createdBranch: Boolean(plan.branchName && !branchAlreadyExisted),
601
- };
602
828
  }
603
829
 
604
830
  export async function ensureLaunchWorktreeCancellable(
@@ -658,33 +884,44 @@ export async function ensureLaunchWorktreeCancellable(
658
884
  };
659
885
  }
660
886
 
661
- if (fs.existsSync(plan.worktreePath)) throw new Error(`worktree_path_conflict:${plan.worktreePath}`);
887
+ if (!isReplaceableWorktreeTarget(plan.worktreePath)) throw new Error(`worktree_path_conflict:${plan.worktreePath}`);
662
888
  if (plan.branchName && hasBranchInUse(allWorktrees, plan.branchName, plan.worktreePath)) {
663
889
  throw new Error(`branch_in_use:${plan.branchName}`);
664
890
  }
665
891
 
666
892
  ensureBucketDirUsable(path.dirname(plan.worktreePath));
667
893
  throwIfAborted(options, timeout);
668
- const branchAlreadyExisted = plan.branchName ? branchExists(plan.repoRoot, plan.branchName) : false;
669
- const args = ["worktree", "add"];
670
- if (plan.detached) args.push("--detach", plan.worktreePath, plan.baseRef);
671
- else if (branchAlreadyExisted) args.push(plan.worktreePath, plan.branchName ?? "");
672
- else args.push("-b", plan.branchName ?? "", plan.worktreePath, plan.baseRef);
894
+ const releaseLock = acquireTargetLock(plan.worktreePath);
895
+ try {
896
+ // Re-check under the lock: a concurrent launcher may have populated the target between the
897
+ // scan above and now. From here the check, evacuation, add, and restore are serialized.
898
+ if (!isReplaceableWorktreeTarget(plan.worktreePath)) {
899
+ throw new Error(`worktree_path_conflict:${plan.worktreePath}`);
900
+ }
901
+ const branchAlreadyExisted = plan.branchName ? branchExists(plan.repoRoot, plan.branchName) : false;
902
+ const args = buildWorktreeAddArgs(plan, branchAlreadyExisted);
903
+ const evacuation = evacuatePreWorktreeTarget(plan.worktreePath);
904
+ try {
905
+ const result = await spawnProcessGroup(["git", ...args], plan.repoRoot, options, timeout);
906
+ if (result.exitCode !== 0) {
907
+ throw classifyWorktreeAddFailure(plan, args, sanitizeWorktreeDiagnostic(result.stderr.trim()));
908
+ }
909
+ } catch (error) {
910
+ tryRestore(evacuation.restore);
911
+ throw error;
912
+ }
913
+ commitPreWorktreeRestore(evacuation, plan);
673
914
 
674
- const result = await spawnProcessGroup(["git", ...args], plan.repoRoot, options, timeout);
675
- if (result.exitCode !== 0) {
676
- const stderr = sanitizeWorktreeDiagnostic(result.stderr.trim());
677
- if (plan.branchName && BRANCH_IN_USE_PATTERN.test(stderr)) throw new Error(`branch_in_use:${plan.branchName}`);
678
- throw new Error(stderr || `worktree_add_failed:${args.join(" ")}`);
915
+ return {
916
+ ...plan,
917
+ worktreePath: path.resolve(plan.worktreePath),
918
+ created: true,
919
+ reused: false,
920
+ createdBranch: Boolean(plan.branchName && !branchAlreadyExisted),
921
+ };
922
+ } finally {
923
+ releaseLock();
679
924
  }
680
-
681
- return {
682
- ...plan,
683
- worktreePath: path.resolve(plan.worktreePath),
684
- created: true,
685
- reused: false,
686
- createdBranch: Boolean(plan.branchName && !branchAlreadyExisted),
687
- };
688
925
  }
689
926
 
690
927
  interface WorkspacePackageManifest {
@@ -177,7 +177,7 @@ export async function assertCwdMatchesRepositoryBinding(
177
177
  if (!repositoryBindingsMatch(active, binding)) {
178
178
  throw new RepositoryBindingError(
179
179
  "identity_mismatch",
180
- `Active worktree does not match plan/task repository binding. active=${active.worktreeRoot} (commonDir=${active.commonDir ?? "none"}) bound=${binding.worktreeRoot} (commonDir=${binding.commonDir ?? "none"}).`,
180
+ `Active worktree does not match plan/task repository binding. A task cannot authorize a foreign repository by changing tasks[].repositoryBinding; start a new session in the approved worktree (for example, gjc --cwd <approved-worktree>) to establish task authority. active=${active.worktreeRoot} (commonDir=${active.commonDir ?? "none"}) bound=${binding.worktreeRoot} (commonDir=${binding.commonDir ?? "none"}).`,
181
181
  );
182
182
  }
183
183
  return active;
@@ -233,6 +233,31 @@ export async function resolveTaskRepositoryBinding(
233
233
  }
234
234
  const taskBinding = parseRepositoryBinding(declared);
235
235
  await assertCwdMatchesRepositoryBinding(sessionCwd, taskBinding);
236
+ const declaredRoot = await captureRepositoryBinding(taskBinding.worktreeRoot);
237
+ if (path.resolve(declaredRoot.worktreeRoot) !== path.resolve(taskBinding.worktreeRoot)) {
238
+ throw new RepositoryBindingError(
239
+ "identity_mismatch",
240
+ `Declared repository binding worktreeRoot is stale or forged: live=${declaredRoot.worktreeRoot} bound=${taskBinding.worktreeRoot}.`,
241
+ );
242
+ }
243
+ if (declaredRoot.commonDir !== taskBinding.commonDir) {
244
+ throw new RepositoryBindingError(
245
+ "identity_mismatch",
246
+ `Declared repository binding commonDir does not match the target worktree: live=${declaredRoot.commonDir ?? "none"} bound=${taskBinding.commonDir ?? "none"}.`,
247
+ );
248
+ }
249
+ if (taskBinding.head !== undefined && taskBinding.head !== declaredRoot.head) {
250
+ throw new RepositoryBindingError(
251
+ "identity_mismatch",
252
+ `Declared repository binding HEAD does not match the target worktree: live=${declaredRoot.head ?? "none"} bound=${taskBinding.head}.`,
253
+ );
254
+ }
255
+ if (taskBinding.branch !== undefined && taskBinding.branch !== declaredRoot.branch) {
256
+ throw new RepositoryBindingError(
257
+ "identity_mismatch",
258
+ `Declared repository binding branch does not match the target worktree: live=${declaredRoot.branch ?? "none"} bound=${taskBinding.branch}.`,
259
+ );
260
+ }
236
261
  if (taskBinding.relativeSubdir) {
237
262
  assertPathUnderRepositoryBinding(taskBinding, ".");
238
263
  }
@@ -125,6 +125,16 @@ export function sessionLogsDir(cwd: string, gjcSessionId: string): string {
125
125
  export function sessionRuntimeDir(cwd: string, gjcSessionId: string): string {
126
126
  return path.join(sessionRoot(cwd, gjcSessionId), "runtime");
127
127
  }
128
+ /**
129
+ * The session-derived runtime-state marker owned by `gjcSessionId` — the marker a session
130
+ * writes when the process carries no coordinator pin for it, and the one a committed cwd move
131
+ * relocates. The coordinator pin is a separate path resolved from
132
+ * `GJC_COORDINATOR_SESSION_STATE_FILE`, so only the sidecar may decide which of the two a
133
+ * session owns; callers use this for the derived half of that decision.
134
+ */
135
+ export function sessionRuntimeStatePath(cwd: string, gjcSessionId: string): string {
136
+ return path.join(sessionRuntimeDir(cwd, gjcSessionId), "runtime-state.json");
137
+ }
128
138
  export function sessionIpykernelsDir(cwd: string, gjcSessionId: string): string {
129
139
  return path.join(sessionRoot(cwd, gjcSessionId), "ipykernels");
130
140
  }