@snappedly-tools/shipyard 0.2.0 → 0.3.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 (36) hide show
  1. package/README.md +2 -1
  2. package/dist/{SandboxProvider-KeFp_nju.d.ts → SandboxProvider-XJQqEdSf.d.ts} +4 -0
  3. package/dist/chunk-2GT3OPO5.js +2620 -0
  4. package/dist/chunk-2GT3OPO5.js.map +1 -0
  5. package/dist/{chunk-CP6YEQKU.js → chunk-KMGNFXKN.js} +2 -6
  6. package/dist/chunk-KMGNFXKN.js.map +1 -0
  7. package/dist/{chunk-OAHPDSOJ.js → chunk-OCJKONZ7.js} +90 -22
  8. package/dist/chunk-OCJKONZ7.js.map +1 -0
  9. package/dist/chunk-VDHZO5PP.js +113 -0
  10. package/dist/chunk-VDHZO5PP.js.map +1 -0
  11. package/dist/{chunk-YVJHSJUW.js → chunk-VW4MI5GJ.js} +5 -6
  12. package/dist/chunk-VW4MI5GJ.js.map +1 -0
  13. package/dist/chunk-WA7QPAXO.js +50 -0
  14. package/dist/chunk-WA7QPAXO.js.map +1 -0
  15. package/dist/index.d.ts +28 -5
  16. package/dist/index.js +471 -297
  17. package/dist/index.js.map +1 -1
  18. package/dist/main.js +224 -19339
  19. package/dist/main.js.map +1 -1
  20. package/dist/sandboxes/docker.d.ts +1 -1
  21. package/dist/sandboxes/docker.js +4 -7
  22. package/dist/sandboxes/no-sandbox.d.ts +1 -1
  23. package/dist/sandboxes/no-sandbox.js +2 -5
  24. package/dist/sandboxes/vercel.d.ts +1 -1
  25. package/dist/sandboxes/vercel.js +2 -4
  26. package/dist/sandboxes/vercel.js.map +1 -1
  27. package/package.json +15 -12
  28. package/dist/chunk-4VDXZY7T.js +0 -27624
  29. package/dist/chunk-4VDXZY7T.js.map +0 -1
  30. package/dist/chunk-BU6XJFJ2.js +0 -90
  31. package/dist/chunk-BU6XJFJ2.js.map +0 -1
  32. package/dist/chunk-CP6YEQKU.js.map +0 -1
  33. package/dist/chunk-OAHPDSOJ.js.map +0 -1
  34. package/dist/chunk-SRUH232X.js +0 -26712
  35. package/dist/chunk-SRUH232X.js.map +0 -1
  36. package/dist/chunk-YVJHSJUW.js.map +0 -1
@@ -0,0 +1,2620 @@
1
+ import { toSessionTransferHandle, shellQuote } from './chunk-KMGNFXKN.js';
2
+ import { existsSync, constants, statSync } from 'fs';
3
+ import { mkdtemp, writeFile, chmod, mkdir, stat, copyFile, rm, readdir, lstat, readFile, open } from 'fs/promises';
4
+ import * as clack from '@clack/prompts';
5
+ import { FileSystem } from '@effect/platform';
6
+ import { join, basename, posix, dirname, resolve, normalize, relative, sep, isAbsolute } from 'path';
7
+ import { Context, Layer, Effect, Data, Exit, Duration, Option, Ref, Deferred, Schedule } from 'effect';
8
+ import { styleText, promisify } from 'util';
9
+ import { tmpdir, homedir } from 'os';
10
+ import { randomBytes } from 'crypto';
11
+ import { exec, execFile } from 'child_process';
12
+
13
+ var Display = class extends Context.Tag("Display")() {
14
+ };
15
+ var SilentDisplay = {
16
+ layer: (ref) => Layer.succeed(Display, {
17
+ intro: (title) => Ref.update(ref, (entries) => [
18
+ ...entries,
19
+ { _tag: "intro", title }
20
+ ]),
21
+ status: (message, severity) => Ref.update(ref, (entries) => [
22
+ ...entries,
23
+ { _tag: "status", message, severity }
24
+ ]),
25
+ spinner: (message, effect) => Effect.flatMap(
26
+ Ref.update(ref, (entries) => [
27
+ ...entries,
28
+ { _tag: "spinner", message }
29
+ ]),
30
+ () => effect
31
+ ),
32
+ summary: (title, rows) => Ref.update(ref, (entries) => [
33
+ ...entries,
34
+ { _tag: "summary", title, rows }
35
+ ]),
36
+ taskLog: (title, effect) => {
37
+ const messages = [];
38
+ return Effect.flatMap(
39
+ effect((msg) => messages.push(msg)),
40
+ (result) => Effect.map(
41
+ Ref.update(ref, (entries) => [
42
+ ...entries,
43
+ {
44
+ _tag: "taskLog",
45
+ title,
46
+ messages: [...messages]
47
+ }
48
+ ]),
49
+ () => result
50
+ )
51
+ );
52
+ },
53
+ text: (message) => Ref.update(ref, (entries) => [
54
+ ...entries,
55
+ { _tag: "text", message }
56
+ ]),
57
+ textChunk: (chunk) => Ref.update(ref, (entries) => [
58
+ ...entries,
59
+ { _tag: "textChunk", message: chunk }
60
+ ]),
61
+ toolCall: (name, formattedArgs) => Ref.update(ref, (entries) => [
62
+ ...entries,
63
+ { _tag: "toolCall", name, formattedArgs }
64
+ ])
65
+ })
66
+ };
67
+ var FileDisplay = {
68
+ layer: (filePath) => Layer.scoped(
69
+ Display,
70
+ Effect.gen(function* () {
71
+ const fs = yield* FileSystem.FileSystem;
72
+ const logFileIsSymlink = yield* fs.readLink(filePath).pipe(
73
+ Effect.map(() => true),
74
+ Effect.catchAll(() => Effect.succeed(false))
75
+ );
76
+ if (logFileIsSymlink) {
77
+ yield* Effect.die(
78
+ new Error(
79
+ `Refusing to write through symlinked log file: ${filePath}`
80
+ )
81
+ );
82
+ }
83
+ yield* fs.makeDirectory(dirname(filePath), { recursive: true, mode: 448 }).pipe(Effect.orDie);
84
+ const delimiter = `
85
+ --- Run started: ${(/* @__PURE__ */ new Date()).toISOString()} ---
86
+ `;
87
+ const file = yield* Effect.acquireRelease(
88
+ Effect.tryPromise(
89
+ () => open(
90
+ filePath,
91
+ constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW,
92
+ 384
93
+ )
94
+ ).pipe(Effect.orDie),
95
+ (handle) => Effect.promise(() => handle.close()).pipe(Effect.orDie)
96
+ );
97
+ yield* Effect.tryPromise(async () => {
98
+ if (!(await file.stat()).isFile())
99
+ throw new Error("Log must be a regular file");
100
+ await file.chmod(384);
101
+ await file.appendFile(delimiter);
102
+ }).pipe(Effect.orDie);
103
+ let midLine = false;
104
+ const appendToLog = (line) => Effect.suspend(() => {
105
+ const prefix = midLine ? "\n" : "";
106
+ midLine = false;
107
+ return Effect.tryPromise(
108
+ () => file.appendFile(`${prefix}${line}
109
+ `)
110
+ ).pipe(Effect.ignore);
111
+ });
112
+ const appendRaw = (chunk) => Effect.suspend(() => {
113
+ if (chunk.length === 0) return Effect.void;
114
+ midLine = !chunk.endsWith("\n");
115
+ return Effect.tryPromise(() => file.appendFile(chunk)).pipe(
116
+ Effect.ignore
117
+ );
118
+ });
119
+ return {
120
+ intro: () => Effect.void,
121
+ status: (message, _severity) => appendToLog(message.replace(/^\[[^\]]+\] /, "")),
122
+ spinner: (message, effect) => Effect.gen(function* () {
123
+ yield* appendToLog(`${message}...`);
124
+ const start = Date.now();
125
+ const result = yield* effect;
126
+ const elapsed = ((Date.now() - start) / 1e3).toFixed(1);
127
+ yield* appendToLog(`${message} done (${elapsed}s)`);
128
+ return result;
129
+ }),
130
+ summary: (title, rows) => {
131
+ const lines = Object.entries(rows).map(([key, value]) => ` ${key}: ${value}`).join("\n");
132
+ return appendToLog(`${title}
133
+ ${lines}`);
134
+ },
135
+ taskLog: (title, effect) => Effect.gen(function* () {
136
+ yield* appendToLog(title);
137
+ const start = Date.now();
138
+ const messages = [];
139
+ const result = yield* effect((msg) => {
140
+ messages.push(msg);
141
+ });
142
+ const elapsed = ((Date.now() - start) / 1e3).toFixed(1);
143
+ for (const msg of messages) {
144
+ yield* appendToLog(` ${msg}`);
145
+ }
146
+ yield* appendToLog(`${title} done (${elapsed}s)`);
147
+ return result;
148
+ }),
149
+ text: (message) => appendToLog(message),
150
+ textChunk: (chunk) => appendRaw(chunk),
151
+ toolCall: (name, formattedArgs) => appendToLog(`${name}(${formattedArgs})`)
152
+ };
153
+ })
154
+ )
155
+ };
156
+ var severityToClack = {
157
+ info: clack.log.info,
158
+ success: clack.log.success,
159
+ warn: clack.log.warning,
160
+ error: clack.log.error
161
+ };
162
+ var terminalStyle = {
163
+ status: (message) => styleText("bold", message),
164
+ summaryTitle: (title) => styleText("bold", title),
165
+ summaryRow: (key, value) => `${styleText("bold", key)}: ${styleText("dim", value)}`,
166
+ toolCall: (text) => styleText("dim", text)
167
+ };
168
+ var ClackDisplay = {
169
+ layer: Layer.succeed(Display, {
170
+ intro: (title) => Effect.sync(() => clack.intro(styleText("inverse", ` ${title} `))),
171
+ status: (message, severity) => Effect.sync(
172
+ () => severityToClack[severity](terminalStyle.status(message))
173
+ ),
174
+ spinner: (message, effect) => Effect.acquireUseRelease(
175
+ Effect.sync(() => {
176
+ const s = clack.spinner();
177
+ s.start(message);
178
+ return s;
179
+ }),
180
+ () => effect,
181
+ (s, exit) => Effect.sync(() => {
182
+ if (exit._tag === "Success") {
183
+ s.stop(message);
184
+ } else {
185
+ s.stop(`${message} (failed)`);
186
+ }
187
+ })
188
+ ),
189
+ summary: (title, rows) => Effect.sync(() => {
190
+ const lines = Object.entries(rows).map(([key, value]) => terminalStyle.summaryRow(key, value)).join("\n");
191
+ clack.note(lines, terminalStyle.summaryTitle(title));
192
+ }),
193
+ taskLog: (title, effect) => Effect.acquireUseRelease(
194
+ Effect.sync(() => clack.taskLog({ title })),
195
+ (log2) => effect((msg) => log2.message(msg)),
196
+ (log2, exit) => Effect.sync(() => {
197
+ if (exit._tag === "Success") {
198
+ log2.success(title, { showLog: true });
199
+ } else {
200
+ log2.error(title, { showLog: true });
201
+ }
202
+ })
203
+ ),
204
+ text: (message) => Effect.sync(() => clack.log.message(message)),
205
+ textChunk: (chunk) => Effect.sync(() => clack.log.message(chunk)),
206
+ toolCall: (name, formattedArgs) => Effect.sync(
207
+ () => clack.log.step(terminalStyle.toolCall(`${name}(${formattedArgs})`))
208
+ )
209
+ })
210
+ };
211
+ var CwdError = class extends Data.TaggedError("CwdError") {
212
+ };
213
+ var resolveCwd = (cwd) => Effect.gen(function* () {
214
+ const resolved = cwd !== void 0 ? resolve(process.cwd(), cwd) : resolve(process.cwd());
215
+ const fs = yield* FileSystem.FileSystem;
216
+ const stat2 = yield* fs.stat(resolved).pipe(
217
+ Effect.mapError(
218
+ () => new CwdError({
219
+ message: `cwd does not exist: ${resolved}`,
220
+ cwd: resolved
221
+ })
222
+ )
223
+ );
224
+ if (stat2.type !== "Directory") {
225
+ return yield* new CwdError({
226
+ message: `cwd is not a directory: ${resolved}`,
227
+ cwd: resolved
228
+ });
229
+ }
230
+ return resolved;
231
+ });
232
+
233
+ // src/errors.ts
234
+ var ExecError = class extends Data.TaggedError("ExecError") {
235
+ };
236
+ var ExecHostError = class extends Data.TaggedError("ExecHostError") {
237
+ };
238
+ var CopyError = class extends Data.TaggedError("CopyError") {
239
+ };
240
+ var DockerError = class extends Data.TaggedError("DockerError") {
241
+ };
242
+ var SyncError = class extends Data.TaggedError("SyncError") {
243
+ };
244
+ var WorktreeError = class extends Data.TaggedError("WorktreeError") {
245
+ };
246
+ var PromptError = class extends Data.TaggedError("PromptError") {
247
+ };
248
+ var AgentError = class extends Data.TaggedError("AgentError") {
249
+ };
250
+ var ConfigDirError = class extends Data.TaggedError("ConfigDirError") {
251
+ };
252
+ var InitError = class extends Data.TaggedError("InitError") {
253
+ };
254
+ var AgentIdleTimeoutError = class extends Data.TaggedError(
255
+ "AgentIdleTimeoutError"
256
+ ) {
257
+ };
258
+ var WorktreeTimeoutError = class extends Data.TaggedError(
259
+ "WorktreeTimeoutError"
260
+ ) {
261
+ };
262
+ var ContainerStartTimeoutError = class extends Data.TaggedError(
263
+ "ContainerStartTimeoutError"
264
+ ) {
265
+ };
266
+ var CopyToWorktreeTimeoutError = class extends Data.TaggedError(
267
+ "CopyToWorktreeTimeoutError"
268
+ ) {
269
+ };
270
+ var CopyToWorktreeError = class extends Data.TaggedError(
271
+ "CopyToWorktreeError"
272
+ ) {
273
+ };
274
+ var SyncInTimeoutError = class extends Data.TaggedError("SyncInTimeoutError") {
275
+ };
276
+ var HookTimeoutError = class extends Data.TaggedError("HookTimeoutError") {
277
+ };
278
+ var GitSetupTimeoutError = class extends Data.TaggedError(
279
+ "GitSetupTimeoutError"
280
+ ) {
281
+ };
282
+ var PromptExpansionTimeoutError = class extends Data.TaggedError(
283
+ "PromptExpansionTimeoutError"
284
+ ) {
285
+ };
286
+ var CommitCollectionTimeoutError = class extends Data.TaggedError(
287
+ "CommitCollectionTimeoutError"
288
+ ) {
289
+ };
290
+ var MergeToHostTimeoutError = class extends Data.TaggedError(
291
+ "MergeToHostTimeoutError"
292
+ ) {
293
+ };
294
+ var withTimeout = (timeoutMs, onTimeout) => (effect) => effect.pipe(
295
+ Effect.timeoutFail({
296
+ duration: Duration.millis(timeoutMs),
297
+ onTimeout
298
+ })
299
+ );
300
+ var SessionCaptureError = class extends Data.TaggedError(
301
+ "SessionCaptureError"
302
+ ) {
303
+ };
304
+
305
+ // src/runtimeNames.ts
306
+ var PRODUCT_NAME = "Shipyard";
307
+ var CLI_NAME = "shipyard";
308
+ var RUNTIME_NAMESPACE = "shipyard";
309
+ var CONFIG_DIR = ".shipyard";
310
+ var LOGS_DIR = "logs";
311
+ var WORKTREES_DIR = "worktrees";
312
+ var PATCHES_DIR = "patches";
313
+ var LOCKS_DIR = "locks";
314
+ var PARENT_GIT_SANDBOX_DIR = "/.shipyard-parent-git";
315
+ var SYNC_BASE_REF = "refs/shipyard/sync-base";
316
+ var CONTAINER_NAME_PREFIX = "shipyard-";
317
+ var WINDOWS_DRIVE_PATH = /^[A-Za-z]:/;
318
+ var assertSafeRelativePath = (relativePath, label = "relative path") => {
319
+ if (relativePath.length === 0 || relativePath.includes("\0") || isAbsolute(relativePath) || WINDOWS_DRIVE_PATH.test(relativePath) || relativePath.startsWith("\\\\") || relativePath.split(/[\\/]/).some((part) => part === "..")) {
320
+ throw new Error(`${label} must stay within its root: ${relativePath}`);
321
+ }
322
+ };
323
+ var assertSafePathSegment = (segment, label = "path segment") => {
324
+ assertSafeRelativePath(segment, label);
325
+ if (segment.includes("/") || segment.includes("\\")) {
326
+ throw new Error(`${label} must be a single filename: ${segment}`);
327
+ }
328
+ };
329
+ var resolveSafeRelativePath = (root, relativePath, label = "relative path") => {
330
+ assertSafeRelativePath(relativePath, label);
331
+ const rootPath = resolve(root);
332
+ const candidate = resolve(rootPath, relativePath);
333
+ const fromRoot = relative(rootPath, candidate);
334
+ if (fromRoot.length === 0 || fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
335
+ throw new Error(`${label} must stay within its root: ${relativePath}`);
336
+ }
337
+ return candidate;
338
+ };
339
+ var assertNoSymlinkComponents = async (root, target, label = "path") => {
340
+ const rootPath = resolve(root);
341
+ const targetPath = resolve(target);
342
+ const fromRoot = relative(rootPath, targetPath);
343
+ if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
344
+ throw new Error(`${label} escapes its root`);
345
+ }
346
+ let current = rootPath;
347
+ for (const part of fromRoot.split(sep).filter(Boolean)) {
348
+ current = join(current, part);
349
+ try {
350
+ const info = await lstat(current);
351
+ if (info.isSymbolicLink()) {
352
+ throw new Error(`${label} contains a symbolic link: ${current}`);
353
+ }
354
+ } catch (error) {
355
+ if (error.code === "ENOENT") break;
356
+ throw error;
357
+ }
358
+ }
359
+ };
360
+ var assertSafeGitWorktreePath = (path) => {
361
+ assertSafeRelativePath(path, "untracked path");
362
+ for (const component of path.split(/[\\/]/)) {
363
+ const normalized = component.replace(/[\u200c-\u200f\u202a-\u202e\u206a-\u206f\ufeff]/g, "").toLowerCase().split(":")[0].replace(/[ .]+$/, "");
364
+ if (normalized === ".git" || /^git~[0-9]+$/.test(normalized)) {
365
+ throw new Error(`Untracked path addresses Git metadata: ${path}`);
366
+ }
367
+ }
368
+ };
369
+ var MAX_BUFFER = 10 * 1024 * 1024;
370
+ var SAFE_CONFIG = [
371
+ ["core.hooksPath", "/dev/null"],
372
+ ["core.fsmonitor", "false"],
373
+ ["core.pager", "cat"],
374
+ ["core.alternateRefsCommand", "false"],
375
+ ["commit.gpgSign", "false"],
376
+ ["tag.gpgSign", "false"],
377
+ ["gpg.program", "false"],
378
+ ["gpg.ssh.program", "false"],
379
+ ["gpg.ssh.defaultKeyCommand", "false"],
380
+ ["merge.autoEdit", "no"],
381
+ ["interactive.diffFilter", ""],
382
+ ["diff.external", ""],
383
+ ["maintenance.auto", "false"],
384
+ ["gc.auto", "0"],
385
+ ["submodule.recurse", "false"],
386
+ ["checkout.recurseSubmodules", "false"]
387
+ ];
388
+ var COMMAND_CONFIG_PATTERN = "^(filter\\..*\\.(clean|smudge|process|required)|merge\\..*\\.driver|diff\\..*\\.(command|textconv)|difftool\\..*\\.cmd|mergetool\\..*\\.cmd)$";
389
+ var runGit = (args, cwd, options = {}) => new Promise((resolve6, reject) => {
390
+ execFile(
391
+ "git",
392
+ [...args],
393
+ {
394
+ cwd,
395
+ env: {
396
+ ...process.env,
397
+ ...options.env,
398
+ GIT_EDITOR: "true",
399
+ GIT_SEQUENCE_EDITOR: "true",
400
+ GIT_PAGER: "cat",
401
+ GIT_TERMINAL_PROMPT: "0"
402
+ },
403
+ maxBuffer: options.maxBuffer ?? MAX_BUFFER
404
+ },
405
+ (error, stdout, stderr) => {
406
+ if (!error) {
407
+ resolve6({ stdout, stderr, exitCode: 0 });
408
+ return;
409
+ }
410
+ if (typeof error.code === "number") {
411
+ resolve6({ stdout, stderr, exitCode: error.code });
412
+ return;
413
+ }
414
+ reject(error);
415
+ }
416
+ );
417
+ });
418
+ var configArgs = (entries) => entries.flatMap(([key, value]) => ["-c", `${key}=${value}`]);
419
+ var commandDriverOverrides = async (cwd, options) => {
420
+ const result = await runGit(
421
+ ["config", "--null", "--name-only", "--get-regexp", COMMAND_CONFIG_PATTERN],
422
+ cwd,
423
+ options
424
+ );
425
+ if (result.exitCode === 1) return [];
426
+ if (result.exitCode !== 0) {
427
+ throw new Error(
428
+ result.stderr.trim() || "Failed to inspect Git configuration"
429
+ );
430
+ }
431
+ const overrides = /* @__PURE__ */ new Map();
432
+ for (const key of result.stdout.split("\0").filter(Boolean)) {
433
+ const filterMatch = /^(filter\..*)\.(clean|smudge|process|required)$/i.exec(
434
+ key
435
+ );
436
+ if (filterMatch) {
437
+ const section = filterMatch[1];
438
+ overrides.set(`${section}.clean`, "cat");
439
+ overrides.set(`${section}.smudge`, "cat");
440
+ overrides.set(`${section}.process`, "");
441
+ overrides.set(`${section}.required`, "false");
442
+ continue;
443
+ }
444
+ overrides.set(key, "false");
445
+ }
446
+ return configArgs([...overrides.entries()]);
447
+ };
448
+ var execHostGit = async (args, cwd, options = {}) => {
449
+ const driverArgs = await commandDriverOverrides(cwd, options);
450
+ const safeArgs = [...configArgs(SAFE_CONFIG), ...driverArgs, ...args];
451
+ const result = await runGit(safeArgs, cwd, options);
452
+ if (result.exitCode !== 0) {
453
+ throw new Error(
454
+ result.stderr.trim() || `git ${args.join(" ")} failed with exit ${result.exitCode}`
455
+ );
456
+ }
457
+ return result.stdout;
458
+ };
459
+
460
+ // src/WorktreeManager.ts
461
+ var WORKTREE_TIMEOUT_MS = 3e4;
462
+ var NO_CONFIG_LOCK_FLAGS = [
463
+ "-c",
464
+ "branch.autoSetupMerge=false",
465
+ "-c",
466
+ "push.autoSetupRemote=false"
467
+ ];
468
+ var formatTimestamp = (date) => {
469
+ const pad = (n) => String(n).padStart(2, "0");
470
+ return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
471
+ };
472
+ var randomBranchSuffix = () => randomBytes(3).toString("hex");
473
+ var sanitizeName = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "-");
474
+ var execGit = (args, cwd) => Effect.tryPromise({
475
+ // Force the C locale so git emits English, machine-stable messages. Several
476
+ // callers match git's stderr (e.g. "invalid reference") to decide control
477
+ // flow; under a localized locale gettext translates those strings and the
478
+ // matches silently fail, breaking worktree creation (issue #595).
479
+ try: () => execHostGit(args, cwd, { env: { LC_ALL: "C" } }),
480
+ catch: (error) => new WorktreeError({
481
+ message: error instanceof Error ? error.message : String(error)
482
+ })
483
+ });
484
+ var generateTempBranchName = (name) => {
485
+ const ts = formatTimestamp(/* @__PURE__ */ new Date());
486
+ const suffix = randomBranchSuffix();
487
+ if (name) {
488
+ return `${RUNTIME_NAMESPACE}/${sanitizeName(name)}/${ts}-${suffix}`;
489
+ }
490
+ return `${RUNTIME_NAMESPACE}/${ts}-${suffix}`;
491
+ };
492
+ var getCurrentBranch = (repoDir) => execGit(["rev-parse", "--abbrev-ref", "HEAD"], repoDir).pipe(
493
+ Effect.map((output) => output.trim())
494
+ );
495
+ var normalizePath = (p) => {
496
+ const normalized = p.replace(/\\/g, "/");
497
+ if (process.platform === "darwin" && normalized.startsWith("/private/")) {
498
+ return normalized.slice("/private".length);
499
+ }
500
+ return normalized;
501
+ };
502
+ var findCollidingWorktree = (existing, branch, worktreePath) => existing.find((wt) => wt.branch === branch) ?? existing.find((wt) => normalizePath(wt.path) === normalizePath(worktreePath));
503
+ var isManagedWorktreePath = (worktreePath, worktreesDir) => {
504
+ const candidate = normalizePath(worktreePath).replace(/\/+$/, "");
505
+ const root = normalizePath(worktreesDir).replace(/\/+$/, "");
506
+ return candidate === root || candidate.startsWith(`${root}/`);
507
+ };
508
+ var isOrphanedWorktreePath = (entryPath, activeWorktreePaths) => {
509
+ const normalizedEntry = normalizePath(entryPath);
510
+ for (const active of activeWorktreePaths) {
511
+ if (normalizePath(active) === normalizedEntry) return false;
512
+ }
513
+ return true;
514
+ };
515
+ var listWorktrees = (repoDir) => execGit(["worktree", "list", "--porcelain"], repoDir).pipe(
516
+ Effect.map((output) => {
517
+ const entries = [];
518
+ let currentPath = null;
519
+ let currentBranch = null;
520
+ for (const line of output.split("\n")) {
521
+ if (line.startsWith("worktree ")) {
522
+ if (currentPath !== null) {
523
+ entries.push({ path: currentPath, branch: currentBranch });
524
+ }
525
+ currentPath = line.slice("worktree ".length).trim();
526
+ currentBranch = null;
527
+ } else if (line.startsWith("branch ")) {
528
+ currentBranch = line.slice("branch refs/heads/".length).trim();
529
+ }
530
+ }
531
+ if (currentPath !== null) {
532
+ entries.push({ path: currentPath, branch: currentBranch });
533
+ }
534
+ return entries;
535
+ })
536
+ );
537
+ var fastForwardFromOrigin = (worktreePath, branch) => Effect.gen(function* () {
538
+ const headRef = yield* execGit(
539
+ ["symbolic-ref", "--quiet", "HEAD"],
540
+ worktreePath
541
+ ).pipe(
542
+ Effect.map((s) => s.trim()),
543
+ Effect.orElseSucceed(() => "")
544
+ );
545
+ if (headRef !== `refs/heads/${branch}`) {
546
+ console.log(
547
+ `Reusing worktree at ${worktreePath} (branch '${branch}') \u2014 HEAD is not on '${branch}', skipping origin refresh`
548
+ );
549
+ return;
550
+ }
551
+ const fetchResult = yield* Effect.either(
552
+ execGit(
553
+ [...NO_CONFIG_LOCK_FLAGS, "fetch", "origin", branch],
554
+ worktreePath
555
+ )
556
+ );
557
+ if (fetchResult._tag === "Left") {
558
+ console.log(
559
+ `Could not fetch from origin (reusing worktree at ${worktreePath} as-is, branch '${branch}')`
560
+ );
561
+ return;
562
+ }
563
+ const before = yield* execGit(["rev-parse", "HEAD"], worktreePath).pipe(
564
+ Effect.map((s) => s.trim()),
565
+ Effect.orElseSucceed(() => "")
566
+ );
567
+ const mergeResult = yield* Effect.either(
568
+ execGit(
569
+ [...NO_CONFIG_LOCK_FLAGS, "merge", "--ff-only", `origin/${branch}`],
570
+ worktreePath
571
+ )
572
+ );
573
+ if (mergeResult._tag === "Left") {
574
+ console.log(
575
+ `Branch '${branch}' has diverged from origin (reusing worktree at ${worktreePath} as-is)`
576
+ );
577
+ return;
578
+ }
579
+ const after = yield* execGit(["rev-parse", "HEAD"], worktreePath).pipe(
580
+ Effect.map((s) => s.trim()),
581
+ Effect.orElseSucceed(() => "")
582
+ );
583
+ if (before && after && before !== after) {
584
+ console.log(
585
+ `Fast-forwarded worktree at ${worktreePath} (branch '${branch}') to origin/${branch}`
586
+ );
587
+ } else {
588
+ console.log(
589
+ `Reusing existing worktree at ${worktreePath} (branch '${branch}')`
590
+ );
591
+ }
592
+ });
593
+ var create = (repoDir, opts) => Effect.gen(function* () {
594
+ const configDir = join(repoDir, CONFIG_DIR);
595
+ const fs = yield* FileSystem.FileSystem;
596
+ const worktreesDir = join(configDir, WORKTREES_DIR);
597
+ yield* Effect.tryPromise({
598
+ try: () => assertNoSymlinkComponents(
599
+ configDir,
600
+ worktreesDir,
601
+ "Shipyard worktree directory"
602
+ ),
603
+ catch: (e) => new WorktreeError({
604
+ message: `Refusing symlinked Shipyard worktree directory: ${e instanceof Error ? e.message : String(e)}`
605
+ })
606
+ });
607
+ yield* fs.makeDirectory(worktreesDir, { recursive: true }).pipe(Effect.mapError((e) => new WorktreeError({ message: e.message })));
608
+ yield* Effect.tryPromise({
609
+ try: () => assertNoSymlinkComponents(
610
+ configDir,
611
+ worktreesDir,
612
+ "Shipyard worktree directory"
613
+ ),
614
+ catch: (e) => new WorktreeError({
615
+ message: `Refusing symlinked Shipyard worktree directory: ${e instanceof Error ? e.message : String(e)}`
616
+ })
617
+ });
618
+ let branch;
619
+ let worktreeName;
620
+ if (opts?.branch) {
621
+ branch = opts.branch;
622
+ yield* execGit(["check-ref-format", "--branch", branch], repoDir);
623
+ worktreeName = branch.replace(/\//g, "-");
624
+ } else {
625
+ const timestamp = formatTimestamp(/* @__PURE__ */ new Date());
626
+ const suffix = randomBranchSuffix();
627
+ if (opts?.name) {
628
+ const sanitized = sanitizeName(opts.name);
629
+ branch = `${RUNTIME_NAMESPACE}/${sanitized}/${timestamp}-${suffix}`;
630
+ worktreeName = `${RUNTIME_NAMESPACE}-${sanitized}-${timestamp}-${suffix}`;
631
+ } else {
632
+ branch = `${RUNTIME_NAMESPACE}/${timestamp}-${suffix}`;
633
+ worktreeName = `${RUNTIME_NAMESPACE}-${timestamp}-${suffix}`;
634
+ }
635
+ }
636
+ const worktreePath = join(worktreesDir, worktreeName);
637
+ const assertWorktreePathIsSafe = () => assertNoSymlinkComponents(configDir, worktreePath, "worktree path");
638
+ yield* Effect.tryPromise({
639
+ try: assertWorktreePathIsSafe,
640
+ catch: (e) => new WorktreeError({
641
+ message: `Refusing symlinked worktree path: ${e instanceof Error ? e.message : String(e)}`
642
+ })
643
+ });
644
+ if (opts?.branch) {
645
+ const existing = yield* listWorktrees(repoDir);
646
+ const collision = findCollidingWorktree(existing, branch, worktreePath);
647
+ if (collision) {
648
+ if (isManagedWorktreePath(collision.path, worktreesDir)) {
649
+ const dirty = yield* hasUncommittedChanges(collision.path);
650
+ if (dirty) {
651
+ console.warn(
652
+ `Reusing worktree at ${collision.path} (branch '${branch}') \u2014 worktree has uncommitted changes`
653
+ );
654
+ } else {
655
+ yield* fastForwardFromOrigin(collision.path, branch);
656
+ }
657
+ return { path: normalize(normalizePath(collision.path)), branch };
658
+ }
659
+ yield* Effect.fail(
660
+ new WorktreeError({
661
+ message: `Branch '${branch}' is already checked out in worktree at '${collision.path}'. Shipyard's branch and merge-to-head strategies run the agent in a git worktree under .shipyard/worktrees/, and git refuses to check out the same branch in two worktrees at once (HEAD would become ambiguous). Pick a different branch, or switch the main working tree to a different branch before re-running.`
662
+ })
663
+ );
664
+ }
665
+ yield* execGit(
666
+ [...NO_CONFIG_LOCK_FLAGS, "worktree", "add", worktreePath, branch],
667
+ repoDir
668
+ ).pipe(
669
+ Effect.catchAll((e) => {
670
+ if (e.message.includes("invalid reference")) {
671
+ return execGit(
672
+ [
673
+ ...NO_CONFIG_LOCK_FLAGS,
674
+ "worktree",
675
+ "add",
676
+ "-b",
677
+ branch,
678
+ worktreePath,
679
+ opts?.baseBranch ?? "HEAD"
680
+ ],
681
+ repoDir
682
+ );
683
+ }
684
+ return Effect.fail(e);
685
+ })
686
+ );
687
+ } else {
688
+ yield* execGit(
689
+ [
690
+ ...NO_CONFIG_LOCK_FLAGS,
691
+ "worktree",
692
+ "add",
693
+ "-b",
694
+ branch,
695
+ worktreePath,
696
+ "HEAD"
697
+ ],
698
+ repoDir
699
+ ).pipe(
700
+ Effect.catchAll((e) => {
701
+ if (e.message.includes("already checked out") || e.message.includes("already exists")) {
702
+ return Effect.fail(
703
+ new WorktreeError({
704
+ message: `Branch '${branch}' is already checked out in another worktree. Use a different branch name, or wait for the other run to finish.`
705
+ })
706
+ );
707
+ }
708
+ return Effect.fail(e);
709
+ })
710
+ );
711
+ }
712
+ yield* Effect.tryPromise({
713
+ try: assertWorktreePathIsSafe,
714
+ catch: (e) => new WorktreeError({
715
+ message: `Refusing symlinked worktree path: ${e instanceof Error ? e.message : String(e)}`
716
+ })
717
+ });
718
+ return { path: worktreePath, branch };
719
+ }).pipe(
720
+ withTimeout(
721
+ WORKTREE_TIMEOUT_MS,
722
+ () => new WorktreeTimeoutError({
723
+ message: `Worktree creation timed out after ${WORKTREE_TIMEOUT_MS}ms`,
724
+ timeoutMs: WORKTREE_TIMEOUT_MS,
725
+ path: repoDir,
726
+ operation: "create"
727
+ })
728
+ )
729
+ );
730
+ var hasUncommittedChanges = (worktreePath) => execGit(["status", "--porcelain"], worktreePath).pipe(
731
+ Effect.map((output) => output.trim().length > 0)
732
+ );
733
+ var remove = (worktreePath) => {
734
+ const candidate = resolve(worktreePath);
735
+ const repoDir = resolve(candidate, "..", "..", "..");
736
+ const worktreesDir = resolve(repoDir, ".shipyard", WORKTREES_DIR);
737
+ if (!isManagedWorktreePath(candidate, worktreesDir) || candidate === worktreesDir) {
738
+ return Effect.fail(
739
+ new WorktreeError({
740
+ message: `Refusing to remove worktree outside ${worktreesDir}: ${worktreePath}`
741
+ })
742
+ );
743
+ }
744
+ return Effect.tryPromise({
745
+ try: () => assertNoSymlinkComponents(worktreesDir, candidate, "worktree path"),
746
+ catch: (e) => new WorktreeError({
747
+ message: `Refusing symlinked worktree path: ${e instanceof Error ? e.message : String(e)}`
748
+ })
749
+ }).pipe(
750
+ Effect.andThen(
751
+ execGit(["worktree", "remove", "--force", candidate], repoDir)
752
+ ),
753
+ Effect.asVoid
754
+ );
755
+ };
756
+ var pruneStale = (repoDir) => Effect.gen(function* () {
757
+ const configDir = join(repoDir, CONFIG_DIR);
758
+ const fs = yield* FileSystem.FileSystem;
759
+ yield* execGit(["worktree", "prune"], repoDir);
760
+ const worktreesDir = join(configDir, WORKTREES_DIR);
761
+ const configDirIsSymlink = yield* fs.readLink(configDir).pipe(
762
+ Effect.map(() => true),
763
+ Effect.catchAll(() => Effect.succeed(false))
764
+ );
765
+ const worktreesDirIsSymlink = yield* fs.readLink(worktreesDir).pipe(
766
+ Effect.map(() => true),
767
+ Effect.catchAll(() => Effect.succeed(false))
768
+ );
769
+ if (configDirIsSymlink || worktreesDirIsSymlink) return;
770
+ const entries = yield* fs.readDirectory(worktreesDir).pipe(
771
+ Effect.map((es) => es),
772
+ Effect.catchSome(
773
+ (e) => e._tag === "SystemError" && e.reason === "NotFound" ? Option.some(Effect.succeed(null)) : Option.none()
774
+ ),
775
+ Effect.mapError((e) => new WorktreeError({ message: e.message }))
776
+ );
777
+ if (entries === null) return;
778
+ const realWorktreesDir = yield* fs.realPath(worktreesDir).pipe(Effect.catchAll(() => Effect.succeed(worktreesDir)));
779
+ const worktreeList = yield* execGit(
780
+ ["worktree", "list", "--porcelain"],
781
+ repoDir
782
+ );
783
+ const activeWorktreePaths = new Set(
784
+ worktreeList.split("\n").filter((line) => line.startsWith("worktree ")).map((line) => line.slice("worktree ".length).trim())
785
+ );
786
+ for (const entry of entries) {
787
+ const entryPath = join(realWorktreesDir, entry);
788
+ const entryIsSymlink = yield* fs.readLink(entryPath).pipe(
789
+ Effect.map(() => true),
790
+ Effect.catchAll(() => Effect.succeed(false))
791
+ );
792
+ if (entryIsSymlink) continue;
793
+ const isDir = yield* fs.stat(entryPath).pipe(
794
+ Effect.map((s) => s.type === "Directory"),
795
+ Effect.catchAll(() => Effect.succeed(false))
796
+ );
797
+ if (isDir && isOrphanedWorktreePath(entryPath, activeWorktreePaths)) {
798
+ yield* fs.remove(entryPath, { recursive: true, force: true }).pipe(
799
+ Effect.mapError(
800
+ (e) => new WorktreeError({
801
+ message: `Failed to remove ${entryPath}: ${e.message}`
802
+ })
803
+ )
804
+ );
805
+ }
806
+ }
807
+ }).pipe(
808
+ withTimeout(
809
+ WORKTREE_TIMEOUT_MS,
810
+ () => new WorktreeTimeoutError({
811
+ message: `Worktree prune timed out after ${WORKTREE_TIMEOUT_MS}ms`,
812
+ timeoutMs: WORKTREE_TIMEOUT_MS,
813
+ path: repoDir,
814
+ operation: "prune"
815
+ })
816
+ )
817
+ );
818
+ var COPY_TO_WORKTREE_TIMEOUT_MS = 6e4;
819
+ var getCopyOnWriteFlags = (platform) => platform === "darwin" ? ["-cR"] : ["-R", "--reflink=auto"];
820
+ var copyToWorktree = (paths, hostRepoDir, worktreePath, timeoutMs) => {
821
+ const effectiveTimeout = timeoutMs ?? COPY_TO_WORKTREE_TIMEOUT_MS;
822
+ return Effect.gen(function* () {
823
+ const cowFlags = getCopyOnWriteFlags(process.platform);
824
+ for (const relativePath of paths) {
825
+ let src;
826
+ let dest;
827
+ try {
828
+ src = resolveSafeRelativePath(hostRepoDir, relativePath, "copy path");
829
+ dest = resolveSafeRelativePath(worktreePath, relativePath, "copy path");
830
+ } catch (error) {
831
+ return yield* Effect.fail(
832
+ new CopyToWorktreeError({
833
+ message: `Refusing unsafe copy path ${relativePath}: ${error instanceof Error ? error.message : String(error)}`,
834
+ path: relativePath,
835
+ stderr: error instanceof Error ? error.message : String(error),
836
+ exitCode: null
837
+ })
838
+ );
839
+ }
840
+ if (!existsSync(src)) {
841
+ continue;
842
+ }
843
+ yield* Effect.tryPromise({
844
+ try: async () => {
845
+ await assertNoSymlinkComponents(hostRepoDir, src, "copy source");
846
+ await assertNoSymlinkComponents(worktreePath, dest, "copy target");
847
+ },
848
+ catch: (error) => new CopyToWorktreeError({
849
+ message: `Refusing symlinked copy path ${relativePath}: ${error instanceof Error ? error.message : String(error)}`,
850
+ path: relativePath,
851
+ stderr: error instanceof Error ? error.message : String(error),
852
+ exitCode: null
853
+ })
854
+ });
855
+ yield* Effect.async((resume) => {
856
+ execFile("cp", [...cowFlags, src, dest], (error) => {
857
+ if (error) {
858
+ execFile("cp", ["-R", src, dest], (fallbackError, _, stderr) => {
859
+ if (fallbackError) {
860
+ resume(
861
+ Effect.fail(
862
+ new CopyToWorktreeError({
863
+ message: `Failed to copy ${relativePath} to worktree: ${stderr || fallbackError.message}`,
864
+ path: relativePath,
865
+ stderr: stderr || fallbackError.message,
866
+ exitCode: typeof fallbackError.code === "number" ? fallbackError.code : null
867
+ })
868
+ )
869
+ );
870
+ } else {
871
+ resume(Effect.succeed(void 0));
872
+ }
873
+ });
874
+ } else {
875
+ resume(Effect.succeed(void 0));
876
+ }
877
+ });
878
+ });
879
+ }
880
+ }).pipe(
881
+ withTimeout(
882
+ effectiveTimeout,
883
+ () => new CopyToWorktreeTimeoutError({
884
+ message: `Copying files to worktree timed out after ${effectiveTimeout}ms`,
885
+ timeoutMs: effectiveTimeout,
886
+ paths
887
+ })
888
+ )
889
+ );
890
+ };
891
+
892
+ // src/RecoveryMessage.ts
893
+ var buildRecoveryMessage = (input) => {
894
+ const { patchDir, failedStep, hasCommits, hasDiff, hasUntracked, branch } = input;
895
+ const cmdPatchDir = branch ? `../../${patchDir}` : patchDir;
896
+ const steps = [];
897
+ if (hasCommits)
898
+ steps.push({ key: "commits", label: "committed changes", has: true });
899
+ if (hasDiff)
900
+ steps.push({ key: "diff", label: "uncommitted changes", has: true });
901
+ if (hasUntracked)
902
+ steps.push({ key: "untracked", label: "untracked files", has: true });
903
+ const failedIndex = steps.findIndex((s) => s.key === failedStep);
904
+ const failedStepInfo = steps[failedIndex];
905
+ const stepNumber = failedIndex + 1;
906
+ const lines = [];
907
+ lines.push(
908
+ `Patch application failed at step ${stepNumber} (${failedStepInfo.label}).`
909
+ );
910
+ lines.push("");
911
+ if (branch) {
912
+ lines.push("Set up worktree, then resolve:");
913
+ lines.push(
914
+ formatCommandBlock([
915
+ `git worktree add .shipyard/worktree ${branch}`,
916
+ `cd .shipyard/worktree`
917
+ ])
918
+ );
919
+ lines.push("");
920
+ }
921
+ if (failedStep === "commits") {
922
+ lines.push("Resolve conflicts, then continue with:");
923
+ lines.push(` git am --continue`);
924
+ const remaining = buildRemainingCommands(
925
+ cmdPatchDir,
926
+ steps.slice(failedIndex + 1)
927
+ );
928
+ if (remaining.length > 0) {
929
+ lines.push("");
930
+ lines.push("After all commits are applied, run the remaining steps:");
931
+ lines.push(formatCommandBlock(remaining));
932
+ }
933
+ } else {
934
+ const remaining = buildRemainingCommands(
935
+ cmdPatchDir,
936
+ steps.slice(failedIndex)
937
+ );
938
+ if (remaining.length > 0) {
939
+ lines.push("Run the remaining steps:");
940
+ lines.push(formatCommandBlock(remaining));
941
+ }
942
+ }
943
+ if (steps.slice(failedIndex).some((step) => step.key === "untracked")) {
944
+ lines.push("");
945
+ lines.push(
946
+ "The untracked-file command includes dotfiles and does not overwrite existing paths; review skipped conflicts manually."
947
+ );
948
+ }
949
+ return lines.join("\n");
950
+ };
951
+ var buildRemainingCommands = (patchDir, steps) => {
952
+ const commands = [];
953
+ for (const step of steps) {
954
+ if (!step.has) continue;
955
+ if (step.key === "commits") {
956
+ commands.push(`git am --3way ${patchDir}/*.patch`);
957
+ } else if (step.key === "diff") {
958
+ commands.push(`git apply ${patchDir}/changes.patch`);
959
+ } else if (step.key === "untracked") {
960
+ commands.push(`cp -Rn ${patchDir}/untracked/. .`);
961
+ }
962
+ }
963
+ return commands;
964
+ };
965
+ var formatCommandBlock = (commands) => {
966
+ if (commands.length === 1) {
967
+ return ` ${commands[0]}`;
968
+ }
969
+ return commands.map((cmd, i) => i < commands.length - 1 ? ` ${cmd} && \\` : ` ${cmd}`).join("\n");
970
+ };
971
+
972
+ // src/syncOut.ts
973
+ var execHostGit2 = (args, cwd) => Effect.tryPromise({
974
+ try: () => execHostGit(args, cwd),
975
+ catch: (e) => new SyncError({
976
+ message: `Host command failed: git ${args.join(" ")}
977
+ ${e instanceof Error ? e.message : String(e)}`
978
+ })
979
+ });
980
+ var execOk = (handle, command, options) => Effect.tryPromise({
981
+ try: () => handle.exec(command, options),
982
+ catch: (e) => new SyncError({
983
+ message: `Sandbox exec failed: ${command}
984
+ ${e instanceof Error ? e.message : String(e)}`
985
+ })
986
+ }).pipe(
987
+ Effect.flatMap(
988
+ (result) => result.exitCode !== 0 ? Effect.fail(
989
+ new SyncError({
990
+ message: `Sandbox command failed (exit ${result.exitCode}): ${command}
991
+ ${result.stderr}`
992
+ })
993
+ ) : Effect.succeed(result)
994
+ )
995
+ );
996
+ var execSandbox = (handle, command, options) => Effect.tryPromise({
997
+ try: () => handle.exec(command, options),
998
+ catch: (e) => new SyncError({
999
+ message: `Sandbox exec failed: ${command}
1000
+ ${e instanceof Error ? e.message : String(e)}`
1001
+ })
1002
+ });
1003
+ var isEmptyPatch = (patchPath) => Effect.tryPromise({
1004
+ try: async () => {
1005
+ const info = await stat(patchPath);
1006
+ if (info.size === 0) return true;
1007
+ const content = await readFile(patchPath, "utf-8");
1008
+ return !content.includes("diff --git");
1009
+ },
1010
+ catch: (e) => new SyncError({
1011
+ message: `Failed to check patch ${patchPath}: ${e instanceof Error ? e.message : String(e)}`
1012
+ })
1013
+ });
1014
+ var createPatchDir = (hostRepoDir) => Effect.tryPromise({
1015
+ try: async () => {
1016
+ const now = /* @__PURE__ */ new Date();
1017
+ const pad = (n) => String(n).padStart(2, "0");
1018
+ const base = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
1019
+ const patchesRoot = join(hostRepoDir, CONFIG_DIR, PATCHES_DIR);
1020
+ await assertNoSymlinkComponents(
1021
+ hostRepoDir,
1022
+ patchesRoot,
1023
+ "patch directory"
1024
+ );
1025
+ await mkdir(patchesRoot, { recursive: true, mode: 448 });
1026
+ await assertNoSymlinkComponents(
1027
+ hostRepoDir,
1028
+ patchesRoot,
1029
+ "patch directory"
1030
+ );
1031
+ let dirName = base;
1032
+ let counter = 0;
1033
+ while (existsSync(join(patchesRoot, dirName))) {
1034
+ counter++;
1035
+ dirName = `${base}-${counter}`;
1036
+ }
1037
+ const patchDir = join(patchesRoot, dirName);
1038
+ await mkdir(patchDir, { recursive: true, mode: 448 });
1039
+ await assertNoSymlinkComponents(hostRepoDir, patchDir, "patch directory");
1040
+ return patchDir;
1041
+ },
1042
+ catch: (e) => new SyncError({
1043
+ message: `Failed to create patch directory: ${e instanceof Error ? e.message : String(e)}`
1044
+ })
1045
+ });
1046
+ var countCommitsToSync = (sandbox, cwd, hostHead) => Effect.gen(function* () {
1047
+ const baseResult = yield* sandbox.exec(`git rev-parse --verify --quiet ${shellQuote(SYNC_BASE_REF)}`, {
1048
+ cwd
1049
+ }).pipe(
1050
+ Effect.catchAll(
1051
+ () => Effect.succeed({
1052
+ stdout: "",
1053
+ stderr: "",
1054
+ exitCode: 1
1055
+ })
1056
+ )
1057
+ );
1058
+ const base = baseResult.exitCode === 0 && baseResult.stdout.trim().length > 0 ? baseResult.stdout.trim() : hostHead;
1059
+ const countResult = yield* sandbox.exec(`git rev-list ${shellQuote(`${base}..HEAD`)} --count`, { cwd }).pipe(
1060
+ Effect.catchAll(
1061
+ () => Effect.succeed({
1062
+ stdout: "0",
1063
+ stderr: "",
1064
+ exitCode: 1
1065
+ })
1066
+ )
1067
+ );
1068
+ if (countResult.exitCode !== 0) return 0;
1069
+ const parsed = parseInt(countResult.stdout.trim(), 10);
1070
+ return Number.isNaN(parsed) ? 0 : parsed;
1071
+ });
1072
+ var syncOut = (hostRepoDir, handle) => Effect.gen(function* () {
1073
+ const worktreePath = handle.worktreePath;
1074
+ const hostHead = (yield* execHostGit2(
1075
+ ["rev-parse", "HEAD"],
1076
+ hostRepoDir
1077
+ )).trim();
1078
+ const sandboxHead = (yield* execOk(handle, "git rev-parse HEAD", {
1079
+ cwd: worktreePath
1080
+ })).stdout.trim();
1081
+ const baseRefResult = yield* execSandbox(
1082
+ handle,
1083
+ `git rev-parse --verify --quiet ${shellQuote(SYNC_BASE_REF)}`,
1084
+ { cwd: worktreePath }
1085
+ );
1086
+ const base = baseRefResult.exitCode === 0 && baseRefResult.stdout.trim().length > 0 ? baseRefResult.stdout.trim() : hostHead;
1087
+ const hasCommits = base !== sandboxHead;
1088
+ const diffResult = yield* execOk(handle, "git diff --binary HEAD", {
1089
+ cwd: worktreePath
1090
+ }).pipe(
1091
+ Effect.mapError(
1092
+ (error) => new SyncError({
1093
+ message: `Failed to inspect sandbox diff: ${error.message}`
1094
+ })
1095
+ )
1096
+ );
1097
+ const hasDiff = diffResult.stdout.trim().length > 0;
1098
+ const lsFilesResult = yield* execOk(
1099
+ handle,
1100
+ "git ls-files --others --exclude-standard -z",
1101
+ { cwd: worktreePath }
1102
+ ).pipe(
1103
+ Effect.mapError(
1104
+ (error) => new SyncError({
1105
+ message: `Failed to inspect sandbox untracked files: ${error.message}`
1106
+ })
1107
+ )
1108
+ );
1109
+ const hasUntracked = lsFilesResult.stdout.length > 0;
1110
+ const untrackedFiles = hasUntracked ? lsFilesResult.stdout.split("\0").filter((f) => f.length > 0) : [];
1111
+ yield* Effect.try({
1112
+ try: () => untrackedFiles.forEach(assertSafeGitWorktreePath),
1113
+ catch: (error) => new SyncError({ message: String(error) })
1114
+ });
1115
+ if (!hasCommits && !hasDiff && !hasUntracked) {
1116
+ return;
1117
+ }
1118
+ const patchDir = yield* createPatchDir(hostRepoDir);
1119
+ const relativePatchDir = join(CONFIG_DIR, PATCHES_DIR, basename(patchDir));
1120
+ const nonEmptyPatches = [];
1121
+ if (hasCommits) {
1122
+ const mkTempResult = yield* execOk(
1123
+ handle,
1124
+ `mktemp -d -t ${RUNTIME_NAMESPACE}-patches-XXXXXX`
1125
+ );
1126
+ const sandboxPatchDir = mkTempResult.stdout.trim();
1127
+ try {
1128
+ yield* execOk(
1129
+ handle,
1130
+ `git format-patch ${shellQuote(`${base}..HEAD`)} -o ${shellQuote(sandboxPatchDir)}`,
1131
+ { cwd: worktreePath }
1132
+ );
1133
+ const lsResult = yield* execOk(
1134
+ handle,
1135
+ `find ${shellQuote(sandboxPatchDir)} -maxdepth 1 -type f -print0`
1136
+ );
1137
+ const patchPaths = lsResult.stdout.split("\0").filter((path) => path.length > 0).sort((left, right) => left.localeCompare(right));
1138
+ const patchPrefix = `${sandboxPatchDir.replace(/\/+$/, "")}/`;
1139
+ for (const sandboxPatchPath of patchPaths) {
1140
+ if (!sandboxPatchPath.startsWith(patchPrefix)) {
1141
+ return yield* Effect.fail(
1142
+ new SyncError({
1143
+ message: `Sandbox returned a patch outside its patch directory: ${sandboxPatchPath}`
1144
+ })
1145
+ );
1146
+ }
1147
+ const patchName = sandboxPatchPath.slice(patchPrefix.length);
1148
+ try {
1149
+ assertSafePathSegment(patchName, "patch filename");
1150
+ } catch (error) {
1151
+ return yield* Effect.fail(
1152
+ new SyncError({
1153
+ message: `Refusing unsafe patch filename ${patchName}: ${error instanceof Error ? error.message : String(error)}`
1154
+ })
1155
+ );
1156
+ }
1157
+ const hostPatchPath = join(patchDir, patchName);
1158
+ yield* Effect.tryPromise({
1159
+ try: async () => {
1160
+ await assertNoSymlinkComponents(
1161
+ hostRepoDir,
1162
+ hostPatchPath,
1163
+ "patch path"
1164
+ );
1165
+ await handle.copyFileOut(sandboxPatchPath, hostPatchPath);
1166
+ await assertNoSymlinkComponents(
1167
+ hostRepoDir,
1168
+ hostPatchPath,
1169
+ "patch path"
1170
+ );
1171
+ },
1172
+ catch: (e) => new SyncError({
1173
+ message: `Failed to copy patch ${patchName}: ${e instanceof Error ? e.message : String(e)}`
1174
+ })
1175
+ });
1176
+ if (!(yield* isEmptyPatch(hostPatchPath))) {
1177
+ nonEmptyPatches.push(hostPatchPath);
1178
+ }
1179
+ }
1180
+ } finally {
1181
+ yield* execSandbox(handle, `rm -rf ${shellQuote(sandboxPatchDir)}`);
1182
+ }
1183
+ }
1184
+ if (hasDiff) {
1185
+ const diffPath = join(patchDir, "changes.patch");
1186
+ yield* Effect.tryPromise({
1187
+ try: async () => {
1188
+ await assertNoSymlinkComponents(hostRepoDir, diffPath, "diff patch");
1189
+ await writeFile(diffPath, diffResult.stdout, { mode: 384 });
1190
+ await chmod(diffPath, 384);
1191
+ },
1192
+ catch: (e) => new SyncError({
1193
+ message: `Failed to write diff patch: ${e instanceof Error ? e.message : String(e)}`
1194
+ })
1195
+ });
1196
+ }
1197
+ if (hasUntracked) {
1198
+ const untrackedDir = join(patchDir, "untracked");
1199
+ for (const relPath of untrackedFiles) {
1200
+ let hostFilePath;
1201
+ try {
1202
+ assertSafeGitWorktreePath(relPath);
1203
+ hostFilePath = resolveSafeRelativePath(
1204
+ untrackedDir,
1205
+ relPath,
1206
+ "untracked path"
1207
+ );
1208
+ } catch (error) {
1209
+ return yield* Effect.fail(
1210
+ new SyncError({
1211
+ message: `Refusing unsafe untracked path ${relPath}: ${error instanceof Error ? error.message : String(error)}`
1212
+ })
1213
+ );
1214
+ }
1215
+ const sandboxFilePath = posix.join(worktreePath, relPath);
1216
+ const quotedSandboxFilePath = shellQuote(sandboxFilePath);
1217
+ const modeResult = yield* execOk(
1218
+ handle,
1219
+ `mode=$(stat -c '%a' -- ${quotedSandboxFilePath} 2>/dev/null || stat -f '%Lp' ${quotedSandboxFilePath}) && printf '%s\\n' "$mode"`
1220
+ );
1221
+ const sandboxMode = Number.parseInt(modeResult.stdout.trim(), 8);
1222
+ if (!Number.isInteger(sandboxMode) || sandboxMode < 0) {
1223
+ return yield* Effect.fail(
1224
+ new SyncError({
1225
+ message: `Failed to read mode for untracked file ${relPath}`
1226
+ })
1227
+ );
1228
+ }
1229
+ yield* Effect.tryPromise({
1230
+ try: async () => {
1231
+ await mkdir(dirname(hostFilePath), {
1232
+ recursive: true,
1233
+ mode: 448
1234
+ });
1235
+ await assertNoSymlinkComponents(
1236
+ hostRepoDir,
1237
+ hostFilePath,
1238
+ "untracked path"
1239
+ );
1240
+ await handle.copyFileOut(sandboxFilePath, hostFilePath);
1241
+ await chmod(hostFilePath, sandboxMode & 511);
1242
+ await assertNoSymlinkComponents(
1243
+ hostRepoDir,
1244
+ hostFilePath,
1245
+ "untracked path"
1246
+ );
1247
+ },
1248
+ catch: (e) => new SyncError({
1249
+ message: `Failed to save untracked file ${relPath}: ${e instanceof Error ? e.message : String(e)}`
1250
+ })
1251
+ });
1252
+ }
1253
+ }
1254
+ let failedStep;
1255
+ if (nonEmptyPatches.length > 0) {
1256
+ yield* Effect.either(
1257
+ execHostGit2(["am", "--abort"], hostRepoDir)
1258
+ );
1259
+ const applyResult = yield* Effect.either(
1260
+ execHostGit2(["am", "--3way", ...nonEmptyPatches], hostRepoDir)
1261
+ );
1262
+ if (applyResult._tag === "Left") {
1263
+ failedStep = "commits";
1264
+ }
1265
+ }
1266
+ if (!failedStep && hasDiff) {
1267
+ const diffPath = join(patchDir, "changes.patch");
1268
+ const applyResult = yield* Effect.either(
1269
+ execHostGit2(["apply", diffPath], hostRepoDir)
1270
+ );
1271
+ if (applyResult._tag === "Left") {
1272
+ failedStep = "diff";
1273
+ }
1274
+ }
1275
+ if (!failedStep && hasUntracked) {
1276
+ const copyResult = yield* Effect.either(
1277
+ Effect.tryPromise({
1278
+ try: async () => {
1279
+ const untrackedDir = join(patchDir, "untracked");
1280
+ for (const relPath of untrackedFiles) {
1281
+ const srcPath = resolveSafeRelativePath(
1282
+ untrackedDir,
1283
+ relPath,
1284
+ "untracked path"
1285
+ );
1286
+ const destPath = resolveSafeRelativePath(
1287
+ hostRepoDir,
1288
+ relPath,
1289
+ "untracked path"
1290
+ );
1291
+ await mkdir(dirname(destPath), { recursive: true, mode: 448 });
1292
+ await assertNoSymlinkComponents(
1293
+ hostRepoDir,
1294
+ srcPath,
1295
+ "saved untracked path"
1296
+ );
1297
+ await assertNoSymlinkComponents(
1298
+ hostRepoDir,
1299
+ destPath,
1300
+ "untracked path"
1301
+ );
1302
+ const sourceMode = (await stat(srcPath)).mode & 511;
1303
+ await copyFile(srcPath, destPath, constants.COPYFILE_EXCL);
1304
+ await chmod(destPath, sourceMode);
1305
+ }
1306
+ },
1307
+ catch: (e) => new SyncError({
1308
+ message: `Failed to copy untracked files: ${e instanceof Error ? e.message : String(e)}`
1309
+ })
1310
+ })
1311
+ );
1312
+ if (copyResult._tag === "Left") {
1313
+ failedStep = "untracked";
1314
+ }
1315
+ }
1316
+ if (hasCommits && failedStep !== "commits") {
1317
+ yield* execOk(
1318
+ handle,
1319
+ `git update-ref ${shellQuote(SYNC_BASE_REF)} ${shellQuote(sandboxHead)}`,
1320
+ {
1321
+ cwd: worktreePath
1322
+ }
1323
+ );
1324
+ }
1325
+ if (failedStep) {
1326
+ const msg = buildRecoveryMessage({
1327
+ patchDir: relativePatchDir,
1328
+ failedStep,
1329
+ hasCommits: nonEmptyPatches.length > 0,
1330
+ hasDiff,
1331
+ hasUntracked
1332
+ });
1333
+ console.error(`
1334
+ ${msg}`);
1335
+ const description = failedStep === "commits" ? "committed changes" : failedStep === "diff" ? "uncommitted changes" : "untracked files";
1336
+ return yield* Effect.fail(
1337
+ new SyncError({
1338
+ message: `Failed to sync ${description}; recovery artifacts preserved at ${relativePatchDir}`
1339
+ })
1340
+ );
1341
+ } else {
1342
+ yield* Effect.tryPromise({
1343
+ try: async () => {
1344
+ await assertNoSymlinkComponents(
1345
+ hostRepoDir,
1346
+ patchDir,
1347
+ "patch directory"
1348
+ );
1349
+ await rm(patchDir, { recursive: true, force: true });
1350
+ const patchesRoot = join(hostRepoDir, CONFIG_DIR, PATCHES_DIR);
1351
+ try {
1352
+ const remaining = await readdir(patchesRoot);
1353
+ if (remaining.length === 0) {
1354
+ await assertNoSymlinkComponents(
1355
+ hostRepoDir,
1356
+ patchesRoot,
1357
+ "patch directory"
1358
+ );
1359
+ await rm(patchesRoot, {
1360
+ recursive: true,
1361
+ force: true
1362
+ });
1363
+ }
1364
+ } catch {
1365
+ }
1366
+ },
1367
+ catch: () => new SyncError({ message: "Failed to clean up patch directory" })
1368
+ });
1369
+ }
1370
+ });
1371
+
1372
+ // src/SandboxLifecycle.ts
1373
+ var GIT_SETUP_TIMEOUT_MS = 1e4;
1374
+ var HOOK_TIMEOUT_MS = 6e4;
1375
+ var COMMIT_COLLECTION_TIMEOUT_MS = 3e4;
1376
+ var MERGE_TO_HOST_TIMEOUT_MS = 3e4;
1377
+ var GIT_SETUP_MAX_RETRIES = 2;
1378
+ var GIT_SETUP_RETRY_DELAY_MS = 250;
1379
+ var TRANSIENT_EXEC_EXIT_CODES = /* @__PURE__ */ new Set([126, 137]);
1380
+ var isTransientExecError = (err) => err._tag === "ExecError" && err.exitCode !== void 0 && TRANSIENT_EXEC_EXIT_CODES.has(err.exitCode);
1381
+ var execOk2 = (sandbox, command, options) => Effect.flatMap(
1382
+ sandbox.exec(command, options),
1383
+ (result) => result.exitCode !== 0 ? Effect.fail(
1384
+ new ExecError({
1385
+ command,
1386
+ exitCode: result.exitCode,
1387
+ message: `Command failed (exit ${result.exitCode}): ${command}
1388
+ ${result.stderr}`
1389
+ })
1390
+ ) : Effect.succeed(result)
1391
+ );
1392
+ var execOkWithGitTimeout = (sandbox, command, gitSetupTimeoutMs, options) => execOk2(sandbox, command, options).pipe(
1393
+ withTimeout(
1394
+ gitSetupTimeoutMs,
1395
+ () => new GitSetupTimeoutError({
1396
+ message: `Git command timed out after ${gitSetupTimeoutMs}ms: ${command}`,
1397
+ timeoutMs: gitSetupTimeoutMs,
1398
+ command
1399
+ })
1400
+ ),
1401
+ // Each attempt is bounded by its own timeout (above); retry only transient
1402
+ // exec races, so a genuine git error or a hung exec still fails fast.
1403
+ Effect.retry({
1404
+ while: isTransientExecError,
1405
+ times: GIT_SETUP_MAX_RETRIES,
1406
+ schedule: Schedule.spaced(Duration.millis(GIT_SETUP_RETRY_DELAY_MS))
1407
+ })
1408
+ );
1409
+ var execAsync = promisify(exec);
1410
+ var runHostHooks = (hooks, cwd, signal) => Effect.gen(function* () {
1411
+ for (const hook of hooks) {
1412
+ const timeout = hook.timeoutMs ?? HOOK_TIMEOUT_MS;
1413
+ yield* Effect.tryPromise({
1414
+ try: () => execAsync(hook.command, { cwd, signal }),
1415
+ catch: (err) => new ExecError({
1416
+ command: hook.command,
1417
+ message: `Host hook failed: ${hook.command}
1418
+ ${err instanceof Error ? err.message : String(err)}`
1419
+ })
1420
+ }).pipe(
1421
+ withTimeout(
1422
+ timeout,
1423
+ () => new HookTimeoutError({
1424
+ message: `Host hook '${hook.command}' timed out after ${timeout}ms`,
1425
+ timeoutMs: timeout,
1426
+ command: hook.command
1427
+ })
1428
+ )
1429
+ );
1430
+ }
1431
+ });
1432
+ var withSandboxLifecycle = (options, sandbox, work) => Effect.gen(function* () {
1433
+ const display = yield* Display;
1434
+ const { hostRepoDir, sandboxRepoDir, hooks, branch, hostWorktreePath } = options;
1435
+ const gitSetupTimeoutMs = options.timeouts?.gitSetupMs ?? GIT_SETUP_TIMEOUT_MS;
1436
+ const commitCollectionTimeoutMs = options.timeouts?.commitCollectionMs ?? COMMIT_COLLECTION_TIMEOUT_MS;
1437
+ const mergeToHostTimeoutMs = options.timeouts?.mergeToHostMs ?? MERGE_TO_HOST_TIMEOUT_MS;
1438
+ const signal = options.signal ?? new AbortController().signal;
1439
+ const hostCurrentBranch = !branch ? yield* Effect.promise(async () => {
1440
+ return (await execHostGit(
1441
+ ["rev-parse", "--abbrev-ref", "HEAD"],
1442
+ hostRepoDir
1443
+ )).trim();
1444
+ }) : null;
1445
+ const [hostGitName, hostGitEmail] = yield* Effect.promise(async () => {
1446
+ const [nameResult, emailResult] = await Promise.all([
1447
+ execHostGit(["config", "user.name"], hostRepoDir).then((stdout) => stdout.trim()).catch(() => ""),
1448
+ execHostGit(["config", "user.email"], hostRepoDir).then((stdout) => stdout.trim()).catch(() => "")
1449
+ ]);
1450
+ return [nameResult, emailResult];
1451
+ });
1452
+ const hostSideWorktreePath = hostWorktreePath ?? sandboxRepoDir;
1453
+ let resolvedBranch = "";
1454
+ yield* display.taskLog(
1455
+ "Setting up sandbox",
1456
+ (message) => Effect.gen(function* () {
1457
+ yield* execOkWithGitTimeout(
1458
+ sandbox,
1459
+ `git config --global --add safe.directory ${shellQuote(sandboxRepoDir)}`,
1460
+ gitSetupTimeoutMs
1461
+ );
1462
+ if (hostGitName) {
1463
+ yield* execOkWithGitTimeout(
1464
+ sandbox,
1465
+ `git config --global user.name ${shellQuote(hostGitName)}`,
1466
+ gitSetupTimeoutMs
1467
+ );
1468
+ }
1469
+ if (hostGitEmail) {
1470
+ yield* execOkWithGitTimeout(
1471
+ sandbox,
1472
+ `git config --global user.email ${shellQuote(hostGitEmail)}`,
1473
+ gitSetupTimeoutMs
1474
+ );
1475
+ }
1476
+ resolvedBranch = (yield* execOkWithGitTimeout(
1477
+ sandbox,
1478
+ "git rev-parse --abbrev-ref HEAD",
1479
+ gitSetupTimeoutMs,
1480
+ { cwd: sandboxRepoDir }
1481
+ )).stdout.trim();
1482
+ const sandboxHooks = hooks?.sandbox?.onSandboxReady;
1483
+ const hostOnSandboxReady = hooks?.host?.onSandboxReady;
1484
+ if (sandboxHooks?.length) {
1485
+ for (const hook of sandboxHooks) {
1486
+ message(hook.command);
1487
+ }
1488
+ }
1489
+ if (hostOnSandboxReady?.length) {
1490
+ for (const hook of hostOnSandboxReady) {
1491
+ message(`[host] ${hook.command}`);
1492
+ }
1493
+ }
1494
+ const abortDeferred = yield* Deferred.make();
1495
+ let abortCleanup = null;
1496
+ if (signal.aborted) {
1497
+ yield* Deferred.fail(
1498
+ abortDeferred,
1499
+ new ExecError({
1500
+ command: "abort",
1501
+ message: `Aborted: ${signal.reason}`
1502
+ })
1503
+ );
1504
+ } else {
1505
+ const onAbort = () => {
1506
+ Effect.runPromise(
1507
+ Deferred.fail(
1508
+ abortDeferred,
1509
+ new ExecError({
1510
+ command: "abort",
1511
+ message: `Aborted: ${signal.reason}`
1512
+ })
1513
+ )
1514
+ ).catch(() => {
1515
+ });
1516
+ };
1517
+ signal.addEventListener("abort", onAbort, { once: true });
1518
+ abortCleanup = () => signal.removeEventListener("abort", onAbort);
1519
+ }
1520
+ const sandboxHookEffects = (sandboxHooks ?? []).map((hook) => {
1521
+ const timeout = hook.timeoutMs ?? HOOK_TIMEOUT_MS;
1522
+ return Effect.raceFirst(
1523
+ execOk2(sandbox, hook.command, {
1524
+ cwd: sandboxRepoDir,
1525
+ sudo: hook.sudo
1526
+ }).pipe(
1527
+ withTimeout(
1528
+ timeout,
1529
+ () => new HookTimeoutError({
1530
+ message: `Hook '${hook.command}' timed out after ${timeout}ms`,
1531
+ timeoutMs: timeout,
1532
+ command: hook.command
1533
+ })
1534
+ )
1535
+ ),
1536
+ Deferred.await(abortDeferred)
1537
+ );
1538
+ });
1539
+ const hostHookEffects = (hostOnSandboxReady ?? []).map((hook) => {
1540
+ const timeout = hook.timeoutMs ?? HOOK_TIMEOUT_MS;
1541
+ return Effect.tryPromise({
1542
+ try: () => execAsync(hook.command, {
1543
+ cwd: hostSideWorktreePath,
1544
+ signal
1545
+ }),
1546
+ catch: (err) => new ExecError({
1547
+ command: hook.command,
1548
+ message: `Host hook failed: ${hook.command}
1549
+ ${err instanceof Error ? err.message : String(err)}`
1550
+ })
1551
+ }).pipe(
1552
+ withTimeout(
1553
+ timeout,
1554
+ () => new HookTimeoutError({
1555
+ message: `Host hook '${hook.command}' timed out after ${timeout}ms`,
1556
+ timeoutMs: timeout,
1557
+ command: hook.command
1558
+ })
1559
+ )
1560
+ );
1561
+ });
1562
+ const allOnSandboxReady = [...sandboxHookEffects, ...hostHookEffects];
1563
+ yield* (allOnSandboxReady.length > 0 ? Effect.all(allOnSandboxReady, { concurrency: "unbounded" }) : Effect.void).pipe(Effect.ensuring(Effect.sync(() => abortCleanup?.())));
1564
+ })
1565
+ );
1566
+ const targetBranch = branch ?? resolvedBranch;
1567
+ const baseHead = yield* Effect.promise(async () => {
1568
+ return (await execHostGit(["rev-parse", "HEAD"], hostSideWorktreePath)).trim();
1569
+ });
1570
+ const result = yield* work({ sandbox, sandboxRepoDir, baseHead });
1571
+ if (options.applyToHost) {
1572
+ const commitCount = yield* countCommitsToSync(
1573
+ sandbox,
1574
+ sandboxRepoDir,
1575
+ baseHead
1576
+ );
1577
+ yield* display.taskLog(
1578
+ commitCount > 0 ? `Syncing ${commitCount} commit${commitCount !== 1 ? "s" : ""} to host` : "No commits to sync out",
1579
+ () => options.applyToHost()
1580
+ );
1581
+ }
1582
+ let commits;
1583
+ let finalBranch;
1584
+ if (hostCurrentBranch !== null) {
1585
+ const hasNewCommits = yield* Effect.promise(async () => {
1586
+ try {
1587
+ const stdout = await execHostGit(
1588
+ ["rev-list", `${baseHead}..HEAD`, "--count"],
1589
+ hostSideWorktreePath
1590
+ );
1591
+ return parseInt(stdout.trim(), 10) > 0;
1592
+ } catch {
1593
+ return false;
1594
+ }
1595
+ });
1596
+ if (!options.keepSourceBranch) {
1597
+ if (options.applyToHost) {
1598
+ yield* Effect.tryPromise({
1599
+ try: () => execHostGit(["checkout", "--detach"], hostSideWorktreePath),
1600
+ catch: (error) => new SyncError({
1601
+ message: `Failed to detach host worktree: ${String(error)}`
1602
+ })
1603
+ });
1604
+ } else {
1605
+ yield* execOk2(sandbox, "git checkout --detach", {
1606
+ cwd: sandboxRepoDir
1607
+ });
1608
+ }
1609
+ }
1610
+ if (hasNewCommits) {
1611
+ yield* display.taskLog(
1612
+ `Merging to ${hostCurrentBranch}`,
1613
+ () => Effect.tryPromise({
1614
+ try: async () => {
1615
+ try {
1616
+ await execHostGit(["merge", "--", resolvedBranch], hostRepoDir);
1617
+ } catch {
1618
+ throw new Error(
1619
+ `Merge of '${resolvedBranch}' onto '${hostCurrentBranch}' failed. The temporary branch '${resolvedBranch}' has been preserved. To retry: git merge ${resolvedBranch}, then clean up: git branch -D ${resolvedBranch}`
1620
+ );
1621
+ }
1622
+ },
1623
+ catch: (e) => new SyncError({
1624
+ message: String(e instanceof Error ? e.message : e)
1625
+ })
1626
+ }).pipe(
1627
+ withTimeout(
1628
+ mergeToHostTimeoutMs,
1629
+ () => new MergeToHostTimeoutError({
1630
+ message: `Merge of '${resolvedBranch}' to '${hostCurrentBranch}' timed out after ${mergeToHostTimeoutMs}ms`,
1631
+ timeoutMs: mergeToHostTimeoutMs,
1632
+ sourceBranch: resolvedBranch,
1633
+ targetBranch: hostCurrentBranch
1634
+ })
1635
+ )
1636
+ )
1637
+ );
1638
+ }
1639
+ if (!options.keepSourceBranch) {
1640
+ yield* Effect.promise(
1641
+ () => execHostGit(
1642
+ ["branch", "-D", "--", resolvedBranch],
1643
+ hostRepoDir
1644
+ ).catch(() => {
1645
+ })
1646
+ );
1647
+ }
1648
+ commits = yield* display.taskLog(
1649
+ "Collecting commits",
1650
+ () => Effect.promise(async () => {
1651
+ try {
1652
+ const stdout = await execHostGit(
1653
+ ["rev-list", `${baseHead}..HEAD`, "--reverse"],
1654
+ hostRepoDir
1655
+ );
1656
+ const lines = stdout.trim();
1657
+ if (!lines) return [];
1658
+ return lines.split("\n").map((sha) => ({ sha }));
1659
+ } catch {
1660
+ return [];
1661
+ }
1662
+ }).pipe(
1663
+ withTimeout(
1664
+ commitCollectionTimeoutMs,
1665
+ () => new CommitCollectionTimeoutError({
1666
+ message: `Commit collection timed out after ${commitCollectionTimeoutMs}ms`,
1667
+ timeoutMs: commitCollectionTimeoutMs
1668
+ })
1669
+ )
1670
+ )
1671
+ );
1672
+ finalBranch = hostCurrentBranch;
1673
+ } else {
1674
+ commits = yield* display.taskLog(
1675
+ "Collecting commits",
1676
+ () => Effect.promise(async () => {
1677
+ try {
1678
+ const stdout = await execHostGit(
1679
+ [
1680
+ "rev-list",
1681
+ `${baseHead}..refs/heads/${targetBranch}`,
1682
+ "--reverse"
1683
+ ],
1684
+ hostRepoDir
1685
+ );
1686
+ const lines = stdout.trim();
1687
+ if (!lines) return [];
1688
+ return lines.split("\n").map((sha) => ({ sha }));
1689
+ } catch {
1690
+ return [];
1691
+ }
1692
+ }).pipe(
1693
+ withTimeout(
1694
+ commitCollectionTimeoutMs,
1695
+ () => new CommitCollectionTimeoutError({
1696
+ message: `Commit collection timed out after ${commitCollectionTimeoutMs}ms`,
1697
+ timeoutMs: commitCollectionTimeoutMs
1698
+ })
1699
+ )
1700
+ )
1701
+ );
1702
+ finalBranch = targetBranch;
1703
+ }
1704
+ return { result, branch: finalBranch, commits };
1705
+ });
1706
+ var execHostGit3 = (args, cwd) => Effect.tryPromise({
1707
+ try: () => execHostGit(args, cwd),
1708
+ catch: (e) => new SyncError({
1709
+ message: `Host command failed: git ${args.join(" ")}
1710
+ ${e instanceof Error ? e.message : String(e)}`
1711
+ })
1712
+ });
1713
+ var execOk3 = (handle, command, options) => Effect.tryPromise({
1714
+ try: () => handle.exec(command, options),
1715
+ catch: (e) => new SyncError({
1716
+ message: `Sandbox exec failed: ${command}
1717
+ ${e instanceof Error ? e.message : String(e)}`
1718
+ })
1719
+ }).pipe(
1720
+ Effect.flatMap(
1721
+ (result) => result.exitCode !== 0 ? Effect.fail(
1722
+ new SyncError({
1723
+ message: `Sandbox command failed (exit ${result.exitCode}): ${command}
1724
+ ${result.stderr}`
1725
+ })
1726
+ ) : Effect.succeed(result)
1727
+ )
1728
+ );
1729
+ var syncIn = (hostRepoDir, handle) => Effect.gen(function* () {
1730
+ const branch = (yield* execHostGit3(
1731
+ ["rev-parse", "--abbrev-ref", "HEAD"],
1732
+ hostRepoDir
1733
+ )).trim();
1734
+ const bundleDir = yield* Effect.tryPromise({
1735
+ try: () => mkdtemp(join(tmpdir(), `${RUNTIME_NAMESPACE}-bundle-`)),
1736
+ catch: (e) => new SyncError({
1737
+ message: `Failed to create temp dir: ${e instanceof Error ? e.message : String(e)}`
1738
+ })
1739
+ });
1740
+ const bundleHostPath = join(bundleDir, "repo.bundle");
1741
+ yield* Effect.ensuring(
1742
+ Effect.gen(function* () {
1743
+ yield* execHostGit3(
1744
+ ["bundle", "create", bundleHostPath, "--all"],
1745
+ hostRepoDir
1746
+ );
1747
+ const mkTempResult = yield* execOk3(
1748
+ handle,
1749
+ `mktemp -d -t ${RUNTIME_NAMESPACE}-XXXXXX`
1750
+ );
1751
+ const sandboxTmpDir = mkTempResult.stdout.trim();
1752
+ const bundleSandboxPath = `${sandboxTmpDir}/repo.bundle`;
1753
+ yield* Effect.tryPromise({
1754
+ try: () => handle.copyIn(bundleHostPath, bundleSandboxPath),
1755
+ catch: (e) => new SyncError({
1756
+ message: `Failed to copy bundle into sandbox: ${e instanceof Error ? e.message : String(e)}`
1757
+ })
1758
+ });
1759
+ const worktreePath = handle.worktreePath;
1760
+ yield* execOk3(
1761
+ handle,
1762
+ `git clone ${shellQuote(bundleSandboxPath)} ${shellQuote(`${worktreePath}_clone`)}`
1763
+ );
1764
+ yield* execOk3(
1765
+ handle,
1766
+ `rm -rf ${shellQuote(worktreePath)} && mv ${shellQuote(`${worktreePath}_clone`)} ${shellQuote(worktreePath)}`
1767
+ );
1768
+ yield* execOk3(handle, `git checkout ${shellQuote(branch)}`, {
1769
+ cwd: worktreePath
1770
+ });
1771
+ yield* Effect.tryPromise({
1772
+ try: () => handle.exec(`rm -rf ${shellQuote(sandboxTmpDir)}`),
1773
+ catch: () => new SyncError({ message: "Failed to clean up sandbox temp dir" })
1774
+ });
1775
+ const hostHead = (yield* execHostGit3(
1776
+ ["rev-parse", "HEAD"],
1777
+ hostRepoDir
1778
+ )).trim();
1779
+ const sandboxHead = (yield* execOk3(handle, "git rev-parse HEAD", {
1780
+ cwd: worktreePath
1781
+ })).stdout.trim();
1782
+ if (hostHead !== sandboxHead) {
1783
+ yield* Effect.fail(
1784
+ new SyncError({
1785
+ message: `HEAD mismatch after sync-in: host=${hostHead} sandbox=${sandboxHead}`
1786
+ })
1787
+ );
1788
+ }
1789
+ }),
1790
+ // Clean up host-side bundle temp dir (runs regardless of success/failure)
1791
+ Effect.promise(() => rm(bundleDir, { recursive: true, force: true }))
1792
+ );
1793
+ return { branch };
1794
+ });
1795
+
1796
+ // src/startSandbox.ts
1797
+ var CONTAINER_START_TIMEOUT_MS = 12e4;
1798
+ var SYNC_IN_TIMEOUT_MS = 12e4;
1799
+ var COPY_PATHS_TIMEOUT_MS = 12e4;
1800
+ var startSandbox = (options) => {
1801
+ if (options.provider.tag === "bind-mount") {
1802
+ return startBindMountSandbox(options);
1803
+ }
1804
+ if (options.provider.tag === "none") {
1805
+ return startNoSandbox(options);
1806
+ }
1807
+ return startIsolatedSandbox(options);
1808
+ };
1809
+ var startNoSandbox = (options) => Effect.tryPromise({
1810
+ try: () => options.provider.create({
1811
+ worktreePath: options.worktreeOrRepoPath,
1812
+ env: options.env
1813
+ }),
1814
+ catch: (e) => new WorktreeError({
1815
+ message: `Provider '${options.provider.name}' create failed: ${e instanceof Error ? e.message : String(e)}`
1816
+ })
1817
+ }).pipe(
1818
+ Effect.map((handle) => ({
1819
+ handle,
1820
+ sandbox: makeSandboxFromHandle(handle),
1821
+ worktreePath: handle.worktreePath
1822
+ }))
1823
+ );
1824
+ var startBindMountSandbox = (options) => Effect.tryPromise({
1825
+ try: () => {
1826
+ const rawMounts = [
1827
+ {
1828
+ hostPath: options.worktreeOrRepoPath,
1829
+ sandboxPath: options.repoDir
1830
+ },
1831
+ ...options.gitMounts
1832
+ ];
1833
+ const mounts = normalizeMounts(
1834
+ rawMounts,
1835
+ options.worktreeOrRepoPath,
1836
+ SANDBOX_REPO_DIR
1837
+ );
1838
+ const worktreePath = process.platform === "win32" ? options.worktreeOrRepoPath.replace(/\\/g, "/") : options.worktreeOrRepoPath;
1839
+ return options.provider.create({
1840
+ worktreePath,
1841
+ hostRepoPath: options.hostRepoDir,
1842
+ mounts,
1843
+ env: options.env
1844
+ });
1845
+ },
1846
+ catch: (e) => new WorktreeError({
1847
+ message: `Provider '${options.provider.name}' create failed: ${e instanceof Error ? e.message : String(e)}`
1848
+ })
1849
+ }).pipe(
1850
+ Effect.map((handle) => ({
1851
+ handle,
1852
+ sandbox: makeSandboxFromHandle(handle),
1853
+ worktreePath: handle.worktreePath
1854
+ })),
1855
+ withTimeout(
1856
+ CONTAINER_START_TIMEOUT_MS,
1857
+ () => new ContainerStartTimeoutError({
1858
+ message: `Sandbox container start timed out after ${CONTAINER_START_TIMEOUT_MS}ms`,
1859
+ timeoutMs: CONTAINER_START_TIMEOUT_MS
1860
+ })
1861
+ )
1862
+ );
1863
+ var startIsolatedSandbox = (options) => Effect.gen(function* () {
1864
+ const handle = yield* Effect.tryPromise({
1865
+ try: () => options.provider.create({
1866
+ env: options.env,
1867
+ hostRepoPath: options.sourceRepoDir ?? options.hostRepoDir
1868
+ }),
1869
+ catch: (e) => new WorktreeError({
1870
+ message: `Isolated provider '${options.provider.name}' setup failed: ${e instanceof Error ? e.message : String(e)}`
1871
+ })
1872
+ }).pipe(
1873
+ withTimeout(
1874
+ CONTAINER_START_TIMEOUT_MS,
1875
+ () => new ContainerStartTimeoutError({
1876
+ message: `Isolated sandbox container start timed out after ${CONTAINER_START_TIMEOUT_MS}ms`,
1877
+ timeoutMs: CONTAINER_START_TIMEOUT_MS
1878
+ })
1879
+ )
1880
+ );
1881
+ return yield* Effect.gen(function* () {
1882
+ yield* syncIn(options.hostRepoDir, handle).pipe(
1883
+ withTimeout(
1884
+ SYNC_IN_TIMEOUT_MS,
1885
+ () => new SyncInTimeoutError({
1886
+ message: `Sync-in timed out after ${SYNC_IN_TIMEOUT_MS}ms`,
1887
+ timeoutMs: SYNC_IN_TIMEOUT_MS
1888
+ })
1889
+ )
1890
+ );
1891
+ if (options.copyPaths && options.copyPaths.length > 0) {
1892
+ const pathsToCopy = options.copyPaths;
1893
+ yield* Effect.gen(function* () {
1894
+ for (const relativePath of pathsToCopy) {
1895
+ let hostPath;
1896
+ let sandboxPath;
1897
+ try {
1898
+ hostPath = resolveSafeRelativePath(
1899
+ options.sourceRepoDir ?? options.hostRepoDir,
1900
+ relativePath,
1901
+ "copy path"
1902
+ );
1903
+ resolveSafeRelativePath(
1904
+ handle.worktreePath,
1905
+ relativePath,
1906
+ "copy path"
1907
+ );
1908
+ sandboxPath = posix.join(handle.worktreePath, relativePath);
1909
+ } catch (error) {
1910
+ return yield* Effect.fail(
1911
+ new WorktreeError({
1912
+ message: `Refusing unsafe copy path ${relativePath}: ${error instanceof Error ? error.message : String(error)}`
1913
+ })
1914
+ );
1915
+ }
1916
+ if (!existsSync(hostPath)) {
1917
+ continue;
1918
+ }
1919
+ yield* Effect.tryPromise({
1920
+ try: async () => {
1921
+ await assertNoSymlinkComponents(
1922
+ options.sourceRepoDir ?? options.hostRepoDir,
1923
+ hostPath,
1924
+ "copy source"
1925
+ );
1926
+ },
1927
+ catch: (error) => new WorktreeError({
1928
+ message: `Refusing symlinked copy path ${relativePath}: ${error instanceof Error ? error.message : String(error)}`
1929
+ })
1930
+ });
1931
+ yield* Effect.tryPromise({
1932
+ try: () => handle.copyIn(hostPath, sandboxPath),
1933
+ catch: (e) => new WorktreeError({
1934
+ message: `Failed to copy ${relativePath} into sandbox: ${e instanceof Error ? e.message : String(e)}`
1935
+ })
1936
+ });
1937
+ }
1938
+ }).pipe(
1939
+ withTimeout(
1940
+ COPY_PATHS_TIMEOUT_MS,
1941
+ () => new CopyToWorktreeTimeoutError({
1942
+ message: `Copying paths to worktree timed out after ${COPY_PATHS_TIMEOUT_MS}ms`,
1943
+ timeoutMs: COPY_PATHS_TIMEOUT_MS,
1944
+ paths: pathsToCopy
1945
+ })
1946
+ )
1947
+ );
1948
+ }
1949
+ return {
1950
+ handle,
1951
+ sandbox: makeSandboxFromHandle(handle),
1952
+ worktreePath: handle.worktreePath
1953
+ };
1954
+ }).pipe(Effect.onError(() => Effect.promise(() => handle.close())));
1955
+ });
1956
+
1957
+ // src/SandboxFactory.ts
1958
+ var getCopyIn = (handle) => {
1959
+ if ("copyIn" in handle) {
1960
+ return (hostPath, sandboxPath) => Effect.tryPromise({
1961
+ try: () => handle.copyIn(hostPath, sandboxPath),
1962
+ catch: (e) => new CopyError({
1963
+ message: `copyIn failed: ${e instanceof Error ? e.message : String(e)}`
1964
+ })
1965
+ });
1966
+ }
1967
+ if ("copyFileIn" in handle) {
1968
+ return (hostPath, sandboxPath) => Effect.tryPromise({
1969
+ try: () => handle.copyFileIn(hostPath, sandboxPath),
1970
+ catch: (e) => new CopyError({
1971
+ message: `copyFileIn failed: ${e instanceof Error ? e.message : String(e)}`
1972
+ })
1973
+ });
1974
+ }
1975
+ return () => Effect.fail(
1976
+ new CopyError({
1977
+ message: "copyIn is not supported for this sandbox provider"
1978
+ })
1979
+ );
1980
+ };
1981
+ var makeSandboxFromHandle = (handle) => ({
1982
+ exec: (command, options) => Effect.tryPromise({
1983
+ try: () => handle.exec(command, options),
1984
+ catch: (e) => new ExecError({
1985
+ command,
1986
+ message: `exec failed: ${e instanceof Error ? e.message : String(e)}`
1987
+ })
1988
+ }),
1989
+ copyIn: getCopyIn(handle),
1990
+ copyFileOut: "copyFileOut" in handle ? (sandboxPath, hostPath) => Effect.tryPromise({
1991
+ try: () => handle.copyFileOut(sandboxPath, hostPath),
1992
+ catch: (e) => new CopyError({
1993
+ message: `copyFileOut failed: ${e instanceof Error ? e.message : String(e)}`
1994
+ })
1995
+ }) : () => Effect.fail(
1996
+ new CopyError({
1997
+ message: "copyFileOut is not supported for this sandbox provider"
1998
+ })
1999
+ )
2000
+ });
2001
+ var SANDBOX_REPO_DIR = "/home/agent/workspace";
2002
+ var SandboxFactory = class extends Context.Tag("SandboxFactory")() {
2003
+ };
2004
+ var SandboxConfig = class extends Context.Tag("SandboxConfig")() {
2005
+ };
2006
+ var printWorktreePreservedMessage = (worktreePath, reason) => {
2007
+ console.error(`
2008
+ ${reason}`);
2009
+ console.error(` To review: cd ${worktreePath}`);
2010
+ console.error(` To clean up: git worktree remove --force ${worktreePath}`);
2011
+ };
2012
+ var cleanupWorktree = (worktreePath, exit) => hasUncommittedChanges(worktreePath).pipe(
2013
+ Effect.catchAll(() => Effect.succeed(false)),
2014
+ Effect.flatMap((isDirty) => {
2015
+ if (isDirty) {
2016
+ printWorktreePreservedMessage(
2017
+ worktreePath,
2018
+ Exit.isSuccess(exit) ? `Run succeeded but worktree has uncommitted changes at ${worktreePath}` : `Worktree preserved at ${worktreePath}`
2019
+ );
2020
+ return Effect.succeed(worktreePath);
2021
+ }
2022
+ if (!Exit.isSuccess(exit)) {
2023
+ console.error(`
2024
+ Worktree removed (no uncommitted changes)`);
2025
+ }
2026
+ return remove(worktreePath).pipe(
2027
+ Effect.map(() => void 0)
2028
+ );
2029
+ })
2030
+ );
2031
+ var attachPreservedPath = (path, e) => {
2032
+ if (path !== void 0) {
2033
+ if (e instanceof AgentIdleTimeoutError) {
2034
+ return new AgentIdleTimeoutError({
2035
+ message: e.message,
2036
+ timeoutMs: e.timeoutMs,
2037
+ preservedWorktreePath: path
2038
+ });
2039
+ }
2040
+ if (e instanceof AgentError) {
2041
+ return new AgentError({
2042
+ message: e.message,
2043
+ preservedWorktreePath: path
2044
+ });
2045
+ }
2046
+ }
2047
+ return e;
2048
+ };
2049
+ var resolveGitMounts = (gitPath) => Effect.gen(function* () {
2050
+ const fs = yield* FileSystem.FileSystem;
2051
+ const stat2 = yield* fs.stat(gitPath);
2052
+ if (stat2.type === "Directory") {
2053
+ return [{ hostPath: gitPath, sandboxPath: gitPath }];
2054
+ }
2055
+ const content = (yield* fs.readFileString(gitPath)).trim();
2056
+ const match = content.match(/^gitdir:\s*(.+)$/);
2057
+ if (!match) {
2058
+ return [{ hostPath: gitPath, sandboxPath: gitPath }];
2059
+ }
2060
+ const gitdirPath = match[1];
2061
+ const parentGitDir = resolve(gitdirPath, "..", "..");
2062
+ return [
2063
+ { hostPath: gitPath, sandboxPath: gitPath },
2064
+ { hostPath: parentGitDir, sandboxPath: parentGitDir }
2065
+ ];
2066
+ });
2067
+ var WorktreeDockerSandboxFactory = {
2068
+ layer: Layer.effect(
2069
+ SandboxFactory,
2070
+ Effect.gen(function* () {
2071
+ const {
2072
+ env,
2073
+ hostRepoDir,
2074
+ copyToWorktree: copyPaths,
2075
+ name,
2076
+ sandboxProvider,
2077
+ branchStrategy,
2078
+ hooks,
2079
+ signal,
2080
+ timeouts
2081
+ } = yield* SandboxConfig;
2082
+ const isHeadMode = branchStrategy.type === "head";
2083
+ const branch = branchStrategy.type === "branch" ? branchStrategy.branch : void 0;
2084
+ const baseBranch = branchStrategy.type === "branch" ? branchStrategy.baseBranch : void 0;
2085
+ const fileSystem = yield* FileSystem.FileSystem;
2086
+ const display = yield* Display;
2087
+ const pruneAndCreate = () => pruneStale(hostRepoDir).pipe(
2088
+ Effect.catchAll(
2089
+ (e) => Effect.sync(() => {
2090
+ console.error(
2091
+ "[shipyard] Warning: failed to prune stale worktrees:",
2092
+ e.message
2093
+ );
2094
+ })
2095
+ ),
2096
+ Effect.andThen(
2097
+ branch ? create(hostRepoDir, { branch, baseBranch }) : create(hostRepoDir, { name })
2098
+ ),
2099
+ Effect.provideService(FileSystem.FileSystem, fileSystem)
2100
+ );
2101
+ return {
2102
+ withSandbox: (makeEffect) => {
2103
+ if (sandboxProvider.tag === "none") {
2104
+ let preservedPath;
2105
+ if (isHeadMode) {
2106
+ return (hooks?.host?.onWorktreeReady?.length ? runHostHooks(
2107
+ hooks.host.onWorktreeReady,
2108
+ hostRepoDir,
2109
+ signal
2110
+ ) : Effect.void).pipe(
2111
+ Effect.andThen(
2112
+ Effect.acquireUseRelease(
2113
+ startSandbox({
2114
+ provider: sandboxProvider,
2115
+ hostRepoDir,
2116
+ env,
2117
+ worktreeOrRepoPath: hostRepoDir
2118
+ }),
2119
+ ({ sandbox, worktreePath }) => makeEffect(
2120
+ {
2121
+ hostWorktreePath: hostRepoDir,
2122
+ sandboxRepoPath: worktreePath
2123
+ },
2124
+ sandbox
2125
+ ),
2126
+ ({ handle }) => Effect.tryPromise({
2127
+ try: () => handle.close(),
2128
+ catch: () => void 0
2129
+ }).pipe(Effect.orDie)
2130
+ ).pipe(
2131
+ Effect.map((value) => ({
2132
+ value,
2133
+ preservedWorktreePath: void 0
2134
+ }))
2135
+ )
2136
+ )
2137
+ );
2138
+ }
2139
+ return Effect.acquireUseRelease(
2140
+ pruneAndCreate(),
2141
+ (worktreeInfo) => (copyPaths && copyPaths.length > 0 ? display.spinner(
2142
+ "Copying to worktree",
2143
+ copyToWorktree(
2144
+ copyPaths,
2145
+ hostRepoDir,
2146
+ worktreeInfo.path,
2147
+ timeouts?.copyToWorktreeMs
2148
+ )
2149
+ ) : Effect.succeed(void 0)).pipe(
2150
+ Effect.andThen(
2151
+ hooks?.host?.onWorktreeReady?.length ? runHostHooks(
2152
+ hooks.host.onWorktreeReady,
2153
+ worktreeInfo.path,
2154
+ signal
2155
+ ) : Effect.void
2156
+ ),
2157
+ Effect.andThen(
2158
+ Effect.acquireUseRelease(
2159
+ startSandbox({
2160
+ provider: sandboxProvider,
2161
+ hostRepoDir,
2162
+ env,
2163
+ worktreeOrRepoPath: worktreeInfo.path
2164
+ }),
2165
+ ({ sandbox, worktreePath }) => makeEffect(
2166
+ {
2167
+ hostWorktreePath: worktreeInfo.path,
2168
+ sandboxRepoPath: worktreePath
2169
+ },
2170
+ sandbox
2171
+ ),
2172
+ ({ handle }) => Effect.tryPromise({
2173
+ try: () => handle.close(),
2174
+ catch: () => void 0
2175
+ }).pipe(Effect.orDie)
2176
+ )
2177
+ )
2178
+ ),
2179
+ (worktreeInfo, exit) => cleanupWorktree(worktreeInfo.path, exit).pipe(
2180
+ Effect.tap((p) => {
2181
+ preservedPath = p;
2182
+ }),
2183
+ Effect.asVoid,
2184
+ Effect.orDie
2185
+ )
2186
+ ).pipe(
2187
+ Effect.map((value) => ({
2188
+ value,
2189
+ preservedWorktreePath: preservedPath
2190
+ })),
2191
+ Effect.mapError(
2192
+ (e) => attachPreservedPath(preservedPath, e)
2193
+ )
2194
+ );
2195
+ }
2196
+ if (sandboxProvider.tag === "isolated") {
2197
+ let preservedPath;
2198
+ return Effect.acquireUseRelease(
2199
+ pruneAndCreate(),
2200
+ (worktreeInfo) => (hooks?.host?.onWorktreeReady?.length ? runHostHooks(
2201
+ hooks.host.onWorktreeReady,
2202
+ worktreeInfo.path,
2203
+ signal
2204
+ ) : Effect.void).pipe(
2205
+ Effect.andThen(
2206
+ Effect.acquireUseRelease(
2207
+ startSandbox({
2208
+ provider: sandboxProvider,
2209
+ hostRepoDir: worktreeInfo.path,
2210
+ sourceRepoDir: hostRepoDir,
2211
+ env,
2212
+ copyPaths
2213
+ }),
2214
+ ({ sandbox, worktreePath, handle }) => makeEffect(
2215
+ {
2216
+ hostWorktreePath: worktreeInfo.path,
2217
+ sandboxRepoPath: worktreePath,
2218
+ bindMountHandle: toSessionTransferHandle(handle),
2219
+ applyToHost: () => syncOut(
2220
+ worktreeInfo.path,
2221
+ handle
2222
+ )
2223
+ },
2224
+ sandbox
2225
+ ),
2226
+ ({ handle }) => Effect.tryPromise({
2227
+ try: () => handle.close(),
2228
+ catch: () => void 0
2229
+ }).pipe(Effect.orDie)
2230
+ )
2231
+ )
2232
+ ),
2233
+ (worktreeInfo, exit) => cleanupWorktree(worktreeInfo.path, exit).pipe(
2234
+ Effect.tap((p) => {
2235
+ preservedPath = p;
2236
+ }),
2237
+ Effect.asVoid,
2238
+ Effect.orDie
2239
+ )
2240
+ ).pipe(
2241
+ Effect.map((value) => ({
2242
+ value,
2243
+ preservedWorktreePath: preservedPath
2244
+ })),
2245
+ Effect.mapError(
2246
+ (e) => attachPreservedPath(preservedPath, e)
2247
+ )
2248
+ );
2249
+ }
2250
+ if (isHeadMode) {
2251
+ const gitPath = join(hostRepoDir, ".git");
2252
+ return (hooks?.host?.onWorktreeReady?.length ? runHostHooks(hooks.host.onWorktreeReady, hostRepoDir, signal) : Effect.void).pipe(
2253
+ Effect.andThen(resolveGitMounts(gitPath)),
2254
+ Effect.provideService(FileSystem.FileSystem, fileSystem),
2255
+ Effect.mapError(
2256
+ (e) => new WorktreeError({
2257
+ message: `Failed to resolve git mounts: ${e}`
2258
+ })
2259
+ ),
2260
+ Effect.flatMap(
2261
+ (gitMounts) => (
2262
+ // Patch git mounts for Windows worktree compatibility (ADR-0006)
2263
+ patchGitMountsForWindows(
2264
+ gitMounts,
2265
+ hostRepoDir,
2266
+ SANDBOX_REPO_DIR
2267
+ )
2268
+ )
2269
+ ),
2270
+ Effect.flatMap(
2271
+ (gitMounts) => Effect.acquireUseRelease(
2272
+ startSandbox({
2273
+ provider: sandboxProvider,
2274
+ hostRepoDir,
2275
+ env,
2276
+ worktreeOrRepoPath: hostRepoDir,
2277
+ gitMounts,
2278
+ repoDir: SANDBOX_REPO_DIR
2279
+ }),
2280
+ // Use
2281
+ ({ sandbox, worktreePath, handle }) => makeEffect(
2282
+ {
2283
+ hostWorktreePath: hostRepoDir,
2284
+ sandboxRepoPath: worktreePath,
2285
+ bindMountHandle: handle
2286
+ },
2287
+ sandbox
2288
+ ),
2289
+ // Release
2290
+ ({ handle }) => Effect.tryPromise({
2291
+ try: () => handle.close(),
2292
+ catch: () => void 0
2293
+ }).pipe(Effect.orDie)
2294
+ ).pipe(
2295
+ Effect.map((value) => ({
2296
+ value,
2297
+ preservedWorktreePath: void 0
2298
+ }))
2299
+ )
2300
+ )
2301
+ );
2302
+ }
2303
+ let preservedWorktreePath;
2304
+ return Effect.acquireUseRelease(
2305
+ // Acquire: prune stale worktrees (best-effort), then create the worktree.
2306
+ pruneAndCreate(),
2307
+ // Use: copy files, run host hooks, resolve+patch git mounts, then start
2308
+ // the sandbox under a nested acquireUseRelease.
2309
+ (worktreeInfo) => (copyPaths && copyPaths.length > 0 ? display.spinner(
2310
+ "Copying to worktree",
2311
+ copyToWorktree(
2312
+ copyPaths,
2313
+ hostRepoDir,
2314
+ worktreeInfo.path,
2315
+ timeouts?.copyToWorktreeMs
2316
+ )
2317
+ ) : Effect.succeed(void 0)).pipe(
2318
+ Effect.andThen(
2319
+ hooks?.host?.onWorktreeReady?.length ? runHostHooks(
2320
+ hooks.host.onWorktreeReady,
2321
+ worktreeInfo.path,
2322
+ signal
2323
+ ) : Effect.void
2324
+ ),
2325
+ Effect.andThen(
2326
+ resolveGitMounts(join(hostRepoDir, ".git")).pipe(
2327
+ Effect.provideService(FileSystem.FileSystem, fileSystem),
2328
+ Effect.mapError(
2329
+ (e) => new WorktreeError({
2330
+ message: `Failed to resolve git mounts: ${e}`
2331
+ })
2332
+ )
2333
+ )
2334
+ ),
2335
+ // Patch git mounts for Windows worktree compatibility (ADR-0006)
2336
+ Effect.flatMap(
2337
+ (gitMounts) => patchGitMountsForWindows(
2338
+ gitMounts,
2339
+ worktreeInfo.path,
2340
+ SANDBOX_REPO_DIR
2341
+ )
2342
+ ),
2343
+ Effect.flatMap(
2344
+ (gitMounts) => Effect.acquireUseRelease(
2345
+ // sandboxProvider is guaranteed bind-mount here
2346
+ // (isolated providers return early above)
2347
+ startSandbox({
2348
+ provider: sandboxProvider,
2349
+ hostRepoDir,
2350
+ env,
2351
+ worktreeOrRepoPath: worktreeInfo.path,
2352
+ gitMounts,
2353
+ repoDir: SANDBOX_REPO_DIR
2354
+ }),
2355
+ ({ sandbox, worktreePath, handle }) => makeEffect(
2356
+ {
2357
+ hostWorktreePath: worktreeInfo.path,
2358
+ sandboxRepoPath: worktreePath,
2359
+ bindMountHandle: handle
2360
+ },
2361
+ sandbox
2362
+ ),
2363
+ ({ handle }) => Effect.tryPromise({
2364
+ try: () => handle.close(),
2365
+ catch: () => void 0
2366
+ }).pipe(Effect.orDie)
2367
+ )
2368
+ )
2369
+ ),
2370
+ // Release: remove or preserve the worktree based on dirty state.
2371
+ (worktreeInfo, exit) => cleanupWorktree(worktreeInfo.path, exit).pipe(
2372
+ Effect.tap((p) => {
2373
+ preservedWorktreePath = p;
2374
+ }),
2375
+ Effect.asVoid,
2376
+ Effect.orDie
2377
+ )
2378
+ ).pipe(
2379
+ Effect.map((value) => ({
2380
+ value,
2381
+ preservedWorktreePath
2382
+ })),
2383
+ Effect.mapError(
2384
+ (e) => attachPreservedPath(preservedWorktreePath, e)
2385
+ )
2386
+ );
2387
+ }
2388
+ };
2389
+ })
2390
+ )
2391
+ };
2392
+
2393
+ // src/mountUtils.ts
2394
+ var defaultImageName = (repoDir) => {
2395
+ const dirName = repoDir.replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? "local";
2396
+ const sanitized = dirName.toLowerCase().replace(/[^a-z0-9_.-]/g, "-");
2397
+ return `${RUNTIME_NAMESPACE}:${sanitized || "local"}`;
2398
+ };
2399
+ var expandTilde = (p, homeDirPath) => {
2400
+ const home = homeDirPath ?? homedir();
2401
+ if (p === "~") return home;
2402
+ if (p.startsWith("~/") || p.startsWith("~\\")) return home + "/" + p.slice(2);
2403
+ return p;
2404
+ };
2405
+ var resolveHostPath = (hostPath) => {
2406
+ const expanded = expandTilde(hostPath);
2407
+ return isAbsolute(expanded) ? expanded : resolve(process.cwd(), expanded);
2408
+ };
2409
+ var resolveSandboxPath = (sandboxPath, sandboxHomedir) => {
2410
+ const hasTilde = sandboxPath === "~" || sandboxPath.startsWith("~/") || sandboxPath.startsWith("~\\");
2411
+ if (hasTilde && sandboxHomedir === void 0) {
2412
+ throw new Error(
2413
+ `sandboxPath "${sandboxPath}" contains a tilde but the provider has no sandboxHomedir set`
2414
+ );
2415
+ }
2416
+ const expanded = hasTilde ? expandTilde(sandboxPath, sandboxHomedir) : sandboxPath;
2417
+ return isAbsolute(expanded) ? resolve(expanded) : resolve(SANDBOX_REPO_DIR, expanded);
2418
+ };
2419
+ var resolveUserMounts = (mounts, sandboxHomedir) => mounts.map((m) => {
2420
+ const resolvedHostPath = resolveHostPath(m.hostPath);
2421
+ if (!existsSync(resolvedHostPath)) {
2422
+ throw new Error(
2423
+ `Mount hostPath does not exist: ${m.hostPath}` + (m.hostPath !== resolvedHostPath ? ` (resolved to ${resolvedHostPath})` : "")
2424
+ );
2425
+ }
2426
+ return {
2427
+ hostPath: resolvedHostPath,
2428
+ sandboxPath: resolveSandboxPath(m.sandboxPath, sandboxHomedir),
2429
+ ...m.readonly ? { readonly: true } : {}
2430
+ };
2431
+ });
2432
+ var normalizeMounts = (mounts, worktreeHostPath, sandboxRepoDir, platform = process.platform) => {
2433
+ if (platform !== "win32") return mounts;
2434
+ const normalizedWorktree = worktreeHostPath.replace(/\\/g, "/");
2435
+ return mounts.map((m) => {
2436
+ const hostPath = m.hostPath.replace(/\\/g, "/");
2437
+ let sandboxPath = m.sandboxPath;
2438
+ if (/^[A-Za-z]:[/\\]/.test(sandboxPath) || sandboxPath.includes("\\")) {
2439
+ const normalizedSandboxPath = sandboxPath.replace(/\\/g, "/");
2440
+ if (normalizedSandboxPath.startsWith(normalizedWorktree + "/")) {
2441
+ const relativeSuffix = normalizedSandboxPath.slice(
2442
+ normalizedWorktree.length
2443
+ );
2444
+ sandboxPath = sandboxRepoDir + relativeSuffix;
2445
+ } else if (normalizedSandboxPath === normalizedWorktree) {
2446
+ sandboxPath = sandboxRepoDir;
2447
+ } else {
2448
+ sandboxPath = normalizedSandboxPath;
2449
+ }
2450
+ }
2451
+ return { ...m, hostPath, sandboxPath };
2452
+ });
2453
+ };
2454
+ var parseGitdirPath = (gitdirPath) => {
2455
+ const normalized = gitdirPath.replace(/\\/g, "/").replace(/\/+$/, "");
2456
+ const segments = normalized.split("/");
2457
+ const worktreeName = segments.pop();
2458
+ segments.pop();
2459
+ const parentGitDir = segments.join("/");
2460
+ return { worktreeName, parentGitDir };
2461
+ };
2462
+ var patchGitMountsForWindows = (gitMounts, worktreeHostPath, sandboxRepoDir, readFile2, statFile, platform = process.platform) => Effect.gen(function* () {
2463
+ if (platform !== "win32") return gitMounts;
2464
+ const _readFile = readFile2 ?? (async (p) => {
2465
+ const { readFile: rf } = await import('fs/promises');
2466
+ return rf(p, "utf-8");
2467
+ });
2468
+ const _statFile = statFile ?? (async (p) => {
2469
+ const { stat: stat2 } = await import('fs/promises');
2470
+ const s = await stat2(p);
2471
+ return s.isDirectory() ? "directory" : "file";
2472
+ });
2473
+ const gitEntryPath = join(worktreeHostPath, ".git");
2474
+ const gitEntryType = yield* Effect.tryPromise({
2475
+ try: () => _statFile(gitEntryPath),
2476
+ catch: () => null
2477
+ }).pipe(Effect.catchAll(() => Effect.succeed(null)));
2478
+ if (gitEntryType === null) return gitMounts;
2479
+ if (gitEntryType === "directory") return gitMounts;
2480
+ const content = yield* Effect.tryPromise({
2481
+ try: () => _readFile(gitEntryPath),
2482
+ catch: () => null
2483
+ }).pipe(
2484
+ Effect.map((s) => s.trim()),
2485
+ Effect.catchAll(() => Effect.succeed(null))
2486
+ );
2487
+ if (content === null) return gitMounts;
2488
+ const match = content.match(/^gitdir:\s*(.+)$/);
2489
+ if (!match) return gitMounts;
2490
+ const gitdirPath = match[1];
2491
+ const { parentGitDir, worktreeName } = parseGitdirPath(gitdirPath);
2492
+ const correctedGitdir = `${PARENT_GIT_SANDBOX_DIR}/worktrees/${worktreeName}`;
2493
+ const tempDir = yield* Effect.tryPromise({
2494
+ try: () => mkdtemp(join(tmpdir(), `${CONTAINER_NAME_PREFIX}git-`)),
2495
+ catch: (e) => new WorktreeError({
2496
+ message: `Failed to create temp dir for git override: ${e instanceof Error ? e.message : String(e)}`
2497
+ })
2498
+ });
2499
+ const tempGitFile = join(tempDir, "git-override");
2500
+ yield* Effect.tryPromise({
2501
+ try: () => writeFile(tempGitFile, `gitdir: ${correctedGitdir}
2502
+ `),
2503
+ catch: (e) => new WorktreeError({
2504
+ message: `Failed to write git override file: ${e instanceof Error ? e.message : String(e)}`
2505
+ })
2506
+ });
2507
+ const normalizedParentGitDir = parentGitDir.replace(/\\/g, "/");
2508
+ const gitFileHostPath = gitEntryPath.replace(/\\/g, "/");
2509
+ const correctedMounts = [];
2510
+ let replacedGitFile = false;
2511
+ for (const m of gitMounts) {
2512
+ const normalizedHostPath = m.hostPath.replace(/\\/g, "/");
2513
+ if (normalizedHostPath === normalizedParentGitDir) {
2514
+ correctedMounts.push({ ...m, sandboxPath: PARENT_GIT_SANDBOX_DIR });
2515
+ } else if (normalizedHostPath === gitFileHostPath) {
2516
+ correctedMounts.push({
2517
+ ...m,
2518
+ hostPath: tempGitFile,
2519
+ sandboxPath: `${sandboxRepoDir}/.git`
2520
+ });
2521
+ replacedGitFile = true;
2522
+ } else {
2523
+ correctedMounts.push(m);
2524
+ }
2525
+ }
2526
+ if (!replacedGitFile) {
2527
+ correctedMounts.push({
2528
+ hostPath: tempGitFile,
2529
+ sandboxPath: `${sandboxRepoDir}/.git`
2530
+ });
2531
+ }
2532
+ return correctedMounts;
2533
+ });
2534
+ var formatVolumeMount = (mount, selinuxLabel) => {
2535
+ const base = `${mount.hostPath}:${mount.sandboxPath}`;
2536
+ const options = [mount.readonly ? "ro" : void 0, selinuxLabel || void 0].filter((option) => option !== void 0).join(",");
2537
+ return options ? `${base}:${options}` : base;
2538
+ };
2539
+ var processFileMountParents = (mounts, sandboxHomedir, statFn = statSync) => {
2540
+ const parentDirs = /* @__PURE__ */ new Set();
2541
+ for (const mount of mounts) {
2542
+ let isFile;
2543
+ try {
2544
+ isFile = statFn(mount.hostPath).isFile();
2545
+ } catch {
2546
+ continue;
2547
+ }
2548
+ if (!isFile) continue;
2549
+ const parentDir = resolve(dirname(mount.sandboxPath));
2550
+ const homeDir = resolve(sandboxHomedir);
2551
+ if (parentDir === homeDir) continue;
2552
+ const fromHome = relative(homeDir, parentDir);
2553
+ if (fromHome === ".." || fromHome.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(fromHome)) {
2554
+ throw new Error(
2555
+ `Cannot mount file to '${mount.sandboxPath}': parent directory '${parentDir}' is outside the sandbox home directory ('${homeDir}'). Mount the parent directory instead, or rebuild the image with '${parentDir}' pre-created.`
2556
+ );
2557
+ }
2558
+ parentDirs.add(parentDir);
2559
+ }
2560
+ return [...parentDirs];
2561
+ };
2562
+ var assertIsolatedWorkspaceMounts = (mounts) => {
2563
+ for (const mount of mounts) {
2564
+ const path = posix.resolve(mount.sandboxPath);
2565
+ if (path === "/" || path === SANDBOX_REPO_DIR || path.startsWith(`${SANDBOX_REPO_DIR}/`) || SANDBOX_REPO_DIR.startsWith(`${path}/`)) {
2566
+ throw new Error(
2567
+ `Mount overlaps the isolated workspace: ${mount.sandboxPath}. Mount outside ${SANDBOX_REPO_DIR} or use copyToWorktree.`
2568
+ );
2569
+ }
2570
+ }
2571
+ };
2572
+
2573
+ // src/shutdownRegistry.ts
2574
+ var teardownCallbacks = /* @__PURE__ */ new Set();
2575
+ var listenersInstalled = false;
2576
+ var runTeardowns = () => {
2577
+ for (const teardown of teardownCallbacks) {
2578
+ try {
2579
+ teardown();
2580
+ } catch {
2581
+ }
2582
+ }
2583
+ };
2584
+ var handleExit = () => {
2585
+ runTeardowns();
2586
+ };
2587
+ var handleSignal = () => {
2588
+ detachListeners();
2589
+ runTeardowns();
2590
+ process.exit(1);
2591
+ };
2592
+ var attachListeners = () => {
2593
+ if (listenersInstalled) return;
2594
+ listenersInstalled = true;
2595
+ process.on("exit", handleExit);
2596
+ process.on("SIGINT", handleSignal);
2597
+ process.on("SIGTERM", handleSignal);
2598
+ };
2599
+ var detachListeners = () => {
2600
+ if (!listenersInstalled) return;
2601
+ listenersInstalled = false;
2602
+ process.removeListener("exit", handleExit);
2603
+ process.removeListener("SIGINT", handleSignal);
2604
+ process.removeListener("SIGTERM", handleSignal);
2605
+ };
2606
+ var registerShutdown = (teardown) => {
2607
+ teardownCallbacks.add(teardown);
2608
+ attachListeners();
2609
+ let active = true;
2610
+ return () => {
2611
+ if (!active) return;
2612
+ active = false;
2613
+ teardownCallbacks.delete(teardown);
2614
+ if (teardownCallbacks.size === 0) detachListeners();
2615
+ };
2616
+ };
2617
+
2618
+ export { AgentError, AgentIdleTimeoutError, CLI_NAME, CONFIG_DIR, ClackDisplay, ConfigDirError, CwdError, Display, DockerError, ExecHostError, FileDisplay, InitError, LOCKS_DIR, LOGS_DIR, PRODUCT_NAME, PromptError, PromptExpansionTimeoutError, SANDBOX_REPO_DIR, SandboxConfig, SandboxFactory, SessionCaptureError, SilentDisplay, WorktreeDockerSandboxFactory, assertIsolatedWorkspaceMounts, assertNoSymlinkComponents, copyToWorktree, create, defaultImageName, formatVolumeMount, generateTempBranchName, getCurrentBranch, hasUncommittedChanges, makeSandboxFromHandle, patchGitMountsForWindows, processFileMountParents, pruneStale, registerShutdown, remove, resolveCwd, resolveGitMounts, resolveUserMounts, runHostHooks, startSandbox, syncOut, withSandboxLifecycle };
2619
+ //# sourceMappingURL=chunk-2GT3OPO5.js.map
2620
+ //# sourceMappingURL=chunk-2GT3OPO5.js.map