@gajae-code/coding-agent 0.15.6 → 0.16.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 (270) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/dist/types/async/job-manager.d.ts +32 -1
  3. package/dist/types/browser-backend.d.ts +12 -0
  4. package/dist/types/cli/args.d.ts +2 -0
  5. package/dist/types/cli/root-flags.d.ts +14 -0
  6. package/dist/types/cli/update-cli.d.ts +14 -0
  7. package/dist/types/cli.d.ts +7 -0
  8. package/dist/types/commands/launch.d.ts +10 -0
  9. package/dist/types/commands/sdk.d.ts +2 -0
  10. package/dist/types/config/autorouting-tier-map.d.ts +24 -0
  11. package/dist/types/config/billing-path.d.ts +25 -0
  12. package/dist/types/config/config-schema-version.d.ts +13 -1
  13. package/dist/types/config/file-lock.d.ts +5 -0
  14. package/dist/types/config/model-registry.d.ts +22 -0
  15. package/dist/types/config/settings-schema.d.ts +27 -8
  16. package/dist/types/config/settings.d.ts +2 -0
  17. package/dist/types/coordinator-mcp/projection-scan.d.ts +8 -0
  18. package/dist/types/crash/record-loader.d.ts +15 -0
  19. package/dist/types/extensibility/extensions/types.d.ts +8 -3
  20. package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
  21. package/dist/types/gjc-runtime/launch-worktree.d.ts +25 -1
  22. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
  23. package/dist/types/gjc-runtime/session-state-lock.d.ts +22 -0
  24. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +66 -1
  25. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
  26. package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
  27. package/dist/types/main.d.ts +1 -0
  28. package/dist/types/master-mode/context.d.ts +21 -0
  29. package/dist/types/master-mode/first-request.d.ts +34 -0
  30. package/dist/types/master-mode/snapshot.d.ts +32 -0
  31. package/dist/types/modes/acp/acp-agent.d.ts +21 -21
  32. package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
  33. package/dist/types/modes/components/model-selector.d.ts +4 -0
  34. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  35. package/dist/types/modes/components/session-selector.d.ts +1 -1
  36. package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
  37. package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
  38. package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
  39. package/dist/types/modes/components/status-line/segments.d.ts +10 -0
  40. package/dist/types/modes/interactive-mode.d.ts +16 -2
  41. package/dist/types/modes/print-mode.d.ts +17 -0
  42. package/dist/types/modes/theme/theme.d.ts +2 -1
  43. package/dist/types/modes/types.d.ts +2 -0
  44. package/dist/types/modes/ui-language.d.ts +7 -1
  45. package/dist/types/sdk/broker/broker.d.ts +79 -2
  46. package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
  47. package/dist/types/sdk/broker/ensure.d.ts +3 -0
  48. package/dist/types/sdk/broker/index.d.ts +3 -0
  49. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +6 -0
  50. package/dist/types/sdk/broker/lifecycle.d.ts +40 -0
  51. package/dist/types/sdk/broker/master-capability.d.ts +10 -0
  52. package/dist/types/sdk/broker/session-index.d.ts +40 -11
  53. package/dist/types/sdk/broker/session-scope.d.ts +72 -0
  54. package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
  55. package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
  56. package/dist/types/sdk/broker/startup-budget.d.ts +52 -0
  57. package/dist/types/sdk/broker/state-version.d.ts +21 -1
  58. package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
  59. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
  60. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -1
  61. package/dist/types/sdk/bus/index.d.ts +9 -1
  62. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +1 -1
  63. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +1 -1
  64. package/dist/types/sdk/bus/slack-conversation.d.ts +1 -1
  65. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  66. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  67. package/dist/types/sdk/cli/index.d.ts +1 -0
  68. package/dist/types/sdk/cli/master-cli.d.ts +63 -0
  69. package/dist/types/sdk/cli/rows.d.ts +12 -5
  70. package/dist/types/sdk/cli/session-cli.d.ts +20 -3
  71. package/dist/types/sdk/client/client.d.ts +3 -2
  72. package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
  73. package/dist/types/sdk/host/control/index.d.ts +1 -1
  74. package/dist/types/sdk/host/control/operations.d.ts +2 -0
  75. package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
  76. package/dist/types/sdk/host/events.d.ts +1 -0
  77. package/dist/types/sdk/host/host.d.ts +10 -0
  78. package/dist/types/sdk/host/query/cursor.d.ts +4 -2
  79. package/dist/types/sdk/host/query/handlers.d.ts +1 -0
  80. package/dist/types/sdk/host/query/revision-store.d.ts +1 -0
  81. package/dist/types/sdk/host/session-runtime.d.ts +70 -17
  82. package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
  83. package/dist/types/sdk/lifecycle/service.d.ts +15 -3
  84. package/dist/types/sdk/lifecycle-session.d.ts +3 -1
  85. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -5
  86. package/dist/types/sdk/receipt-state.d.ts +2 -0
  87. package/dist/types/sdk/router/session-router.d.ts +6 -0
  88. package/dist/types/sdk/session.d.ts +16 -0
  89. package/dist/types/sdk/startup-capability.d.ts +2 -1
  90. package/dist/types/sdk/turn-result.d.ts +6 -0
  91. package/dist/types/session/agent-session.d.ts +52 -7
  92. package/dist/types/session/credential-store-errors.d.ts +1 -0
  93. package/dist/types/session/fold-coordinator.d.ts +29 -2
  94. package/dist/types/session/session-manager.d.ts +79 -2
  95. package/dist/types/session/yield-queue.d.ts +5 -4
  96. package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
  97. package/dist/types/tools/ask.d.ts +1 -0
  98. package/dist/types/tools/bash.d.ts +16 -0
  99. package/dist/types/tools/browser/launch.d.ts +10 -0
  100. package/dist/types/tools/browser/registry.d.ts +8 -0
  101. package/dist/types/tools/gh.d.ts +8 -0
  102. package/dist/types/tools/index.d.ts +10 -10
  103. package/dist/types/tools/job.d.ts +3 -1
  104. package/dist/types/utils/external-editor.d.ts +7 -0
  105. package/examples/extensions/README.md +1 -1
  106. package/examples/extensions/pirate.ts +7 -4
  107. package/package.json +7 -7
  108. package/scripts/generate-sdk-operation-inventory.ts +13 -1
  109. package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
  110. package/src/async/job-manager.ts +107 -9
  111. package/src/browser-backend.ts +29 -0
  112. package/src/cli/accounts-cli.ts +9 -5
  113. package/src/cli/args.ts +17 -0
  114. package/src/cli/fast-help.ts +1 -0
  115. package/src/cli/plugin-cli.ts +29 -0
  116. package/src/cli/root-flags.ts +5 -0
  117. package/src/cli/update-cli.ts +140 -23
  118. package/src/commands/launch.ts +86 -18
  119. package/src/commands/sdk.ts +135 -28
  120. package/src/config/autorouting-tier-map.ts +12 -0
  121. package/src/config/billing-path.ts +42 -0
  122. package/src/config/config-schema-version.ts +18 -1
  123. package/src/config/file-lock.ts +158 -83
  124. package/src/config/model-preset-registry.ts +178 -23
  125. package/src/config/model-profiles.ts +5 -5
  126. package/src/config/model-registry.ts +291 -42
  127. package/src/config/model-resolver.ts +5 -0
  128. package/src/config/settings-schema.ts +27 -8
  129. package/src/config/settings.ts +51 -15
  130. package/src/coordinator-mcp/projection-scan.ts +27 -6
  131. package/src/coordinator-mcp/question-state.ts +9 -11
  132. package/src/coordinator-mcp/server.ts +115 -21
  133. package/src/crash/index-store.ts +2 -0
  134. package/src/crash/record-loader.ts +10 -0
  135. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  136. package/src/extensibility/extensions/runner.ts +27 -0
  137. package/src/extensibility/extensions/types.ts +8 -3
  138. package/src/extensibility/gjc-plugins/installer.ts +51 -11
  139. package/src/extensibility/runtime-skill-discovery.ts +110 -3
  140. package/src/extensibility/skills.ts +22 -12
  141. package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
  142. package/src/gjc-runtime/launch-worktree.ts +285 -4
  143. package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
  144. package/src/gjc-runtime/session-state-lock.ts +213 -14
  145. package/src/gjc-runtime/session-state-sidecar.ts +938 -39
  146. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
  147. package/src/gjc-runtime/tmux-owner-isolation.ts +18 -3
  148. package/src/gjc-runtime/tmux-sessions.ts +252 -13
  149. package/src/hooks/skill-state.ts +6 -0
  150. package/src/internal-urls/docs-index.generated.ts +15 -15
  151. package/src/internal-urls/issue-pr-protocol.ts +4 -2
  152. package/src/main.ts +61 -3
  153. package/src/master-mode/context.ts +50 -0
  154. package/src/master-mode/first-request.ts +65 -0
  155. package/src/master-mode/snapshot.ts +205 -0
  156. package/src/modes/acp/acp-agent.ts +610 -161
  157. package/src/modes/components/footer.ts +4 -2
  158. package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
  159. package/src/modes/components/model-selector.ts +76 -8
  160. package/src/modes/components/oauth-selector.ts +6 -1
  161. package/src/modes/components/session-selector.ts +23 -15
  162. package/src/modes/components/sessions-dashboard.ts +8 -5
  163. package/src/modes/components/settings-selector.ts +1 -0
  164. package/src/modes/components/status-line/model-name.ts +53 -0
  165. package/src/modes/components/status-line/priority-row.ts +107 -0
  166. package/src/modes/components/status-line/segments.ts +23 -9
  167. package/src/modes/components/tool-status-header.ts +151 -15
  168. package/src/modes/controllers/command-controller.ts +1 -1
  169. package/src/modes/controllers/event-controller.ts +30 -4
  170. package/src/modes/controllers/extension-ui-controller.ts +13 -13
  171. package/src/modes/controllers/input-controller.ts +30 -2
  172. package/src/modes/controllers/selector-controller.ts +200 -98
  173. package/src/modes/interactive-mode.ts +36 -12
  174. package/src/modes/print-mode.ts +64 -0
  175. package/src/modes/prompt-action-autocomplete.ts +30 -4
  176. package/src/modes/runtime-init.ts +12 -9
  177. package/src/modes/theme/theme.ts +5 -0
  178. package/src/modes/types.ts +2 -0
  179. package/src/modes/ui-language.ts +49 -2
  180. package/src/modes/utils/ui-helpers.ts +22 -3
  181. package/src/prompts/agent-fragments/browser-aside.md +22 -0
  182. package/src/prompts/system/master-mode.md +28 -0
  183. package/src/prompts/tools/ask.md +1 -1
  184. package/src/prompts/tools/bash.md +1 -0
  185. package/src/prompts/tools/job.md +1 -1
  186. package/src/sdk/acp/adapter.ts +5 -0
  187. package/src/sdk/broker/broker.ts +1646 -27
  188. package/src/sdk/broker/endpoint-authority.ts +86 -0
  189. package/src/sdk/broker/ensure.ts +81 -3
  190. package/src/sdk/broker/index.ts +3 -0
  191. package/src/sdk/broker/lifecycle-ledger.ts +6 -0
  192. package/src/sdk/broker/lifecycle.ts +440 -75
  193. package/src/sdk/broker/master-capability.ts +232 -0
  194. package/src/sdk/broker/session-index.ts +121 -17
  195. package/src/sdk/broker/session-scope.ts +272 -0
  196. package/src/sdk/broker/spawn-authority.ts +1031 -0
  197. package/src/sdk/broker/spawn-substrate.ts +591 -0
  198. package/src/sdk/broker/startup-budget.ts +144 -2
  199. package/src/sdk/broker/state-version.ts +43 -14
  200. package/src/sdk/broker/transport.ts +1 -0
  201. package/src/sdk/broker/worktree-occupancy.ts +90 -0
  202. package/src/sdk/bus/chat-daemon-control.ts +5 -2
  203. package/src/sdk/bus/chat-daemon-runtime.ts +136 -18
  204. package/src/sdk/bus/discord-daemon.ts +50 -10
  205. package/src/sdk/bus/index.ts +84 -7
  206. package/src/sdk/bus/kind-aware-reconciliation.ts +296 -48
  207. package/src/sdk/bus/lifecycle-commands.ts +4 -0
  208. package/src/sdk/bus/notification-service.ts +22 -5
  209. package/src/sdk/bus/prompt-reconciliation.ts +7 -2
  210. package/src/sdk/bus/reconciliation-store.ts +55 -31
  211. package/src/sdk/bus/slack-conversation.ts +1 -1
  212. package/src/sdk/bus/slack-daemon.ts +72 -38
  213. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  214. package/src/sdk/cli/index.ts +1 -0
  215. package/src/sdk/cli/master-cli.ts +259 -0
  216. package/src/sdk/cli/rows.ts +41 -15
  217. package/src/sdk/cli/session-cli.ts +297 -26
  218. package/src/sdk/client/client.ts +14 -8
  219. package/src/sdk/host/control/dispatch.ts +101 -40
  220. package/src/sdk/host/control/index.ts +2 -0
  221. package/src/sdk/host/control/operations.ts +2 -0
  222. package/src/sdk/host/control/runtime-gate.ts +203 -11
  223. package/src/sdk/host/events.ts +1 -0
  224. package/src/sdk/host/host.ts +29 -2
  225. package/src/sdk/host/query/cursor.ts +52 -7
  226. package/src/sdk/host/query/handlers.ts +33 -12
  227. package/src/sdk/host/query/revision-store.ts +12 -0
  228. package/src/sdk/host/session-runtime.test.ts +673 -14
  229. package/src/sdk/host/session-runtime.ts +674 -103
  230. package/src/sdk/host/surface-policy.ts +3 -3
  231. package/src/sdk/lifecycle/broker-client.ts +28 -0
  232. package/src/sdk/lifecycle/service.ts +279 -20
  233. package/src/sdk/lifecycle-session.ts +17 -4
  234. package/src/sdk/prompt-deadline-manager.ts +2 -1
  235. package/src/sdk/protocol/operation-inventory.generated.json +105 -3
  236. package/src/sdk/protocol/operation-registry.ts +14 -4
  237. package/src/sdk/receipt-state.ts +10 -0
  238. package/src/sdk/router/session-router.ts +219 -118
  239. package/src/sdk/session.ts +416 -195
  240. package/src/sdk/startup-capability.ts +1 -0
  241. package/src/sdk/turn-result.ts +22 -0
  242. package/src/session/agent-session.ts +1628 -318
  243. package/src/session/blob-store.ts +15 -5
  244. package/src/session/credential-store-errors.ts +2 -0
  245. package/src/session/fold-coordinator.ts +40 -3
  246. package/src/session/internal/managed-session-storage.ts +153 -78
  247. package/src/session/session-manager.ts +850 -97
  248. package/src/session/yield-queue.ts +24 -10
  249. package/src/setup/frictionless-onboarding.ts +2 -0
  250. package/src/slash-commands/builtin-registry.ts +41 -0
  251. package/src/task/executor.ts +9 -4
  252. package/src/telemetry/events.ts +1138 -12
  253. package/src/tools/ask-contract.test.ts +64 -0
  254. package/src/tools/ask-contract.ts +36 -1
  255. package/src/tools/ask.ts +10 -8
  256. package/src/tools/bash-interactive.ts +11 -2
  257. package/src/tools/bash.ts +412 -75
  258. package/src/tools/browser/launch.ts +37 -0
  259. package/src/tools/browser/registry.ts +99 -9
  260. package/src/tools/cron.ts +6 -4
  261. package/src/tools/descriptors.test.ts +15 -0
  262. package/src/tools/descriptors.ts +5 -1
  263. package/src/tools/gh.ts +104 -48
  264. package/src/tools/github-cache.ts +2 -2
  265. package/src/tools/index.ts +10 -6
  266. package/src/tools/job.ts +4 -1
  267. package/src/tools/monitor.ts +12 -9
  268. package/src/tools/resolve.ts +30 -10
  269. package/src/tools/tool-catalog.generated.ts +2 -2
  270. package/src/utils/external-editor.ts +72 -1
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Cross-process reservation for a deterministic launch-worktree path.
3
+ *
4
+ * The durable session index is the long-lived ownership record, but a newly
5
+ * planned worktree has no index row yet. This reservation closes that interval:
6
+ * it is acquired before preparation and removed after `host_registered` is
7
+ * durably appended to the index.
8
+ */
9
+
10
+ import { createHash, randomUUID } from "node:crypto";
11
+ import type { Dirent } from "node:fs";
12
+ import * as fs from "node:fs/promises";
13
+ import * as path from "node:path";
14
+ import { resolveEquivalentPath } from "@gajae-code/utils";
15
+ import {
16
+ type FileLockGcObservation,
17
+ processStartTime,
18
+ readFileLockObservationForGc,
19
+ removeFileLockDirForGc,
20
+ } from "../config/file-lock";
21
+ import { processIncarnation } from "../sdk/broker/process-incarnation";
22
+
23
+ const RESERVATION_VERSION = 1;
24
+ const RESERVATION_DIRECTORY = "launch-worktree-reservations";
25
+
26
+ interface LaunchWorktreeReservationRecord {
27
+ version: typeof RESERVATION_VERSION;
28
+ worktreePath: string;
29
+ pid: number;
30
+ start_time?: string;
31
+ processIncarnation: string | null;
32
+ timestamp: number;
33
+ reservationId: string;
34
+ }
35
+
36
+ export interface LaunchWorktreeReservation {
37
+ release(): Promise<void>;
38
+ }
39
+
40
+ function canonicalWorktreePath(worktreePath: string): string {
41
+ return resolveEquivalentPath(path.resolve(worktreePath));
42
+ }
43
+
44
+ function reservationRoot(agentDir: string): string {
45
+ return path.join(agentDir, "sdk", "sessions", RESERVATION_DIRECTORY);
46
+ }
47
+
48
+ function launchWorktreeReservationDirectory(agentDir: string, worktreePath: string): string {
49
+ const digest = createHash("sha256").update(canonicalWorktreePath(worktreePath)).digest("hex");
50
+ return path.join(reservationRoot(agentDir), `${digest}.lock`);
51
+ }
52
+
53
+ /** Test seam for seeding a dead-owner reservation at the exact production path. */
54
+ export function launchWorktreeReservationDirectoryForTest(agentDir: string, worktreePath: string): string {
55
+ return launchWorktreeReservationDirectory(agentDir, worktreePath);
56
+ }
57
+
58
+ function isErrno(error: unknown, code: string): boolean {
59
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
60
+ }
61
+
62
+ function isReservationRecord(value: unknown): value is LaunchWorktreeReservationRecord {
63
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
64
+ const record = value as Partial<LaunchWorktreeReservationRecord>;
65
+ return (
66
+ record.version === RESERVATION_VERSION &&
67
+ typeof record.worktreePath === "string" &&
68
+ record.worktreePath.length > 0 &&
69
+ typeof record.pid === "number" &&
70
+ Number.isSafeInteger(record.pid) &&
71
+ record.pid > 0 &&
72
+ (record.start_time === undefined || (typeof record.start_time === "string" && record.start_time.length > 0)) &&
73
+ (record.processIncarnation === null ||
74
+ (typeof record.processIncarnation === "string" && record.processIncarnation.length > 0)) &&
75
+ typeof record.timestamp === "number" &&
76
+ Number.isFinite(record.timestamp) &&
77
+ typeof record.reservationId === "string" &&
78
+ record.reservationId.length > 0
79
+ );
80
+ }
81
+
82
+ async function readReservation(lockDir: string): Promise<LaunchWorktreeReservationRecord | null> {
83
+ let value: unknown;
84
+ try {
85
+ value = await Bun.file(path.join(lockDir, "info")).json();
86
+ } catch (error) {
87
+ if (isErrno(error, "ENOENT") || error instanceof SyntaxError) return null;
88
+ throw error;
89
+ }
90
+ return isReservationRecord(value) ? value : null;
91
+ }
92
+
93
+ function ownerDefinitelyExited(record: LaunchWorktreeReservationRecord): boolean {
94
+ try {
95
+ process.kill(record.pid, 0);
96
+ } catch (error) {
97
+ return isErrno(error, "ESRCH");
98
+ }
99
+ if (record.processIncarnation !== null) {
100
+ const actualIncarnation = processIncarnation(record.pid);
101
+ if (actualIncarnation !== undefined) return actualIncarnation !== record.processIncarnation;
102
+ }
103
+ if (record.start_time) {
104
+ const actualStartTime = processStartTime(record.pid);
105
+ if (actualStartTime !== null) return actualStartTime !== record.start_time;
106
+ }
107
+ return false;
108
+ }
109
+
110
+ async function removeReservation(
111
+ lockDir: string,
112
+ record: LaunchWorktreeReservationRecord,
113
+ ): Promise<"removed" | "kept"> {
114
+ // Capture the exact on-disk identity BEFORE authorizing removal: the generic
115
+ // GC remover only deletes a tree whose pre-verdict identity it was handed,
116
+ // so a successor reservation published at the same path is never reaped.
117
+ let observation: FileLockGcObservation | null;
118
+ try {
119
+ observation = await readFileLockObservationForGc(lockDir);
120
+ } catch {
121
+ return "kept";
122
+ }
123
+ if (!observation) return "kept";
124
+ let onDisk: unknown;
125
+ try {
126
+ onDisk = JSON.parse(observation.bytes);
127
+ } catch {
128
+ return "kept";
129
+ }
130
+ if (
131
+ !isReservationRecord(onDisk) ||
132
+ onDisk.reservationId !== record.reservationId ||
133
+ onDisk.pid !== record.pid ||
134
+ onDisk.timestamp !== record.timestamp ||
135
+ canonicalWorktreePath(onDisk.worktreePath) !== canonicalWorktreePath(record.worktreePath)
136
+ )
137
+ return "kept";
138
+ const result = await removeFileLockDirForGc(lockDir, observation.info, observation.identity);
139
+ return result === "removed" ? "removed" : "kept";
140
+ }
141
+
142
+ async function writeReservation(lockDir: string, record: LaunchWorktreeReservationRecord): Promise<void> {
143
+ try {
144
+ await Bun.write(path.join(lockDir, "info"), JSON.stringify(record));
145
+ } catch (error) {
146
+ await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined);
147
+ throw error;
148
+ }
149
+ }
150
+
151
+ async function tryPublishReservation(lockDir: string, record: LaunchWorktreeReservationRecord): Promise<boolean> {
152
+ const pendingDir = `${lockDir}.pending.${process.pid}.${randomUUID()}`;
153
+ await fs.mkdir(pendingDir, { mode: 0o700 });
154
+ try {
155
+ await writeReservation(pendingDir, record);
156
+ try {
157
+ await fs.rename(pendingDir, lockDir);
158
+ return true;
159
+ } catch (error) {
160
+ if (isErrno(error, "EEXIST") || isErrno(error, "ENOTEMPTY")) return false;
161
+ if (isErrno(error, "EPERM")) {
162
+ try {
163
+ await fs.stat(lockDir);
164
+ return false;
165
+ } catch (statError) {
166
+ if (!isErrno(statError, "ENOENT")) throw statError;
167
+ }
168
+ }
169
+ throw error;
170
+ }
171
+ } finally {
172
+ await fs.rm(pendingDir, { recursive: true, force: true }).catch(() => undefined);
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Atomically claim `worktreePath`, returning `null` only when another live or
178
+ * unverified launch already owns the same deterministic target. A dead or
179
+ * PID-reused owner is reclaimed before retrying the exclusive directory create.
180
+ */
181
+ export async function reserveLaunchWorktree(
182
+ agentDir: string,
183
+ worktreePath: string,
184
+ ): Promise<LaunchWorktreeReservation | null> {
185
+ const canonicalPath = canonicalWorktreePath(worktreePath);
186
+ const lockDir = launchWorktreeReservationDirectory(agentDir, canonicalPath);
187
+ await fs.mkdir(path.dirname(lockDir), { recursive: true, mode: 0o700 });
188
+ const ownStartTime = processStartTime(process.pid) ?? undefined;
189
+ const ownIncarnation = processIncarnation(process.pid) ?? null;
190
+
191
+ for (let attempt = 0; attempt < 2; attempt += 1) {
192
+ const record: LaunchWorktreeReservationRecord = {
193
+ version: RESERVATION_VERSION,
194
+ worktreePath: canonicalPath,
195
+ pid: process.pid,
196
+ ...(ownStartTime ? { start_time: ownStartTime } : {}),
197
+ processIncarnation: ownIncarnation,
198
+ timestamp: Date.now(),
199
+ reservationId: randomUUID(),
200
+ };
201
+ if (await tryPublishReservation(lockDir, record)) {
202
+ return {
203
+ async release(): Promise<void> {
204
+ await removeReservation(lockDir, record);
205
+ },
206
+ };
207
+ }
208
+
209
+ const existing = await readReservation(lockDir);
210
+ if (
211
+ !existing ||
212
+ canonicalWorktreePath(existing.worktreePath) !== canonicalPath ||
213
+ !ownerDefinitelyExited(existing)
214
+ )
215
+ return null;
216
+ if ((await removeReservation(lockDir, existing)) !== "removed") return null;
217
+ }
218
+
219
+ return null;
220
+ }
221
+
222
+ /** Remove the transient reservation only after the matching worktree is indexed. */
223
+ export async function releaseLaunchWorktreeReservationAfterRegistration(
224
+ agentDir: string,
225
+ worktreePath: string,
226
+ ): Promise<void> {
227
+ const canonicalPath = canonicalWorktreePath(worktreePath);
228
+ let entries: Dirent[];
229
+ try {
230
+ entries = await fs.readdir(reservationRoot(agentDir), { withFileTypes: true });
231
+ } catch (error) {
232
+ if (isErrno(error, "ENOENT")) return;
233
+ throw error;
234
+ }
235
+ for (const entry of entries) {
236
+ if (!entry.isDirectory() || !entry.name.endsWith(".lock")) continue;
237
+ const lockDir = path.join(reservationRoot(agentDir), entry.name);
238
+ const record = await readReservation(lockDir);
239
+ if (!record || canonicalWorktreePath(record.worktreePath) !== canonicalPath) continue;
240
+ await removeReservation(lockDir, record);
241
+ }
242
+ }
@@ -29,6 +29,27 @@ export interface GjcLaunchWorktreeResult extends GjcLaunchWorktreePlan {
29
29
  createdBranch: boolean;
30
30
  dirty?: boolean;
31
31
  }
32
+ export interface LaunchWorktreeAbortOptions {
33
+ signal?: AbortSignal;
34
+ deadlineAt?: number;
35
+ now?: () => number;
36
+ }
37
+
38
+ export class WorktreePreparationTimeoutError extends Error {
39
+ readonly code = "worktree_preparation_timeout";
40
+ constructor(message = "Worktree preparation exceeded its deadline before the session host was spawned.") {
41
+ super(message);
42
+ this.name = "WorktreePreparationTimeoutError";
43
+ }
44
+ }
45
+
46
+ export class DependencyPreparationTimeoutError extends Error {
47
+ readonly code = "dependency_preparation_timeout";
48
+ constructor(message = "Dependency preparation exceeded its deadline before the session host was spawned.") {
49
+ super(message);
50
+ this.name = "DependencyPreparationTimeoutError";
51
+ }
52
+ }
32
53
 
33
54
  interface GitWorktreeEntry {
34
55
  path: string;
@@ -39,10 +60,93 @@ interface GitWorktreeEntry {
39
60
 
40
61
  const BRANCH_IN_USE_PATTERN = /already checked out|already used by worktree|is already checked out/i;
41
62
 
63
+ function throwIfAborted(options: LaunchWorktreeAbortOptions | undefined, timeout: Error): void {
64
+ if (options?.signal?.aborted) throw timeout;
65
+ if (options?.deadlineAt !== undefined && (options.now ?? Date.now)() >= options.deadlineAt) throw timeout;
66
+ }
67
+
68
+ function sanitizeWorktreeDiagnostic(value: string): string {
69
+ return value
70
+ .replace(/([?&](?:token|secret|password|key|api[_-]?key)=[^\s&]*)/giu, "[redacted-query]")
71
+ .replace(
72
+ /(^|[^\p{L}\p{N}_-])[\p{L}\p{N}_-]*(?:token|secret|password|api[_-]?key|key|credential|auth)\s*[=:]\s*[^\s,;]+/giu,
73
+ "$1[redacted-secret]",
74
+ )
75
+ .replace(/\b(?:https?|wss?):\/\/[^\s]+/giu, "[redacted-url]")
76
+ .slice(0, 512);
77
+ }
78
+
79
+ async function spawnProcessGroup(
80
+ command: string[],
81
+ cwd: string,
82
+ options: LaunchWorktreeAbortOptions | undefined,
83
+ timeout: Error,
84
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
85
+ throwIfAborted(options, timeout);
86
+ const [file, ...args] = command;
87
+ if (!file) throw timeout;
88
+ const proc = Bun.spawn([file, ...args], {
89
+ cwd,
90
+ stdout: "pipe",
91
+ stderr: "pipe",
92
+ stdin: "ignore",
93
+ // Windows has no POSIX process-group kill: parent-only proc.kill() is the
94
+ // documented seam. Leftover classify still runs after wait on both OS.
95
+ detached: process.platform !== "win32",
96
+ ...(process.platform === "win32" ? { windowsHide: true } : {}),
97
+ });
98
+ const killGroup = (): void => {
99
+ try {
100
+ if (process.platform === "win32") proc.kill();
101
+ else if (proc.pid) process.kill(-proc.pid, "SIGKILL");
102
+ else proc.kill();
103
+ } catch {
104
+ try {
105
+ proc.kill();
106
+ } catch {
107
+ // already gone
108
+ }
109
+ }
110
+ };
111
+ const onAbort = (): void => {
112
+ killGroup();
113
+ };
114
+ options?.signal?.addEventListener("abort", onAbort, { once: true });
115
+ let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
116
+ if (options?.deadlineAt !== undefined) {
117
+ const remaining = options.deadlineAt - (options.now ?? Date.now)();
118
+ if (remaining <= 0) {
119
+ killGroup();
120
+ options?.signal?.removeEventListener("abort", onAbort);
121
+ await proc.exited.catch(() => undefined);
122
+ throw timeout;
123
+ }
124
+ deadlineTimer = setTimeout(() => {
125
+ killGroup();
126
+ }, remaining);
127
+ }
128
+ try {
129
+ const [stdoutBytes, stderrBytes, exitCode] = await Promise.all([
130
+ new Response(proc.stdout).arrayBuffer(),
131
+ new Response(proc.stderr).arrayBuffer(),
132
+ proc.exited,
133
+ ]);
134
+ throwIfAborted(options, timeout);
135
+ return {
136
+ stdout: new TextDecoder().decode(stdoutBytes),
137
+ stderr: new TextDecoder().decode(stderrBytes),
138
+ exitCode: exitCode ?? 1,
139
+ };
140
+ } finally {
141
+ if (deadlineTimer) clearTimeout(deadlineTimer);
142
+ options?.signal?.removeEventListener("abort", onAbort);
143
+ }
144
+ }
145
+
42
146
  function runGit(cwd: string, args: string[]): string {
43
147
  const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
44
148
  if (result.exitCode === 0) return result.stdout.toString().trim();
45
- const stderr = result.stderr.toString().trim();
149
+ const stderr = sanitizeWorktreeDiagnostic(result.stderr.toString().trim());
46
150
  throw new Error(stderr || `git ${args.join(" ")} failed`);
47
151
  }
48
152
 
@@ -408,6 +512,16 @@ export function planLaunchWorktree(
408
512
 
409
513
  export function ensureLaunchWorktree(
410
514
  plan: GjcLaunchWorktreePlan | { enabled: false },
515
+ options?: LaunchWorktreeAbortOptions,
516
+ ): GjcLaunchWorktreeResult | { enabled: false } {
517
+ if (options?.signal || options?.deadlineAt !== undefined) {
518
+ throw new Error("ensureLaunchWorktree is synchronous; use ensureLaunchWorktreeCancellable for abortable prep.");
519
+ }
520
+ return ensureLaunchWorktreeSync(plan);
521
+ }
522
+
523
+ function ensureLaunchWorktreeSync(
524
+ plan: GjcLaunchWorktreePlan | { enabled: false },
411
525
  ): GjcLaunchWorktreeResult | { enabled: false } {
412
526
  if (!plan.enabled) return { enabled: false };
413
527
  const bucketPath = path.dirname(plan.worktreePath);
@@ -473,7 +587,93 @@ export function ensureLaunchWorktree(
473
587
 
474
588
  const result = Bun.spawnSync(["git", ...args], { cwd: plan.repoRoot, stdout: "pipe", stderr: "pipe" });
475
589
  if (result.exitCode !== 0) {
476
- const stderr = result.stderr.toString().trim();
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(" ")}`);
593
+ }
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
+ }
603
+
604
+ export async function ensureLaunchWorktreeCancellable(
605
+ plan: GjcLaunchWorktreePlan | { enabled: false },
606
+ options: LaunchWorktreeAbortOptions = {},
607
+ ): Promise<GjcLaunchWorktreeResult | { enabled: false }> {
608
+ if (!plan.enabled) return { enabled: false };
609
+ const timeout = new WorktreePreparationTimeoutError();
610
+ throwIfAborted(options, timeout);
611
+ const bucketPath = path.dirname(plan.worktreePath);
612
+ inspectBucketDir(bucketPath);
613
+ ensureRepositoryBucketIgnored(plan.repoRoot, bucketPath);
614
+ let allWorktrees = listWorktrees(plan.repoRoot);
615
+ const staleAtPath = findWorktreeByPath(allWorktrees, plan.worktreePath);
616
+ if (staleAtPath && !fs.existsSync(staleAtPath.path)) {
617
+ pruneStaleWorktreePath(plan.repoRoot);
618
+ allWorktrees = listWorktrees(plan.repoRoot);
619
+ }
620
+ throwIfAborted(options, timeout);
621
+
622
+ const listedAtPath = findWorktreeByPath(allWorktrees, plan.worktreePath);
623
+ const existingAtPath = readWorktreeEntryFromPath(plan.repoRoot, plan.worktreePath);
624
+ if (listedAtPath && !existingAtPath) {
625
+ if (fs.existsSync(plan.worktreePath)) throw new Error(`worktree_path_conflict:${plan.worktreePath}`);
626
+ throw new Error(
627
+ [
628
+ "worktree_path_unavailable",
629
+ "The requested launch worktree is still registered by Git but its directory is unavailable or locked.",
630
+ `Path: ${formatBucketPath(plan.worktreePath)}`,
631
+ "Safe remediation: inspect the worktree lock and remove or repair it with git worktree remove/prune when it is no longer needed, then relaunch. GJC did not delete or replace the entry.",
632
+ ].join("\n"),
633
+ );
634
+ }
635
+ const expectedBranchRef = plan.branchName ? `refs/heads/${plan.branchName}` : null;
636
+
637
+ if (existingAtPath) {
638
+ let dirty = isWorktreeDirty(plan.worktreePath);
639
+ if (plan.detached) {
640
+ if (!existingAtPath.detached) {
641
+ throw new Error(formatWorktreeTargetMismatch(plan, existingAtPath));
642
+ }
643
+ if (existingAtPath.head !== plan.baseRef) {
644
+ if (dirty) throw new Error(`worktree_dirty:${plan.worktreePath}`);
645
+ runGit(plan.worktreePath, ["checkout", "--detach", plan.baseRef]);
646
+ dirty = false;
647
+ }
648
+ } else if (existingAtPath.branchRef !== expectedBranchRef) {
649
+ throw new Error(formatWorktreeTargetMismatch(plan, existingAtPath));
650
+ }
651
+ return {
652
+ ...plan,
653
+ worktreePath: path.resolve(plan.worktreePath),
654
+ created: false,
655
+ reused: true,
656
+ createdBranch: false,
657
+ ...(dirty ? { dirty: true } : {}),
658
+ };
659
+ }
660
+
661
+ if (fs.existsSync(plan.worktreePath)) throw new Error(`worktree_path_conflict:${plan.worktreePath}`);
662
+ if (plan.branchName && hasBranchInUse(allWorktrees, plan.branchName, plan.worktreePath)) {
663
+ throw new Error(`branch_in_use:${plan.branchName}`);
664
+ }
665
+
666
+ ensureBucketDirUsable(path.dirname(plan.worktreePath));
667
+ 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);
673
+
674
+ const result = await spawnProcessGroup(["git", ...args], plan.repoRoot, options, timeout);
675
+ if (result.exitCode !== 0) {
676
+ const stderr = sanitizeWorktreeDiagnostic(result.stderr.trim());
477
677
  if (plan.branchName && BRANCH_IN_USE_PATTERN.test(stderr)) throw new Error(`branch_in_use:${plan.branchName}`);
478
678
  throw new Error(stderr || `worktree_add_failed:${args.join(" ")}`);
479
679
  }
@@ -598,10 +798,49 @@ function installWorkspaceDependencies(
598
798
  stderr: "pipe",
599
799
  });
600
800
  } catch (error) {
601
- throw new Error(`worktree_dependency_install_failed:${manager.name}:${String(error)}`);
801
+ throw new Error(
802
+ `worktree_dependency_install_failed:${manager.name}:${sanitizeWorktreeDiagnostic(String(error))}`,
803
+ );
804
+ }
805
+ if (result.exitCode !== 0) {
806
+ throw new Error(
807
+ `worktree_dependency_install_failed:${manager.name}:${sanitizeWorktreeDiagnostic(result.stderr.toString().trim())}`,
808
+ );
809
+ }
810
+ let installed: fs.Stats;
811
+ try {
812
+ installed = fs.lstatSync(path.join(worktreePath, "node_modules"));
813
+ } catch (error) {
814
+ if (fileSystemErrorCode(error) === "ENOENT") return;
815
+ throw error;
816
+ }
817
+ if (!installed.isDirectory() || installed.isSymbolicLink()) throw new Error("worktree_dependency_install_not_local");
818
+ }
819
+
820
+ async function installWorkspaceDependenciesCancellable(
821
+ sourceRoot: string,
822
+ worktreePath: string,
823
+ manifest: WorkspacePackageManifest | null,
824
+ options: LaunchWorktreeAbortOptions = {},
825
+ ): Promise<void> {
826
+ const timeout = new DependencyPreparationTimeoutError();
827
+ throwIfAborted(options, timeout);
828
+ removeLegacySourceNodeModulesLink(sourceRoot, worktreePath);
829
+ const manager = resolveWorkspacePackageManager(worktreePath, manifest);
830
+ const command = workspaceInstallCommand(manager);
831
+ let result: { stdout: string; stderr: string; exitCode: number };
832
+ try {
833
+ result = await spawnProcessGroup(command, worktreePath, options, timeout);
834
+ } catch (error) {
835
+ if (error instanceof DependencyPreparationTimeoutError) throw error;
836
+ throw new Error(
837
+ `worktree_dependency_install_failed:${manager.name}:${sanitizeWorktreeDiagnostic(String(error))}`,
838
+ );
602
839
  }
603
840
  if (result.exitCode !== 0) {
604
- throw new Error(`worktree_dependency_install_failed:${manager.name}:${result.stderr.toString().trim()}`);
841
+ throw new Error(
842
+ `worktree_dependency_install_failed:${manager.name}:${sanitizeWorktreeDiagnostic(result.stderr.trim())}`,
843
+ );
605
844
  }
606
845
  let installed: fs.Stats;
607
846
  try {
@@ -628,6 +867,26 @@ export function ensureReusableNodeModules(sourceRoot: string, worktreePath: stri
628
867
  return "symlink";
629
868
  }
630
869
 
870
+ export async function ensureReusableNodeModulesCancellable(
871
+ sourceRoot: string,
872
+ worktreePath: string,
873
+ options: LaunchWorktreeAbortOptions = {},
874
+ ): Promise<"symlink" | "present" | "missing"> {
875
+ const timeout = new DependencyPreparationTimeoutError();
876
+ throwIfAborted(options, timeout);
877
+ const target = path.join(worktreePath, "node_modules");
878
+ const manifest = readWorkspacePackageManifest(worktreePath);
879
+ if (isWorkspaceRoot(worktreePath, manifest)) {
880
+ await installWorkspaceDependenciesCancellable(sourceRoot, worktreePath, manifest, options);
881
+ return "present";
882
+ }
883
+ if (fs.existsSync(target)) return "present";
884
+ const source = path.join(sourceRoot, "node_modules");
885
+ if (!fs.existsSync(source)) return "missing";
886
+ fs.symlinkSync(source, target, "junction");
887
+ return "symlink";
888
+ }
889
+
631
890
  /** Result of {@link prepareLaunchWorktree}: the effective working directory, remaining args, and resolved worktree plan. */
632
891
  export interface PreparedLaunchWorktree {
633
892
  cwd: string;
@@ -643,3 +902,25 @@ export function prepareLaunchWorktree(cwd: string, args: string[]): PreparedLaun
643
902
  ensureReusableNodeModules(ensured.repoRoot, ensured.worktreePath);
644
903
  return { cwd: ensured.worktreePath, args: parsed.remainingArgs, worktree: ensured };
645
904
  }
905
+ export function removeOwnedLaunchWorktree(
906
+ plan: GjcLaunchWorktreePlan,
907
+ receipt: { created: boolean; reused: boolean; createdBranch: boolean },
908
+ ): void {
909
+ if (!receipt.created || receipt.reused) return;
910
+ try {
911
+ runGit(plan.repoRoot, ["worktree", "remove", "--force", plan.worktreePath]);
912
+ } catch {
913
+ try {
914
+ runGit(plan.repoRoot, ["worktree", "prune"]);
915
+ } catch {
916
+ // Proof stays on the ledger; leftover classify is best-effort after abort.
917
+ }
918
+ }
919
+ if (receipt.createdBranch && plan.branchName) {
920
+ try {
921
+ runGit(plan.repoRoot, ["branch", "-D", plan.branchName]);
922
+ } catch {
923
+ // Owned-branch delete is best-effort; collisions keep the pre-existing branch.
924
+ }
925
+ }
926
+ }