@remixhq/claude-plugin 0.1.16 → 0.1.18

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.
@@ -37,8 +37,8 @@ var require_windows = __commonJS({
37
37
  "use strict";
38
38
  module2.exports = isexe;
39
39
  isexe.sync = sync;
40
- var fs9 = require("fs");
41
- function checkPathExt(path13, options) {
40
+ var fs8 = require("fs");
41
+ function checkPathExt(path12, options) {
42
42
  var pathext = options.pathExt !== void 0 ? options.pathExt : process.env.PATHEXT;
43
43
  if (!pathext) {
44
44
  return true;
@@ -49,25 +49,25 @@ var require_windows = __commonJS({
49
49
  }
50
50
  for (var i2 = 0; i2 < pathext.length; i2++) {
51
51
  var p = pathext[i2].toLowerCase();
52
- if (p && path13.substr(-p.length).toLowerCase() === p) {
52
+ if (p && path12.substr(-p.length).toLowerCase() === p) {
53
53
  return true;
54
54
  }
55
55
  }
56
56
  return false;
57
57
  }
58
- function checkStat(stat, path13, options) {
58
+ function checkStat(stat, path12, options) {
59
59
  if (!stat.isSymbolicLink() && !stat.isFile()) {
60
60
  return false;
61
61
  }
62
- return checkPathExt(path13, options);
62
+ return checkPathExt(path12, options);
63
63
  }
64
- function isexe(path13, options, cb) {
65
- fs9.stat(path13, function(er, stat) {
66
- cb(er, er ? false : checkStat(stat, path13, options));
64
+ function isexe(path12, options, cb) {
65
+ fs8.stat(path12, function(er, stat) {
66
+ cb(er, er ? false : checkStat(stat, path12, options));
67
67
  });
68
68
  }
69
- function sync(path13, options) {
70
- return checkStat(fs9.statSync(path13), path13, options);
69
+ function sync(path12, options) {
70
+ return checkStat(fs8.statSync(path12), path12, options);
71
71
  }
72
72
  }
73
73
  });
@@ -78,14 +78,14 @@ var require_mode = __commonJS({
78
78
  "use strict";
79
79
  module2.exports = isexe;
80
80
  isexe.sync = sync;
81
- var fs9 = require("fs");
82
- function isexe(path13, options, cb) {
83
- fs9.stat(path13, function(er, stat) {
81
+ var fs8 = require("fs");
82
+ function isexe(path12, options, cb) {
83
+ fs8.stat(path12, function(er, stat) {
84
84
  cb(er, er ? false : checkStat(stat, options));
85
85
  });
86
86
  }
87
- function sync(path13, options) {
88
- return checkStat(fs9.statSync(path13), options);
87
+ function sync(path12, options) {
88
+ return checkStat(fs8.statSync(path12), options);
89
89
  }
90
90
  function checkStat(stat, options) {
91
91
  return stat.isFile() && checkMode(stat, options);
@@ -110,7 +110,7 @@ var require_mode = __commonJS({
110
110
  var require_isexe = __commonJS({
111
111
  "node_modules/isexe/index.js"(exports2, module2) {
112
112
  "use strict";
113
- var fs9 = require("fs");
113
+ var fs8 = require("fs");
114
114
  var core;
115
115
  if (process.platform === "win32" || global.TESTING_WINDOWS) {
116
116
  core = require_windows();
@@ -119,7 +119,7 @@ var require_isexe = __commonJS({
119
119
  }
120
120
  module2.exports = isexe;
121
121
  isexe.sync = sync;
122
- function isexe(path13, options, cb) {
122
+ function isexe(path12, options, cb) {
123
123
  if (typeof options === "function") {
124
124
  cb = options;
125
125
  options = {};
@@ -129,7 +129,7 @@ var require_isexe = __commonJS({
129
129
  throw new TypeError("callback not provided");
130
130
  }
131
131
  return new Promise(function(resolve, reject) {
132
- isexe(path13, options || {}, function(er, is) {
132
+ isexe(path12, options || {}, function(er, is) {
133
133
  if (er) {
134
134
  reject(er);
135
135
  } else {
@@ -138,7 +138,7 @@ var require_isexe = __commonJS({
138
138
  });
139
139
  });
140
140
  }
141
- core(path13, options || {}, function(er, is) {
141
+ core(path12, options || {}, function(er, is) {
142
142
  if (er) {
143
143
  if (er.code === "EACCES" || options && options.ignoreErrors) {
144
144
  er = null;
@@ -148,9 +148,9 @@ var require_isexe = __commonJS({
148
148
  cb(er, is);
149
149
  });
150
150
  }
151
- function sync(path13, options) {
151
+ function sync(path12, options) {
152
152
  try {
153
- return core.sync(path13, options || {});
153
+ return core.sync(path12, options || {});
154
154
  } catch (er) {
155
155
  if (options && options.ignoreErrors || er.code === "EACCES") {
156
156
  return false;
@@ -167,7 +167,7 @@ var require_which = __commonJS({
167
167
  "node_modules/which/which.js"(exports2, module2) {
168
168
  "use strict";
169
169
  var isWindows = process.platform === "win32" || process.env.OSTYPE === "cygwin" || process.env.OSTYPE === "msys";
170
- var path13 = require("path");
170
+ var path12 = require("path");
171
171
  var COLON = isWindows ? ";" : ":";
172
172
  var isexe = require_isexe();
173
173
  var getNotFoundError = (cmd) => Object.assign(new Error(`not found: ${cmd}`), { code: "ENOENT" });
@@ -205,7 +205,7 @@ var require_which = __commonJS({
205
205
  return opt.all && found.length ? resolve(found) : reject(getNotFoundError(cmd));
206
206
  const ppRaw = pathEnv[i2];
207
207
  const pathPart = /^".*"$/.test(ppRaw) ? ppRaw.slice(1, -1) : ppRaw;
208
- const pCmd = path13.join(pathPart, cmd);
208
+ const pCmd = path12.join(pathPart, cmd);
209
209
  const p = !pathPart && /^\.[\\\/]/.test(cmd) ? cmd.slice(0, 2) + pCmd : pCmd;
210
210
  resolve(subStep(p, i2, 0));
211
211
  });
@@ -232,7 +232,7 @@ var require_which = __commonJS({
232
232
  for (let i2 = 0; i2 < pathEnv.length; i2++) {
233
233
  const ppRaw = pathEnv[i2];
234
234
  const pathPart = /^".*"$/.test(ppRaw) ? ppRaw.slice(1, -1) : ppRaw;
235
- const pCmd = path13.join(pathPart, cmd);
235
+ const pCmd = path12.join(pathPart, cmd);
236
236
  const p = !pathPart && /^\.[\\\/]/.test(cmd) ? cmd.slice(0, 2) + pCmd : pCmd;
237
237
  for (let j = 0; j < pathExt.length; j++) {
238
238
  const cur = p + pathExt[j];
@@ -280,7 +280,7 @@ var require_path_key = __commonJS({
280
280
  var require_resolveCommand = __commonJS({
281
281
  "node_modules/cross-spawn/lib/util/resolveCommand.js"(exports2, module2) {
282
282
  "use strict";
283
- var path13 = require("path");
283
+ var path12 = require("path");
284
284
  var which = require_which();
285
285
  var getPathKey = require_path_key();
286
286
  function resolveCommandAttempt(parsed, withoutPathExt) {
@@ -298,7 +298,7 @@ var require_resolveCommand = __commonJS({
298
298
  try {
299
299
  resolved = which.sync(parsed.command, {
300
300
  path: env[getPathKey({ env })],
301
- pathExt: withoutPathExt ? path13.delimiter : void 0
301
+ pathExt: withoutPathExt ? path12.delimiter : void 0
302
302
  });
303
303
  } catch (e) {
304
304
  } finally {
@@ -307,7 +307,7 @@ var require_resolveCommand = __commonJS({
307
307
  }
308
308
  }
309
309
  if (resolved) {
310
- resolved = path13.resolve(hasCustomCwd ? parsed.options.cwd : "", resolved);
310
+ resolved = path12.resolve(hasCustomCwd ? parsed.options.cwd : "", resolved);
311
311
  }
312
312
  return resolved;
313
313
  }
@@ -361,8 +361,8 @@ var require_shebang_command = __commonJS({
361
361
  if (!match) {
362
362
  return null;
363
363
  }
364
- const [path13, argument] = match[0].replace(/#! ?/, "").split(" ");
365
- const binary = path13.split("/").pop();
364
+ const [path12, argument] = match[0].replace(/#! ?/, "").split(" ");
365
+ const binary = path12.split("/").pop();
366
366
  if (binary === "env") {
367
367
  return argument;
368
368
  }
@@ -375,16 +375,16 @@ var require_shebang_command = __commonJS({
375
375
  var require_readShebang = __commonJS({
376
376
  "node_modules/cross-spawn/lib/util/readShebang.js"(exports2, module2) {
377
377
  "use strict";
378
- var fs9 = require("fs");
378
+ var fs8 = require("fs");
379
379
  var shebangCommand = require_shebang_command();
380
380
  function readShebang(command) {
381
381
  const size = 150;
382
382
  const buffer = Buffer.alloc(size);
383
383
  let fd;
384
384
  try {
385
- fd = fs9.openSync(command, "r");
386
- fs9.readSync(fd, buffer, 0, size, 0);
387
- fs9.closeSync(fd);
385
+ fd = fs8.openSync(command, "r");
386
+ fs8.readSync(fd, buffer, 0, size, 0);
387
+ fs8.closeSync(fd);
388
388
  } catch (e) {
389
389
  }
390
390
  return shebangCommand(buffer.toString());
@@ -397,7 +397,7 @@ var require_readShebang = __commonJS({
397
397
  var require_parse = __commonJS({
398
398
  "node_modules/cross-spawn/lib/parse.js"(exports2, module2) {
399
399
  "use strict";
400
- var path13 = require("path");
400
+ var path12 = require("path");
401
401
  var resolveCommand = require_resolveCommand();
402
402
  var escape = require_escape();
403
403
  var readShebang = require_readShebang();
@@ -422,7 +422,7 @@ var require_parse = __commonJS({
422
422
  const needsShell = !isExecutableRegExp.test(commandFile);
423
423
  if (parsed.options.forceShell || needsShell) {
424
424
  const needsDoubleEscapeMetaChars = isCmdShimRegExp.test(commandFile);
425
- parsed.command = path13.normalize(parsed.command);
425
+ parsed.command = path12.normalize(parsed.command);
426
426
  parsed.command = escape.command(parsed.command);
427
427
  parsed.args = parsed.args.map((arg) => escape.argument(arg, needsDoubleEscapeMetaChars));
428
428
  const shellCommand = [parsed.command].concat(parsed.args).join(" ");
@@ -512,7 +512,7 @@ var require_cross_spawn = __commonJS({
512
512
  var cp = require("child_process");
513
513
  var parse = require_parse();
514
514
  var enoent = require_enoent();
515
- function spawn2(command, args, options) {
515
+ function spawn3(command, args, options) {
516
516
  const parsed = parse(command, args, options);
517
517
  const spawned = cp.spawn(parsed.command, parsed.args, parsed.options);
518
518
  enoent.hookChildProcess(spawned, parsed);
@@ -524,8 +524,8 @@ var require_cross_spawn = __commonJS({
524
524
  result.error = result.error || enoent.verifyENOENTSync(result.status, parsed);
525
525
  return result;
526
526
  }
527
- module2.exports = spawn2;
528
- module2.exports.spawn = spawn2;
527
+ module2.exports = spawn3;
528
+ module2.exports.spawn = spawn3;
529
529
  module2.exports.sync = spawnSync2;
530
530
  module2.exports._parse = parse;
531
531
  module2.exports._enoent = enoent;
@@ -538,15 +538,7 @@ __export(hook_stop_collab_exports, {
538
538
  runHookStopCollab: () => runHookStopCollab
539
539
  });
540
540
  module.exports = __toCommonJS(hook_stop_collab_exports);
541
-
542
- // node_modules/@remixhq/core/dist/chunk-GC2MOT3U.js
543
- var REMIX_ERROR_CODES = {
544
- REPO_LOCK_HELD: "REPO_LOCK_HELD",
545
- REPO_LOCK_TIMEOUT: "REPO_LOCK_TIMEOUT",
546
- REPO_LOCK_STALE_RECOVERED: "REPO_LOCK_STALE_RECOVERED",
547
- REPO_STATE_CHANGED_DURING_OPERATION: "REPO_STATE_CHANGED_DURING_OPERATION",
548
- PREFERRED_BRANCH_MISMATCH: "PREFERRED_BRANCH_MISMATCH"
549
- };
541
+ var import_node_child_process6 = require("child_process");
550
542
 
551
543
  // node_modules/@remixhq/core/dist/chunk-YZ34ICNN.js
552
544
  var RemixError = class extends Error {
@@ -562,12 +554,6 @@ var RemixError = class extends Error {
562
554
  }
563
555
  };
564
556
 
565
- // node_modules/@remixhq/core/dist/chunk-RREREIGW.js
566
- var import_promises12 = __toESM(require("fs/promises"), 1);
567
- var import_crypto = require("crypto");
568
- var import_os = __toESM(require("os"), 1);
569
- var import_path = __toESM(require("path"), 1);
570
-
571
557
  // node_modules/is-plain-obj/index.js
572
558
  function isPlainObject(value) {
573
559
  if (typeof value !== "object" || value === null) {
@@ -4949,13 +4935,13 @@ var logOutputSync = ({ serializedResult, fdNumber, state, verboseInfo, encoding,
4949
4935
  }
4950
4936
  };
4951
4937
  var writeToFiles = (serializedResult, stdioItems, outputFiles) => {
4952
- for (const { path: path13, append } of stdioItems.filter(({ type }) => FILE_TYPES.has(type))) {
4953
- const pathString = typeof path13 === "string" ? path13 : path13.toString();
4938
+ for (const { path: path12, append } of stdioItems.filter(({ type }) => FILE_TYPES.has(type))) {
4939
+ const pathString = typeof path12 === "string" ? path12 : path12.toString();
4954
4940
  if (append || outputFiles.has(pathString)) {
4955
- (0, import_node_fs4.appendFileSync)(path13, serializedResult);
4941
+ (0, import_node_fs4.appendFileSync)(path12, serializedResult);
4956
4942
  } else {
4957
4943
  outputFiles.add(pathString);
4958
- (0, import_node_fs4.writeFileSync)(path13, serializedResult);
4944
+ (0, import_node_fs4.writeFileSync)(path12, serializedResult);
4959
4945
  }
4960
4946
  }
4961
4947
  };
@@ -7343,131 +7329,11 @@ var {
7343
7329
  getCancelSignal: getCancelSignal2
7344
7330
  } = getIpcExport();
7345
7331
 
7346
- // node_modules/@remixhq/core/dist/chunk-RREREIGW.js
7332
+ // node_modules/@remixhq/core/dist/chunk-WT6VRLXU.js
7347
7333
  async function runGit(args, cwd) {
7348
7334
  const res = await execa("git", args, { cwd, stderr: "ignore" });
7349
7335
  return String(res.stdout || "").trim();
7350
7336
  }
7351
- async function runGitWithEnv(args, cwd, env) {
7352
- const res = await execa("git", args, { cwd, env, stderr: "ignore" });
7353
- return String(res.stdout || "").trim();
7354
- }
7355
- async function runGitRawWithEnv(args, cwd, env) {
7356
- const res = await execa("git", args, { cwd, env, stderr: "ignore", stripFinalNewline: false });
7357
- return String(res.stdout || "");
7358
- }
7359
- async function runGitDetailed(args, cwd) {
7360
- const res = await execa("git", args, { cwd, reject: false });
7361
- return {
7362
- exitCode: res.exitCode ?? 1,
7363
- stdout: String(res.stdout || ""),
7364
- stderr: String(res.stderr || "")
7365
- };
7366
- }
7367
- function normalizeRepoRelativePath(value) {
7368
- const normalized = import_path.default.posix.normalize(value.replace(/\\/g, "/").trim());
7369
- if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../") || import_path.default.posix.isAbsolute(normalized)) {
7370
- throw new RemixError("Git returned an invalid repository-relative path.", {
7371
- exitCode: 1,
7372
- hint: `Path: ${value}`
7373
- });
7374
- }
7375
- return normalized;
7376
- }
7377
- function resolveRepoRelativePath(repoRoot, relativePath) {
7378
- return import_path.default.resolve(repoRoot, ...relativePath.split("/"));
7379
- }
7380
- function isWithinRepoRoot(repoRoot, targetPath) {
7381
- const relative = import_path.default.relative(repoRoot, targetPath);
7382
- return relative === "" || !relative.startsWith("..") && !import_path.default.isAbsolute(relative);
7383
- }
7384
- function sha256Hex(value) {
7385
- return (0, import_crypto.createHash)("sha256").update(value).digest("hex");
7386
- }
7387
- function resolveGitReportedPath(cwd, reportedPath) {
7388
- const value = reportedPath.trim();
7389
- if (!value) {
7390
- throw new RemixError("Git returned an empty internal path.", { exitCode: 1 });
7391
- }
7392
- return import_path.default.isAbsolute(value) ? value : import_path.default.resolve(cwd, value);
7393
- }
7394
- function parseGitNameStatusZ(stdout) {
7395
- const tokens = stdout.split("\0");
7396
- const entries = [];
7397
- for (let i2 = 0; i2 < tokens.length; i2 += 1) {
7398
- const rawToken = tokens[i2];
7399
- if (!rawToken) continue;
7400
- let status = rawToken;
7401
- let inlinePath = null;
7402
- const tabIdx = rawToken.indexOf(" ");
7403
- if (tabIdx >= 0) {
7404
- status = rawToken.slice(0, tabIdx);
7405
- inlinePath = rawToken.slice(tabIdx + 1) || null;
7406
- }
7407
- const kind = status[0]?.toUpperCase() ?? "";
7408
- if (kind === "R" || kind === "C") {
7409
- const oldPath = inlinePath ?? tokens[++i2] ?? "";
7410
- const newPath = tokens[++i2] ?? "";
7411
- if (!newPath) continue;
7412
- entries.push({
7413
- status,
7414
- path: normalizeRepoRelativePath(newPath),
7415
- oldPath: oldPath ? normalizeRepoRelativePath(oldPath) : null
7416
- });
7417
- continue;
7418
- }
7419
- const nextPath = inlinePath ?? tokens[++i2] ?? "";
7420
- if (!nextPath) continue;
7421
- entries.push({
7422
- status,
7423
- path: normalizeRepoRelativePath(nextPath),
7424
- oldPath: null
7425
- });
7426
- }
7427
- return entries;
7428
- }
7429
- function buildStagePlan(entries) {
7430
- const updatePaths = /* @__PURE__ */ new Set();
7431
- const addPaths = /* @__PURE__ */ new Set();
7432
- for (const entry of entries) {
7433
- const kind = entry.status[0]?.toUpperCase() ?? "";
7434
- if (kind === "A" || kind === "C") {
7435
- addPaths.add(entry.path);
7436
- continue;
7437
- }
7438
- if (kind === "R") {
7439
- if (entry.oldPath) updatePaths.add(entry.oldPath);
7440
- addPaths.add(entry.path);
7441
- continue;
7442
- }
7443
- updatePaths.add(entry.path);
7444
- }
7445
- const expectedPaths = /* @__PURE__ */ new Set([...updatePaths, ...addPaths]);
7446
- return {
7447
- updatePaths: Array.from(updatePaths).sort(),
7448
- addPaths: Array.from(addPaths).sort(),
7449
- expectedPaths: Array.from(expectedPaths).sort()
7450
- };
7451
- }
7452
- function classifyGitApplyFailure(detail) {
7453
- const normalized = String(detail ?? "").toLowerCase();
7454
- if (normalized.includes("corrupt patch") || normalized.includes("patch with only garbage") || normalized.includes("unrecognized input") || normalized.includes("malformed patch") || normalized.includes("patch fragment without header")) {
7455
- return "malformed_patch";
7456
- }
7457
- if (normalized.includes("patch failed") || normalized.includes("does not apply") || normalized.includes("merge conflict") || normalized.includes("conflict")) {
7458
- return "apply_conflict";
7459
- }
7460
- return "unknown";
7461
- }
7462
- async function pruneEmptyParentDirectories(repoRoot, filePath) {
7463
- let current = import_path.default.dirname(filePath);
7464
- while (current !== repoRoot && isWithinRepoRoot(repoRoot, current)) {
7465
- const entries = await import_promises12.default.readdir(current).catch(() => null);
7466
- if (!entries || entries.length > 0) return;
7467
- await import_promises12.default.rmdir(current).catch(() => void 0);
7468
- current = import_path.default.dirname(current);
7469
- }
7470
- }
7471
7337
  async function findGitRoot(startDir) {
7472
7338
  try {
7473
7339
  const root = await runGit(["rev-parse", "--show-toplevel"], startDir);
@@ -7480,17 +7346,6 @@ async function findGitRoot(startDir) {
7480
7346
  });
7481
7347
  }
7482
7348
  }
7483
- async function getGitCommonDir(cwd) {
7484
- try {
7485
- const commonDir = await runGit(["rev-parse", "--git-common-dir"], cwd);
7486
- return resolveGitReportedPath(cwd, commonDir);
7487
- } catch {
7488
- throw new RemixError("Failed to resolve the git common directory.", {
7489
- exitCode: 1,
7490
- hint: "Ensure the current working directory is inside a valid git repository."
7491
- });
7492
- }
7493
- }
7494
7349
  async function getCurrentBranch(cwd) {
7495
7350
  try {
7496
7351
  const branch = await runGit(["branch", "--show-current"], cwd);
@@ -7499,121 +7354,6 @@ async function getCurrentBranch(cwd) {
7499
7354
  return null;
7500
7355
  }
7501
7356
  }
7502
- async function listUntrackedFiles(cwd) {
7503
- try {
7504
- const out = await runGit(["ls-files", "--others", "--exclude-standard"], cwd);
7505
- return out.split("\n").map((line) => line.trim()).filter(Boolean);
7506
- } catch {
7507
- return [];
7508
- }
7509
- }
7510
- async function getWorkspaceDiff(cwd) {
7511
- const snapshot = await getWorkspaceSnapshot(cwd);
7512
- return {
7513
- diff: snapshot.diff,
7514
- includedUntrackedPaths: snapshot.includedUntrackedPaths
7515
- };
7516
- }
7517
- async function getWorkspaceSnapshot(cwd) {
7518
- const headCommitHash = await getHeadCommitHash(cwd);
7519
- if (!headCommitHash) {
7520
- throw new RemixError("Failed to resolve local HEAD commit.", { exitCode: 1 });
7521
- }
7522
- const includedUntrackedPaths = Array.from(new Set((await listUntrackedFiles(cwd)).map((entry) => normalizeRepoRelativePath(entry))));
7523
- const tempDir = await import_promises12.default.mkdtemp(import_path.default.join(import_os.default.tmpdir(), "remix-index-"));
7524
- const tempIndexPath = import_path.default.join(tempDir, "index");
7525
- const env = { ...process.env, GIT_INDEX_FILE: tempIndexPath };
7526
- try {
7527
- try {
7528
- await runGitWithEnv(["read-tree", "HEAD"], cwd, env);
7529
- await runGitWithEnv(["add", "-A", "--", "."], cwd, env);
7530
- const diff = await runGitRawWithEnv(["diff", "--binary", "--no-ext-diff", "--cached", "HEAD"], cwd, env);
7531
- const nameStatus = await runGitRawWithEnv(["diff", "--name-status", "--find-renames", "-z", "--cached", "HEAD"], cwd, env);
7532
- const pathEntries = parseGitNameStatusZ(nameStatus);
7533
- return {
7534
- diff,
7535
- diffSha256: sha256Hex(diff),
7536
- includedUntrackedPaths,
7537
- pathEntries,
7538
- stagePlan: buildStagePlan(pathEntries)
7539
- };
7540
- } catch {
7541
- throw new RemixError("Failed to generate workspace diff.", {
7542
- exitCode: 1,
7543
- hint: "Git could not snapshot the current workspace into an isolated index."
7544
- });
7545
- }
7546
- } finally {
7547
- await import_promises12.default.rm(tempDir, { recursive: true, force: true }).catch(() => void 0);
7548
- }
7549
- }
7550
- async function writeTempUnifiedDiffBackup(diff, prefix = "remix-add-backup") {
7551
- const safePrefix = prefix.replace(/[^a-zA-Z0-9._-]+/g, "-") || "remix-add-backup";
7552
- const tmpDir = await import_promises12.default.mkdtemp(import_path.default.join(import_os.default.tmpdir(), `${safePrefix}-`));
7553
- const backupPath = import_path.default.join(tmpDir, "submitted.diff");
7554
- await import_promises12.default.writeFile(backupPath, diff, "utf8");
7555
- return { backupPath, diffSha256: sha256Hex(diff) };
7556
- }
7557
- async function validateUnifiedDiff(cwd, diff) {
7558
- const { backupPath } = await writeTempUnifiedDiffBackup(diff, "remix-validate-diff");
7559
- try {
7560
- const applyRes = await runGitDetailed(["apply", "--check", "--index", "--3way", backupPath], cwd);
7561
- if (applyRes.exitCode === 0) {
7562
- return { ok: true };
7563
- }
7564
- const detail = [applyRes.stderr.trim(), applyRes.stdout.trim()].filter(Boolean).join("\n\n") || null;
7565
- return {
7566
- ok: false,
7567
- kind: classifyGitApplyFailure(detail),
7568
- detail
7569
- };
7570
- } finally {
7571
- await import_promises12.default.rm(import_path.default.dirname(backupPath), { recursive: true, force: true }).catch(() => void 0);
7572
- }
7573
- }
7574
- async function preserveWorkspaceChanges(cwd, prefix = "remix-add-preserve") {
7575
- const baseHeadCommitHash = await getHeadCommitHash(cwd);
7576
- if (!baseHeadCommitHash) {
7577
- throw new RemixError("Failed to resolve local HEAD before preserving workspace changes.", { exitCode: 1 });
7578
- }
7579
- const snapshot = await getWorkspaceSnapshot(cwd);
7580
- const { backupPath, diffSha256 } = await writeTempUnifiedDiffBackup(snapshot.diff, prefix);
7581
- return {
7582
- baseHeadCommitHash,
7583
- preservedDiffPath: backupPath,
7584
- preservedDiffSha256: diffSha256,
7585
- includedUntrackedPaths: snapshot.includedUntrackedPaths,
7586
- stagePlan: snapshot.stagePlan
7587
- };
7588
- }
7589
- async function reapplyPreservedWorkspaceChanges(cwd, preserved, operation = "`remix collab add`") {
7590
- const patchFilePath = preserved.preservedDiffPath;
7591
- const tempPatchHash = await import_promises12.default.readFile(patchFilePath, "utf8").then((content) => sha256Hex(content)).catch(() => null);
7592
- if (!tempPatchHash) {
7593
- return { status: "failed", detail: "Preserved diff artifact is missing." };
7594
- }
7595
- if (tempPatchHash !== preserved.preservedDiffSha256) {
7596
- return { status: "failed", detail: "Preserved diff artifact failed integrity verification." };
7597
- }
7598
- const applyRes = await runGitDetailed(["apply", "--index", "--3way", patchFilePath], cwd);
7599
- if (applyRes.exitCode !== 0) {
7600
- await runGitDetailed(["reset", "--hard", "HEAD"], cwd).catch(() => void 0);
7601
- await runGitDetailed(["clean", "-fd"], cwd).catch(() => void 0);
7602
- const detail = [applyRes.stderr.trim(), applyRes.stdout.trim()].filter(Boolean).join("\n\n") || null;
7603
- return { status: "conflict", detail };
7604
- }
7605
- const unstageRes = await runGitDetailed(["reset", "--mixed", "HEAD", "--", "."], cwd);
7606
- if (unstageRes.exitCode !== 0) {
7607
- await runGitDetailed(["reset", "--hard", "HEAD"], cwd).catch(() => void 0);
7608
- await runGitDetailed(["clean", "-fd"], cwd).catch(() => void 0);
7609
- const detail = [unstageRes.stderr.trim(), unstageRes.stdout.trim()].filter(Boolean).join("\n\n") || null;
7610
- return {
7611
- status: "failed",
7612
- detail: detail || `Git could not restore the working tree state while running ${operation}.`
7613
- };
7614
- }
7615
- return { status: "clean" };
7616
- }
7617
7357
  async function getHeadCommitHash(cwd) {
7618
7358
  try {
7619
7359
  const hash = await runGit(["rev-parse", "HEAD"], cwd);
@@ -7631,157 +7371,6 @@ async function getWorktreeStatus(cwd) {
7631
7371
  return { isClean: false, entries: [] };
7632
7372
  }
7633
7373
  }
7634
- async function captureRepoSnapshot(cwd, options) {
7635
- const [branch, headCommitHash, worktreeStatus] = await Promise.all([
7636
- getCurrentBranch(cwd),
7637
- getHeadCommitHash(cwd),
7638
- getWorktreeStatus(cwd)
7639
- ]);
7640
- const workspaceDiffSha256 = options?.includeWorkspaceDiffHash ? (await getWorkspaceSnapshot(cwd)).diffSha256 : null;
7641
- return {
7642
- branch,
7643
- headCommitHash,
7644
- statusEntries: worktreeStatus.entries,
7645
- statusSha256: sha256Hex(worktreeStatus.entries.join("\n")),
7646
- workspaceDiffSha256
7647
- };
7648
- }
7649
- async function assertRepoSnapshotUnchanged(cwd, snapshot, params) {
7650
- const current = await captureRepoSnapshot(cwd, {
7651
- includeWorkspaceDiffHash: snapshot.workspaceDiffSha256 !== null
7652
- });
7653
- const changes = [];
7654
- if (current.branch !== snapshot.branch) {
7655
- changes.push(`Branch changed: ${snapshot.branch ?? "(detached)"} -> ${current.branch ?? "(detached)"}`);
7656
- }
7657
- if (current.headCommitHash !== snapshot.headCommitHash) {
7658
- changes.push(`HEAD changed: ${snapshot.headCommitHash ?? "(missing)"} -> ${current.headCommitHash ?? "(missing)"}`);
7659
- }
7660
- if (current.statusSha256 !== snapshot.statusSha256) {
7661
- changes.push("Working tree status changed.");
7662
- }
7663
- if (snapshot.workspaceDiffSha256 !== null && current.workspaceDiffSha256 !== snapshot.workspaceDiffSha256) {
7664
- changes.push("Workspace diff changed.");
7665
- }
7666
- if (changes.length === 0) return;
7667
- const operation = params?.operation?.trim() || "this Remix operation";
7668
- const hint = [
7669
- `Operation: ${operation}`,
7670
- ...changes,
7671
- params?.recoveryHint?.trim() || "Review the local changes, then rerun the operation."
7672
- ].filter(Boolean).join("\n");
7673
- throw new RemixError("Repository state changed during the operation.", {
7674
- code: REMIX_ERROR_CODES.REPO_STATE_CHANGED_DURING_OPERATION,
7675
- exitCode: 2,
7676
- hint
7677
- });
7678
- }
7679
- async function ensureCleanWorktree(cwd, operation = "`remix collab sync`") {
7680
- const status = await getWorktreeStatus(cwd);
7681
- if (status.isClean) return;
7682
- const preview = status.entries.slice(0, 10).join("\n");
7683
- const suffix = status.entries.length > 10 ? `
7684
- ...and ${status.entries.length - 10} more` : "";
7685
- throw new RemixError(`Working tree must be clean before running ${operation}.`, {
7686
- exitCode: 2,
7687
- hint: `Commit, stash, or discard local changes first.
7688
-
7689
- ${preview}${suffix}`
7690
- });
7691
- }
7692
- async function discardTrackedChanges(cwd, operation = "`remix collab add`") {
7693
- const res = await runGitDetailed(["reset", "--hard", "HEAD"], cwd);
7694
- if (res.exitCode !== 0) {
7695
- const detail = [res.stderr.trim(), res.stdout.trim()].filter(Boolean).join("\n\n");
7696
- throw new RemixError(`Failed to discard local tracked changes while running ${operation}.`, {
7697
- exitCode: 1,
7698
- hint: detail || "Git could not reset tracked changes back to HEAD."
7699
- });
7700
- }
7701
- const hash = await getHeadCommitHash(cwd);
7702
- if (!hash) {
7703
- throw new RemixError("Failed to resolve local HEAD after discarding tracked changes.", { exitCode: 1 });
7704
- }
7705
- return hash;
7706
- }
7707
- async function discardCapturedUntrackedChanges(repoRoot, capturedPaths) {
7708
- const normalizedCapturedPaths = Array.from(new Set(capturedPaths.map((entry) => normalizeRepoRelativePath(entry))));
7709
- if (normalizedCapturedPaths.length === 0) {
7710
- return { removedPaths: [] };
7711
- }
7712
- const currentUntracked = new Set((await listUntrackedFiles(repoRoot)).map((entry) => normalizeRepoRelativePath(entry)));
7713
- const removedPaths = [];
7714
- for (const relativePath of normalizedCapturedPaths) {
7715
- if (!currentUntracked.has(relativePath)) continue;
7716
- const absolutePath = resolveRepoRelativePath(repoRoot, relativePath);
7717
- if (!isWithinRepoRoot(repoRoot, absolutePath)) {
7718
- throw new RemixError("Refusing to delete a path outside the repository root.", {
7719
- exitCode: 1,
7720
- hint: `Path: ${relativePath}`
7721
- });
7722
- }
7723
- await import_promises12.default.rm(absolutePath, { recursive: true, force: true }).catch((err) => {
7724
- throw new RemixError("Failed to remove a captured untracked path before syncing.", {
7725
- exitCode: 1,
7726
- hint: err instanceof Error ? `${relativePath}
7727
-
7728
- ${err.message}` : relativePath
7729
- });
7730
- });
7731
- removedPaths.push(relativePath);
7732
- await pruneEmptyParentDirectories(repoRoot, absolutePath);
7733
- }
7734
- return { removedPaths };
7735
- }
7736
- async function requireCurrentBranch(cwd) {
7737
- const branch = await getCurrentBranch(cwd);
7738
- if (!branch) {
7739
- throw new RemixError("`remix collab sync` requires a checked out local branch.", {
7740
- exitCode: 2,
7741
- hint: "Checkout a branch before syncing."
7742
- });
7743
- }
7744
- return branch;
7745
- }
7746
- async function importGitBundle(cwd, bundlePath, bundleRef) {
7747
- const verifyRes = await runGitDetailed(["bundle", "verify", bundlePath], cwd);
7748
- if (verifyRes.exitCode !== 0) {
7749
- const detail = [verifyRes.stderr.trim(), verifyRes.stdout.trim()].filter(Boolean).join("\n\n");
7750
- throw new RemixError("Failed to verify sync bundle.", {
7751
- exitCode: 1,
7752
- hint: detail || "Git bundle verification failed."
7753
- });
7754
- }
7755
- const fetchRes = await runGitDetailed(["fetch", "--quiet", bundlePath, bundleRef], cwd);
7756
- if (fetchRes.exitCode !== 0) {
7757
- const detail = [fetchRes.stderr.trim(), fetchRes.stdout.trim()].filter(Boolean).join("\n\n");
7758
- throw new RemixError("Failed to import sync bundle.", {
7759
- exitCode: 1,
7760
- hint: detail || "Git could not fetch objects from the sync bundle."
7761
- });
7762
- }
7763
- }
7764
- async function ensureCommitExists(cwd, commitHash) {
7765
- const res = await runGitDetailed(["cat-file", "-e", `${commitHash}^{commit}`], cwd);
7766
- if (res.exitCode === 0) return;
7767
- throw new RemixError("Expected target commit is missing after bundle import.", {
7768
- exitCode: 1,
7769
- hint: `Commit ${commitHash} is not available in the local repository.`
7770
- });
7771
- }
7772
- async function fastForwardToCommit(cwd, commitHash) {
7773
- const res = await runGitDetailed(["merge", "--ff-only", commitHash], cwd);
7774
- if (res.exitCode !== 0) {
7775
- const detail = [res.stderr.trim(), res.stdout.trim()].filter(Boolean).join("\n\n");
7776
- throw new RemixError("Failed to fast-forward local branch.", {
7777
- exitCode: 1,
7778
- hint: detail || "Git could not fast-forward to the target commit."
7779
- });
7780
- }
7781
- const hash = await getHeadCommitHash(cwd);
7782
- if (!hash) throw new RemixError("Failed to resolve local HEAD after fast-forward sync.", { exitCode: 1 });
7783
- return hash;
7784
- }
7785
7374
  function summarizeUnifiedDiff(diff) {
7786
7375
  const lines = diff.split("\n");
7787
7376
  let changedFilesCount = 0;
@@ -7795,21 +7384,21 @@ function summarizeUnifiedDiff(diff) {
7795
7384
  return { changedFilesCount, insertions, deletions };
7796
7385
  }
7797
7386
 
7798
- // node_modules/@remixhq/core/dist/chunk-4L3ZBZUQ.js
7387
+ // node_modules/@remixhq/core/dist/chunk-YCFLOHJV.js
7388
+ var import_promises12 = __toESM(require("fs/promises"), 1);
7389
+ var import_path = __toESM(require("path"), 1);
7799
7390
  var import_promises13 = __toESM(require("fs/promises"), 1);
7800
7391
  var import_path2 = __toESM(require("path"), 1);
7801
- var import_promises14 = __toESM(require("fs/promises"), 1);
7802
- var import_path3 = __toESM(require("path"), 1);
7803
7392
  async function writeJsonAtomic(filePath, value) {
7804
- const dir = import_path3.default.dirname(filePath);
7805
- await import_promises14.default.mkdir(dir, { recursive: true });
7393
+ const dir = import_path2.default.dirname(filePath);
7394
+ await import_promises13.default.mkdir(dir, { recursive: true });
7806
7395
  const tmp = `${filePath}.tmp-${Date.now()}`;
7807
- await import_promises14.default.writeFile(tmp, `${JSON.stringify(value, null, 2)}
7396
+ await import_promises13.default.writeFile(tmp, `${JSON.stringify(value, null, 2)}
7808
7397
  `, "utf8");
7809
- await import_promises14.default.rename(tmp, filePath);
7398
+ await import_promises13.default.rename(tmp, filePath);
7810
7399
  }
7811
7400
  function getCollabBindingPath(repoRoot) {
7812
- return import_path2.default.join(repoRoot, ".remix", "config.json");
7401
+ return import_path.default.join(repoRoot, ".remix", "config.json");
7813
7402
  }
7814
7403
  function buildBindingFileV3(params) {
7815
7404
  return {
@@ -7817,7 +7406,8 @@ function buildBindingFileV3(params) {
7817
7406
  repoFingerprint: params.repoFingerprint,
7818
7407
  remoteUrl: params.remoteUrl,
7819
7408
  defaultBranch: params.defaultBranch,
7820
- branchBindings: params.branchBindings
7409
+ branchBindings: params.branchBindings,
7410
+ ...params.explicitRootBinding ? { explicitRootBinding: params.explicitRootBinding } : {}
7821
7411
  };
7822
7412
  }
7823
7413
  function normalizeBranchName(value) {
@@ -7836,7 +7426,7 @@ function normalizeBranchBinding(value) {
7836
7426
  upstreamAppId: value.upstreamAppId,
7837
7427
  threadId: value.threadId ?? null,
7838
7428
  laneId: value.laneId ?? null,
7839
- bindingMode: value.bindingMode === "legacy" ? "legacy" : "lane"
7429
+ bindingMode: value.bindingMode === "legacy" ? "legacy" : value.bindingMode === "explicit_root" ? "explicit_root" : "lane"
7840
7430
  };
7841
7431
  }
7842
7432
  function buildResolvedBinding(params) {
@@ -7871,7 +7461,7 @@ async function readCollabBindingState(repoRoot, options) {
7871
7461
  try {
7872
7462
  const persist = options?.persist === true;
7873
7463
  const filePath = getCollabBindingPath(repoRoot);
7874
- const raw = await import_promises13.default.readFile(filePath, "utf8");
7464
+ const raw = await import_promises12.default.readFile(filePath, "utf8");
7875
7465
  const parsed = JSON.parse(raw);
7876
7466
  if (!parsed || typeof parsed !== "object") return null;
7877
7467
  const currentBranch = normalizeBranchName(await getCurrentBranch(repoRoot).catch(() => null));
@@ -7910,6 +7500,7 @@ async function readCollabBindingState(repoRoot, options) {
7910
7500
  defaultBranch: migratedFile.defaultBranch,
7911
7501
  currentBranch,
7912
7502
  branchBindings: migratedFile.branchBindings,
7503
+ explicitRootBinding: null,
7913
7504
  binding: buildResolvedBinding({
7914
7505
  fallbackProjectId: projectId,
7915
7506
  repoFingerprint: migratedFile.repoFingerprint,
@@ -7953,7 +7544,22 @@ async function readCollabBindingState(repoRoot, options) {
7953
7544
  };
7954
7545
  shouldPersistNormalizedBranchBindings = true;
7955
7546
  }
7956
- if (persist && ("explicitBinding" in file || shouldPersistNormalizedBranchBindings || parsed.schemaVersion === 2)) {
7547
+ let explicitRootBinding = normalizeBranchBinding(file.explicitRootBinding ?? null);
7548
+ if (explicitRootBinding && !explicitRootBinding.projectId && legacyProjectId) {
7549
+ explicitRootBinding = {
7550
+ ...explicitRootBinding,
7551
+ projectId: legacyProjectId
7552
+ };
7553
+ shouldPersistNormalizedBranchBindings = true;
7554
+ }
7555
+ if (explicitRootBinding && explicitRootBinding.bindingMode !== "explicit_root") {
7556
+ explicitRootBinding = {
7557
+ ...explicitRootBinding,
7558
+ bindingMode: "explicit_root"
7559
+ };
7560
+ shouldPersistNormalizedBranchBindings = true;
7561
+ }
7562
+ if (persist && ("explicitBinding" in file || "explicitRootBinding" in file || shouldPersistNormalizedBranchBindings || parsed.schemaVersion === 2)) {
7957
7563
  try {
7958
7564
  await writeJsonAtomic(
7959
7565
  filePath,
@@ -7961,7 +7567,8 @@ async function readCollabBindingState(repoRoot, options) {
7961
7567
  repoFingerprint: file.repoFingerprint ?? null,
7962
7568
  remoteUrl: file.remoteUrl ?? null,
7963
7569
  defaultBranch: file.defaultBranch ?? null,
7964
- branchBindings
7570
+ branchBindings,
7571
+ explicitRootBinding
7965
7572
  })
7966
7573
  );
7967
7574
  } catch {
@@ -7972,8 +7579,23 @@ async function readCollabBindingState(repoRoot, options) {
7972
7579
  branchBindings,
7973
7580
  currentBranch: resolvedBranch,
7974
7581
  defaultBranch: normalizeBranchName(file.defaultBranch),
7975
- legacyProjectId
7582
+ legacyProjectId: explicitRootBinding?.projectId ?? legacyProjectId
7976
7583
  });
7584
+ const resolvedBinding = buildResolvedBinding({
7585
+ fallbackProjectId,
7586
+ repoFingerprint: file.repoFingerprint ?? null,
7587
+ remoteUrl: file.remoteUrl ?? null,
7588
+ defaultBranch: file.defaultBranch ?? null,
7589
+ branchName: resolvedBranch,
7590
+ binding: resolvedBranch ? branchBindings[resolvedBranch] ?? null : null
7591
+ }) ?? (resolvedBranch && resolvedBranch === normalizeBranchName(file.defaultBranch) && explicitRootBinding ? buildResolvedBinding({
7592
+ fallbackProjectId,
7593
+ repoFingerprint: file.repoFingerprint ?? null,
7594
+ remoteUrl: file.remoteUrl ?? null,
7595
+ defaultBranch: file.defaultBranch ?? null,
7596
+ branchName: normalizeBranchName(file.defaultBranch),
7597
+ binding: explicitRootBinding
7598
+ }) : null);
7977
7599
  return {
7978
7600
  schemaVersion: parsed.schemaVersion,
7979
7601
  projectId: fallbackProjectId,
@@ -7982,14 +7604,15 @@ async function readCollabBindingState(repoRoot, options) {
7982
7604
  defaultBranch: file.defaultBranch ?? null,
7983
7605
  currentBranch,
7984
7606
  branchBindings,
7985
- binding: buildResolvedBinding({
7607
+ explicitRootBinding: buildResolvedBinding({
7986
7608
  fallbackProjectId,
7987
7609
  repoFingerprint: file.repoFingerprint ?? null,
7988
7610
  remoteUrl: file.remoteUrl ?? null,
7989
7611
  defaultBranch: file.defaultBranch ?? null,
7990
- branchName: resolvedBranch,
7991
- binding: resolvedBranch ? branchBindings[resolvedBranch] ?? null : null
7992
- })
7612
+ branchName: normalizeBranchName(file.defaultBranch),
7613
+ binding: explicitRootBinding
7614
+ }),
7615
+ binding: resolvedBinding
7993
7616
  };
7994
7617
  } catch {
7995
7618
  return null;
@@ -8013,28 +7636,44 @@ async function writeCollabBinding(repoRoot, binding) {
8013
7636
  laneId: binding.laneId ?? null,
8014
7637
  bindingMode: binding.bindingMode ?? "lane"
8015
7638
  };
7639
+ const explicitRootBinding = binding.bindingMode === "explicit_root" ? {
7640
+ ...branchBindings[branchName],
7641
+ bindingMode: "explicit_root"
7642
+ } : existing?.explicitRootBinding ? {
7643
+ projectId: existing.explicitRootBinding.projectId,
7644
+ currentAppId: existing.explicitRootBinding.currentAppId,
7645
+ upstreamAppId: existing.explicitRootBinding.upstreamAppId,
7646
+ threadId: existing.explicitRootBinding.threadId,
7647
+ laneId: existing.explicitRootBinding.laneId,
7648
+ bindingMode: "explicit_root"
7649
+ } : null;
8016
7650
  await writeJsonAtomic(
8017
7651
  filePath,
8018
7652
  buildBindingFileV3({
8019
7653
  repoFingerprint: binding.repoFingerprint ?? null,
8020
7654
  remoteUrl: binding.remoteUrl ?? null,
8021
7655
  defaultBranch: binding.defaultBranch ?? null,
8022
- branchBindings
7656
+ branchBindings,
7657
+ explicitRootBinding
8023
7658
  })
8024
7659
  );
8025
7660
  return filePath;
8026
7661
  }
8027
7662
 
8028
7663
  // node_modules/@remixhq/core/dist/collab.js
8029
- var import_promises15 = __toESM(require("fs/promises"), 1);
7664
+ var import_promises14 = __toESM(require("fs/promises"), 1);
7665
+ var import_path3 = __toESM(require("path"), 1);
7666
+ var import_crypto = require("crypto");
7667
+ var import_os = __toESM(require("os"), 1);
8030
7668
  var import_path4 = __toESM(require("path"), 1);
8031
7669
  var import_crypto2 = require("crypto");
8032
- var import_promises16 = __toESM(require("fs/promises"), 1);
7670
+ var import_promises15 = __toESM(require("fs/promises"), 1);
8033
7671
  var import_os2 = __toESM(require("os"), 1);
8034
7672
  var import_path5 = __toESM(require("path"), 1);
8035
- var import_promises17 = __toESM(require("fs/promises"), 1);
8036
- var import_os3 = __toESM(require("os"), 1);
7673
+ var import_crypto3 = require("crypto");
7674
+ var import_promises16 = __toESM(require("fs/promises"), 1);
8037
7675
  var import_path6 = __toESM(require("path"), 1);
7676
+ var import_crypto4 = require("crypto");
8038
7677
  function describeBranch(value) {
8039
7678
  const normalized = String(value ?? "").trim();
8040
7679
  return normalized || "(detached)";
@@ -8053,18 +7692,562 @@ function buildBranchMismatchHint(params) {
8053
7692
  `Switch to ${describeBranch(params.branchName)} or rerun with ${overrideFlag} if this is intentional.`
8054
7693
  ].join("\n");
8055
7694
  }
8056
- function assertBoundBranchMatch(params) {
8057
- if (params.allowBranchMismatch) return;
8058
- if (isBoundBranchMatch(params.currentBranch, params.branchName)) return;
8059
- throw new RemixError(`Current branch does not match this checkout's bound Remix branch while running ${params.operation}.`, {
8060
- code: REMIX_ERROR_CODES.PREFERRED_BRANCH_MISMATCH,
8061
- exitCode: 2,
8062
- hint: buildBranchMismatchHint({
8063
- currentBranch: params.currentBranch,
8064
- branchName: params.branchName,
8065
- overrideFlag: params.overrideFlag
8066
- })
7695
+ function sha256Hex(value) {
7696
+ return (0, import_crypto.createHash)("sha256").update(value).digest("hex");
7697
+ }
7698
+ function getCollabStateRoot() {
7699
+ const configured = process.env.REMIX_COLLAB_STATE_ROOT?.trim();
7700
+ return configured || import_path4.default.join(import_os.default.homedir(), ".remix", "collab-state");
7701
+ }
7702
+ function buildLaneStateKey(params) {
7703
+ const fingerprint = params.repoFingerprint?.trim();
7704
+ const laneId = params.laneId?.trim();
7705
+ const repoRoot = params.repoRoot?.trim();
7706
+ const stableSource = repoRoot || "unknown-repo-root";
7707
+ const fingerprintSource = fingerprint || "unknown-repo-fingerprint";
7708
+ const laneSource = laneId || "unknown-lane";
7709
+ return sha256Hex(`${stableSource}::${fingerprintSource}::${laneSource}`);
7710
+ }
7711
+ function getSnapshotsRoot() {
7712
+ return import_path4.default.join(getCollabStateRoot(), "snapshots");
7713
+ }
7714
+ function getSnapshotRecordsRoot() {
7715
+ return import_path4.default.join(getSnapshotsRoot(), "records");
7716
+ }
7717
+ function getSnapshotBlobsRoot() {
7718
+ return import_path4.default.join(getSnapshotsRoot(), "blobs");
7719
+ }
7720
+ function getBaselinesRoot() {
7721
+ return import_path4.default.join(getCollabStateRoot(), "baselines");
7722
+ }
7723
+ function getFinalizeQueueRoot() {
7724
+ return import_path4.default.join(getCollabStateRoot(), "finalize-queue");
7725
+ }
7726
+ function getBaselinePath(params) {
7727
+ return import_path3.default.join(getBaselinesRoot(), `${buildLaneStateKey(params)}.json`);
7728
+ }
7729
+ async function readLocalBaseline(params) {
7730
+ try {
7731
+ const raw = await import_promises14.default.readFile(getBaselinePath(params), "utf8");
7732
+ const parsed = JSON.parse(raw);
7733
+ if (!parsed || typeof parsed !== "object") return null;
7734
+ if (parsed.schemaVersion !== 1 || typeof parsed.key !== "string" || typeof parsed.repoRoot !== "string") {
7735
+ return null;
7736
+ }
7737
+ return {
7738
+ schemaVersion: 1,
7739
+ key: parsed.key,
7740
+ repoRoot: parsed.repoRoot,
7741
+ repoFingerprint: parsed.repoFingerprint ?? null,
7742
+ laneId: parsed.laneId ?? null,
7743
+ currentAppId: String(parsed.currentAppId ?? ""),
7744
+ branchName: parsed.branchName ?? null,
7745
+ lastSnapshotId: parsed.lastSnapshotId ?? null,
7746
+ lastSnapshotHash: parsed.lastSnapshotHash ?? null,
7747
+ lastServerHeadHash: parsed.lastServerHeadHash ?? null,
7748
+ lastSeenLocalCommitHash: parsed.lastSeenLocalCommitHash ?? null,
7749
+ updatedAt: String(parsed.updatedAt ?? "")
7750
+ };
7751
+ } catch {
7752
+ return null;
7753
+ }
7754
+ }
7755
+ async function writeLocalBaseline(baseline) {
7756
+ const key = buildLaneStateKey(baseline);
7757
+ const normalized = {
7758
+ schemaVersion: 1,
7759
+ key,
7760
+ repoRoot: baseline.repoRoot,
7761
+ repoFingerprint: baseline.repoFingerprint ?? null,
7762
+ laneId: baseline.laneId ?? null,
7763
+ currentAppId: baseline.currentAppId,
7764
+ branchName: baseline.branchName ?? null,
7765
+ lastSnapshotId: baseline.lastSnapshotId ?? null,
7766
+ lastSnapshotHash: baseline.lastSnapshotHash ?? null,
7767
+ lastServerHeadHash: baseline.lastServerHeadHash ?? null,
7768
+ lastSeenLocalCommitHash: baseline.lastSeenLocalCommitHash ?? null,
7769
+ updatedAt: baseline.updatedAt ?? (/* @__PURE__ */ new Date()).toISOString()
7770
+ };
7771
+ await writeJsonAtomic(getBaselinePath(baseline), normalized);
7772
+ return normalized;
7773
+ }
7774
+ function sha256Hex2(value) {
7775
+ return (0, import_crypto2.createHash)("sha256").update(value).digest("hex");
7776
+ }
7777
+ function getSnapshotRecordPath(snapshotId) {
7778
+ return import_path5.default.join(getSnapshotRecordsRoot(), `${snapshotId}.json`);
7779
+ }
7780
+ function getBlobPath(blobHash) {
7781
+ return import_path5.default.join(getSnapshotBlobsRoot(), blobHash.slice(0, 2), blobHash);
7782
+ }
7783
+ async function runGitZ(args, cwd) {
7784
+ const res = await execa("git", args, {
7785
+ cwd,
7786
+ stderr: "ignore",
7787
+ stripFinalNewline: false
8067
7788
  });
7789
+ return String(res.stdout || "");
7790
+ }
7791
+ async function listWorkspaceFiles(repoRoot) {
7792
+ const raw = await runGitZ(["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--deduplicate"], repoRoot);
7793
+ const seen = /* @__PURE__ */ new Set();
7794
+ const result = [];
7795
+ for (const entry of raw.split("\0")) {
7796
+ const relativePath = entry.trim();
7797
+ if (!relativePath || seen.has(relativePath)) continue;
7798
+ const absolutePath = import_path5.default.join(repoRoot, relativePath);
7799
+ try {
7800
+ const stat = await import_promises15.default.lstat(absolutePath);
7801
+ if (stat.isFile() || stat.isSymbolicLink()) {
7802
+ seen.add(relativePath);
7803
+ result.push(relativePath);
7804
+ }
7805
+ } catch {
7806
+ }
7807
+ }
7808
+ return result.sort((a2, b) => a2.localeCompare(b));
7809
+ }
7810
+ async function persistBlob(blobHash, content) {
7811
+ const blobPath = getBlobPath(blobHash);
7812
+ try {
7813
+ await import_promises15.default.access(blobPath);
7814
+ } catch {
7815
+ await import_promises15.default.mkdir(import_path5.default.dirname(blobPath), { recursive: true });
7816
+ if (typeof content === "string") {
7817
+ await import_promises15.default.writeFile(blobPath, content, "utf8");
7818
+ } else {
7819
+ await import_promises15.default.writeFile(blobPath, content);
7820
+ }
7821
+ }
7822
+ }
7823
+ function buildSnapshotHash(files) {
7824
+ const manifest = files.map((file) => `${file.path} ${file.mode} ${file.blobHash} ${file.size}`).join("\n");
7825
+ return sha256Hex2(manifest);
7826
+ }
7827
+ async function inspectLocalSnapshot(params) {
7828
+ const repoRoot = params.repoRoot;
7829
+ const files = await listWorkspaceFiles(repoRoot);
7830
+ const manifest = [];
7831
+ for (const relativePath of files) {
7832
+ const absolutePath = import_path5.default.join(repoRoot, relativePath);
7833
+ const stat = await import_promises15.default.lstat(absolutePath);
7834
+ if (stat.isSymbolicLink()) {
7835
+ const linkTarget = await import_promises15.default.readlink(absolutePath);
7836
+ const blobHash2 = sha256Hex2(`symlink:${linkTarget}`);
7837
+ if (params.persistBlobs !== false) {
7838
+ await persistBlob(blobHash2, linkTarget);
7839
+ }
7840
+ manifest.push({
7841
+ path: relativePath,
7842
+ mode: "symlink",
7843
+ blobHash: blobHash2,
7844
+ size: Buffer.byteLength(linkTarget)
7845
+ });
7846
+ continue;
7847
+ }
7848
+ const content = await import_promises15.default.readFile(absolutePath);
7849
+ const blobHash = sha256Hex2(content);
7850
+ if (params.persistBlobs !== false) {
7851
+ await persistBlob(blobHash, content);
7852
+ }
7853
+ manifest.push({
7854
+ path: relativePath,
7855
+ mode: stat.mode & 73 ? "executable" : "file",
7856
+ blobHash,
7857
+ size: stat.size
7858
+ });
7859
+ }
7860
+ const normalizedManifest = manifest.sort((a2, b) => a2.path.localeCompare(b.path));
7861
+ return {
7862
+ repoRoot,
7863
+ repoFingerprint: params.repoFingerprint ?? null,
7864
+ laneId: params.laneId ?? null,
7865
+ branchName: params.branchName ?? await getCurrentBranch(repoRoot).catch(() => null),
7866
+ localCommitHash: await getHeadCommitHash(repoRoot).catch(() => null),
7867
+ snapshotHash: buildSnapshotHash(normalizedManifest),
7868
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
7869
+ files: normalizedManifest
7870
+ };
7871
+ }
7872
+ async function captureLocalSnapshot(params) {
7873
+ const inspection = await inspectLocalSnapshot({ ...params, persistBlobs: true });
7874
+ const snapshot = {
7875
+ schemaVersion: 1,
7876
+ id: (0, import_crypto2.randomUUID)(),
7877
+ ...inspection
7878
+ };
7879
+ await writeJsonAtomic(getSnapshotRecordPath(snapshot.id), snapshot);
7880
+ return snapshot;
7881
+ }
7882
+ async function readLocalSnapshot(snapshotId) {
7883
+ if (!snapshotId) return null;
7884
+ try {
7885
+ const raw = await import_promises15.default.readFile(getSnapshotRecordPath(snapshotId), "utf8");
7886
+ const parsed = JSON.parse(raw);
7887
+ if (!parsed || parsed.schemaVersion !== 1) return null;
7888
+ return parsed;
7889
+ } catch {
7890
+ return null;
7891
+ }
7892
+ }
7893
+ async function materializeLocalSnapshot(snapshotId, targetDir) {
7894
+ const snapshot = await readLocalSnapshot(snapshotId);
7895
+ await import_promises15.default.mkdir(targetDir, { recursive: true });
7896
+ if (!snapshot) return;
7897
+ for (const entry of snapshot.files) {
7898
+ const destination = import_path5.default.join(targetDir, entry.path);
7899
+ await import_promises15.default.mkdir(import_path5.default.dirname(destination), { recursive: true });
7900
+ const blobPath = getBlobPath(entry.blobHash);
7901
+ if (entry.mode === "symlink") {
7902
+ const linkTarget = await import_promises15.default.readFile(blobPath, "utf8");
7903
+ await import_promises15.default.symlink(linkTarget, destination);
7904
+ continue;
7905
+ }
7906
+ await import_promises15.default.copyFile(blobPath, destination);
7907
+ if (entry.mode === "executable") {
7908
+ await import_promises15.default.chmod(destination, 493);
7909
+ }
7910
+ }
7911
+ }
7912
+ async function clearDirectoryExceptGit(targetDir) {
7913
+ const entries = await import_promises15.default.readdir(targetDir, { withFileTypes: true });
7914
+ for (const entry of entries) {
7915
+ if (entry.name === ".git") continue;
7916
+ await import_promises15.default.rm(import_path5.default.join(targetDir, entry.name), { recursive: true, force: true });
7917
+ }
7918
+ }
7919
+ async function diffLocalSnapshots(params) {
7920
+ const tempRoot = await import_promises15.default.mkdtemp(import_path5.default.join(import_os2.default.tmpdir(), "remix-snapshot-diff-"));
7921
+ const repoDir = import_path5.default.join(tempRoot, "repo");
7922
+ await import_promises15.default.mkdir(repoDir, { recursive: true });
7923
+ try {
7924
+ await materializeLocalSnapshot(params.baseSnapshotId, repoDir);
7925
+ await execa("git", ["init"], { cwd: repoDir, stderr: "ignore" });
7926
+ await execa("git", ["add", "-A"], { cwd: repoDir, stderr: "ignore" });
7927
+ await execa(
7928
+ "git",
7929
+ ["-c", "user.name=Remix", "-c", "user.email=remix@local", "commit", "--allow-empty", "-m", "baseline snapshot"],
7930
+ { cwd: repoDir, stderr: "ignore" }
7931
+ );
7932
+ await clearDirectoryExceptGit(repoDir);
7933
+ await materializeLocalSnapshot(params.targetSnapshotId, repoDir);
7934
+ await execa("git", ["add", "-A"], { cwd: repoDir, stderr: "ignore" });
7935
+ const diffRes = await execa("git", ["diff", "--binary", "--no-ext-diff", "--cached", "HEAD"], {
7936
+ cwd: repoDir,
7937
+ reject: false,
7938
+ stderr: "ignore",
7939
+ stripFinalNewline: false
7940
+ });
7941
+ const pathsRes = await execa("git", ["diff", "--name-only", "--cached", "HEAD", "-z"], {
7942
+ cwd: repoDir,
7943
+ reject: false,
7944
+ stderr: "ignore",
7945
+ stripFinalNewline: false
7946
+ });
7947
+ const diff = String(diffRes.stdout || "");
7948
+ const changedPaths = String(pathsRes.stdout || "").split("\0").map((value) => value.trim()).filter(Boolean);
7949
+ return {
7950
+ baseSnapshotId: params.baseSnapshotId,
7951
+ targetSnapshotId: params.targetSnapshotId,
7952
+ diff,
7953
+ diffSha256: diff ? sha256Hex2(diff) : null,
7954
+ changedPaths,
7955
+ stats: summarizeUnifiedDiff(diff)
7956
+ };
7957
+ } finally {
7958
+ await import_promises15.default.rm(tempRoot, { recursive: true, force: true });
7959
+ }
7960
+ }
7961
+ var FINALIZE_JOB_LOCK_STALE_MS = 10 * 60 * 1e3;
7962
+ var TERMINAL_FINALIZE_JOB_RETENTION_MS = 24 * 60 * 60 * 1e3;
7963
+ function getJobPath(id) {
7964
+ return import_path6.default.join(getFinalizeQueueRoot(), `${id}.json`);
7965
+ }
7966
+ function getJobLockPath(id) {
7967
+ return import_path6.default.join(getFinalizeQueueRoot(), `${id}.lock`);
7968
+ }
7969
+ function isPastDue(isoTimestamp) {
7970
+ if (!isoTimestamp) return true;
7971
+ const parsed = Date.parse(isoTimestamp);
7972
+ return Number.isFinite(parsed) && parsed <= Date.now();
7973
+ }
7974
+ function isStaleAttempt(job) {
7975
+ if (job.status !== "processing") return false;
7976
+ if (!job.lastAttemptAt) return true;
7977
+ const parsed = Date.parse(job.lastAttemptAt);
7978
+ if (!Number.isFinite(parsed)) return true;
7979
+ return Date.now() - parsed >= FINALIZE_JOB_LOCK_STALE_MS;
7980
+ }
7981
+ function readMetadataDisposition(job) {
7982
+ const value = job.metadata.failureDisposition;
7983
+ return value === "retryable" || value === "terminal" ? value : null;
7984
+ }
7985
+ function isTerminalFailure(job) {
7986
+ return job.status === "failed" && readMetadataDisposition(job) === "terminal";
7987
+ }
7988
+ function isTerminalFailureExpired(job) {
7989
+ if (!isTerminalFailure(job)) return false;
7990
+ const updatedAtMs = Date.parse(job.updatedAt);
7991
+ if (!Number.isFinite(updatedAtMs)) return false;
7992
+ return Date.now() - updatedAtMs >= TERMINAL_FINALIZE_JOB_RETENTION_MS;
7993
+ }
7994
+ function matchesJobScope(job, scope) {
7995
+ if (job.repoRoot !== scope.repoRoot) return false;
7996
+ if (scope.currentAppId && job.currentAppId !== scope.currentAppId) return false;
7997
+ if (scope.laneId && job.laneId !== scope.laneId) return false;
7998
+ if (scope.repoFingerprint && job.repoFingerprint && job.repoFingerprint !== scope.repoFingerprint) return false;
7999
+ return true;
8000
+ }
8001
+ function createEmptyPendingFinalizeQueueSummary() {
8002
+ return {
8003
+ state: "idle",
8004
+ activeJobCount: 0,
8005
+ queuedJobCount: 0,
8006
+ processingJobCount: 0,
8007
+ retryScheduledJobCount: 0,
8008
+ failedJobCount: 0,
8009
+ oldestCapturedAt: null,
8010
+ newestCapturedAt: null,
8011
+ nextRetryAt: null,
8012
+ latestError: null
8013
+ };
8014
+ }
8015
+ async function acquireJobLock(jobId) {
8016
+ const lockPath = getJobLockPath(jobId);
8017
+ try {
8018
+ await import_promises16.default.mkdir(lockPath);
8019
+ return true;
8020
+ } catch (error) {
8021
+ if (error?.code !== "EEXIST") {
8022
+ throw error;
8023
+ }
8024
+ }
8025
+ try {
8026
+ const stat = await import_promises16.default.stat(lockPath);
8027
+ if (Date.now() - stat.mtimeMs < FINALIZE_JOB_LOCK_STALE_MS) {
8028
+ return false;
8029
+ }
8030
+ await import_promises16.default.rm(lockPath, { recursive: true, force: true });
8031
+ } catch (error) {
8032
+ if (error?.code !== "ENOENT") {
8033
+ throw error;
8034
+ }
8035
+ }
8036
+ try {
8037
+ await import_promises16.default.mkdir(lockPath);
8038
+ return true;
8039
+ } catch (error) {
8040
+ if (error?.code === "EEXIST") {
8041
+ return false;
8042
+ }
8043
+ throw error;
8044
+ }
8045
+ }
8046
+ function normalizeJob(input) {
8047
+ const now = (/* @__PURE__ */ new Date()).toISOString();
8048
+ return {
8049
+ schemaVersion: 1,
8050
+ id: input.id ?? (0, import_crypto3.randomUUID)(),
8051
+ status: input.status,
8052
+ repoRoot: input.repoRoot,
8053
+ repoFingerprint: input.repoFingerprint ?? null,
8054
+ currentAppId: input.currentAppId,
8055
+ laneId: input.laneId ?? null,
8056
+ threadId: input.threadId ?? null,
8057
+ branchName: input.branchName ?? null,
8058
+ prompt: input.prompt,
8059
+ assistantResponse: input.assistantResponse,
8060
+ baselineSnapshotId: input.baselineSnapshotId ?? null,
8061
+ baselineServerHeadHash: input.baselineServerHeadHash ?? null,
8062
+ currentSnapshotId: input.currentSnapshotId,
8063
+ capturedAt: input.capturedAt ?? now,
8064
+ updatedAt: input.updatedAt ?? now,
8065
+ idempotencyKey: input.idempotencyKey ?? null,
8066
+ error: input.error ?? null,
8067
+ retryCount: Number.isFinite(input.retryCount) ? Math.max(0, Number(input.retryCount)) : 0,
8068
+ lastAttemptAt: input.lastAttemptAt ?? null,
8069
+ nextRetryAt: input.nextRetryAt ?? null,
8070
+ metadata: input.metadata ?? {}
8071
+ };
8072
+ }
8073
+ async function enqueuePendingFinalizeJob(input) {
8074
+ const job = normalizeJob(input);
8075
+ await writeJsonAtomic(getJobPath(job.id), job);
8076
+ return job;
8077
+ }
8078
+ async function readPendingFinalizeJob(jobId) {
8079
+ try {
8080
+ const raw = await import_promises16.default.readFile(getJobPath(jobId), "utf8");
8081
+ const parsed = JSON.parse(raw);
8082
+ if (!parsed || parsed.schemaVersion !== 1 || typeof parsed.id !== "string") return null;
8083
+ return normalizeJob({
8084
+ id: parsed.id,
8085
+ status: parsed.status ?? "queued",
8086
+ repoRoot: String(parsed.repoRoot ?? ""),
8087
+ repoFingerprint: parsed.repoFingerprint ?? null,
8088
+ currentAppId: String(parsed.currentAppId ?? ""),
8089
+ laneId: parsed.laneId ?? null,
8090
+ threadId: parsed.threadId ?? null,
8091
+ branchName: parsed.branchName ?? null,
8092
+ prompt: String(parsed.prompt ?? ""),
8093
+ assistantResponse: String(parsed.assistantResponse ?? ""),
8094
+ baselineSnapshotId: parsed.baselineSnapshotId ?? null,
8095
+ baselineServerHeadHash: parsed.baselineServerHeadHash ?? null,
8096
+ currentSnapshotId: String(parsed.currentSnapshotId ?? ""),
8097
+ capturedAt: parsed.capturedAt,
8098
+ updatedAt: parsed.updatedAt,
8099
+ idempotencyKey: parsed.idempotencyKey ?? null,
8100
+ error: parsed.error ?? null,
8101
+ retryCount: parsed.retryCount ?? 0,
8102
+ lastAttemptAt: parsed.lastAttemptAt ?? null,
8103
+ nextRetryAt: parsed.nextRetryAt ?? null,
8104
+ metadata: parsed.metadata ?? {}
8105
+ });
8106
+ } catch {
8107
+ return null;
8108
+ }
8109
+ }
8110
+ async function listPendingFinalizeJobs() {
8111
+ try {
8112
+ const entries = await import_promises16.default.readdir(getFinalizeQueueRoot(), { withFileTypes: true });
8113
+ const jobs = await Promise.all(
8114
+ entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => readPendingFinalizeJob(entry.name.replace(/\.json$/, "")))
8115
+ );
8116
+ return jobs.filter((job) => Boolean(job)).sort((a2, b) => a2.capturedAt.localeCompare(b.capturedAt));
8117
+ } catch (error) {
8118
+ if (error?.code === "ENOENT") {
8119
+ return [];
8120
+ }
8121
+ throw error;
8122
+ }
8123
+ }
8124
+ async function prunePendingFinalizeJobs() {
8125
+ const jobs = await listPendingFinalizeJobs();
8126
+ await Promise.all(
8127
+ jobs.filter((job) => job.status === "completed" || isTerminalFailureExpired(job)).map((job) => removePendingFinalizeJob(job.id))
8128
+ );
8129
+ }
8130
+ async function summarizePendingFinalizeJobs(scope) {
8131
+ const jobs = (await listPendingFinalizeJobs()).filter((job) => matchesJobScope(job, scope));
8132
+ const summary = createEmptyPendingFinalizeQueueSummary();
8133
+ const relevantJobs = jobs.filter((job) => job.status !== "completed");
8134
+ if (relevantJobs.length === 0) return summary;
8135
+ summary.oldestCapturedAt = relevantJobs[0]?.capturedAt ?? null;
8136
+ summary.newestCapturedAt = relevantJobs[relevantJobs.length - 1]?.capturedAt ?? null;
8137
+ for (const job of relevantJobs) {
8138
+ if (job.error) {
8139
+ summary.latestError = job.error;
8140
+ }
8141
+ if (job.nextRetryAt && (!summary.nextRetryAt || job.nextRetryAt < summary.nextRetryAt)) {
8142
+ summary.nextRetryAt = job.nextRetryAt;
8143
+ }
8144
+ if (job.status === "processing") {
8145
+ summary.processingJobCount += 1;
8146
+ continue;
8147
+ }
8148
+ if (job.status === "failed") {
8149
+ summary.failedJobCount += 1;
8150
+ continue;
8151
+ }
8152
+ if (!isPastDue(job.nextRetryAt)) {
8153
+ summary.retryScheduledJobCount += 1;
8154
+ continue;
8155
+ }
8156
+ summary.queuedJobCount += 1;
8157
+ }
8158
+ summary.activeJobCount = summary.queuedJobCount + summary.processingJobCount + summary.retryScheduledJobCount;
8159
+ if (summary.processingJobCount > 0) {
8160
+ summary.state = "processing";
8161
+ } else if (summary.queuedJobCount > 0) {
8162
+ summary.state = "queued";
8163
+ } else if (summary.retryScheduledJobCount > 0) {
8164
+ summary.state = "retry_scheduled";
8165
+ } else if (summary.failedJobCount > 0) {
8166
+ summary.state = "failed";
8167
+ }
8168
+ return summary;
8169
+ }
8170
+ async function updatePendingFinalizeJob(jobId, update) {
8171
+ const existing = await readPendingFinalizeJob(jobId);
8172
+ if (!existing) return null;
8173
+ const next = {
8174
+ ...existing,
8175
+ ...update,
8176
+ schemaVersion: 1,
8177
+ id: existing.id,
8178
+ capturedAt: existing.capturedAt,
8179
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
8180
+ metadata: update.metadata ? { ...existing.metadata, ...update.metadata } : existing.metadata
8181
+ };
8182
+ await writeJsonAtomic(getJobPath(jobId), next);
8183
+ return next;
8184
+ }
8185
+ async function claimPendingFinalizeJob(jobId) {
8186
+ const lockPath = getJobLockPath(jobId);
8187
+ const lockAcquired = await acquireJobLock(jobId);
8188
+ if (!lockAcquired) return null;
8189
+ let released = false;
8190
+ const release = async () => {
8191
+ if (released) return;
8192
+ released = true;
8193
+ await import_promises16.default.rm(lockPath, { recursive: true, force: true }).catch(() => void 0);
8194
+ };
8195
+ try {
8196
+ let existing = await readPendingFinalizeJob(jobId);
8197
+ if (!existing) {
8198
+ await release();
8199
+ return null;
8200
+ }
8201
+ if (isStaleAttempt(existing)) {
8202
+ const recovered = await updatePendingFinalizeJob(jobId, {
8203
+ status: "queued",
8204
+ error: existing.error ?? "Recovered a stale finalize processing lease.",
8205
+ nextRetryAt: null
8206
+ });
8207
+ existing = recovered ?? existing;
8208
+ }
8209
+ if (existing.status === "failed") {
8210
+ if (isTerminalFailure(existing)) {
8211
+ await release();
8212
+ return null;
8213
+ }
8214
+ const recovered = await updatePendingFinalizeJob(jobId, {
8215
+ status: "queued",
8216
+ nextRetryAt: existing.nextRetryAt ?? null
8217
+ });
8218
+ existing = recovered ?? existing;
8219
+ }
8220
+ if (existing.status !== "queued" || !isPastDue(existing.nextRetryAt)) {
8221
+ await release();
8222
+ return null;
8223
+ }
8224
+ const now = (/* @__PURE__ */ new Date()).toISOString();
8225
+ const claimed = await updatePendingFinalizeJob(jobId, {
8226
+ status: "processing",
8227
+ error: null,
8228
+ retryCount: existing.retryCount + 1,
8229
+ lastAttemptAt: now,
8230
+ nextRetryAt: null
8231
+ });
8232
+ if (!claimed) {
8233
+ await release();
8234
+ return null;
8235
+ }
8236
+ return { job: claimed, release };
8237
+ } catch (error) {
8238
+ await release();
8239
+ throw error;
8240
+ }
8241
+ }
8242
+ async function removePendingFinalizeJob(jobId) {
8243
+ try {
8244
+ await import_promises16.default.unlink(getJobPath(jobId));
8245
+ } catch (error) {
8246
+ if (error?.code !== "ENOENT") {
8247
+ throw error;
8248
+ }
8249
+ }
8250
+ await import_promises16.default.rm(getJobLockPath(jobId), { recursive: true, force: true }).catch(() => void 0);
8068
8251
  }
8069
8252
  function unwrapResponseObject(resp, label) {
8070
8253
  const obj = resp?.responseObject;
@@ -8078,7 +8261,7 @@ function sleep(ms) {
8078
8261
  return new Promise((resolve) => setTimeout(resolve, ms));
8079
8262
  }
8080
8263
  function buildDeterministicIdempotencyKey(parts) {
8081
- return (0, import_crypto2.createHash)("sha256").update(JSON.stringify(parts)).digest("hex");
8264
+ return (0, import_crypto4.createHash)("sha256").update(JSON.stringify(parts)).digest("hex");
8082
8265
  }
8083
8266
  function formatCliErrorDetail(err) {
8084
8267
  if (err instanceof RemixError) {
@@ -8089,25 +8272,6 @@ function formatCliErrorDetail(err) {
8089
8272
  }
8090
8273
  return typeof err === "string" && err.trim() ? err.trim() : null;
8091
8274
  }
8092
- async function pollAppReady(api, appId) {
8093
- const started = Date.now();
8094
- let delay = 2e3;
8095
- while (Date.now() - started < 20 * 60 * 1e3) {
8096
- const appResp = await api.getApp(appId);
8097
- const app = unwrapResponseObject(appResp, "app");
8098
- const status = typeof app.status === "string" ? app.status : "";
8099
- if (status === "ready") return app;
8100
- if (status === "error") {
8101
- throw new RemixError("App is in error state.", {
8102
- exitCode: 1,
8103
- hint: typeof app.statusError === "string" ? app.statusError : null
8104
- });
8105
- }
8106
- await sleep(delay);
8107
- delay = Math.min(1e4, Math.floor(delay * 1.4));
8108
- }
8109
- throw new RemixError("Timed out waiting for app to become ready.", { exitCode: 1 });
8110
- }
8111
8275
  async function pollChangeStep(api, appId, changeStepId) {
8112
8276
  const started = Date.now();
8113
8277
  let delay = 1500;
@@ -8157,6 +8321,10 @@ function normalizeBranchName2(value) {
8157
8321
  }
8158
8322
  function buildBindingFromLane(state, lane) {
8159
8323
  if (!lane.currentAppId || !lane.upstreamAppId) return null;
8324
+ const resolvedBranch = normalizeBranchName2(lane.branchName) ?? state.currentBranch ?? null;
8325
+ const resolvedDefaultBranch = normalizeBranchName2(lane.defaultBranch) ?? normalizeBranchName2(state.defaultBranch);
8326
+ const explicitRootProjectId = state.explicitRootBinding?.projectId ?? null;
8327
+ const bindingMode = explicitRootProjectId && lane.projectId === explicitRootProjectId && resolvedBranch && resolvedBranch === resolvedDefaultBranch ? "explicit_root" : "lane";
8160
8328
  return {
8161
8329
  schemaVersion: 3,
8162
8330
  projectId: lane.projectId ?? state.projectId,
@@ -8167,8 +8335,8 @@ function buildBindingFromLane(state, lane) {
8167
8335
  remoteUrl: lane.remoteUrl ?? state.remoteUrl ?? null,
8168
8336
  defaultBranch: lane.defaultBranch ?? state.defaultBranch ?? null,
8169
8337
  laneId: lane.laneId ?? null,
8170
- branchName: lane.branchName ?? state.currentBranch ?? null,
8171
- bindingMode: "lane"
8338
+ branchName: resolvedBranch,
8339
+ bindingMode
8172
8340
  };
8173
8341
  }
8174
8342
  function shouldPersistRemoteLaneMetadata(localBinding, lane) {
@@ -8197,6 +8365,16 @@ async function persistResolvedLane(repoRoot, binding) {
8197
8365
  });
8198
8366
  return readCollabBinding(repoRoot);
8199
8367
  }
8368
+ function buildAmbiguousResolution(params) {
8369
+ return {
8370
+ status: "ambiguous_family_selection",
8371
+ currentBranch: params.currentBranch,
8372
+ projectIds: Array.isArray(params.lane.projectIds) ? params.lane.projectIds.filter((value) => typeof value === "string" && value.trim().length > 0) : [],
8373
+ repoFingerprint: params.lane.repoFingerprint ?? params.state.repoFingerprint,
8374
+ remoteUrl: params.lane.remoteUrl ?? params.state.remoteUrl,
8375
+ defaultBranch: params.lane.defaultBranch ?? params.state.defaultBranch
8376
+ };
8377
+ }
8200
8378
  async function resolveActiveLaneBinding(params) {
8201
8379
  const state = await readCollabBindingState(params.repoRoot);
8202
8380
  if (!state) {
@@ -8219,13 +8397,16 @@ async function resolveActiveLaneBinding(params) {
8219
8397
  };
8220
8398
  }
8221
8399
  const laneResp2 = await params.api.resolveProjectLaneBinding({
8222
- projectId: localBinding.projectId ?? state.projectId ?? void 0,
8400
+ projectId: state.explicitRootBinding?.projectId ?? (requireRemoteLane ? void 0 : localBinding.projectId ?? state.projectId ?? void 0),
8223
8401
  repoFingerprint: state.repoFingerprint ?? void 0,
8224
8402
  remoteUrl: state.remoteUrl ?? void 0,
8225
8403
  defaultBranch: state.defaultBranch ?? void 0,
8226
8404
  branchName: currentBranch
8227
8405
  });
8228
8406
  const lane2 = unwrapResponseObject(laneResp2, "project lane binding");
8407
+ if (lane2.status === "ambiguous_family_selection") {
8408
+ return buildAmbiguousResolution({ state, currentBranch, lane: lane2 });
8409
+ }
8229
8410
  if (lane2.status === "resolved") {
8230
8411
  const resolvedBranch = normalizeBranchName2(lane2.branchName);
8231
8412
  const resolvedProjectId = lane2.projectId ?? state.projectId;
@@ -8268,12 +8449,12 @@ async function resolveActiveLaneBinding(params) {
8268
8449
  return {
8269
8450
  status: "missing_branch_binding",
8270
8451
  currentBranch,
8271
- projectId: state.projectId,
8272
- repoFingerprint: state.repoFingerprint,
8273
- remoteUrl: state.remoteUrl,
8274
- defaultBranch: state.defaultBranch,
8275
- upstreamAppId: localBinding.upstreamAppId ?? null,
8276
- threadId: localBinding.threadId ?? null
8452
+ projectId: lane2.projectId ?? state.projectId,
8453
+ repoFingerprint: lane2.repoFingerprint ?? state.repoFingerprint,
8454
+ remoteUrl: lane2.remoteUrl ?? state.remoteUrl,
8455
+ defaultBranch: lane2.defaultBranch ?? state.defaultBranch,
8456
+ upstreamAppId: lane2.upstreamAppId ?? localBinding.upstreamAppId ?? null,
8457
+ threadId: lane2.threadId ?? localBinding.threadId ?? null
8277
8458
  };
8278
8459
  }
8279
8460
  return {
@@ -8296,13 +8477,16 @@ async function resolveActiveLaneBinding(params) {
8296
8477
  };
8297
8478
  }
8298
8479
  const laneResp = await params.api.resolveProjectLaneBinding({
8299
- projectId: state.projectId ?? void 0,
8480
+ projectId: state.explicitRootBinding?.projectId ?? state.projectId ?? void 0,
8300
8481
  repoFingerprint: state.repoFingerprint ?? void 0,
8301
8482
  remoteUrl: state.remoteUrl ?? void 0,
8302
8483
  defaultBranch: state.defaultBranch ?? void 0,
8303
8484
  branchName: currentBranch
8304
8485
  });
8305
8486
  const lane = unwrapResponseObject(laneResp, "project lane binding");
8487
+ if (lane.status === "ambiguous_family_selection") {
8488
+ return buildAmbiguousResolution({ state, currentBranch, lane });
8489
+ }
8306
8490
  if (lane.status === "resolved") {
8307
8491
  const binding = buildBindingFromLane(state, lane);
8308
8492
  if (binding) {
@@ -8314,18 +8498,15 @@ async function resolveActiveLaneBinding(params) {
8314
8498
  };
8315
8499
  }
8316
8500
  }
8317
- if (lane.status === "binding_not_found") {
8318
- return { status: "not_bound", currentBranch };
8319
- }
8320
8501
  return {
8321
8502
  status: "missing_branch_binding",
8322
8503
  currentBranch,
8323
- projectId: lane.projectId ?? state.projectId,
8504
+ projectId: lane.projectId ?? state.explicitRootBinding?.projectId ?? state.projectId,
8324
8505
  repoFingerprint: lane.repoFingerprint ?? state.repoFingerprint,
8325
8506
  remoteUrl: lane.remoteUrl ?? state.remoteUrl,
8326
8507
  defaultBranch: lane.defaultBranch ?? state.defaultBranch,
8327
- upstreamAppId: lane.upstreamAppId ?? null,
8328
- threadId: lane.threadId ?? null
8508
+ upstreamAppId: lane.upstreamAppId ?? state.explicitRootBinding?.upstreamAppId ?? null,
8509
+ threadId: lane.threadId ?? state.explicitRootBinding?.threadId ?? null
8329
8510
  };
8330
8511
  }
8331
8512
  async function ensureActiveLaneBinding(params) {
@@ -8345,6 +8526,12 @@ async function ensureActiveLaneBinding(params) {
8345
8526
  hint: `Local app ${resolved.binding.currentAppId}; server app ${resolved.resolvedLane.currentAppId ?? "(unknown)"}. Repair the branch binding before running ${params.operation ?? "this command"}.`
8346
8527
  });
8347
8528
  }
8529
+ if (resolved.status === "ambiguous_family_selection") {
8530
+ throw new RemixError("Multiple canonical Remix families match this repository.", {
8531
+ exitCode: 2,
8532
+ hint: "This checkout is not specific enough to choose a single family for the current branch. Continue from a checkout already bound to the intended family, or run `remix collab init --force-new` to create a new canonical family."
8533
+ });
8534
+ }
8348
8535
  if (resolved.status === "not_bound") {
8349
8536
  return null;
8350
8537
  }
@@ -8359,614 +8546,567 @@ async function ensureActiveLaneBinding(params) {
8359
8546
  hint: `Run \`remix collab init\` on branch ${resolved.currentBranch} before running ${params.operation ?? "this command"}.`
8360
8547
  });
8361
8548
  }
8362
- async function collabRecordingPreflight(params) {
8549
+ function buildBaseState() {
8550
+ return {
8551
+ status: "ready",
8552
+ repoState: null,
8553
+ repoRoot: null,
8554
+ binding: null,
8555
+ currentBranch: null,
8556
+ branchName: null,
8557
+ localCommitHash: null,
8558
+ currentSnapshotHash: null,
8559
+ currentServerHeadHash: null,
8560
+ currentServerHeadCommitId: null,
8561
+ worktreeClean: false,
8562
+ pendingFinalize: {
8563
+ state: "idle",
8564
+ activeJobCount: 0,
8565
+ queuedJobCount: 0,
8566
+ processingJobCount: 0,
8567
+ retryScheduledJobCount: 0,
8568
+ failedJobCount: 0,
8569
+ oldestCapturedAt: null,
8570
+ newestCapturedAt: null,
8571
+ nextRetryAt: null,
8572
+ latestError: null
8573
+ },
8574
+ warnings: [],
8575
+ hint: null,
8576
+ metadataWarnings: [],
8577
+ baseline: {
8578
+ lastSnapshotId: null,
8579
+ lastSnapshotHash: null,
8580
+ lastServerHeadHash: null,
8581
+ lastSeenLocalCommitHash: null
8582
+ }
8583
+ };
8584
+ }
8585
+ async function collabDetectRepoState(params) {
8586
+ const detected = buildBaseState();
8363
8587
  let repoRoot;
8364
8588
  try {
8365
8589
  repoRoot = await findGitRoot(params.cwd);
8366
8590
  } catch (error) {
8367
- const message = error instanceof Error ? error.message : "Not inside a git repository.";
8368
- return {
8369
- status: "not_git_repo",
8370
- repoRoot: null,
8371
- appId: null,
8372
- currentBranch: null,
8373
- branchName: null,
8374
- headCommitHash: null,
8375
- worktreeClean: false,
8376
- syncStatus: null,
8377
- syncTargetCommitHash: null,
8378
- syncTargetCommitId: null,
8379
- reconcileTargetHeadCommitHash: null,
8380
- reconcileTargetHeadCommitId: null,
8381
- warnings: [],
8382
- hint: message
8383
- };
8591
+ detected.status = "not_git_repo";
8592
+ detected.hint = formatCliErrorDetail(error) ?? "Not inside a git repository.";
8593
+ return detected;
8384
8594
  }
8385
- const bindingResolution = await resolveActiveLaneBinding({ repoRoot, api: params.api });
8595
+ detected.repoRoot = repoRoot;
8596
+ const bindingResolution = await resolveActiveLaneBinding({ repoRoot, api: params.api ?? void 0 });
8386
8597
  if (bindingResolution.status === "not_bound") {
8387
- return {
8388
- status: "not_bound",
8389
- repoRoot,
8390
- appId: null,
8391
- currentBranch: null,
8392
- branchName: null,
8393
- headCommitHash: null,
8394
- worktreeClean: false,
8395
- syncStatus: null,
8396
- syncTargetCommitHash: null,
8397
- syncTargetCommitId: null,
8398
- reconcileTargetHeadCommitHash: null,
8399
- reconcileTargetHeadCommitId: null,
8400
- warnings: [],
8401
- hint: "Run `remix collab init` first."
8402
- };
8598
+ detected.status = "not_bound";
8599
+ detected.repoState = "binding_problem";
8600
+ detected.hint = "Run `remix collab init` first.";
8601
+ return detected;
8403
8602
  }
8404
8603
  if (bindingResolution.status === "missing_branch_binding") {
8405
- return {
8406
- status: "branch_binding_missing",
8407
- repoRoot,
8408
- appId: null,
8409
- currentBranch: bindingResolution.currentBranch,
8410
- branchName: bindingResolution.currentBranch,
8411
- headCommitHash: null,
8412
- worktreeClean: false,
8413
- syncStatus: null,
8414
- syncTargetCommitHash: null,
8415
- syncTargetCommitId: null,
8416
- reconcileTargetHeadCommitHash: null,
8417
- reconcileTargetHeadCommitId: null,
8418
- warnings: [],
8419
- hint: `Current branch ${bindingResolution.currentBranch ?? "(detached)"} is not yet bound to a Remix lane.`
8420
- };
8604
+ detected.status = "branch_binding_missing";
8605
+ detected.repoState = "binding_problem";
8606
+ detected.currentBranch = bindingResolution.currentBranch;
8607
+ detected.branchName = bindingResolution.currentBranch;
8608
+ detected.hint = `Current branch ${bindingResolution.currentBranch ?? "(detached)"} is not yet bound to a Remix lane.`;
8609
+ return detected;
8610
+ }
8611
+ if (bindingResolution.status === "ambiguous_family_selection") {
8612
+ detected.status = "family_ambiguous";
8613
+ detected.repoState = "binding_problem";
8614
+ detected.currentBranch = bindingResolution.currentBranch;
8615
+ detected.branchName = bindingResolution.currentBranch;
8616
+ detected.hint = "Multiple canonical Remix families match this repository. Continue from a checkout already bound to the intended family, or run `remix collab init --force-new`.";
8617
+ return detected;
8421
8618
  }
8422
8619
  if (bindingResolution.status === "binding_conflict") {
8423
- return {
8424
- status: "metadata_conflict",
8425
- repoRoot,
8426
- appId: bindingResolution.binding.currentAppId,
8427
- currentBranch: bindingResolution.currentBranch,
8428
- branchName: bindingResolution.binding.branchName,
8429
- headCommitHash: null,
8430
- worktreeClean: false,
8431
- syncStatus: null,
8432
- syncTargetCommitHash: null,
8433
- syncTargetCommitId: null,
8434
- reconcileTargetHeadCommitHash: null,
8435
- reconcileTargetHeadCommitId: null,
8436
- warnings: [],
8437
- hint: `Local binding for ${bindingResolution.currentBranch ?? "(detached)"} points to app ${bindingResolution.binding.currentAppId}, but the server resolved lane ${bindingResolution.resolvedLane.laneId ?? "(unknown)"} / app ${bindingResolution.resolvedLane.currentAppId ?? "(unknown)"}. Repair the branch binding before recording work.`
8438
- };
8620
+ detected.status = "metadata_conflict";
8621
+ detected.repoState = "metadata_conflict";
8622
+ detected.binding = bindingResolution.binding;
8623
+ detected.currentBranch = bindingResolution.currentBranch;
8624
+ detected.branchName = bindingResolution.binding.branchName;
8625
+ detected.hint = `Local binding for ${bindingResolution.currentBranch ?? "(detached)"} points to app ${bindingResolution.binding.currentAppId}, but the server resolved lane ${bindingResolution.resolvedLane.laneId ?? "(unknown)"} / app ${bindingResolution.resolvedLane.currentAppId ?? "(unknown)"}.`;
8626
+ return detected;
8439
8627
  }
8440
8628
  const binding = bindingResolution.binding;
8441
- const [currentBranch, headCommitHash, worktreeStatus] = await Promise.all([
8629
+ detected.binding = binding;
8630
+ const [currentBranch, localCommitHash, worktreeStatus] = await Promise.all([
8442
8631
  getCurrentBranch(repoRoot),
8443
8632
  getHeadCommitHash(repoRoot),
8444
8633
  getWorktreeStatus(repoRoot)
8445
8634
  ]);
8446
- const branchName = binding.branchName ?? null;
8447
- if (!headCommitHash) {
8448
- return {
8449
- status: "missing_head",
8450
- repoRoot,
8451
- appId: binding.currentAppId,
8452
- currentBranch,
8453
- branchName,
8454
- headCommitHash: null,
8455
- worktreeClean: worktreeStatus.isClean,
8456
- syncStatus: null,
8457
- syncTargetCommitHash: null,
8458
- syncTargetCommitId: null,
8459
- reconcileTargetHeadCommitHash: null,
8460
- reconcileTargetHeadCommitId: null,
8461
- warnings: [],
8462
- hint: "Failed to resolve local HEAD commit."
8463
- };
8464
- }
8465
- if (!params.allowBranchMismatch && !isBoundBranchMatch(currentBranch, branchName)) {
8466
- return {
8467
- status: "branch_mismatch",
8468
- repoRoot,
8469
- appId: binding.currentAppId,
8470
- currentBranch,
8471
- branchName,
8472
- headCommitHash,
8473
- worktreeClean: worktreeStatus.isClean,
8474
- syncStatus: null,
8475
- syncTargetCommitHash: null,
8476
- syncTargetCommitId: null,
8477
- reconcileTargetHeadCommitHash: null,
8478
- reconcileTargetHeadCommitId: null,
8479
- warnings: [],
8480
- hint: buildBranchMismatchHint({
8481
- currentBranch,
8482
- branchName
8635
+ detected.currentBranch = currentBranch;
8636
+ detected.branchName = binding.branchName ?? null;
8637
+ detected.localCommitHash = localCommitHash;
8638
+ detected.worktreeClean = worktreeStatus.isClean;
8639
+ if (!localCommitHash) {
8640
+ detected.status = "missing_head";
8641
+ detected.repoState = "binding_problem";
8642
+ detected.hint = "Failed to resolve local HEAD commit.";
8643
+ return detected;
8644
+ }
8645
+ if (!params.allowBranchMismatch && !isBoundBranchMatch(currentBranch, binding.branchName ?? null)) {
8646
+ detected.status = "branch_mismatch";
8647
+ detected.repoState = "binding_problem";
8648
+ detected.hint = buildBranchMismatchHint({ currentBranch, branchName: binding.branchName ?? null });
8649
+ return detected;
8650
+ }
8651
+ if (!params.api) {
8652
+ const [inspection, pendingFinalize] = await Promise.all([
8653
+ inspectLocalSnapshot({
8654
+ repoRoot,
8655
+ repoFingerprint: binding.repoFingerprint,
8656
+ laneId: binding.laneId,
8657
+ branchName: binding.branchName,
8658
+ persistBlobs: false
8659
+ }),
8660
+ summarizePendingFinalizeJobs({
8661
+ repoRoot,
8662
+ repoFingerprint: binding.repoFingerprint,
8663
+ currentAppId: binding.currentAppId,
8664
+ laneId: binding.laneId
8483
8665
  })
8484
- };
8485
- }
8486
- const syncResp = await params.api.syncLocalApp(binding.currentAppId, {
8487
- baseCommitHash: headCommitHash,
8488
- repoFingerprint: binding.repoFingerprint ?? void 0,
8489
- remoteUrl: binding.remoteUrl ?? void 0,
8490
- defaultBranch: binding.defaultBranch ?? void 0,
8491
- dryRun: true
8492
- });
8493
- const sync = unwrapResponseObject(syncResp, "sync result");
8494
- if (sync.status === "conflict_risk") {
8495
- return {
8496
- status: "metadata_conflict",
8497
- repoRoot,
8498
- appId: binding.currentAppId,
8499
- currentBranch,
8500
- branchName,
8501
- headCommitHash,
8502
- worktreeClean: worktreeStatus.isClean,
8503
- syncStatus: sync.status,
8504
- syncTargetCommitHash: sync.targetCommitHash,
8505
- syncTargetCommitId: sync.targetCommitId,
8506
- reconcileTargetHeadCommitHash: null,
8507
- reconcileTargetHeadCommitId: null,
8508
- warnings: sync.warnings,
8509
- hint: sync.warnings.join("\n") || "Run the command from the correct bound repository."
8510
- };
8511
- }
8512
- if (sync.status === "up_to_date" || sync.status === "ready_to_fast_forward") {
8513
- return {
8514
- status: sync.status,
8515
- repoRoot,
8516
- appId: binding.currentAppId,
8517
- currentBranch,
8518
- branchName,
8519
- headCommitHash,
8520
- worktreeClean: worktreeStatus.isClean,
8521
- syncStatus: sync.status,
8522
- syncTargetCommitHash: sync.targetCommitHash,
8523
- syncTargetCommitId: sync.targetCommitId,
8524
- reconcileTargetHeadCommitHash: null,
8525
- reconcileTargetHeadCommitId: null,
8526
- warnings: sync.warnings,
8527
- hint: null
8528
- };
8529
- }
8530
- const reconcileResp = await params.api.preflightAppReconcile(binding.currentAppId, {
8531
- localHeadCommitHash: headCommitHash,
8532
- repoFingerprint: binding.repoFingerprint ?? void 0,
8533
- remoteUrl: binding.remoteUrl ?? void 0,
8534
- defaultBranch: binding.defaultBranch ?? void 0
8535
- });
8536
- const reconcile = unwrapResponseObject(reconcileResp, "reconcile preflight");
8537
- if (reconcile.status === "metadata_conflict") {
8538
- return {
8539
- status: "metadata_conflict",
8540
- repoRoot,
8541
- appId: binding.currentAppId,
8542
- currentBranch,
8543
- branchName,
8544
- headCommitHash,
8545
- worktreeClean: worktreeStatus.isClean,
8546
- syncStatus: sync.status,
8547
- syncTargetCommitHash: sync.targetCommitHash,
8548
- syncTargetCommitId: sync.targetCommitId,
8549
- reconcileTargetHeadCommitHash: reconcile.targetHeadCommitHash,
8550
- reconcileTargetHeadCommitId: reconcile.targetHeadCommitId,
8551
- warnings: reconcile.warnings,
8552
- hint: reconcile.warnings.join("\n") || "Run the command from the correct bound repository."
8553
- };
8554
- }
8555
- if (reconcile.status === "up_to_date") {
8556
- return {
8557
- status: "up_to_date",
8558
- repoRoot,
8559
- appId: binding.currentAppId,
8560
- currentBranch,
8561
- branchName,
8562
- headCommitHash,
8563
- worktreeClean: worktreeStatus.isClean,
8564
- syncStatus: sync.status,
8565
- syncTargetCommitHash: sync.targetCommitHash,
8566
- syncTargetCommitId: sync.targetCommitId,
8567
- reconcileTargetHeadCommitHash: reconcile.targetHeadCommitHash,
8568
- reconcileTargetHeadCommitId: reconcile.targetHeadCommitId,
8569
- warnings: reconcile.warnings,
8570
- hint: null
8571
- };
8666
+ ]);
8667
+ detected.currentSnapshotHash = inspection.snapshotHash;
8668
+ detected.pendingFinalize = pendingFinalize;
8669
+ return detected;
8572
8670
  }
8573
- return {
8574
- status: "reconcile_required",
8575
- repoRoot,
8576
- appId: binding.currentAppId,
8577
- currentBranch,
8578
- branchName,
8579
- headCommitHash,
8580
- worktreeClean: worktreeStatus.isClean,
8581
- syncStatus: sync.status,
8582
- syncTargetCommitHash: sync.targetCommitHash,
8583
- syncTargetCommitId: sync.targetCommitId,
8584
- reconcileTargetHeadCommitHash: reconcile.targetHeadCommitHash,
8585
- reconcileTargetHeadCommitId: reconcile.targetHeadCommitId,
8586
- warnings: reconcile.warnings,
8587
- hint: reconcile.warnings.join("\n") || "Run `remix collab reconcile` first because the local history is no longer fast-forward compatible with the app."
8588
- };
8589
- }
8590
- var DEFAULT_ACQUIRE_TIMEOUT_MS = 15e3;
8591
- var DEFAULT_STALE_MS = 45e3;
8592
- var DEFAULT_HEARTBEAT_MS = 5e3;
8593
- var RETRY_DELAY_MS = 250;
8594
- var heldLocks = /* @__PURE__ */ new Map();
8595
- function sleep2(ms) {
8596
- return new Promise((resolve) => setTimeout(resolve, ms));
8597
- }
8598
- function createOwner(params) {
8599
- const now = (/* @__PURE__ */ new Date()).toISOString();
8600
- return {
8601
- operation: params.operation,
8602
- repoRoot: params.repoRoot,
8603
- pid: process.pid,
8604
- hostname: import_os2.default.hostname(),
8605
- startedAt: now,
8606
- heartbeatAt: now,
8607
- version: process.version,
8608
- requestId: params.requestId?.trim() || null
8609
- };
8610
- }
8611
- async function writeOwnerMetadata(ownerPath, owner) {
8612
- await import_promises16.default.writeFile(ownerPath, `${JSON.stringify(owner, null, 2)}
8613
- `, "utf8");
8614
- }
8615
- async function readOwnerMetadata(ownerPath) {
8616
8671
  try {
8617
- const raw = await import_promises16.default.readFile(ownerPath, "utf8");
8618
- const parsed = JSON.parse(raw);
8619
- if (!parsed || typeof parsed !== "object") return null;
8620
- if (!parsed.operation || !parsed.repoRoot || typeof parsed.pid !== "number" || !parsed.startedAt || !parsed.heartbeatAt) {
8621
- return null;
8622
- }
8623
- return {
8624
- operation: parsed.operation,
8625
- repoRoot: parsed.repoRoot,
8626
- pid: parsed.pid,
8627
- hostname: typeof parsed.hostname === "string" ? parsed.hostname : "unknown",
8628
- startedAt: parsed.startedAt,
8629
- heartbeatAt: parsed.heartbeatAt,
8630
- version: typeof parsed.version === "string" ? parsed.version : "unknown",
8631
- requestId: typeof parsed.requestId === "string" ? parsed.requestId : null
8672
+ const [headResp, inspection, baseline, pendingFinalize] = await Promise.all([
8673
+ params.api.getAppHead(binding.currentAppId),
8674
+ inspectLocalSnapshot({
8675
+ repoRoot,
8676
+ repoFingerprint: binding.repoFingerprint,
8677
+ laneId: binding.laneId,
8678
+ branchName: binding.branchName,
8679
+ persistBlobs: false
8680
+ }),
8681
+ readLocalBaseline({
8682
+ repoFingerprint: binding.repoFingerprint,
8683
+ laneId: binding.laneId,
8684
+ repoRoot
8685
+ }),
8686
+ summarizePendingFinalizeJobs({
8687
+ repoRoot,
8688
+ repoFingerprint: binding.repoFingerprint,
8689
+ currentAppId: binding.currentAppId,
8690
+ laneId: binding.laneId
8691
+ })
8692
+ ]);
8693
+ const appHead = unwrapResponseObject(headResp, "app head");
8694
+ detected.currentServerHeadHash = appHead.headCommitHash;
8695
+ detected.currentServerHeadCommitId = appHead.headCommitId;
8696
+ detected.currentSnapshotHash = inspection.snapshotHash;
8697
+ detected.pendingFinalize = pendingFinalize;
8698
+ detected.baseline = {
8699
+ lastSnapshotId: baseline?.lastSnapshotId ?? null,
8700
+ lastSnapshotHash: baseline?.lastSnapshotHash ?? null,
8701
+ lastServerHeadHash: baseline?.lastServerHeadHash ?? null,
8702
+ lastSeenLocalCommitHash: baseline?.lastSeenLocalCommitHash ?? null
8632
8703
  };
8633
- } catch {
8634
- return null;
8635
- }
8636
- }
8637
- async function isProcessAlive(owner) {
8638
- if (!owner) return null;
8639
- if (owner.hostname !== import_os2.default.hostname()) return null;
8640
- try {
8641
- process.kill(owner.pid, 0);
8642
- return true;
8704
+ if (!baseline?.lastSnapshotHash || !baseline.lastServerHeadHash) {
8705
+ if (detected.worktreeClean && localCommitHash && localCommitHash !== appHead.headCommitHash) {
8706
+ try {
8707
+ const bootstrapResp = await params.api.getAppDelta(binding.currentAppId, {
8708
+ baseHeadHash: localCommitHash,
8709
+ targetHeadHash: appHead.headCommitHash,
8710
+ repoFingerprint: binding.repoFingerprint ?? void 0,
8711
+ remoteUrl: binding.remoteUrl ?? void 0,
8712
+ defaultBranch: binding.defaultBranch ?? void 0
8713
+ });
8714
+ const bootstrapDelta = unwrapResponseObject(bootstrapResp, "app delta");
8715
+ detected.metadataWarnings = Array.from(/* @__PURE__ */ new Set([...detected.metadataWarnings, ...bootstrapDelta.warnings]));
8716
+ detected.warnings.push(...bootstrapDelta.warnings);
8717
+ if (bootstrapDelta.status === "conflict_risk") {
8718
+ detected.status = "metadata_conflict";
8719
+ detected.repoState = "metadata_conflict";
8720
+ detected.hint = bootstrapDelta.warnings.join("\n") || "Run the command from the correct bound repository.";
8721
+ return detected;
8722
+ }
8723
+ if (bootstrapDelta.status === "delta_ready" || bootstrapDelta.status === "up_to_date") {
8724
+ detected.repoState = "server_only_changed";
8725
+ detected.hint = "This checkout has not stored a local Remix baseline yet, but its current Git HEAD is already known to Remix. Pull the server delta locally to create the first baseline for this checkout.";
8726
+ return detected;
8727
+ }
8728
+ } catch {
8729
+ }
8730
+ }
8731
+ detected.repoState = "external_local_base_changed";
8732
+ detected.hint = "No local Remix baseline exists for this lane yet. Run `remix collab re-anchor` to anchor this checkout.";
8733
+ return detected;
8734
+ }
8735
+ const localHeadMovedSinceBaseline = Boolean(baseline.lastSeenLocalCommitHash) && localCommitHash !== baseline.lastSeenLocalCommitHash;
8736
+ if (localHeadMovedSinceBaseline) {
8737
+ detected.warnings.push(
8738
+ "Local Git HEAD changed since the last Remix baseline. Remix will use the current workspace snapshot to detect divergence."
8739
+ );
8740
+ }
8741
+ const metadataBaseHeadHash = baseline.lastServerHeadHash || appHead.headCommitHash;
8742
+ const metadataResp = await params.api.getAppDelta(binding.currentAppId, {
8743
+ baseHeadHash: metadataBaseHeadHash,
8744
+ targetHeadHash: metadataBaseHeadHash,
8745
+ repoFingerprint: binding.repoFingerprint ?? void 0,
8746
+ remoteUrl: binding.remoteUrl ?? void 0,
8747
+ defaultBranch: binding.defaultBranch ?? void 0
8748
+ });
8749
+ const metadataCheck = unwrapResponseObject(metadataResp, "app delta metadata");
8750
+ detected.metadataWarnings = metadataCheck.warnings;
8751
+ detected.warnings.push(...metadataCheck.warnings);
8752
+ if (metadataCheck.status === "conflict_risk") {
8753
+ detected.status = "metadata_conflict";
8754
+ detected.repoState = "metadata_conflict";
8755
+ detected.hint = metadataCheck.warnings.join("\n") || "Run the command from the correct bound repository.";
8756
+ return detected;
8757
+ }
8758
+ const localChanged = inspection.snapshotHash !== baseline.lastSnapshotHash;
8759
+ const serverChanged = appHead.headCommitHash !== baseline.lastServerHeadHash;
8760
+ if (!localChanged && !serverChanged) {
8761
+ detected.repoState = "idle";
8762
+ return detected;
8763
+ }
8764
+ if (localChanged && !serverChanged) {
8765
+ detected.repoState = "local_only_changed";
8766
+ return detected;
8767
+ }
8768
+ if (!localChanged && serverChanged) {
8769
+ detected.repoState = "server_only_changed";
8770
+ detected.hint = "The server lane advanced since the last agreed baseline. Pull the server delta locally before continuing.";
8771
+ return detected;
8772
+ }
8773
+ detected.repoState = "both_changed";
8774
+ detected.hint = "Both the local workspace and the server lane changed since the last agreed baseline. Replay or reconcile is required before normal recording continues.";
8775
+ return detected;
8643
8776
  } catch (error) {
8644
- if (error?.code === "EPERM") return true;
8645
- if (error?.code === "ESRCH") return false;
8646
- return null;
8777
+ detected.status = "remote_error";
8778
+ detected.hint = formatCliErrorDetail(error) ?? "Failed to detect the current Remix repo state.";
8779
+ return detected;
8647
8780
  }
8648
8781
  }
8649
- async function getLastKnownUpdateMs(lockDir, ownerPath, owner) {
8650
- const heartbeatMs = owner ? Date.parse(owner.heartbeatAt) : Number.NaN;
8651
- if (Number.isFinite(heartbeatMs)) return heartbeatMs;
8652
- const startedMs = owner ? Date.parse(owner.startedAt) : Number.NaN;
8653
- if (Number.isFinite(startedMs)) return startedMs;
8654
- const stat = await import_promises16.default.stat(ownerPath).catch(() => null);
8655
- if (stat) return stat.mtimeMs;
8656
- const dirStat = await import_promises16.default.stat(lockDir).catch(() => null);
8657
- if (dirStat) return dirStat.mtimeMs;
8658
- return 0;
8782
+ var FINALIZE_RETRY_BASE_DELAY_MS = 15e3;
8783
+ var FINALIZE_RETRY_MAX_DELAY_MS = 5 * 60 * 1e3;
8784
+ function readMetadataString(job, key) {
8785
+ const value = job.metadata[key];
8786
+ return typeof value === "string" && value.trim() ? value.trim() : null;
8659
8787
  }
8660
- async function ensureLockDir(lockDir) {
8661
- await import_promises16.default.mkdir(import_path5.default.dirname(lockDir), { recursive: true });
8788
+ function readMetadataActor(job) {
8789
+ const actor = job.metadata.actor;
8790
+ return actor && typeof actor === "object" ? actor : void 0;
8662
8791
  }
8663
- async function tryAcquireLock(lockDir, ownerPath, owner) {
8664
- try {
8665
- await ensureLockDir(lockDir);
8666
- await import_promises16.default.mkdir(lockDir);
8667
- try {
8668
- await writeOwnerMetadata(ownerPath, owner);
8669
- } catch (error) {
8670
- await import_promises16.default.rm(lockDir, { recursive: true, force: true }).catch(() => void 0);
8671
- throw error;
8672
- }
8673
- return true;
8674
- } catch (error) {
8675
- if (error?.code === "EEXIST") return false;
8676
- throw error;
8677
- }
8792
+ function buildNextRetryAt(retryCount) {
8793
+ const exponent = Math.max(0, retryCount - 1);
8794
+ const delayMs = Math.min(FINALIZE_RETRY_BASE_DELAY_MS * 2 ** exponent, FINALIZE_RETRY_MAX_DELAY_MS);
8795
+ return new Date(Date.now() + delayMs).toISOString();
8678
8796
  }
8679
- function formatLockHint(params) {
8680
- const lines = [
8681
- params.observedHeldLock ? `Observed lock state: ${REMIX_ERROR_CODES.REPO_LOCK_HELD}.` : null,
8682
- params.owner ? `Active operation: ${params.owner.operation}` : "Active operation: unknown",
8683
- params.owner ? `Repo root: ${params.owner.repoRoot}` : null,
8684
- params.owner ? `Owner: pid=${params.owner.pid} host=${params.owner.hostname}` : null,
8685
- params.owner ? `Started at: ${params.owner.startedAt}` : null,
8686
- params.owner ? `Heartbeat at: ${params.owner.heartbeatAt}` : null,
8687
- `Waited ${params.waitedMs}ms for the repo mutation lock.`,
8688
- `Stale lock threshold: ${params.staleMs}ms.`,
8689
- "Retry after the active operation finishes. If the process crashed, wait for stale lock recovery or remove the stale lock manually if necessary."
8690
- ];
8691
- return lines.filter(Boolean).join("\n");
8797
+ function buildFinalizeCliError(params) {
8798
+ const error = new RemixError(params.message, {
8799
+ exitCode: params.exitCode,
8800
+ hint: params.hint
8801
+ });
8802
+ error.finalizeDisposition = params.disposition;
8803
+ error.finalizeReason = params.reason;
8804
+ return error;
8692
8805
  }
8693
- function formatOwnerSummary(owner) {
8694
- if (!owner) {
8695
- return "unknown owner";
8696
- }
8697
- return `operation=${owner.operation} pid=${owner.pid} host=${owner.hostname} startedAt=${owner.startedAt} heartbeatAt=${owner.heartbeatAt}`;
8806
+ function classifyFinalizeError(error) {
8807
+ const tagged = error;
8808
+ return {
8809
+ disposition: tagged.finalizeDisposition ?? "retryable",
8810
+ reason: tagged.finalizeReason ?? "unknown",
8811
+ message: error instanceof Error ? error.message : String(error)
8812
+ };
8698
8813
  }
8699
- function buildStaleRecoveryNotice(owner) {
8814
+ function buildWorkspaceMetadata(params) {
8700
8815
  return {
8701
- code: REMIX_ERROR_CODES.REPO_LOCK_STALE_RECOVERED,
8702
- owner,
8703
- message: `[${REMIX_ERROR_CODES.REPO_LOCK_STALE_RECOVERED}] Recovered a stale Remix repo mutation lock (${formatOwnerSummary(owner)}).`
8816
+ branch: params.branchName,
8817
+ repoRoot: params.repoRoot,
8818
+ remoteUrl: params.remoteUrl,
8819
+ defaultBranch: params.defaultBranch,
8820
+ recordingMode: "boundary_delta",
8821
+ baselineSnapshotId: params.baselineSnapshotId,
8822
+ currentSnapshotId: params.currentSnapshotId,
8823
+ baselineServerHeadHash: params.baselineServerHeadHash,
8824
+ currentSnapshotHash: params.currentSnapshotHash,
8825
+ localCommitHash: params.localCommitHash,
8826
+ repoStateAtCapture: params.repoState,
8827
+ replayedFromBaseHash: params.replayedFromBaseHash ?? null
8704
8828
  };
8705
8829
  }
8706
- async function acquirePhysicalLock(lockDir, ownerPath, owner, options) {
8707
- const startedAt = Date.now();
8708
- const notices = [];
8709
- let observedHeldLock = false;
8710
- while (Date.now() - startedAt < options.acquireTimeoutMs) {
8711
- if (await tryAcquireLock(lockDir, ownerPath, owner)) return notices;
8712
- const currentOwner2 = await readOwnerMetadata(ownerPath);
8713
- observedHeldLock = true;
8714
- const lastUpdateMs = await getLastKnownUpdateMs(lockDir, ownerPath, currentOwner2);
8715
- const ageMs = Math.max(0, Date.now() - lastUpdateMs);
8716
- const alive = await isProcessAlive(currentOwner2);
8717
- if (ageMs >= options.staleMs && alive !== true) {
8718
- notices.push(buildStaleRecoveryNotice(currentOwner2));
8719
- await import_promises16.default.rm(lockDir, { recursive: true, force: true }).catch(() => void 0);
8720
- continue;
8830
+ async function processClaimedPendingFinalizeJob(params) {
8831
+ const job = params.job;
8832
+ try {
8833
+ const [snapshot, baseline, appHeadResp] = await Promise.all([
8834
+ readLocalSnapshot(job.currentSnapshotId),
8835
+ readLocalBaseline({
8836
+ repoFingerprint: job.repoFingerprint,
8837
+ laneId: job.laneId,
8838
+ repoRoot: job.repoRoot
8839
+ }),
8840
+ params.api.getAppHead(job.currentAppId)
8841
+ ]);
8842
+ if (!snapshot) {
8843
+ throw buildFinalizeCliError({
8844
+ message: "Captured snapshot is missing from the local snapshot store.",
8845
+ exitCode: 1,
8846
+ disposition: "terminal",
8847
+ reason: "snapshot_missing"
8848
+ });
8721
8849
  }
8722
- await sleep2(RETRY_DELAY_MS);
8723
- }
8724
- const currentOwner = await readOwnerMetadata(ownerPath);
8725
- throw new RemixError("Repository is busy with another Remix mutation.", {
8726
- code: REMIX_ERROR_CODES.REPO_LOCK_TIMEOUT,
8727
- exitCode: 2,
8728
- hint: formatLockHint({
8729
- owner: currentOwner,
8730
- waitedMs: Date.now() - startedAt,
8731
- staleMs: options.staleMs,
8732
- observedHeldLock
8733
- })
8734
- });
8735
- }
8736
- function startHeartbeat(lockDir, ownerPath, owner, heartbeatMs) {
8737
- return setInterval(() => {
8738
- const nextOwner = {
8739
- ...owner,
8740
- heartbeatAt: (/* @__PURE__ */ new Date()).toISOString()
8741
- };
8742
- owner.heartbeatAt = nextOwner.heartbeatAt;
8743
- void writeOwnerMetadata(ownerPath, nextOwner).catch(() => void 0);
8744
- void import_promises16.default.utimes(lockDir, /* @__PURE__ */ new Date(), /* @__PURE__ */ new Date()).catch(() => void 0);
8745
- }, heartbeatMs);
8746
- }
8747
- async function releaseReentrantLock(lockDir) {
8748
- const held = heldLocks.get(lockDir);
8749
- if (!held) return;
8750
- held.count -= 1;
8751
- if (held.count > 0) return;
8752
- clearInterval(held.heartbeatTimer);
8753
- heldLocks.delete(lockDir);
8754
- await import_promises16.default.rm(lockDir, { recursive: true, force: true }).catch(() => void 0);
8755
- }
8756
- async function withRepoMutationLock(options, fn) {
8757
- const repoRoot = await findGitRoot(options.cwd);
8758
- const gitCommonDir = await getGitCommonDir(repoRoot);
8759
- const lockDir = import_path5.default.join(gitCommonDir, "remix", "locks", "repo-mutation.lock");
8760
- const owner = createOwner({
8761
- operation: options.operation,
8762
- repoRoot,
8763
- requestId: options.requestId
8764
- });
8765
- const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
8766
- const acquireTimeoutMs = options.acquireTimeoutMs ?? DEFAULT_ACQUIRE_TIMEOUT_MS;
8767
- const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
8768
- const existing = heldLocks.get(lockDir);
8769
- let notices = [];
8770
- if (!existing) {
8771
- notices = await acquirePhysicalLock(lockDir, import_path5.default.join(lockDir, "owner.json"), owner, {
8772
- acquireTimeoutMs,
8773
- staleMs
8774
- });
8775
- const ownerPath = import_path5.default.join(lockDir, "owner.json");
8776
- heldLocks.set(lockDir, {
8777
- count: 1,
8778
- lockDir,
8779
- ownerPath,
8780
- owner,
8781
- heartbeatTimer: startHeartbeat(lockDir, ownerPath, owner, heartbeatMs)
8850
+ if (!baseline) {
8851
+ throw buildFinalizeCliError({
8852
+ message: "Local baseline is missing for this queued finalize job.",
8853
+ exitCode: 2,
8854
+ hint: "Run `remix collab re-anchor` to anchor the repository again.",
8855
+ disposition: "terminal",
8856
+ reason: "baseline_missing"
8857
+ });
8858
+ }
8859
+ if (baseline.lastSnapshotId !== job.baselineSnapshotId || baseline.lastServerHeadHash !== job.baselineServerHeadHash) {
8860
+ throw buildFinalizeCliError({
8861
+ message: "Finalize queue baseline drifted before this job was processed.",
8862
+ exitCode: 1,
8863
+ hint: "Process queued finalize jobs in capture order, or re-anchor the repository before retrying.",
8864
+ disposition: "terminal",
8865
+ reason: "baseline_drifted"
8866
+ });
8867
+ }
8868
+ const appHead = unwrapResponseObject(appHeadResp, "app head");
8869
+ const remoteUrl = readMetadataString(job, "remoteUrl");
8870
+ const defaultBranch = readMetadataString(job, "defaultBranch");
8871
+ const repoState = readMetadataString(job, "repoState");
8872
+ const actor = readMetadataActor(job);
8873
+ const diffResult = await diffLocalSnapshots({
8874
+ baseSnapshotId: job.baselineSnapshotId,
8875
+ targetSnapshotId: job.currentSnapshotId
8876
+ });
8877
+ if (!diffResult.diff.trim()) {
8878
+ if (appHead.headCommitHash !== job.baselineServerHeadHash) {
8879
+ throw buildFinalizeCliError({
8880
+ message: "Server lane changed before a no-diff turn could be recorded.",
8881
+ exitCode: 2,
8882
+ hint: "Pull the server changes locally before recording another no-diff turn.",
8883
+ disposition: "terminal",
8884
+ reason: "server_lane_changed"
8885
+ });
8886
+ }
8887
+ const collabTurnResp = await params.api.createCollabTurn(job.currentAppId, {
8888
+ threadId: job.threadId ?? void 0,
8889
+ collabLaneId: job.laneId ?? void 0,
8890
+ prompt: job.prompt,
8891
+ assistantResponse: job.assistantResponse,
8892
+ actor,
8893
+ workspaceMetadata: buildWorkspaceMetadata({
8894
+ repoRoot: job.repoRoot,
8895
+ branchName: job.branchName,
8896
+ remoteUrl,
8897
+ defaultBranch,
8898
+ baselineSnapshotId: job.baselineSnapshotId,
8899
+ currentSnapshotId: job.currentSnapshotId,
8900
+ baselineServerHeadHash: job.baselineServerHeadHash,
8901
+ currentSnapshotHash: snapshot.snapshotHash,
8902
+ localCommitHash: snapshot.localCommitHash,
8903
+ repoState
8904
+ }),
8905
+ idempotencyKey: job.idempotencyKey ?? void 0
8906
+ });
8907
+ const collabTurn = unwrapResponseObject(collabTurnResp, "collab turn");
8908
+ await writeLocalBaseline({
8909
+ repoRoot: job.repoRoot,
8910
+ repoFingerprint: job.repoFingerprint,
8911
+ laneId: job.laneId,
8912
+ currentAppId: job.currentAppId,
8913
+ branchName: job.branchName,
8914
+ lastSnapshotId: snapshot.id,
8915
+ lastSnapshotHash: snapshot.snapshotHash,
8916
+ lastServerHeadHash: appHead.headCommitHash,
8917
+ lastSeenLocalCommitHash: snapshot.localCommitHash
8918
+ });
8919
+ await updatePendingFinalizeJob(job.id, {
8920
+ status: "completed",
8921
+ metadata: { collabTurnId: collabTurn.id }
8922
+ });
8923
+ return {
8924
+ mode: "no_diff_turn",
8925
+ idempotencyKey: job.idempotencyKey ?? "",
8926
+ queued: false,
8927
+ jobId: job.id,
8928
+ repoState,
8929
+ changeStep: null,
8930
+ collabTurn,
8931
+ autoSync: null,
8932
+ warnings: []
8933
+ };
8934
+ }
8935
+ let submissionDiff = diffResult.diff;
8936
+ let submissionBaseHeadHash = job.baselineServerHeadHash;
8937
+ let replayedFromBaseHash = null;
8938
+ if (!submissionBaseHeadHash) {
8939
+ throw buildFinalizeCliError({
8940
+ message: "Baseline server head is missing for this finalize job.",
8941
+ exitCode: 1,
8942
+ disposition: "terminal",
8943
+ reason: "baseline_server_head_missing"
8944
+ });
8945
+ }
8946
+ if (appHead.headCommitHash !== submissionBaseHeadHash) {
8947
+ const replayResp = await params.api.startChangeStepReplay(job.currentAppId, {
8948
+ prompt: job.prompt,
8949
+ assistantResponse: job.assistantResponse,
8950
+ diff: diffResult.diff,
8951
+ baseCommitHash: submissionBaseHeadHash,
8952
+ targetHeadCommitHash: appHead.headCommitHash,
8953
+ expectedPaths: diffResult.changedPaths,
8954
+ actor,
8955
+ workspaceMetadata: buildWorkspaceMetadata({
8956
+ repoRoot: job.repoRoot,
8957
+ branchName: job.branchName,
8958
+ remoteUrl,
8959
+ defaultBranch,
8960
+ baselineSnapshotId: job.baselineSnapshotId,
8961
+ currentSnapshotId: job.currentSnapshotId,
8962
+ baselineServerHeadHash: job.baselineServerHeadHash,
8963
+ currentSnapshotHash: snapshot.snapshotHash,
8964
+ localCommitHash: snapshot.localCommitHash,
8965
+ repoState
8966
+ }),
8967
+ idempotencyKey: buildDeterministicIdempotencyKey({
8968
+ kind: "collab_finalize_turn_replay_v1",
8969
+ appId: job.currentAppId,
8970
+ baseCommitHash: submissionBaseHeadHash,
8971
+ targetHeadCommitHash: appHead.headCommitHash,
8972
+ currentSnapshotId: job.currentSnapshotId,
8973
+ diffSha256: diffResult.diffSha256
8974
+ })
8975
+ });
8976
+ const replayStart = unwrapResponseObject(replayResp, "change step replay");
8977
+ const replay = await pollChangeStepReplay(params.api, job.currentAppId, String(replayStart.id));
8978
+ const replayDiffResp = await params.api.getChangeStepReplayDiff(job.currentAppId, replay.id);
8979
+ const replayDiff = unwrapResponseObject(replayDiffResp, "change step replay diff");
8980
+ submissionDiff = replayDiff.diff;
8981
+ replayedFromBaseHash = submissionBaseHeadHash;
8982
+ submissionBaseHeadHash = appHead.headCommitHash;
8983
+ }
8984
+ const changeStepResp = await params.api.createChangeStep(job.currentAppId, {
8985
+ threadId: job.threadId ?? void 0,
8986
+ collabLaneId: job.laneId ?? void 0,
8987
+ prompt: job.prompt,
8988
+ assistantResponse: job.assistantResponse,
8989
+ diff: submissionDiff,
8990
+ baseCommitHash: submissionBaseHeadHash,
8991
+ headCommitHash: submissionBaseHeadHash,
8992
+ changedFilesCount: diffResult.stats.changedFilesCount,
8993
+ insertions: diffResult.stats.insertions,
8994
+ deletions: diffResult.stats.deletions,
8995
+ actor,
8996
+ workspaceMetadata: buildWorkspaceMetadata({
8997
+ repoRoot: job.repoRoot,
8998
+ branchName: job.branchName,
8999
+ remoteUrl,
9000
+ defaultBranch,
9001
+ baselineSnapshotId: job.baselineSnapshotId,
9002
+ currentSnapshotId: job.currentSnapshotId,
9003
+ baselineServerHeadHash: job.baselineServerHeadHash,
9004
+ currentSnapshotHash: snapshot.snapshotHash,
9005
+ localCommitHash: snapshot.localCommitHash,
9006
+ repoState,
9007
+ replayedFromBaseHash
9008
+ }),
9009
+ idempotencyKey: job.idempotencyKey ?? void 0
9010
+ });
9011
+ const createdStep = unwrapResponseObject(changeStepResp, "change step");
9012
+ const changeStep = await pollChangeStep(params.api, job.currentAppId, String(createdStep.id));
9013
+ const nextHeadResp = await params.api.getAppHead(job.currentAppId);
9014
+ const nextHead = unwrapResponseObject(nextHeadResp, "app head");
9015
+ await writeLocalBaseline({
9016
+ repoRoot: job.repoRoot,
9017
+ repoFingerprint: job.repoFingerprint,
9018
+ laneId: job.laneId,
9019
+ currentAppId: job.currentAppId,
9020
+ branchName: job.branchName,
9021
+ lastSnapshotId: snapshot.id,
9022
+ lastSnapshotHash: snapshot.snapshotHash,
9023
+ lastServerHeadHash: nextHead.headCommitHash,
9024
+ lastSeenLocalCommitHash: snapshot.localCommitHash
9025
+ });
9026
+ await updatePendingFinalizeJob(job.id, {
9027
+ status: "completed",
9028
+ metadata: { changeStepId: String(changeStep.id ?? "") }
8782
9029
  });
8783
- } else {
8784
- existing.count += 1;
8785
- }
8786
- try {
8787
- return await fn({
8788
- repoRoot,
8789
- gitCommonDir,
8790
- lockDir,
8791
- notices,
8792
- warnings: notices.map((notice) => notice.message)
9030
+ return {
9031
+ mode: "changed_turn",
9032
+ idempotencyKey: job.idempotencyKey ?? "",
9033
+ queued: false,
9034
+ jobId: job.id,
9035
+ repoState,
9036
+ changeStep,
9037
+ collabTurn: null,
9038
+ autoSync: null,
9039
+ warnings: []
9040
+ };
9041
+ } catch (error) {
9042
+ const classified = classifyFinalizeError(error);
9043
+ await updatePendingFinalizeJob(job.id, {
9044
+ status: classified.disposition === "terminal" ? "failed" : "queued",
9045
+ error: classified.message,
9046
+ nextRetryAt: classified.disposition === "terminal" ? null : buildNextRetryAt(job.retryCount),
9047
+ metadata: {
9048
+ failureDisposition: classified.disposition,
9049
+ failureReason: classified.reason
9050
+ }
8793
9051
  });
9052
+ throw error;
8794
9053
  } finally {
8795
- await releaseReentrantLock(lockDir);
9054
+ await params.release();
8796
9055
  }
8797
9056
  }
8798
- async function collabSync(params) {
8799
- const repoRoot = await findGitRoot(params.cwd);
8800
- const binding = await ensureActiveLaneBinding({
8801
- repoRoot,
8802
- api: params.api,
8803
- operation: "`remix collab sync`"
8804
- });
8805
- if (!binding) {
8806
- throw new RemixError("Repository is not bound to Remix.", {
8807
- exitCode: 2,
8808
- hint: "Run `remix collab init` first."
8809
- });
8810
- }
8811
- await ensureCleanWorktree(repoRoot);
8812
- const branch = await requireCurrentBranch(repoRoot);
8813
- assertBoundBranchMatch({
8814
- currentBranch: branch,
8815
- branchName: binding.branchName,
8816
- allowBranchMismatch: params.allowBranchMismatch,
8817
- operation: "`remix collab sync`"
8818
- });
8819
- const headCommitHash = await getHeadCommitHash(repoRoot);
8820
- const repoSnapshot = await captureRepoSnapshot(repoRoot);
8821
- if (!headCommitHash) {
8822
- throw new RemixError("Failed to resolve local HEAD commit.", { exitCode: 1 });
8823
- }
8824
- const resp = await params.api.syncLocalApp(binding.currentAppId, {
8825
- baseCommitHash: headCommitHash,
8826
- repoFingerprint: binding.repoFingerprint ?? void 0,
8827
- remoteUrl: binding.remoteUrl ?? void 0,
8828
- defaultBranch: binding.defaultBranch ?? void 0,
8829
- dryRun: params.dryRun
9057
+ async function enqueueCapturedFinalizeTurn(params) {
9058
+ return enqueuePendingFinalizeJob({
9059
+ status: "queued",
9060
+ repoRoot: params.repoRoot,
9061
+ repoFingerprint: params.repoFingerprint,
9062
+ currentAppId: params.currentAppId,
9063
+ laneId: params.laneId,
9064
+ threadId: params.threadId,
9065
+ branchName: params.branchName,
9066
+ prompt: params.prompt,
9067
+ assistantResponse: params.assistantResponse,
9068
+ baselineSnapshotId: params.baselineSnapshotId,
9069
+ baselineServerHeadHash: params.baselineServerHeadHash,
9070
+ currentSnapshotId: params.currentSnapshotId,
9071
+ idempotencyKey: params.idempotencyKey,
9072
+ error: null,
9073
+ retryCount: 0,
9074
+ lastAttemptAt: null,
9075
+ nextRetryAt: null,
9076
+ metadata: params.metadata ?? {}
8830
9077
  });
8831
- const sync = unwrapResponseObject(resp, "sync result");
8832
- if (sync.status === "conflict_risk") {
8833
- throw new RemixError("Local repository metadata conflicts with the bound Remix app.", {
8834
- exitCode: 2,
8835
- hint: sync.warnings.join("\n") || "Run the command from the correct bound repository."
8836
- });
8837
- }
8838
- if (sync.status === "base_unknown") {
8839
- throw new RemixError("Local repository cannot be fast-forward synced.", {
8840
- exitCode: 2,
8841
- hint: "Your local HEAD is not on the app sandbox history. Reconcile the repository manually before syncing."
8842
- });
8843
- }
8844
- if (sync.status === "up_to_date") {
8845
- return {
8846
- status: sync.status,
8847
- branch,
8848
- repoRoot,
8849
- baseCommitHash: sync.baseCommitHash,
8850
- targetCommitHash: sync.targetCommitHash,
8851
- targetCommitId: sync.targetCommitId,
8852
- stats: sync.stats,
8853
- localCommitHash: headCommitHash,
8854
- applied: false,
8855
- dryRun: params.dryRun
8856
- };
8857
- }
8858
- const previewResult = {
8859
- status: sync.status,
8860
- branch,
8861
- repoRoot,
8862
- baseCommitHash: sync.baseCommitHash,
8863
- targetCommitHash: sync.targetCommitHash,
8864
- targetCommitId: sync.targetCommitId,
8865
- stats: sync.stats,
8866
- bundleRef: sync.bundleRef,
8867
- bundleSizeBytes: sync.bundleSizeBytes,
8868
- localCommitHash: headCommitHash,
8869
- applied: false,
8870
- dryRun: params.dryRun
8871
- };
8872
- if (params.dryRun) {
8873
- return previewResult;
8874
- }
8875
- if (!sync.bundleBase64 || !sync.bundleRef) {
8876
- throw new RemixError("Sync bundle payload is missing.", { exitCode: 1 });
8877
- }
8878
- const bundleBase64 = sync.bundleBase64;
8879
- const bundleRef = sync.bundleRef;
8880
- return withRepoMutationLock(
8881
- {
8882
- cwd: repoRoot,
8883
- operation: "collabSync"
8884
- },
8885
- async ({ repoRoot: lockedRepoRoot, warnings }) => {
8886
- await assertRepoSnapshotUnchanged(lockedRepoRoot, repoSnapshot, {
8887
- operation: "`remix collab sync`",
8888
- recoveryHint: "The repository changed after sync was prepared. Review the local changes and rerun `remix collab sync`."
8889
- });
8890
- await ensureCleanWorktree(lockedRepoRoot);
8891
- const lockedBranch = await requireCurrentBranch(lockedRepoRoot);
8892
- assertBoundBranchMatch({
8893
- currentBranch: lockedBranch,
8894
- branchName: binding.branchName,
8895
- allowBranchMismatch: params.allowBranchMismatch,
8896
- operation: "`remix collab sync`"
9078
+ }
9079
+ async function drainPendingFinalizeQueue(params) {
9080
+ await prunePendingFinalizeJobs();
9081
+ const jobs = await listPendingFinalizeJobs();
9082
+ const results = [];
9083
+ for (const job of jobs) {
9084
+ const claimed = await claimPendingFinalizeJob(job.id);
9085
+ if (!claimed) continue;
9086
+ try {
9087
+ const result = await processClaimedPendingFinalizeJob({
9088
+ api: params.api,
9089
+ job: claimed.job,
9090
+ release: claimed.release
8897
9091
  });
8898
- const tempDir = await import_promises17.default.mkdtemp(import_path6.default.join(import_os3.default.tmpdir(), "remix-sync-"));
8899
- const bundlePath = import_path6.default.join(tempDir, "sync-local.bundle");
8900
- try {
8901
- await import_promises17.default.writeFile(bundlePath, Buffer.from(bundleBase64, "base64"));
8902
- await importGitBundle(lockedRepoRoot, bundlePath, bundleRef);
8903
- await ensureCommitExists(lockedRepoRoot, sync.targetCommitHash);
8904
- const localCommitHash = await fastForwardToCommit(lockedRepoRoot, sync.targetCommitHash);
8905
- return {
8906
- ...previewResult,
8907
- localCommitHash,
8908
- applied: true,
8909
- dryRun: false,
8910
- ...warnings.length > 0 ? { warnings } : {}
8911
- };
8912
- } finally {
8913
- await import_promises17.default.rm(tempDir, { recursive: true, force: true });
8914
- }
9092
+ results.push(result);
9093
+ await removePendingFinalizeJob(job.id);
9094
+ } catch {
8915
9095
  }
8916
- );
8917
- }
8918
- function assertSupportedRecordingPreflight(preflight) {
8919
- if (preflight.status === "not_bound") {
8920
- throw new RemixError("Repository is not bound to Remix.", {
8921
- exitCode: 2,
8922
- hint: preflight.hint
8923
- });
8924
- }
8925
- if (preflight.status === "branch_binding_missing") {
8926
- throw new RemixError("Current branch is not yet bound to a Remix lane.", {
8927
- exitCode: 2,
8928
- hint: preflight.hint
8929
- });
8930
- }
8931
- if (preflight.status === "not_git_repo") {
8932
- throw new RemixError(preflight.hint || "Not inside a git repository.", {
8933
- exitCode: 2,
8934
- hint: preflight.hint
8935
- });
8936
- }
8937
- if (preflight.status === "missing_head") {
8938
- throw new RemixError("Failed to resolve local HEAD commit.", {
8939
- exitCode: 1,
8940
- hint: preflight.hint
8941
- });
8942
- }
8943
- if (preflight.status === "branch_mismatch") {
8944
- assertBoundBranchMatch({
8945
- currentBranch: preflight.currentBranch,
8946
- branchName: preflight.branchName,
8947
- allowBranchMismatch: false,
8948
- operation: "`remix collab add`"
8949
- });
8950
- }
8951
- if (preflight.status === "metadata_conflict") {
8952
- throw new RemixError("Local repository metadata conflicts with the bound Remix app.", {
8953
- exitCode: 2,
8954
- hint: preflight.hint
8955
- });
8956
- }
8957
- if (preflight.status === "reconcile_required") {
8958
- throw new RemixError("Local repository cannot be fast-forward synced.", {
8959
- exitCode: 2,
8960
- hint: preflight.hint
8961
- });
8962
9096
  }
9097
+ return results;
8963
9098
  }
8964
- async function collabAdd(params) {
9099
+ function collectWarnings(value) {
9100
+ if (!Array.isArray(value)) return [];
9101
+ return value.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
9102
+ }
9103
+ var FINALIZE_QUEUED_WARNING = "Queued only: the local Remix turn was captured, but no remote change step or collab turn exists yet. Drain or await finalize before merge-related flows.";
9104
+ async function collabFinalizeTurn(params) {
8965
9105
  const repoRoot = await findGitRoot(params.cwd);
8966
9106
  const binding = await ensureActiveLaneBinding({
8967
9107
  repoRoot,
8968
9108
  api: params.api,
8969
- operation: "`remix collab add`"
9109
+ operation: "`remix collab finalize-turn`"
8970
9110
  });
8971
9111
  if (!binding) {
8972
9112
  throw new RemixError("Repository is not bound to Remix.", {
@@ -8975,379 +9115,114 @@ async function collabAdd(params) {
8975
9115
  });
8976
9116
  }
8977
9117
  const prompt = params.prompt.trim();
9118
+ const assistantResponse = params.assistantResponse.trim();
8978
9119
  if (!prompt) throw new RemixError("Prompt is required.", { exitCode: 2 });
8979
- const assistantResponse = params.assistantResponse?.trim() || null;
8980
- const attachWarnings = (value, warnings) => warnings.length > 0 ? { ...value, warnings } : value;
8981
- const diffSource = params.diffSource ?? (params.diff ? "external" : "worktree");
8982
- const autoSyncEnabled = params.sync !== false;
8983
- const run = async (lockWarnings = []) => {
8984
- const preflight = await collabRecordingPreflight({
8985
- api: params.api,
8986
- cwd: repoRoot,
8987
- allowBranchMismatch: params.allowBranchMismatch
8988
- });
8989
- assertSupportedRecordingPreflight(preflight);
8990
- const branch = preflight.currentBranch;
8991
- assertBoundBranchMatch({
8992
- currentBranch: branch,
8993
- branchName: binding.branchName,
8994
- allowBranchMismatch: params.allowBranchMismatch,
8995
- operation: "`remix collab add`"
8996
- });
8997
- let headCommitHash = preflight.headCommitHash;
8998
- if (!headCommitHash) {
8999
- throw new RemixError("Failed to resolve local HEAD commit.", { exitCode: 1 });
9000
- }
9001
- const worktreeStatus = await getWorktreeStatus(repoRoot);
9002
- if (preflight.status === "ready_to_fast_forward") {
9003
- if (!autoSyncEnabled) {
9004
- throw new RemixError("Local repository is stale and `collab add` sync automation is disabled.", {
9005
- exitCode: 2,
9006
- hint: "Run `remix collab sync` first, or rerun without disabling sync automation."
9007
- });
9008
- }
9009
- if (!worktreeStatus.isClean && diffSource !== "worktree") {
9010
- throw new RemixError("Automatic stale-work replay requires the current worktree diff.", {
9011
- exitCode: 2,
9012
- hint: "Use `remix collab add` without an external diff while the local repo is dirty, or clean the repo before submitting an external diff."
9013
- });
9014
- }
9015
- if (worktreeStatus.isClean) {
9016
- await collabSync({
9017
- api: params.api,
9018
- cwd: repoRoot,
9019
- dryRun: false,
9020
- allowBranchMismatch: params.allowBranchMismatch
9021
- });
9022
- headCommitHash = await getHeadCommitHash(repoRoot);
9023
- if (!headCommitHash) {
9024
- throw new RemixError("Failed to resolve local HEAD after syncing.", { exitCode: 1 });
9025
- }
9026
- } else {
9027
- const staleWorkSnapshot = await captureRepoSnapshot(repoRoot, { includeWorkspaceDiffHash: true });
9028
- const preserved = await preserveWorkspaceChanges(repoRoot, "remix-add-preserve");
9029
- try {
9030
- await assertRepoSnapshotUnchanged(repoRoot, staleWorkSnapshot, {
9031
- operation: "`remix collab add` stale-work pre-sync",
9032
- recoveryHint: "The worktree changed while local changes were being preserved. Review the local changes and rerun `remix collab add`."
9033
- });
9034
- await discardTrackedChanges(repoRoot, "`remix collab add`");
9035
- await discardCapturedUntrackedChanges(repoRoot, preserved.includedUntrackedPaths);
9036
- await collabSync({
9037
- api: params.api,
9038
- cwd: repoRoot,
9039
- dryRun: false,
9040
- allowBranchMismatch: params.allowBranchMismatch
9041
- });
9042
- } catch (err) {
9043
- const detail = formatCliErrorDetail(err);
9044
- const hint = [
9045
- detail,
9046
- `The preserved local diff is available at: ${preserved.preservedDiffPath}`
9047
- ].filter(Boolean).join("\n\n");
9048
- throw new RemixError("Failed to sync the stale repository before submitting the change step.", {
9049
- exitCode: err instanceof RemixError ? err.exitCode : 1,
9050
- hint
9051
- });
9052
- }
9053
- headCommitHash = await getHeadCommitHash(repoRoot);
9054
- if (!headCommitHash) {
9055
- throw new RemixError("Failed to resolve local HEAD after syncing.", { exitCode: 1 });
9056
- }
9057
- const deterministicReapply = await reapplyPreservedWorkspaceChanges(repoRoot, preserved);
9058
- if (deterministicReapply.status === "failed") {
9059
- const hint = [
9060
- deterministicReapply.detail,
9061
- `The preserved local diff is available at: ${preserved.preservedDiffPath}`
9062
- ].filter(Boolean).join("\n\n");
9063
- throw new RemixError("Failed to restore preserved local changes after syncing.", {
9064
- exitCode: 1,
9065
- hint
9066
- });
9067
- }
9068
- if (deterministicReapply.status === "conflict") {
9069
- try {
9070
- const replayResp = await params.api.startChangeStepReplay(binding.currentAppId, {
9071
- prompt,
9072
- assistantResponse: assistantResponse ?? void 0,
9073
- diff: await import_promises15.default.readFile(preserved.preservedDiffPath, "utf8"),
9074
- baseCommitHash: preserved.baseHeadCommitHash,
9075
- targetHeadCommitHash: headCommitHash,
9076
- expectedPaths: preserved.stagePlan.expectedPaths,
9077
- actor: params.actor,
9078
- workspaceMetadata: {
9079
- branch,
9080
- repoRoot,
9081
- remoteUrl: binding.remoteUrl,
9082
- defaultBranch: binding.defaultBranch
9083
- },
9084
- idempotencyKey: buildDeterministicIdempotencyKey({
9085
- appId: binding.currentAppId,
9086
- baseCommitHash: preserved.baseHeadCommitHash,
9087
- targetHeadCommitHash: headCommitHash,
9088
- prompt,
9089
- assistantResponse,
9090
- preservedDiffSha256: preserved.preservedDiffSha256
9091
- })
9092
- });
9093
- const startedReplay = unwrapResponseObject(replayResp, "change step replay");
9094
- const replay = await pollChangeStepReplay(params.api, binding.currentAppId, String(startedReplay.id));
9095
- const replayDiffResp = await params.api.getChangeStepReplayDiff(binding.currentAppId, String(replay.id));
9096
- const replayDiff = unwrapResponseObject(replayDiffResp, "change step replay diff");
9097
- const { backupPath: backupPath2, diffSha256 } = await writeTempUnifiedDiffBackup(replayDiff.diff, "remix-add-ai-replay");
9098
- const replayApply = await reapplyPreservedWorkspaceChanges(repoRoot, {
9099
- baseHeadCommitHash: headCommitHash,
9100
- preservedDiffPath: backupPath2,
9101
- preservedDiffSha256: diffSha256,
9102
- includedUntrackedPaths: [],
9103
- stagePlan: preserved.stagePlan
9104
- });
9105
- if (replayApply.status !== "clean") {
9106
- const hint = [
9107
- replayApply.detail,
9108
- `The preserved local diff is available at: ${preserved.preservedDiffPath}`,
9109
- `The AI-replayed diff is available at: ${backupPath2}`
9110
- ].filter(Boolean).join("\n\n");
9111
- throw new RemixError("AI-assisted stale-work replay produced a diff that could not be applied locally.", {
9112
- exitCode: 1,
9113
- hint
9114
- });
9115
- }
9116
- } catch (err) {
9117
- const detail = formatCliErrorDetail(err);
9118
- const hint = [
9119
- detail,
9120
- `The preserved local diff is available at: ${preserved.preservedDiffPath}`,
9121
- "Resolve the local conflict manually if needed, then rerun `remix collab add`."
9122
- ].filter(Boolean).join("\n\n");
9123
- throw new RemixError("AI-assisted stale-work replay could not complete safely.", {
9124
- exitCode: err instanceof RemixError ? err.exitCode : 1,
9125
- hint
9126
- });
9127
- }
9128
- }
9129
- }
9130
- }
9131
- const workspaceSnapshot = diffSource === "external" ? null : await getWorkspaceSnapshot(repoRoot);
9132
- const submissionSnapshot = diffSource === "worktree" ? await captureRepoSnapshot(repoRoot, { includeWorkspaceDiffHash: true }) : null;
9133
- const diff = params.diff ?? workspaceSnapshot?.diff ?? "";
9134
- if (!diff.trim()) {
9135
- throw new RemixError("Diff is empty.", {
9136
- exitCode: 2,
9137
- hint: "Make changes first, or pass `--diff-file`/`--diff-stdin`."
9138
- });
9139
- }
9140
- if (diffSource === "external") {
9141
- const validation = await validateUnifiedDiff(repoRoot, diff);
9142
- if (!validation.ok) {
9143
- const actionHint = validation.kind === "malformed_patch" ? "The provided external diff is malformed. Recreate it with `git diff --binary --no-ext-diff`, avoid hand-editing patch hunks, and ensure the patch ends with a trailing newline." : validation.kind === "apply_conflict" ? "The external diff is valid patch syntax, but it does not apply cleanly to the current local HEAD. Sync or update the repo and regenerate the diff against the latest base." : "Git could not validate the provided external diff against the current repository state.";
9144
- const hint = [validation.detail, actionHint].filter(Boolean).join("\n\n");
9145
- throw new RemixError("External diff validation failed.", {
9146
- exitCode: validation.kind === "malformed_patch" ? 2 : 1,
9147
- hint
9148
- });
9149
- }
9150
- }
9151
- headCommitHash = await getHeadCommitHash(repoRoot);
9152
- if (!headCommitHash) {
9153
- throw new RemixError("Failed to resolve local HEAD before creating the change step.", { exitCode: 1 });
9154
- }
9155
- const stats = summarizeUnifiedDiff(diff);
9156
- const idempotencyKey = params.idempotencyKey?.trim() || buildDeterministicIdempotencyKey({
9157
- appId: binding.currentAppId,
9158
- upstreamAppId: binding.upstreamAppId,
9159
- headCommitHash,
9160
- prompt,
9161
- assistantResponse,
9162
- diff
9163
- });
9164
- const resp = await params.api.createChangeStep(binding.currentAppId, {
9165
- threadId: binding.threadId ?? void 0,
9166
- collabLaneId: binding.laneId ?? void 0,
9167
- prompt,
9168
- assistantResponse: assistantResponse ?? void 0,
9169
- diff,
9170
- baseCommitHash: headCommitHash,
9171
- headCommitHash,
9172
- changedFilesCount: stats.changedFilesCount,
9173
- insertions: stats.insertions,
9174
- deletions: stats.deletions,
9175
- actor: params.actor,
9176
- workspaceMetadata: {
9177
- branch,
9178
- repoRoot,
9179
- remoteUrl: binding.remoteUrl,
9180
- defaultBranch: binding.defaultBranch
9181
- },
9182
- idempotencyKey
9183
- });
9184
- const created = unwrapResponseObject(resp, "change step");
9185
- const step = await pollChangeStep(params.api, binding.currentAppId, String(created.id));
9186
- const canAutoSyncLocally = autoSyncEnabled && diffSource === "worktree";
9187
- if (!autoSyncEnabled || !canAutoSyncLocally) {
9188
- return attachWarnings(step, lockWarnings);
9189
- }
9190
- const { backupPath } = await writeTempUnifiedDiffBackup(diff, "remix-add");
9191
- try {
9192
- await pollAppReady(params.api, binding.currentAppId);
9193
- if (submissionSnapshot) {
9194
- await assertRepoSnapshotUnchanged(repoRoot, submissionSnapshot, {
9195
- operation: "`remix collab add` auto-sync",
9196
- recoveryHint: "The repository changed after the change step was submitted. Review the local changes, inspect the preserved diff if needed, and rerun `remix collab sync` manually."
9197
- });
9198
- }
9199
- await discardTrackedChanges(repoRoot, "`remix collab add`");
9200
- await discardCapturedUntrackedChanges(repoRoot, workspaceSnapshot?.includedUntrackedPaths ?? []);
9201
- await collabSync({
9202
- api: params.api,
9203
- cwd: repoRoot,
9204
- dryRun: false,
9205
- allowBranchMismatch: params.allowBranchMismatch
9206
- });
9207
- await import_promises15.default.rm(import_path4.default.dirname(backupPath), { recursive: true, force: true }).catch(() => void 0);
9208
- } catch (err) {
9209
- const detail = formatCliErrorDetail(err);
9210
- const hint = [
9211
- detail,
9212
- `The submitted diff backup was preserved at: ${backupPath}`,
9213
- "The change step already succeeded remotely. Inspect or reapply that diff manually if needed, then run `remix collab sync`."
9214
- ].filter(Boolean).join("\n\n");
9215
- throw new RemixError("Change step succeeded remotely, but automatic local sync failed.", {
9216
- exitCode: err instanceof RemixError ? err.exitCode : 1,
9217
- hint
9218
- });
9219
- }
9220
- return attachWarnings(step, lockWarnings);
9221
- };
9222
- if (diffSource === "worktree") {
9223
- return withRepoMutationLock(
9224
- {
9225
- cwd: repoRoot,
9226
- operation: "collabAdd"
9227
- },
9228
- async ({ warnings }) => run(warnings)
9229
- );
9230
- }
9231
- return run();
9232
- }
9233
- function assertSupportedRecordingPreflight2(preflight) {
9234
- if (preflight.status === "not_bound") {
9235
- throw new RemixError("Repository is not bound to Remix.", {
9120
+ if (!assistantResponse) throw new RemixError("Assistant response is required.", { exitCode: 2 });
9121
+ if (params.diff?.trim()) {
9122
+ throw new RemixError("External diff submission is no longer supported for `finalize_turn`.", {
9236
9123
  exitCode: 2,
9237
- hint: preflight.hint
9124
+ hint: "Finalize turns now capture the real workspace boundary from the local snapshot store."
9238
9125
  });
9239
9126
  }
9240
- if (preflight.status === "branch_binding_missing") {
9241
- throw new RemixError("Current branch is not yet bound to a Remix lane.", {
9242
- exitCode: 2,
9243
- hint: preflight.hint
9244
- });
9127
+ const detected = await collabDetectRepoState({
9128
+ api: params.api,
9129
+ cwd: repoRoot,
9130
+ allowBranchMismatch: params.allowBranchMismatch
9131
+ });
9132
+ if (detected.status === "not_bound") {
9133
+ throw new RemixError("Repository is not bound to Remix.", { exitCode: 2, hint: detected.hint });
9245
9134
  }
9246
- if (preflight.status === "not_git_repo") {
9247
- throw new RemixError(preflight.hint || "Not inside a git repository.", {
9135
+ if (detected.status === "branch_binding_missing" || detected.status === "family_ambiguous") {
9136
+ throw new RemixError(detected.hint || "Current branch is not ready for Remix recording.", { exitCode: 2, hint: detected.hint });
9137
+ }
9138
+ if (detected.status === "metadata_conflict" || detected.status === "branch_mismatch") {
9139
+ throw new RemixError("Repository must be realigned before finalizing the turn.", {
9248
9140
  exitCode: 2,
9249
- hint: preflight.hint
9141
+ hint: detected.hint
9250
9142
  });
9251
9143
  }
9252
- if (preflight.status === "missing_head") {
9253
- throw new RemixError("Failed to resolve local HEAD commit.", {
9144
+ if (detected.status === "missing_head" || detected.status === "remote_error") {
9145
+ throw new RemixError(detected.hint || "Failed to determine the current repo state.", {
9254
9146
  exitCode: 1,
9255
- hint: preflight.hint
9256
- });
9257
- }
9258
- if (preflight.status === "branch_mismatch") {
9259
- assertBoundBranchMatch({
9260
- currentBranch: preflight.currentBranch,
9261
- branchName: preflight.branchName,
9262
- allowBranchMismatch: false,
9263
- operation: "`remix collab record-turn`"
9147
+ hint: detected.hint
9264
9148
  });
9265
9149
  }
9266
- if (preflight.status === "metadata_conflict") {
9267
- throw new RemixError("Local repository metadata conflicts with the bound Remix app.", {
9150
+ if (detected.repoState === "server_only_changed") {
9151
+ throw new RemixError("Server changes must be pulled locally before finalizing this turn.", {
9268
9152
  exitCode: 2,
9269
- hint: preflight.hint
9153
+ hint: detected.hint
9270
9154
  });
9271
9155
  }
9272
- if (preflight.status === "reconcile_required") {
9273
- throw new RemixError("Local repository cannot be fast-forward synced.", {
9274
- exitCode: 2,
9275
- hint: preflight.hint
9276
- });
9277
- }
9278
- }
9279
- async function collabRecordTurn(params) {
9280
- const repoRoot = await findGitRoot(params.cwd);
9281
- const binding = await ensureActiveLaneBinding({
9282
- repoRoot,
9283
- api: params.api,
9284
- operation: "`remix collab record-turn`"
9285
- });
9286
- if (!binding) {
9287
- throw new RemixError("Repository is not bound to Remix.", {
9156
+ if (detected.repoState === "external_local_base_changed") {
9157
+ throw new RemixError("The local checkout must be re-anchored before finalizing this turn.", {
9288
9158
  exitCode: 2,
9289
- hint: "Run `remix collab init` first."
9159
+ hint: detected.hint
9290
9160
  });
9291
9161
  }
9292
- const prompt = params.prompt.trim();
9293
- const assistantResponse = params.assistantResponse.trim();
9294
- if (!prompt) throw new RemixError("Prompt is required.", { exitCode: 2 });
9295
- if (!assistantResponse) throw new RemixError("Assistant response is required.", { exitCode: 2 });
9296
- const preflight = await collabRecordingPreflight({
9297
- api: params.api,
9298
- cwd: repoRoot,
9299
- allowBranchMismatch: params.allowBranchMismatch
9162
+ const baseline = await readLocalBaseline({
9163
+ repoFingerprint: binding.repoFingerprint,
9164
+ laneId: binding.laneId,
9165
+ repoRoot
9300
9166
  });
9301
- assertSupportedRecordingPreflight2(preflight);
9302
- if (!preflight.worktreeClean) {
9303
- throw new RemixError("Cannot record a no-diff turn while the worktree has local changes.", {
9167
+ if (!baseline) {
9168
+ throw new RemixError("Local Remix baseline is missing for this lane.", {
9304
9169
  exitCode: 2,
9305
- hint: "Record the pending code changes as a Remix change step with `remix collab add`, or clean the worktree before retrying `remix collab record-turn`."
9306
- });
9307
- }
9308
- if (preflight.status === "ready_to_fast_forward") {
9309
- await collabSync({
9310
- api: params.api,
9311
- cwd: repoRoot,
9312
- dryRun: false,
9313
- allowBranchMismatch: params.allowBranchMismatch
9170
+ hint: "Run `remix collab re-anchor` to create a fresh baseline."
9314
9171
  });
9315
9172
  }
9316
- const branch = await getCurrentBranch(repoRoot);
9317
- assertBoundBranchMatch({
9318
- currentBranch: branch,
9319
- branchName: binding.branchName,
9320
- allowBranchMismatch: params.allowBranchMismatch,
9321
- operation: "`remix collab record-turn`"
9173
+ const snapshot = await captureLocalSnapshot({
9174
+ repoRoot,
9175
+ repoFingerprint: binding.repoFingerprint,
9176
+ laneId: binding.laneId,
9177
+ branchName: binding.branchName
9322
9178
  });
9323
- const headCommitHash = await getHeadCommitHash(repoRoot);
9179
+ const mode = snapshot.snapshotHash === baseline.lastSnapshotHash ? "no_diff_turn" : "changed_turn";
9324
9180
  const idempotencyKey = params.idempotencyKey?.trim() || buildDeterministicIdempotencyKey({
9181
+ kind: "collab_finalize_turn_boundary_v1",
9325
9182
  appId: binding.currentAppId,
9326
- upstreamAppId: binding.upstreamAppId,
9327
- headCommitHash,
9183
+ laneId: binding.laneId,
9184
+ baselineSnapshotId: baseline.lastSnapshotId,
9185
+ baselineServerHeadHash: baseline.lastServerHeadHash,
9186
+ currentSnapshotId: snapshot.id,
9187
+ currentSnapshotHash: snapshot.snapshotHash,
9188
+ repoState: detected.repoState,
9328
9189
  prompt,
9329
9190
  assistantResponse
9330
9191
  });
9331
- const resp = await params.api.createCollabTurn(binding.currentAppId, {
9332
- threadId: binding.threadId ?? void 0,
9333
- collabLaneId: binding.laneId ?? void 0,
9192
+ const job = await enqueueCapturedFinalizeTurn({
9193
+ repoRoot,
9194
+ repoFingerprint: binding.repoFingerprint,
9195
+ currentAppId: binding.currentAppId,
9196
+ laneId: binding.laneId,
9197
+ threadId: binding.threadId,
9198
+ branchName: binding.branchName,
9334
9199
  prompt,
9335
9200
  assistantResponse,
9336
- actor: params.actor,
9337
- workspaceMetadata: {
9338
- branch,
9339
- repoRoot,
9201
+ baselineSnapshotId: baseline.lastSnapshotId,
9202
+ baselineServerHeadHash: baseline.lastServerHeadHash,
9203
+ currentSnapshotId: snapshot.id,
9204
+ idempotencyKey,
9205
+ metadata: {
9340
9206
  remoteUrl: binding.remoteUrl,
9341
9207
  defaultBranch: binding.defaultBranch,
9342
- headCommitHash
9343
- },
9344
- idempotencyKey
9208
+ actor: params.actor ?? null,
9209
+ repoState: detected.repoState
9210
+ }
9345
9211
  });
9346
- const turn = unwrapResponseObject(resp, "collab turn");
9347
- return turn;
9212
+ return {
9213
+ mode,
9214
+ idempotencyKey,
9215
+ queued: true,
9216
+ jobId: job.id,
9217
+ repoState: detected.repoState,
9218
+ changeStep: null,
9219
+ collabTurn: null,
9220
+ autoSync: null,
9221
+ warnings: [FINALIZE_QUEUED_WARNING, ...collectWarnings(detected.warnings)]
9222
+ };
9348
9223
  }
9349
9224
 
9350
- // node_modules/@remixhq/core/dist/chunk-BNKPTE2U.js
9225
+ // node_modules/@remixhq/core/dist/chunk-R7FVSCQW.js
9351
9226
  async function readJsonSafe(res) {
9352
9227
  const ct = res.headers.get("content-type") ?? "";
9353
9228
  if (!ct.toLowerCase().includes("application/json")) return null;
@@ -9361,7 +9236,7 @@ function createApiClient(config, opts) {
9361
9236
  const apiKey = (opts?.apiKey ?? "").trim();
9362
9237
  const tokenProvider = opts?.tokenProvider;
9363
9238
  const CLIENT_KEY_HEADER = "x-comerge-api-key";
9364
- async function request(path13, init) {
9239
+ async function request(path12, init) {
9365
9240
  if (!tokenProvider) {
9366
9241
  throw new RemixError("API client is missing a token provider.", {
9367
9242
  exitCode: 1,
@@ -9369,7 +9244,7 @@ function createApiClient(config, opts) {
9369
9244
  });
9370
9245
  }
9371
9246
  const auth = await tokenProvider();
9372
- const url = new URL(path13, config.apiUrl).toString();
9247
+ const url = new URL(path12, config.apiUrl).toString();
9373
9248
  const doFetch = async (bearer) => fetch(url, {
9374
9249
  ...init,
9375
9250
  headers: {
@@ -9393,7 +9268,7 @@ function createApiClient(config, opts) {
9393
9268
  const json = await readJsonSafe(res);
9394
9269
  return json ?? null;
9395
9270
  }
9396
- async function requestBinary(path13, init) {
9271
+ async function requestBinary(path12, init) {
9397
9272
  if (!tokenProvider) {
9398
9273
  throw new RemixError("API client is missing a token provider.", {
9399
9274
  exitCode: 1,
@@ -9401,7 +9276,7 @@ function createApiClient(config, opts) {
9401
9276
  });
9402
9277
  }
9403
9278
  const auth = await tokenProvider();
9404
- const url = new URL(path13, config.apiUrl).toString();
9279
+ const url = new URL(path12, config.apiUrl).toString();
9405
9280
  const doFetch = async (bearer) => fetch(url, {
9406
9281
  ...init,
9407
9282
  headers: {
@@ -9500,6 +9375,15 @@ function createApiClient(config, opts) {
9500
9375
  const suffix = qs.toString() ? `?${qs.toString()}` : "";
9501
9376
  return request(`/v1/apps/${encodeURIComponent(appId)}/edit-queue${suffix}`, { method: "GET" });
9502
9377
  },
9378
+ listAppJobQueue: (appId, params) => {
9379
+ const qs = new URLSearchParams();
9380
+ if (typeof params?.limit === "number") qs.set("limit", String(params.limit));
9381
+ if (typeof params?.offset === "number") qs.set("offset", String(params.offset));
9382
+ for (const kind of params?.kind ?? []) qs.append("kind", kind);
9383
+ for (const status of params?.status ?? []) qs.append("status", status);
9384
+ const suffix = qs.toString() ? `?${qs.toString()}` : "";
9385
+ return request(`/v1/apps/${encodeURIComponent(appId)}/job-queue${suffix}`, { method: "GET" });
9386
+ },
9503
9387
  getMergeRequest: (mrId) => request(`/v1/merge-requests/${encodeURIComponent(mrId)}`, { method: "GET" }),
9504
9388
  presignImportUpload: (payload) => request("/v1/apps/import/upload/presign", { method: "POST", body: JSON.stringify(payload) }),
9505
9389
  importFromUpload: (payload) => request("/v1/apps/import/upload", { method: "POST", body: JSON.stringify(payload) }),
@@ -9507,6 +9391,11 @@ function createApiClient(config, opts) {
9507
9391
  importFromUploadFirstParty: (payload) => request("/v1/apps/import/upload/first-party", { method: "POST", body: JSON.stringify(payload) }),
9508
9392
  importFromGithubFirstParty: (payload) => request("/v1/apps/import/github/first-party", { method: "POST", body: JSON.stringify(payload) }),
9509
9393
  forkApp: (appId, payload) => request(`/v1/apps/${encodeURIComponent(appId)}/fork`, { method: "POST", body: JSON.stringify(payload ?? {}) }),
9394
+ getAppHead: (appId) => request(`/v1/apps/${encodeURIComponent(appId)}/head`, { method: "GET" }),
9395
+ getAppDelta: (appId, payload) => request(`/v1/apps/${encodeURIComponent(appId)}/delta`, {
9396
+ method: "POST",
9397
+ body: JSON.stringify(payload)
9398
+ }),
9510
9399
  downloadAppBundle: (appId) => requestBinary(`/v1/apps/${encodeURIComponent(appId)}/download.bundle`, { method: "GET" }),
9511
9400
  createChangeStep: (appId, payload) => request(`/v1/apps/${encodeURIComponent(appId)}/change-steps`, {
9512
9401
  method: "POST",
@@ -10219,8 +10108,8 @@ function getErrorMap() {
10219
10108
 
10220
10109
  // node_modules/zod/v3/helpers/parseUtil.js
10221
10110
  var makeIssue = (params) => {
10222
- const { data, path: path13, errorMaps, issueData } = params;
10223
- const fullPath = [...path13, ...issueData.path || []];
10111
+ const { data, path: path12, errorMaps, issueData } = params;
10112
+ const fullPath = [...path12, ...issueData.path || []];
10224
10113
  const fullIssue = {
10225
10114
  ...issueData,
10226
10115
  path: fullPath
@@ -10336,11 +10225,11 @@ var errorUtil;
10336
10225
 
10337
10226
  // node_modules/zod/v3/types.js
10338
10227
  var ParseInputLazyPath = class {
10339
- constructor(parent, value, path13, key) {
10228
+ constructor(parent, value, path12, key) {
10340
10229
  this._cachedPath = [];
10341
10230
  this.parent = parent;
10342
10231
  this.data = value;
10343
- this._path = path13;
10232
+ this._path = path12;
10344
10233
  this._key = key;
10345
10234
  }
10346
10235
  get path() {
@@ -13783,8 +13672,8 @@ var coerce = {
13783
13672
  var NEVER = INVALID;
13784
13673
 
13785
13674
  // node_modules/@remixhq/core/dist/chunk-EVWDYCBL.js
13786
- var import_promises18 = __toESM(require("fs/promises"), 1);
13787
- var import_os4 = __toESM(require("os"), 1);
13675
+ var import_promises17 = __toESM(require("fs/promises"), 1);
13676
+ var import_os3 = __toESM(require("os"), 1);
13788
13677
  var import_path7 = __toESM(require("path"), 1);
13789
13678
 
13790
13679
  // node_modules/tslib/tslib.es6.mjs
@@ -22673,8 +22562,8 @@ var IcebergError = class extends Error {
22673
22562
  return this.status === 419;
22674
22563
  }
22675
22564
  };
22676
- function buildUrl(baseUrl, path13, query) {
22677
- const url = new URL(path13, baseUrl);
22565
+ function buildUrl(baseUrl, path12, query) {
22566
+ const url = new URL(path12, baseUrl);
22678
22567
  if (query) {
22679
22568
  for (const [key, value] of Object.entries(query)) {
22680
22569
  if (value !== void 0) {
@@ -22704,12 +22593,12 @@ function createFetchClient(options) {
22704
22593
  return {
22705
22594
  async request({
22706
22595
  method,
22707
- path: path13,
22596
+ path: path12,
22708
22597
  query,
22709
22598
  body,
22710
22599
  headers
22711
22600
  }) {
22712
- const url = buildUrl(options.baseUrl, path13, query);
22601
+ const url = buildUrl(options.baseUrl, path12, query);
22713
22602
  const authHeaders = await buildAuthHeaders(options.auth);
22714
22603
  const res = await fetchFn(url, {
22715
22604
  method,
@@ -23528,7 +23417,7 @@ var StorageFileApi = class extends BaseApiClient {
23528
23417
  * @param path The relative file path. Should be of the format `folder/subfolder/filename.png`. The bucket must already exist before attempting to upload.
23529
23418
  * @param fileBody The body of the file to be stored in the bucket.
23530
23419
  */
23531
- async uploadOrUpdate(method, path13, fileBody, fileOptions) {
23420
+ async uploadOrUpdate(method, path12, fileBody, fileOptions) {
23532
23421
  var _this = this;
23533
23422
  return _this.handleOperation(async () => {
23534
23423
  let body;
@@ -23552,7 +23441,7 @@ var StorageFileApi = class extends BaseApiClient {
23552
23441
  if ((typeof ReadableStream !== "undefined" && body instanceof ReadableStream || body && typeof body === "object" && "pipe" in body && typeof body.pipe === "function") && !options.duplex) options.duplex = "half";
23553
23442
  }
23554
23443
  if (fileOptions === null || fileOptions === void 0 ? void 0 : fileOptions.headers) headers = _objectSpread22(_objectSpread22({}, headers), fileOptions.headers);
23555
- const cleanPath = _this._removeEmptyFolders(path13);
23444
+ const cleanPath = _this._removeEmptyFolders(path12);
23556
23445
  const _path = _this._getFinalPath(cleanPath);
23557
23446
  const data = await (method == "PUT" ? put : post)(_this.fetch, `${_this.url}/object/${_path}`, body, _objectSpread22({ headers }, (options === null || options === void 0 ? void 0 : options.duplex) ? { duplex: options.duplex } : {}));
23558
23447
  return {
@@ -23613,8 +23502,8 @@ var StorageFileApi = class extends BaseApiClient {
23613
23502
  * - Refer to the [Storage guide](/docs/guides/storage/security/access-control) on how access control works
23614
23503
  * - For React Native, using either `Blob`, `File` or `FormData` does not work as intended. Upload file using `ArrayBuffer` from base64 file data instead, see example below.
23615
23504
  */
23616
- async upload(path13, fileBody, fileOptions) {
23617
- return this.uploadOrUpdate("POST", path13, fileBody, fileOptions);
23505
+ async upload(path12, fileBody, fileOptions) {
23506
+ return this.uploadOrUpdate("POST", path12, fileBody, fileOptions);
23618
23507
  }
23619
23508
  /**
23620
23509
  * Upload a file with a token generated from `createSignedUploadUrl`.
@@ -23653,9 +23542,9 @@ var StorageFileApi = class extends BaseApiClient {
23653
23542
  * - `objects` table permissions: none
23654
23543
  * - Refer to the [Storage guide](/docs/guides/storage/security/access-control) on how access control works
23655
23544
  */
23656
- async uploadToSignedUrl(path13, token, fileBody, fileOptions) {
23545
+ async uploadToSignedUrl(path12, token, fileBody, fileOptions) {
23657
23546
  var _this3 = this;
23658
- const cleanPath = _this3._removeEmptyFolders(path13);
23547
+ const cleanPath = _this3._removeEmptyFolders(path12);
23659
23548
  const _path = _this3._getFinalPath(cleanPath);
23660
23549
  const url = new URL(_this3.url + `/object/upload/sign/${_path}`);
23661
23550
  url.searchParams.set("token", token);
@@ -23717,10 +23606,10 @@ var StorageFileApi = class extends BaseApiClient {
23717
23606
  * - `objects` table permissions: `insert`
23718
23607
  * - Refer to the [Storage guide](/docs/guides/storage/security/access-control) on how access control works
23719
23608
  */
23720
- async createSignedUploadUrl(path13, options) {
23609
+ async createSignedUploadUrl(path12, options) {
23721
23610
  var _this4 = this;
23722
23611
  return _this4.handleOperation(async () => {
23723
- let _path = _this4._getFinalPath(path13);
23612
+ let _path = _this4._getFinalPath(path12);
23724
23613
  const headers = _objectSpread22({}, _this4.headers);
23725
23614
  if (options === null || options === void 0 ? void 0 : options.upsert) headers["x-upsert"] = "true";
23726
23615
  const data = await post(_this4.fetch, `${_this4.url}/object/upload/sign/${_path}`, {}, { headers });
@@ -23729,7 +23618,7 @@ var StorageFileApi = class extends BaseApiClient {
23729
23618
  if (!token) throw new StorageError("No token returned by API");
23730
23619
  return {
23731
23620
  signedUrl: url.toString(),
23732
- path: path13,
23621
+ path: path12,
23733
23622
  token
23734
23623
  };
23735
23624
  });
@@ -23785,8 +23674,8 @@ var StorageFileApi = class extends BaseApiClient {
23785
23674
  * - Refer to the [Storage guide](/docs/guides/storage/security/access-control) on how access control works
23786
23675
  * - For React Native, using either `Blob`, `File` or `FormData` does not work as intended. Update file using `ArrayBuffer` from base64 file data instead, see example below.
23787
23676
  */
23788
- async update(path13, fileBody, fileOptions) {
23789
- return this.uploadOrUpdate("PUT", path13, fileBody, fileOptions);
23677
+ async update(path12, fileBody, fileOptions) {
23678
+ return this.uploadOrUpdate("PUT", path12, fileBody, fileOptions);
23790
23679
  }
23791
23680
  /**
23792
23681
  * Moves an existing file to a new path in the same bucket.
@@ -23933,10 +23822,10 @@ var StorageFileApi = class extends BaseApiClient {
23933
23822
  * - `objects` table permissions: `select`
23934
23823
  * - Refer to the [Storage guide](/docs/guides/storage/security/access-control) on how access control works
23935
23824
  */
23936
- async createSignedUrl(path13, expiresIn, options) {
23825
+ async createSignedUrl(path12, expiresIn, options) {
23937
23826
  var _this8 = this;
23938
23827
  return _this8.handleOperation(async () => {
23939
- let _path = _this8._getFinalPath(path13);
23828
+ let _path = _this8._getFinalPath(path12);
23940
23829
  const hasTransform = typeof (options === null || options === void 0 ? void 0 : options.transform) === "object" && options.transform !== null && Object.keys(options.transform).length > 0;
23941
23830
  let data = await post(_this8.fetch, `${_this8.url}/object/sign/${_path}`, _objectSpread22({ expiresIn }, hasTransform ? { transform: options.transform } : {}), { headers: _this8.headers });
23942
23831
  const downloadQueryParam = (options === null || options === void 0 ? void 0 : options.download) ? `&download=${options.download === true ? "" : options.download}` : "";
@@ -24063,11 +23952,11 @@ var StorageFileApi = class extends BaseApiClient {
24063
23952
  * - `objects` table permissions: `select`
24064
23953
  * - Refer to the [Storage guide](/docs/guides/storage/security/access-control) on how access control works
24065
23954
  */
24066
- download(path13, options, parameters) {
23955
+ download(path12, options, parameters) {
24067
23956
  const renderPath = typeof (options === null || options === void 0 ? void 0 : options.transform) !== "undefined" ? "render/image/authenticated" : "object";
24068
23957
  const transformationQuery = this.transformOptsToQueryString((options === null || options === void 0 ? void 0 : options.transform) || {});
24069
23958
  const queryString = transformationQuery ? `?${transformationQuery}` : "";
24070
- const _path = this._getFinalPath(path13);
23959
+ const _path = this._getFinalPath(path12);
24071
23960
  const downloadFn = () => get(this.fetch, `${this.url}/${renderPath}/${_path}${queryString}`, {
24072
23961
  headers: this.headers,
24073
23962
  noResolveJson: true
@@ -24097,9 +23986,9 @@ var StorageFileApi = class extends BaseApiClient {
24097
23986
  * }
24098
23987
  * ```
24099
23988
  */
24100
- async info(path13) {
23989
+ async info(path12) {
24101
23990
  var _this10 = this;
24102
- const _path = _this10._getFinalPath(path13);
23991
+ const _path = _this10._getFinalPath(path12);
24103
23992
  return _this10.handleOperation(async () => {
24104
23993
  return recursiveToCamel(await get(_this10.fetch, `${_this10.url}/object/info/${_path}`, { headers: _this10.headers }));
24105
23994
  });
@@ -24119,9 +24008,9 @@ var StorageFileApi = class extends BaseApiClient {
24119
24008
  * .exists('folder/avatar1.png')
24120
24009
  * ```
24121
24010
  */
24122
- async exists(path13) {
24011
+ async exists(path12) {
24123
24012
  var _this11 = this;
24124
- const _path = _this11._getFinalPath(path13);
24013
+ const _path = _this11._getFinalPath(path12);
24125
24014
  try {
24126
24015
  await head(_this11.fetch, `${_this11.url}/object/${_path}`, { headers: _this11.headers });
24127
24016
  return {
@@ -24198,8 +24087,8 @@ var StorageFileApi = class extends BaseApiClient {
24198
24087
  * - `objects` table permissions: none
24199
24088
  * - Refer to the [Storage guide](/docs/guides/storage/security/access-control) on how access control works
24200
24089
  */
24201
- getPublicUrl(path13, options) {
24202
- const _path = this._getFinalPath(path13);
24090
+ getPublicUrl(path12, options) {
24091
+ const _path = this._getFinalPath(path12);
24203
24092
  const _queryString = [];
24204
24093
  const downloadQueryParam = (options === null || options === void 0 ? void 0 : options.download) ? `download=${options.download === true ? "" : options.download}` : "";
24205
24094
  if (downloadQueryParam !== "") _queryString.push(downloadQueryParam);
@@ -24338,10 +24227,10 @@ var StorageFileApi = class extends BaseApiClient {
24338
24227
  * - `objects` table permissions: `select`
24339
24228
  * - Refer to the [Storage guide](/docs/guides/storage/security/access-control) on how access control works
24340
24229
  */
24341
- async list(path13, options, parameters) {
24230
+ async list(path12, options, parameters) {
24342
24231
  var _this13 = this;
24343
24232
  return _this13.handleOperation(async () => {
24344
- const body = _objectSpread22(_objectSpread22(_objectSpread22({}, DEFAULT_SEARCH_OPTIONS), options), {}, { prefix: path13 || "" });
24233
+ const body = _objectSpread22(_objectSpread22(_objectSpread22({}, DEFAULT_SEARCH_OPTIONS), options), {}, { prefix: path12 || "" });
24345
24234
  return await post(_this13.fetch, `${_this13.url}/object/list/${_this13.bucketId}`, body, { headers: _this13.headers }, parameters);
24346
24235
  });
24347
24236
  }
@@ -24405,11 +24294,11 @@ var StorageFileApi = class extends BaseApiClient {
24405
24294
  if (typeof Buffer !== "undefined") return Buffer.from(data).toString("base64");
24406
24295
  return btoa(data);
24407
24296
  }
24408
- _getFinalPath(path13) {
24409
- return `${this.bucketId}/${path13.replace(/^\/+/, "")}`;
24297
+ _getFinalPath(path12) {
24298
+ return `${this.bucketId}/${path12.replace(/^\/+/, "")}`;
24410
24299
  }
24411
- _removeEmptyFolders(path13) {
24412
- return path13.replace(/^\/|\/$/g, "").replace(/\/+/g, "/");
24300
+ _removeEmptyFolders(path12) {
24301
+ return path12.replace(/^\/|\/$/g, "").replace(/\/+/g, "/");
24413
24302
  }
24414
24303
  transformOptsToQueryString(transform) {
24415
24304
  const params = [];
@@ -26123,7 +26012,7 @@ function decodeJWT(token) {
26123
26012
  };
26124
26013
  return data;
26125
26014
  }
26126
- async function sleep3(time) {
26015
+ async function sleep2(time) {
26127
26016
  return await new Promise((accept) => {
26128
26017
  setTimeout(() => accept(null), time);
26129
26018
  });
@@ -31892,7 +31781,7 @@ var GoTrueClient = class _GoTrueClient {
31892
31781
  const startedAt = Date.now();
31893
31782
  return await retryable(async (attempt) => {
31894
31783
  if (attempt > 0) {
31895
- await sleep3(200 * Math.pow(2, attempt - 1));
31784
+ await sleep2(200 * Math.pow(2, attempt - 1));
31896
31785
  }
31897
31786
  this._debug(debugName, "refreshing attempt", attempt);
31898
31787
  return await _request(this.fetch, "POST", `${this.url}/token?grant_type=refresh_token`, {
@@ -33444,7 +33333,7 @@ var storedSessionSchema = external_exports.object({
33444
33333
  function xdgConfigHome() {
33445
33334
  const value = process.env.XDG_CONFIG_HOME;
33446
33335
  if (typeof value === "string" && value.trim()) return value;
33447
- return import_path7.default.join(import_os4.default.homedir(), ".config");
33336
+ return import_path7.default.join(import_os3.default.homedir(), ".config");
33448
33337
  }
33449
33338
  async function maybeLoadKeytar() {
33450
33339
  try {
@@ -33463,21 +33352,21 @@ async function maybeLoadKeytar() {
33463
33352
  }
33464
33353
  async function ensurePathPermissions(filePath) {
33465
33354
  const dir = import_path7.default.dirname(filePath);
33466
- await import_promises18.default.mkdir(dir, { recursive: true });
33355
+ await import_promises17.default.mkdir(dir, { recursive: true });
33467
33356
  try {
33468
- await import_promises18.default.chmod(dir, 448);
33357
+ await import_promises17.default.chmod(dir, 448);
33469
33358
  } catch {
33470
33359
  }
33471
33360
  try {
33472
- await import_promises18.default.chmod(filePath, 384);
33361
+ await import_promises17.default.chmod(filePath, 384);
33473
33362
  } catch {
33474
33363
  }
33475
33364
  }
33476
33365
  async function writeJsonAtomic2(filePath, value) {
33477
- await import_promises18.default.mkdir(import_path7.default.dirname(filePath), { recursive: true });
33366
+ await import_promises17.default.mkdir(import_path7.default.dirname(filePath), { recursive: true });
33478
33367
  const tmpPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
33479
- await import_promises18.default.writeFile(tmpPath, JSON.stringify(value, null, 2) + "\n", "utf8");
33480
- await import_promises18.default.rename(tmpPath, filePath);
33368
+ await import_promises17.default.writeFile(tmpPath, JSON.stringify(value, null, 2) + "\n", "utf8");
33369
+ await import_promises17.default.rename(tmpPath, filePath);
33481
33370
  }
33482
33371
  async function writeSessionFileFallback(filePath, session) {
33483
33372
  await writeJsonAtomic2(filePath, session);
@@ -33500,7 +33389,7 @@ function createLocalSessionStore(params) {
33500
33389
  return null;
33501
33390
  }
33502
33391
  }
33503
- const raw = await import_promises18.default.readFile(filePath, "utf8").catch(() => null);
33392
+ const raw = await import_promises17.default.readFile(filePath, "utf8").catch(() => null);
33504
33393
  if (!raw) return null;
33505
33394
  try {
33506
33395
  const parsed = storedSessionSchema.safeParse(JSON.parse(raw));
@@ -33687,12 +33576,12 @@ async function createHookCollabApiClient() {
33687
33576
 
33688
33577
  // src/hook-diagnostics.ts
33689
33578
  var import_node_crypto2 = require("crypto");
33690
- var import_promises20 = __toESM(require("fs/promises"), 1);
33579
+ var import_promises19 = __toESM(require("fs/promises"), 1);
33691
33580
  var import_node_os5 = __toESM(require("os"), 1);
33692
33581
  var import_node_path7 = __toESM(require("path"), 1);
33693
33582
 
33694
33583
  // src/hook-state.ts
33695
- var import_promises19 = __toESM(require("fs/promises"), 1);
33584
+ var import_promises18 = __toESM(require("fs/promises"), 1);
33696
33585
  var import_node_os4 = __toESM(require("os"), 1);
33697
33586
  var import_node_path6 = __toESM(require("path"), 1);
33698
33587
  var import_node_crypto = require("crypto");
@@ -33710,20 +33599,20 @@ function stateLockMetaPath(sessionId) {
33710
33599
  return import_node_path6.default.join(stateLockPath(sessionId), "owner.json");
33711
33600
  }
33712
33601
  async function writeJsonAtomic3(filePath, value) {
33713
- await import_promises19.default.mkdir(import_node_path6.default.dirname(filePath), { recursive: true });
33602
+ await import_promises18.default.mkdir(import_node_path6.default.dirname(filePath), { recursive: true });
33714
33603
  const tmpPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
33715
- await import_promises19.default.writeFile(tmpPath, JSON.stringify(value, null, 2) + "\n", "utf8");
33716
- await import_promises19.default.rename(tmpPath, filePath);
33604
+ await import_promises18.default.writeFile(tmpPath, JSON.stringify(value, null, 2) + "\n", "utf8");
33605
+ await import_promises18.default.rename(tmpPath, filePath);
33717
33606
  }
33718
33607
  var STATE_LOCK_WAIT_MS = 2e3;
33719
33608
  var STATE_LOCK_POLL_MS = 25;
33720
33609
  var STATE_LOCK_STALE_MS = 3e4;
33721
33610
  var STATE_LOCK_HEARTBEAT_MS = 5e3;
33722
- async function sleep4(ms) {
33611
+ async function sleep3(ms) {
33723
33612
  await new Promise((resolve) => setTimeout(resolve, ms));
33724
33613
  }
33725
33614
  async function readStateLockMetadata(sessionId) {
33726
- const raw = await import_promises19.default.readFile(stateLockMetaPath(sessionId), "utf8").catch(() => null);
33615
+ const raw = await import_promises18.default.readFile(stateLockMetaPath(sessionId), "utf8").catch(() => null);
33727
33616
  if (!raw) return null;
33728
33617
  try {
33729
33618
  const parsed = JSON.parse(raw);
@@ -33748,13 +33637,13 @@ async function tryRemoveStaleStateLock(sessionId) {
33748
33637
  const metadata = await readStateLockMetadata(sessionId);
33749
33638
  const staleByHeartbeat = metadata && Date.now() - new Date(metadata.heartbeatAt).getTime() > STATE_LOCK_STALE_MS;
33750
33639
  if (staleByHeartbeat) {
33751
- await import_promises19.default.rm(lockPath, { recursive: true, force: true }).catch(() => void 0);
33640
+ await import_promises18.default.rm(lockPath, { recursive: true, force: true }).catch(() => void 0);
33752
33641
  return true;
33753
33642
  }
33754
33643
  if (!metadata) {
33755
- const lockStat = await import_promises19.default.stat(lockPath).catch(() => null);
33644
+ const lockStat = await import_promises18.default.stat(lockPath).catch(() => null);
33756
33645
  if (lockStat && Date.now() - lockStat.mtimeMs > STATE_LOCK_STALE_MS) {
33757
- await import_promises19.default.rm(lockPath, { recursive: true, force: true }).catch(() => void 0);
33646
+ await import_promises18.default.rm(lockPath, { recursive: true, force: true }).catch(() => void 0);
33758
33647
  return true;
33759
33648
  }
33760
33649
  }
@@ -33763,10 +33652,10 @@ async function tryRemoveStaleStateLock(sessionId) {
33763
33652
  async function acquireStateLock(sessionId) {
33764
33653
  const lockPath = stateLockPath(sessionId);
33765
33654
  const deadline = Date.now() + STATE_LOCK_WAIT_MS;
33766
- await import_promises19.default.mkdir(stateRoot(), { recursive: true });
33655
+ await import_promises18.default.mkdir(stateRoot(), { recursive: true });
33767
33656
  while (true) {
33768
33657
  try {
33769
- await import_promises19.default.mkdir(lockPath);
33658
+ await import_promises18.default.mkdir(lockPath);
33770
33659
  const ownerId = (0, import_node_crypto.randomUUID)();
33771
33660
  const createdAt = (/* @__PURE__ */ new Date()).toISOString();
33772
33661
  const metadata = {
@@ -33791,7 +33680,7 @@ async function acquireStateLock(sessionId) {
33791
33680
  clearInterval(heartbeat);
33792
33681
  const currentMetadata = await readStateLockMetadata(sessionId);
33793
33682
  if (currentMetadata?.ownerId === ownerId) {
33794
- await import_promises19.default.rm(lockPath, { recursive: true, force: true }).catch(() => void 0);
33683
+ await import_promises18.default.rm(lockPath, { recursive: true, force: true }).catch(() => void 0);
33795
33684
  }
33796
33685
  };
33797
33686
  } catch (error) {
@@ -33805,7 +33694,7 @@ async function acquireStateLock(sessionId) {
33805
33694
  if (Date.now() >= deadline) {
33806
33695
  throw new Error(`Timed out acquiring hook state lock for session ${sessionId}.`);
33807
33696
  }
33808
- await sleep4(STATE_LOCK_POLL_MS);
33697
+ await sleep3(STATE_LOCK_POLL_MS);
33809
33698
  }
33810
33699
  }
33811
33700
  }
@@ -33831,6 +33720,12 @@ function normalizeStringArray(value) {
33831
33720
  )
33832
33721
  );
33833
33722
  }
33723
+ function normalizeManualRecordingScope(value) {
33724
+ if (value === "full_turn") {
33725
+ return "full_turn";
33726
+ }
33727
+ return null;
33728
+ }
33834
33729
  function normalizeTouchedRepo(value, repoRoot) {
33835
33730
  if (!value || typeof value !== "object") return null;
33836
33731
  const parsed = value;
@@ -33849,7 +33744,7 @@ function normalizeTouchedRepo(value, repoRoot) {
33849
33744
  manuallyRecorded: Boolean(parsed.manuallyRecorded),
33850
33745
  manuallyRecordedAt: normalizeString(parsed.manuallyRecordedAt),
33851
33746
  manuallyRecordedByTool: normalizeString(parsed.manuallyRecordedByTool),
33852
- manualRecordingScope: parsed.manualRecordingScope === "change_step" || parsed.manualRecordingScope === "full_turn" ? parsed.manualRecordingScope : null,
33747
+ manualRecordingScope: normalizeManualRecordingScope(parsed.manualRecordingScope),
33853
33748
  manualRemoteChangeRecordedAt: normalizeString(parsed.manualRemoteChangeRecordedAt),
33854
33749
  stopAttempted: Boolean(parsed.stopAttempted),
33855
33750
  stopRecorded: Boolean(parsed.stopRecorded),
@@ -33903,7 +33798,7 @@ async function updatePendingTurnState(sessionId, updater) {
33903
33798
  });
33904
33799
  }
33905
33800
  async function loadPendingTurnState(sessionId) {
33906
- const raw = await import_promises19.default.readFile(statePath(sessionId), "utf8").catch(() => null);
33801
+ const raw = await import_promises18.default.readFile(statePath(sessionId), "utf8").catch(() => null);
33907
33802
  if (!raw) return null;
33908
33803
  try {
33909
33804
  const parsed = JSON.parse(raw);
@@ -34005,14 +33900,14 @@ async function listTouchedRepos(sessionId) {
34005
33900
  }
34006
33901
  async function clearPendingTurnState(sessionId) {
34007
33902
  await withStateLock(sessionId, async () => {
34008
- await import_promises19.default.rm(statePath(sessionId), { force: true }).catch(() => void 0);
33903
+ await import_promises18.default.rm(statePath(sessionId), { force: true }).catch(() => void 0);
34009
33904
  });
34010
33905
  }
34011
33906
 
34012
33907
  // package.json
34013
33908
  var package_default = {
34014
33909
  name: "@remixhq/claude-plugin",
34015
- version: "0.1.16",
33910
+ version: "0.1.18",
34016
33911
  description: "Claude Code plugin for Remix collaboration workflows",
34017
33912
  homepage: "https://github.com/RemixDotOne/remix-claude-plugin",
34018
33913
  license: "MIT",
@@ -34043,8 +33938,8 @@ var package_default = {
34043
33938
  prepack: "npm run build"
34044
33939
  },
34045
33940
  dependencies: {
34046
- "@remixhq/core": "^0.1.11",
34047
- "@remixhq/mcp": "^0.1.11"
33941
+ "@remixhq/core": "^0.1.13",
33942
+ "@remixhq/mcp": "^0.1.13"
34048
33943
  },
34049
33944
  devDependencies: {
34050
33945
  "@types/node": "^25.4.0",
@@ -34095,13 +33990,13 @@ function normalizeFields(fields) {
34095
33990
  return Object.fromEntries(normalizedEntries);
34096
33991
  }
34097
33992
  async function rotateLogIfNeeded(logPath) {
34098
- const stat = await import_promises20.default.stat(logPath).catch(() => null);
33993
+ const stat = await import_promises19.default.stat(logPath).catch(() => null);
34099
33994
  if (!stat || stat.size < MAX_LOG_BYTES) {
34100
33995
  return;
34101
33996
  }
34102
33997
  const rotatedPath = `${logPath}.1`;
34103
- await import_promises20.default.rm(rotatedPath, { force: true }).catch(() => void 0);
34104
- await import_promises20.default.rename(logPath, rotatedPath).catch(() => void 0);
33998
+ await import_promises19.default.rm(rotatedPath, { force: true }).catch(() => void 0);
33999
+ await import_promises19.default.rename(logPath, rotatedPath).catch(() => void 0);
34105
34000
  }
34106
34001
  function summarizeText(value) {
34107
34002
  if (typeof value !== "string" || !value.trim()) {
@@ -34121,7 +34016,7 @@ function summarizeText(value) {
34121
34016
  async function appendHookDiagnosticsEvent(params) {
34122
34017
  try {
34123
34018
  const logPath = getHookDiagnosticsLogPath();
34124
- await import_promises20.default.mkdir(import_node_path7.default.dirname(logPath), { recursive: true });
34019
+ await import_promises19.default.mkdir(import_node_path7.default.dirname(logPath), { recursive: true });
34125
34020
  await rotateLogIfNeeded(logPath);
34126
34021
  const event = {
34127
34022
  ts: (/* @__PURE__ */ new Date()).toISOString(),
@@ -34138,14 +34033,14 @@ async function appendHookDiagnosticsEvent(params) {
34138
34033
  message: params.message?.trim() || null,
34139
34034
  fields: normalizeFields(params.fields)
34140
34035
  };
34141
- await import_promises20.default.appendFile(logPath, `${JSON.stringify(event)}
34036
+ await import_promises19.default.appendFile(logPath, `${JSON.stringify(event)}
34142
34037
  `, "utf8");
34143
34038
  } catch {
34144
34039
  }
34145
34040
  }
34146
34041
 
34147
34042
  // src/hook-utils.ts
34148
- var import_promises21 = __toESM(require("fs/promises"), 1);
34043
+ var import_promises20 = __toESM(require("fs/promises"), 1);
34149
34044
  var import_node_path8 = __toESM(require("path"), 1);
34150
34045
  async function readJsonStdin() {
34151
34046
  const chunks = [];
@@ -34209,13 +34104,13 @@ function extractBoolean(input, keys) {
34209
34104
  async function findBoundRepo(startPath) {
34210
34105
  if (!startPath) return null;
34211
34106
  let current = import_node_path8.default.resolve(startPath);
34212
- let stats = await import_promises21.default.stat(current).catch(() => null);
34107
+ let stats = await import_promises20.default.stat(current).catch(() => null);
34213
34108
  if (stats?.isFile()) {
34214
34109
  current = import_node_path8.default.dirname(current);
34215
34110
  }
34216
34111
  while (true) {
34217
34112
  const bindingPath = import_node_path8.default.join(current, ".remix", "config.json");
34218
- const bindingStats = await import_promises21.default.stat(bindingPath).catch(() => null);
34113
+ const bindingStats = await import_promises20.default.stat(bindingPath).catch(() => null);
34219
34114
  if (bindingStats?.isFile()) return current;
34220
34115
  const parent = import_node_path8.default.dirname(current);
34221
34116
  if (parent === current) return null;
@@ -34242,6 +34137,7 @@ var HOOK_ACTOR = {
34242
34137
  version: pluginMetadata.version,
34243
34138
  provider: "anthropic"
34244
34139
  };
34140
+ var collabFinalizeTurn2 = collabFinalizeTurn;
34245
34141
  function getErrorDetails(error) {
34246
34142
  if (error instanceof Error) {
34247
34143
  const hint = typeof error.hint === "string" ? String(error.hint) : null;
@@ -34253,52 +34149,13 @@ function getErrorDetails(error) {
34253
34149
  const message = typeof error === "string" && error.trim() ? error.trim() : "Fallback Remix turn recording failed.";
34254
34150
  return { message, hint: null };
34255
34151
  }
34256
- function getRecordingBlockedMessage(status, repoRoot) {
34257
- if (status.status === "branch_binding_missing") {
34258
- return {
34259
- message: "Fallback Remix turn recording was blocked because the current branch does not have a Remix lane binding yet.",
34260
- hint: status.hint || `Run \`remix_collab_status\` for ${repoRoot}, then initialize or provision the current branch lane before recording work.`
34261
- };
34262
- }
34263
- switch (status.status) {
34264
- case "not_git_repo":
34265
- return {
34266
- message: "Fallback Remix turn recording failed because the repository is no longer inside a git repository.",
34267
- hint: status.hint || `Repo root: ${repoRoot}`
34268
- };
34269
- case "not_bound":
34270
- return {
34271
- message: "Fallback Remix turn recording failed because the repository is no longer bound to Remix.",
34272
- hint: status.hint || `Repo root: ${repoRoot}`
34273
- };
34274
- case "missing_head":
34275
- return {
34276
- message: "Fallback Remix turn recording failed because the repository HEAD could not be resolved.",
34277
- hint: status.hint || `Repo root: ${repoRoot}`
34278
- };
34279
- case "branch_mismatch":
34280
- return {
34281
- message: "Fallback Remix turn recording was blocked because the current checkout branch does not match the branch expected by the bound Remix lane.",
34282
- hint: status.hint || `Run \`remix_collab_status\` for ${repoRoot}, then switch back to the expected branch or refresh this branch's binding before recording or syncing.`
34283
- };
34284
- case "metadata_conflict":
34285
- return {
34286
- message: "Fallback Remix turn recording was blocked because local repository metadata conflicts with the bound Remix app.",
34287
- hint: status.hint || `Repo root: ${repoRoot}`
34288
- };
34289
- case "reconcile_required":
34290
- return {
34291
- message: "Fallback Remix turn recording was blocked because the repository must be reconciled before recording can continue safely.",
34292
- hint: status.hint || `Repo root: ${repoRoot}`
34293
- };
34294
- default:
34295
- return null;
34296
- }
34297
- }
34298
34152
  function buildRepoIdempotencyKey(turnId, repo) {
34299
34153
  const repoToken = repo.currentAppId?.trim() || repo.repoRoot;
34300
34154
  return `${turnId}:${repoToken}:finalize_turn`;
34301
34155
  }
34156
+ function isLegacyManualRecordingTool(toolName) {
34157
+ return /remix_collab_(add|add_change_step|record_turn|record_no_diff_turn)$/i.test(toolName ?? "");
34158
+ }
34302
34159
  function shouldSkipStopRecording(repo) {
34303
34160
  if (repo.stopRecorded) {
34304
34161
  return true;
@@ -34306,7 +34163,8 @@ function shouldSkipStopRecording(repo) {
34306
34163
  if (!repo.manuallyRecorded) {
34307
34164
  return false;
34308
34165
  }
34309
- if (repo.manualRecordingScope !== "full_turn") {
34166
+ const alreadyRecordedByCompatibleFlow = repo.manualRecordingScope === "full_turn" || isLegacyManualRecordingTool(repo.manuallyRecordedByTool);
34167
+ if (!alreadyRecordedByCompatibleFlow) {
34310
34168
  return false;
34311
34169
  }
34312
34170
  if (!repo.manuallyRecordedAt) {
@@ -34317,12 +34175,6 @@ function shouldSkipStopRecording(repo) {
34317
34175
  }
34318
34176
  return new Date(repo.lastObservedWriteAt).getTime() <= new Date(repo.manuallyRecordedAt).getTime();
34319
34177
  }
34320
- function hasNewObservedWriteSince(timestamp, repo) {
34321
- if (!repo.lastObservedWriteAt) {
34322
- return false;
34323
- }
34324
- return new Date(repo.lastObservedWriteAt).getTime() > new Date(timestamp).getTime();
34325
- }
34326
34178
  function createFallbackTouchedRepo(params) {
34327
34179
  const now = (/* @__PURE__ */ new Date()).toISOString();
34328
34180
  return {
@@ -34381,113 +34233,9 @@ async function recordTouchedRepo(params) {
34381
34233
  reason: "repo_not_bound",
34382
34234
  repoRoot: repo.repoRoot
34383
34235
  });
34384
- return false;
34385
- }
34386
- const workspaceDiff = await getWorkspaceDiff(repo.repoRoot);
34387
- if (workspaceDiff.diff.trim()) {
34388
- await appendHookDiagnosticsEvent({
34389
- hook,
34390
- sessionId,
34391
- turnId,
34392
- stage: "workspace_diff_checked",
34393
- result: "info",
34394
- repoRoot: repo.repoRoot,
34395
- fields: {
34396
- hasWorkspaceDiff: true,
34397
- diffLength: workspaceDiff.diff.length
34398
- }
34399
- });
34400
- if (repo.manualRemoteChangeRecordedAt && !hasNewObservedWriteSince(repo.manualRemoteChangeRecordedAt, repo)) {
34401
- await markTouchedRepoStopRecorded(sessionId, repo.repoRoot, { mode: "changed_turn" });
34402
- await appendHookDiagnosticsEvent({
34403
- hook,
34404
- sessionId,
34405
- turnId,
34406
- stage: "recording_skipped",
34407
- result: "success",
34408
- reason: "manual_recording_already_covers_diff",
34409
- repoRoot: repo.repoRoot
34410
- });
34411
- return true;
34412
- }
34413
- const recordingPreflight2 = await collabRecordingPreflight({
34414
- api,
34415
- cwd: repo.repoRoot
34416
- });
34417
- const blocked2 = getRecordingBlockedMessage(recordingPreflight2, repo.repoRoot);
34418
- if (blocked2) {
34419
- await markTouchedRepoRecordingFailure(sessionId, repo.repoRoot, blocked2);
34420
- await appendHookDiagnosticsEvent({
34421
- hook,
34422
- sessionId,
34423
- turnId,
34424
- stage: "recording_preflight",
34425
- result: "error",
34426
- reason: recordingPreflight2.status,
34427
- repoRoot: repo.repoRoot,
34428
- message: blocked2.message,
34429
- fields: {
34430
- hint: blocked2.hint
34431
- }
34432
- });
34433
- return false;
34434
- }
34435
- await collabAdd({
34436
- api,
34437
- cwd: repo.repoRoot,
34438
- prompt,
34439
- assistantResponse,
34440
- diffSource: "worktree",
34441
- idempotencyKey: buildRepoIdempotencyKey(turnId, repo),
34442
- actor: HOOK_ACTOR
34443
- });
34444
- await markTouchedRepoStopRecorded(sessionId, repo.repoRoot, { mode: "changed_turn" });
34445
- await appendHookDiagnosticsEvent({
34446
- hook,
34447
- sessionId,
34448
- turnId,
34449
- stage: "recording_completed",
34450
- result: "success",
34451
- reason: "changed_turn_recorded",
34452
- repoRoot: repo.repoRoot
34453
- });
34454
- return true;
34455
- }
34456
- await appendHookDiagnosticsEvent({
34457
- hook,
34458
- sessionId,
34459
- turnId,
34460
- stage: "workspace_diff_checked",
34461
- result: "info",
34462
- repoRoot: repo.repoRoot,
34463
- fields: {
34464
- hasWorkspaceDiff: false,
34465
- diffLength: 0
34466
- }
34467
- });
34468
- const recordingPreflight = await collabRecordingPreflight({
34469
- api,
34470
- cwd: repo.repoRoot
34471
- });
34472
- const blocked = getRecordingBlockedMessage(recordingPreflight, repo.repoRoot);
34473
- if (blocked) {
34474
- await markTouchedRepoRecordingFailure(sessionId, repo.repoRoot, blocked);
34475
- await appendHookDiagnosticsEvent({
34476
- hook,
34477
- sessionId,
34478
- turnId,
34479
- stage: "recording_preflight",
34480
- result: "error",
34481
- reason: recordingPreflight.status,
34482
- repoRoot: repo.repoRoot,
34483
- message: blocked.message,
34484
- fields: {
34485
- hint: blocked.hint
34486
- }
34487
- });
34488
- return false;
34236
+ return { recorded: false, queued: false };
34489
34237
  }
34490
- await collabRecordTurn({
34238
+ const result = await collabFinalizeTurn2({
34491
34239
  api,
34492
34240
  cwd: repo.repoRoot,
34493
34241
  prompt,
@@ -34495,17 +34243,17 @@ async function recordTouchedRepo(params) {
34495
34243
  idempotencyKey: buildRepoIdempotencyKey(turnId, repo),
34496
34244
  actor: HOOK_ACTOR
34497
34245
  });
34498
- await markTouchedRepoStopRecorded(sessionId, repo.repoRoot, { mode: "no_diff_turn" });
34246
+ await markTouchedRepoStopRecorded(sessionId, repo.repoRoot, { mode: result.mode });
34499
34247
  await appendHookDiagnosticsEvent({
34500
34248
  hook,
34501
34249
  sessionId,
34502
34250
  turnId,
34503
34251
  stage: "recording_completed",
34504
34252
  result: "success",
34505
- reason: "no_diff_turn_recorded",
34253
+ reason: result.mode,
34506
34254
  repoRoot: repo.repoRoot
34507
34255
  });
34508
- return true;
34256
+ return { recorded: true, queued: result.queued === true };
34509
34257
  } catch (error) {
34510
34258
  const details = getErrorDetails(error);
34511
34259
  await markTouchedRepoRecordingFailure(sessionId, repo.repoRoot, details);
@@ -34522,9 +34270,19 @@ async function recordTouchedRepo(params) {
34522
34270
  hint: details.hint
34523
34271
  }
34524
34272
  });
34525
- return false;
34273
+ return { recorded: false, queued: false };
34526
34274
  }
34527
34275
  }
34276
+ function spawnFinalizeQueueDrainer() {
34277
+ const entrypoint = process.argv[1];
34278
+ if (!entrypoint) return;
34279
+ const child = (0, import_node_child_process6.spawn)(process.execPath, [...process.execArgv, entrypoint, "--drain-finalize-queue"], {
34280
+ detached: true,
34281
+ stdio: "ignore",
34282
+ env: process.env
34283
+ });
34284
+ child.unref();
34285
+ }
34528
34286
  async function runHookStopCollab(payload) {
34529
34287
  const hook = "Stop";
34530
34288
  if (extractBoolean(payload, ["stop_hook_active"])) {
@@ -34671,8 +34429,11 @@ async function runHookStopCollab(payload) {
34671
34429
  }
34672
34430
  });
34673
34431
  let hadFailure = false;
34432
+ let queuedFinalizeWork = false;
34674
34433
  for (const repo of touchedRepos) {
34675
34434
  if (shouldSkipStopRecording(repo)) {
34435
+ const backupDrainQueued = repo.manuallyRecordedByTool === "remix_collab_finalize_turn" && repo.manualRecordingScope === "full_turn";
34436
+ queuedFinalizeWork = queuedFinalizeWork || backupDrainQueued;
34676
34437
  await appendHookDiagnosticsEvent({
34677
34438
  hook,
34678
34439
  sessionId,
@@ -34683,12 +34444,13 @@ async function runHookStopCollab(payload) {
34683
34444
  repoRoot: repo.repoRoot,
34684
34445
  fields: {
34685
34446
  manuallyRecorded: repo.manuallyRecorded,
34686
- stopRecorded: repo.stopRecorded
34447
+ stopRecorded: repo.stopRecorded,
34448
+ backupDrainQueued
34687
34449
  }
34688
34450
  });
34689
34451
  continue;
34690
34452
  }
34691
- const recorded = await recordTouchedRepo({
34453
+ const recording = await recordTouchedRepo({
34692
34454
  hook,
34693
34455
  sessionId,
34694
34456
  turnId: state.turnId,
@@ -34697,10 +34459,14 @@ async function runHookStopCollab(payload) {
34697
34459
  assistantResponse,
34698
34460
  api
34699
34461
  });
34700
- if (!recorded) {
34462
+ queuedFinalizeWork = queuedFinalizeWork || recording.queued;
34463
+ if (!recording.recorded) {
34701
34464
  hadFailure = true;
34702
34465
  }
34703
34466
  }
34467
+ if (queuedFinalizeWork) {
34468
+ spawnFinalizeQueueDrainer();
34469
+ }
34704
34470
  if (!hadFailure) {
34705
34471
  await clearPendingTurnState(sessionId);
34706
34472
  await appendHookDiagnosticsEvent({
@@ -34739,6 +34505,14 @@ async function runHookStopCollab(payload) {
34739
34505
  }
34740
34506
  }
34741
34507
  async function main() {
34508
+ if (process.argv.includes("--drain-finalize-queue")) {
34509
+ const api = await createHookCollabApiClient();
34510
+ const drainPendingFinalizeQueue2 = drainPendingFinalizeQueue;
34511
+ if (typeof drainPendingFinalizeQueue2 === "function") {
34512
+ await drainPendingFinalizeQueue2({ api });
34513
+ }
34514
+ return;
34515
+ }
34742
34516
  const payload = await readJsonStdin();
34743
34517
  await runHookStopCollab(payload);
34744
34518
  }