@remixhq/claude-plugin 0.1.22 → 0.1.23

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.
@@ -540,17 +540,23 @@ __export(hook_stop_collab_exports, {
540
540
  module.exports = __toCommonJS(hook_stop_collab_exports);
541
541
  var import_node_child_process9 = require("child_process");
542
542
 
543
- // node_modules/@remixhq/core/dist/chunk-YZ34ICNN.js
543
+ // node_modules/@remixhq/core/dist/chunk-7XJGOKEO.js
544
544
  var RemixError = class extends Error {
545
545
  code;
546
546
  exitCode;
547
547
  hint;
548
+ // HTTP status code when this error originates from an API response.
549
+ // null for non-HTTP errors (validation, local IO, programming bugs).
550
+ // Callers use this to distinguish transient (5xx) from permanent (4xx)
551
+ // API failures without resorting to error-message string matching.
552
+ statusCode;
548
553
  constructor(message, opts) {
549
554
  super(message);
550
555
  this.name = "RemixError";
551
556
  this.code = opts?.code ?? null;
552
557
  this.exitCode = opts?.exitCode ?? 1;
553
558
  this.hint = opts?.hint ?? null;
559
+ this.statusCode = opts?.statusCode ?? null;
554
560
  }
555
561
  };
556
562
 
@@ -7329,7 +7335,7 @@ var {
7329
7335
  getCancelSignal: getCancelSignal2
7330
7336
  } = getIpcExport();
7331
7337
 
7332
- // node_modules/@remixhq/core/dist/chunk-WT6VRLXU.js
7338
+ // node_modules/@remixhq/core/dist/chunk-S4ECO35X.js
7333
7339
  async function runGit(args, cwd) {
7334
7340
  const res = await execa("git", args, { cwd, stderr: "ignore" });
7335
7341
  return String(res.stdout || "").trim();
@@ -7384,7 +7390,7 @@ function summarizeUnifiedDiff(diff) {
7384
7390
  return { changedFilesCount, insertions, deletions };
7385
7391
  }
7386
7392
 
7387
- // node_modules/@remixhq/core/dist/chunk-YCFLOHJV.js
7393
+ // node_modules/@remixhq/core/dist/chunk-DBVN42RF.js
7388
7394
  var import_promises12 = __toESM(require("fs/promises"), 1);
7389
7395
  var import_path = __toESM(require("path"), 1);
7390
7396
  var import_promises13 = __toESM(require("fs/promises"), 1);
@@ -7683,6 +7689,8 @@ function buildAppDeltaCacheKey(appId, payload) {
7683
7689
  appId,
7684
7690
  payload.baseHeadHash,
7685
7691
  payload.targetHeadHash ?? "",
7692
+ payload.baseRevisionId ?? "",
7693
+ payload.targetRevisionId ?? "",
7686
7694
  payload.localSnapshotHash ?? "",
7687
7695
  payload.repoFingerprint ?? "",
7688
7696
  payload.remoteUrl ?? "",
@@ -7929,11 +7937,11 @@ async function readLocalBaseline(params) {
7929
7937
  const raw = await import_promises15.default.readFile(getBaselinePath(params), "utf8");
7930
7938
  const parsed = JSON.parse(raw);
7931
7939
  if (!parsed || typeof parsed !== "object") return null;
7932
- if (parsed.schemaVersion !== 1 || typeof parsed.key !== "string" || typeof parsed.repoRoot !== "string") {
7940
+ if (![1, 2].includes(Number(parsed.schemaVersion)) || typeof parsed.key !== "string" || typeof parsed.repoRoot !== "string") {
7933
7941
  return null;
7934
7942
  }
7935
7943
  return {
7936
- schemaVersion: 1,
7944
+ schemaVersion: Number(parsed.schemaVersion) === 2 ? 2 : 1,
7937
7945
  key: parsed.key,
7938
7946
  repoRoot: parsed.repoRoot,
7939
7947
  repoFingerprint: parsed.repoFingerprint ?? null,
@@ -7942,6 +7950,8 @@ async function readLocalBaseline(params) {
7942
7950
  branchName: parsed.branchName ?? null,
7943
7951
  lastSnapshotId: parsed.lastSnapshotId ?? null,
7944
7952
  lastSnapshotHash: parsed.lastSnapshotHash ?? null,
7953
+ lastServerRevisionId: parsed.lastServerRevisionId ?? null,
7954
+ lastServerTreeHash: parsed.lastServerTreeHash ?? null,
7945
7955
  lastServerHeadHash: parsed.lastServerHeadHash ?? null,
7946
7956
  lastSeenLocalCommitHash: parsed.lastSeenLocalCommitHash ?? null,
7947
7957
  updatedAt: String(parsed.updatedAt ?? "")
@@ -7953,7 +7963,7 @@ async function readLocalBaseline(params) {
7953
7963
  async function writeLocalBaseline(baseline) {
7954
7964
  const key = buildLaneStateKey(baseline);
7955
7965
  const normalized = {
7956
- schemaVersion: 1,
7966
+ schemaVersion: 2,
7957
7967
  key,
7958
7968
  repoRoot: baseline.repoRoot,
7959
7969
  repoFingerprint: baseline.repoFingerprint ?? null,
@@ -7962,6 +7972,8 @@ async function writeLocalBaseline(baseline) {
7962
7972
  branchName: baseline.branchName ?? null,
7963
7973
  lastSnapshotId: baseline.lastSnapshotId ?? null,
7964
7974
  lastSnapshotHash: baseline.lastSnapshotHash ?? null,
7975
+ lastServerRevisionId: baseline.lastServerRevisionId ?? null,
7976
+ lastServerTreeHash: baseline.lastServerTreeHash ?? null,
7965
7977
  lastServerHeadHash: baseline.lastServerHeadHash ?? null,
7966
7978
  lastSeenLocalCommitHash: baseline.lastSeenLocalCommitHash ?? null,
7967
7979
  updatedAt: baseline.updatedAt ?? (/* @__PURE__ */ new Date()).toISOString()
@@ -8266,6 +8278,7 @@ function normalizeJob2(input) {
8266
8278
  prompt: input.prompt,
8267
8279
  assistantResponse: input.assistantResponse,
8268
8280
  baselineSnapshotId: input.baselineSnapshotId ?? null,
8281
+ baselineServerRevisionId: input.baselineServerRevisionId ?? null,
8269
8282
  baselineServerHeadHash: input.baselineServerHeadHash ?? null,
8270
8283
  currentSnapshotId: input.currentSnapshotId,
8271
8284
  capturedAt: input.capturedAt ?? now,
@@ -8300,6 +8313,7 @@ async function readPendingFinalizeJob(jobId) {
8300
8313
  prompt: String(parsed.prompt ?? ""),
8301
8314
  assistantResponse: String(parsed.assistantResponse ?? ""),
8302
8315
  baselineSnapshotId: parsed.baselineSnapshotId ?? null,
8316
+ baselineServerRevisionId: parsed.baselineServerRevisionId ?? null,
8303
8317
  baselineServerHeadHash: parsed.baselineServerHeadHash ?? null,
8304
8318
  currentSnapshotId: String(parsed.currentSnapshotId ?? ""),
8305
8319
  capturedAt: parsed.capturedAt,
@@ -8801,6 +8815,8 @@ function buildBaseState() {
8801
8815
  branchName: null,
8802
8816
  localCommitHash: null,
8803
8817
  currentSnapshotHash: null,
8818
+ currentServerRevisionId: null,
8819
+ currentServerTreeHash: null,
8804
8820
  currentServerHeadHash: null,
8805
8821
  currentServerHeadCommitId: null,
8806
8822
  worktreeClean: false,
@@ -8834,6 +8850,8 @@ function buildBaseState() {
8834
8850
  baseline: {
8835
8851
  lastSnapshotId: null,
8836
8852
  lastSnapshotHash: null,
8853
+ lastServerRevisionId: null,
8854
+ lastServerTreeHash: null,
8837
8855
  lastServerHeadHash: null,
8838
8856
  lastSeenLocalCommitHash: null
8839
8857
  }
@@ -8960,6 +8978,8 @@ async function collabDetectRepoState(params) {
8960
8978
  summarizeAsyncJobs({ repoRoot, branchName: binding.branchName ?? null })
8961
8979
  ]);
8962
8980
  const appHead = unwrapResponseObject(headResp, "app head");
8981
+ detected.currentServerRevisionId = appHead.headRevisionId ?? null;
8982
+ detected.currentServerTreeHash = appHead.treeHash ?? null;
8963
8983
  detected.currentServerHeadHash = appHead.headCommitHash;
8964
8984
  detected.currentServerHeadCommitId = appHead.headCommitId;
8965
8985
  detected.currentSnapshotHash = inspection.snapshotHash;
@@ -8968,6 +8988,8 @@ async function collabDetectRepoState(params) {
8968
8988
  detected.baseline = {
8969
8989
  lastSnapshotId: baseline?.lastSnapshotId ?? null,
8970
8990
  lastSnapshotHash: baseline?.lastSnapshotHash ?? null,
8991
+ lastServerRevisionId: baseline?.lastServerRevisionId ?? null,
8992
+ lastServerTreeHash: baseline?.lastServerTreeHash ?? null,
8971
8993
  lastServerHeadHash: baseline?.lastServerHeadHash ?? null,
8972
8994
  lastSeenLocalCommitHash: baseline?.lastSeenLocalCommitHash ?? null
8973
8995
  };
@@ -8977,6 +8999,7 @@ async function collabDetectRepoState(params) {
8977
8999
  const bootstrapResp = await params.api.getAppDelta(binding.currentAppId, {
8978
9000
  baseHeadHash: localCommitHash,
8979
9001
  targetHeadHash: appHead.headCommitHash,
9002
+ targetRevisionId: appHead.headRevisionId,
8980
9003
  repoFingerprint: binding.repoFingerprint ?? void 0,
8981
9004
  remoteUrl: binding.remoteUrl ?? void 0,
8982
9005
  defaultBranch: binding.defaultBranch ?? void 0
@@ -8999,7 +9022,7 @@ async function collabDetectRepoState(params) {
8999
9022
  }
9000
9023
  }
9001
9024
  detected.repoState = "external_local_base_changed";
9002
- detected.hint = "No local Remix baseline exists for this lane yet. Run `remix collab re-anchor` to anchor this checkout.";
9025
+ detected.hint = "No local Remix revision baseline exists for this lane yet. Run `remix collab init` or sync this lane to seed the baseline.";
9003
9026
  return detected;
9004
9027
  }
9005
9028
  const localHeadMovedSinceBaseline = Boolean(baseline.lastSeenLocalCommitHash) && localCommitHash !== baseline.lastSeenLocalCommitHash;
@@ -9018,7 +9041,30 @@ async function collabDetectRepoState(params) {
9018
9041
  return detected;
9019
9042
  }
9020
9043
  const localChanged = inspection.snapshotHash !== baseline.lastSnapshotHash;
9021
- const serverChanged = appHead.headCommitHash !== baseline.lastServerHeadHash;
9044
+ const serverHeadChanged = appHead.headCommitHash !== baseline.lastServerHeadHash;
9045
+ const revisionChanged = Boolean(
9046
+ baseline.lastServerRevisionId && (appHead.headRevisionId ?? null) !== baseline.lastServerRevisionId
9047
+ );
9048
+ const equivalentRevisionDrift = revisionChanged && !serverHeadChanged;
9049
+ if (equivalentRevisionDrift) {
9050
+ await writeLocalBaseline({
9051
+ repoRoot,
9052
+ repoFingerprint: binding.repoFingerprint,
9053
+ laneId: binding.laneId,
9054
+ currentAppId: binding.currentAppId,
9055
+ branchName: binding.branchName,
9056
+ lastSnapshotId: baseline.lastSnapshotId,
9057
+ lastSnapshotHash: baseline.lastSnapshotHash,
9058
+ lastServerRevisionId: appHead.headRevisionId ?? null,
9059
+ lastServerTreeHash: appHead.treeHash ?? baseline.lastServerTreeHash ?? null,
9060
+ lastServerHeadHash: appHead.headCommitHash,
9061
+ lastSeenLocalCommitHash: baseline.lastSeenLocalCommitHash
9062
+ });
9063
+ detected.baseline.lastServerRevisionId = appHead.headRevisionId ?? null;
9064
+ detected.baseline.lastServerTreeHash = appHead.treeHash ?? baseline.lastServerTreeHash ?? null;
9065
+ detected.baseline.lastServerHeadHash = appHead.headCommitHash;
9066
+ }
9067
+ const serverChanged = serverHeadChanged;
9022
9068
  if (!localChanged && !serverChanged) {
9023
9069
  detected.repoState = "idle";
9024
9070
  return detected;
@@ -9442,6 +9488,7 @@ function buildWorkspaceMetadata(params) {
9442
9488
  recordingMode: "boundary_delta",
9443
9489
  baselineSnapshotId: params.baselineSnapshotId,
9444
9490
  currentSnapshotId: params.currentSnapshotId,
9491
+ baselineServerRevisionId: params.baselineServerRevisionId ?? null,
9445
9492
  baselineServerHeadHash: params.baselineServerHeadHash,
9446
9493
  currentSnapshotHash: params.currentSnapshotHash,
9447
9494
  localCommitHash: params.localCommitHash,
@@ -9520,12 +9567,12 @@ async function processClaimedPendingFinalizeJobInner(params) {
9520
9567
  throw buildFinalizeCliError({
9521
9568
  message: "Local baseline is missing for this queued finalize job.",
9522
9569
  exitCode: 2,
9523
- hint: "Run `remix collab re-anchor` to anchor the repository again.",
9570
+ hint: "Run `remix collab init` to seed this checkout's revision baseline.",
9524
9571
  disposition: "terminal",
9525
9572
  reason: "baseline_missing"
9526
9573
  });
9527
9574
  }
9528
- const baselineDrifted = baseline.lastSnapshotId !== job.baselineSnapshotId || baseline.lastServerHeadHash !== job.baselineServerHeadHash;
9575
+ const baselineDrifted = baseline.lastSnapshotId !== job.baselineSnapshotId || (job.baselineServerRevisionId ? baseline.lastServerRevisionId !== job.baselineServerRevisionId : false) || baseline.lastServerHeadHash !== job.baselineServerHeadHash;
9529
9576
  const appHead = unwrapResponseObject(appHeadResp, "app head");
9530
9577
  const remoteUrl = readMetadataString(job, "remoteUrl");
9531
9578
  const defaultBranch = readMetadataString(job, "defaultBranch");
@@ -9548,12 +9595,13 @@ async function processClaimedPendingFinalizeJobInner(params) {
9548
9595
  throw buildFinalizeCliError({
9549
9596
  message: "Finalize queue baseline drifted before this job was processed.",
9550
9597
  exitCode: 1,
9551
- hint: "Process queued finalize jobs in capture order, or re-anchor the repository before retrying.",
9598
+ hint: "Process queued finalize jobs in capture order, or run `remix collab init` to refresh the revision baseline before retrying.",
9552
9599
  disposition: "terminal",
9553
9600
  reason: "baseline_drifted"
9554
9601
  });
9555
9602
  }
9556
- if (appHead.headCommitHash !== job.baselineServerHeadHash) {
9603
+ const serverStillAtBaseline = job.baselineServerRevisionId ? appHead.headRevisionId === job.baselineServerRevisionId : appHead.headCommitHash === job.baselineServerHeadHash;
9604
+ if (!serverStillAtBaseline) {
9557
9605
  throw buildFinalizeCliError({
9558
9606
  message: "Server lane changed before a no-diff turn could be recorded.",
9559
9607
  exitCode: 2,
@@ -9575,6 +9623,7 @@ async function processClaimedPendingFinalizeJobInner(params) {
9575
9623
  defaultBranch,
9576
9624
  baselineSnapshotId: job.baselineSnapshotId,
9577
9625
  currentSnapshotId: job.currentSnapshotId,
9626
+ baselineServerRevisionId: job.baselineServerRevisionId,
9578
9627
  baselineServerHeadHash: job.baselineServerHeadHash,
9579
9628
  currentSnapshotHash: snapshot.snapshotHash,
9580
9629
  localCommitHash: snapshot.localCommitHash,
@@ -9595,6 +9644,8 @@ async function processClaimedPendingFinalizeJobInner(params) {
9595
9644
  branchName: job.branchName,
9596
9645
  lastSnapshotId: snapshot.id,
9597
9646
  lastSnapshotHash: snapshot.snapshotHash,
9647
+ lastServerRevisionId: appHead.headRevisionId ?? null,
9648
+ lastServerTreeHash: appHead.treeHash ?? null,
9598
9649
  lastServerHeadHash: appHead.headCommitHash,
9599
9650
  lastSeenLocalCommitHash: snapshot.localCommitHash
9600
9651
  });
@@ -9615,14 +9666,14 @@ async function processClaimedPendingFinalizeJobInner(params) {
9615
9666
  };
9616
9667
  }
9617
9668
  const localBaselineAdvanced = baseline.lastSnapshotId !== job.baselineSnapshotId;
9618
- const serverHeadAdvanced = appHead.headCommitHash !== job.baselineServerHeadHash;
9669
+ const serverHeadAdvanced = job.baselineServerRevisionId ? appHead.headRevisionId !== job.baselineServerRevisionId : appHead.headCommitHash !== job.baselineServerHeadHash;
9619
9670
  if (baselineDrifted) {
9620
9671
  const consistentAdvance = localBaselineAdvanced && serverHeadAdvanced;
9621
9672
  if (!consistentAdvance) {
9622
9673
  throw buildFinalizeCliError({
9623
9674
  message: `Finalize queue baseline advanced inconsistently before this job was processed (localBaselineAdvanced=${localBaselineAdvanced}, serverHeadAdvanced=${serverHeadAdvanced}, jobBaselineSnapshotId=${job.baselineSnapshotId ?? "null"}, liveBaselineSnapshotId=${baseline.lastSnapshotId ?? "null"}, jobBaselineServerHeadHash=${job.baselineServerHeadHash ?? "null"}, liveBaselineServerHeadHash=${baseline.lastServerHeadHash ?? "null"}, currentAppHeadHash=${appHead.headCommitHash}). This indicates local Remix state diverged from the backend in a way that should not be reachable in normal operation; please report this as a bug.`,
9624
9675
  exitCode: 1,
9625
- hint: "Run `remix collab status` to inspect, then `remix collab re-anchor` only if the lane has no valid baseline.",
9676
+ hint: "Run `remix collab status` to inspect, then sync or reconcile before retrying.",
9626
9677
  disposition: "terminal",
9627
9678
  reason: "baseline_drifted"
9628
9679
  });
@@ -9630,6 +9681,7 @@ async function processClaimedPendingFinalizeJobInner(params) {
9630
9681
  }
9631
9682
  let submissionDiff = diffResult.diff;
9632
9683
  let submissionBaseHeadHash = job.baselineServerHeadHash;
9684
+ let submissionBaseRevisionId = job.baselineServerRevisionId;
9633
9685
  let replayedFromBaseHash = null;
9634
9686
  if (!submissionBaseHeadHash) {
9635
9687
  throw buildFinalizeCliError({
@@ -9647,7 +9699,9 @@ async function processClaimedPendingFinalizeJobInner(params) {
9647
9699
  assistantResponse: job.assistantResponse,
9648
9700
  diff: diffResult.diff,
9649
9701
  baseCommitHash: submissionBaseHeadHash,
9702
+ baseRevisionId: job.baselineServerRevisionId,
9650
9703
  targetHeadCommitHash: appHead.headCommitHash,
9704
+ targetRevisionId: appHead.headRevisionId,
9651
9705
  expectedPaths: diffResult.changedPaths,
9652
9706
  actor,
9653
9707
  workspaceMetadata: buildWorkspaceMetadata({
@@ -9657,6 +9711,7 @@ async function processClaimedPendingFinalizeJobInner(params) {
9657
9711
  defaultBranch,
9658
9712
  baselineSnapshotId: job.baselineSnapshotId,
9659
9713
  currentSnapshotId: job.currentSnapshotId,
9714
+ baselineServerRevisionId: job.baselineServerRevisionId,
9660
9715
  baselineServerHeadHash: job.baselineServerHeadHash,
9661
9716
  currentSnapshotHash: snapshot.snapshotHash,
9662
9717
  localCommitHash: snapshot.localCommitHash,
@@ -9682,6 +9737,7 @@ async function processClaimedPendingFinalizeJobInner(params) {
9682
9737
  submissionDiff = replayDiff.diff;
9683
9738
  replayedFromBaseHash = submissionBaseHeadHash;
9684
9739
  submissionBaseHeadHash = appHead.headCommitHash;
9740
+ submissionBaseRevisionId = appHead.headRevisionId;
9685
9741
  } catch (error) {
9686
9742
  if (error instanceof RemixError && error.finalizeDisposition === void 0) {
9687
9743
  const detail = error.hint ? `${error.message} (${error.hint})` : error.message;
@@ -9703,6 +9759,7 @@ async function processClaimedPendingFinalizeJobInner(params) {
9703
9759
  assistantResponse: job.assistantResponse,
9704
9760
  diff: submissionDiff,
9705
9761
  baseCommitHash: submissionBaseHeadHash,
9762
+ baseRevisionId: submissionBaseRevisionId,
9706
9763
  headCommitHash: submissionBaseHeadHash,
9707
9764
  changedFilesCount: diffResult.stats.changedFilesCount,
9708
9765
  insertions: diffResult.stats.insertions,
@@ -9715,6 +9772,7 @@ async function processClaimedPendingFinalizeJobInner(params) {
9715
9772
  defaultBranch,
9716
9773
  baselineSnapshotId: job.baselineSnapshotId,
9717
9774
  currentSnapshotId: job.currentSnapshotId,
9775
+ baselineServerRevisionId: job.baselineServerRevisionId,
9718
9776
  baselineServerHeadHash: job.baselineServerHeadHash,
9719
9777
  currentSnapshotHash: snapshot.snapshotHash,
9720
9778
  localCommitHash: snapshot.localCommitHash,
@@ -9736,11 +9794,28 @@ async function processClaimedPendingFinalizeJobInner(params) {
9736
9794
  throw buildFinalizeCliError({
9737
9795
  message: "Backend returned a succeeded change step without a head commit hash.",
9738
9796
  exitCode: 1,
9739
- hint: "This is a backend invariant violation; retry will not help. Re-anchor and try again.",
9797
+ hint: "This is a backend invariant violation; retry will not help. Run `remix collab status` before trying again.",
9740
9798
  disposition: "terminal",
9741
9799
  reason: "missing_head_commit_hash"
9742
9800
  });
9743
9801
  }
9802
+ let nextServerRevisionId = typeof changeStep.resultRevisionId === "string" ? changeStep.resultRevisionId.trim() : "";
9803
+ let nextServerTreeHash = null;
9804
+ if (!nextServerRevisionId) {
9805
+ const freshHeadResp = await params.api.getAppHead(job.currentAppId);
9806
+ const freshHead = unwrapResponseObject(freshHeadResp, "app head");
9807
+ if (freshHead.headCommitHash !== nextServerHeadHash || !freshHead.headRevisionId) {
9808
+ throw buildFinalizeCliError({
9809
+ message: "Backend returned a succeeded change step without a matching result revision.",
9810
+ exitCode: 1,
9811
+ hint: "The local baseline was not advanced because the post-step revision could not be verified. Restart the backend/CLI and retry after checking `remix collab status`.",
9812
+ disposition: "terminal",
9813
+ reason: "missing_result_revision_id"
9814
+ });
9815
+ }
9816
+ nextServerRevisionId = freshHead.headRevisionId;
9817
+ nextServerTreeHash = freshHead.treeHash ?? null;
9818
+ }
9744
9819
  await writeLocalBaseline({
9745
9820
  repoRoot: job.repoRoot,
9746
9821
  repoFingerprint: job.repoFingerprint,
@@ -9749,6 +9824,8 @@ async function processClaimedPendingFinalizeJobInner(params) {
9749
9824
  branchName: job.branchName,
9750
9825
  lastSnapshotId: snapshot.id,
9751
9826
  lastSnapshotHash: snapshot.snapshotHash,
9827
+ lastServerRevisionId: nextServerRevisionId,
9828
+ lastServerTreeHash: nextServerTreeHash,
9752
9829
  lastServerHeadHash: nextServerHeadHash,
9753
9830
  lastSeenLocalCommitHash: snapshot.localCommitHash
9754
9831
  });
@@ -9780,6 +9857,7 @@ async function enqueueCapturedFinalizeTurn(params) {
9780
9857
  prompt: params.prompt,
9781
9858
  assistantResponse: params.assistantResponse,
9782
9859
  baselineSnapshotId: params.baselineSnapshotId,
9860
+ baselineServerRevisionId: params.baselineServerRevisionId ?? null,
9783
9861
  baselineServerHeadHash: params.baselineServerHeadHash,
9784
9862
  currentSnapshotId: params.currentSnapshotId,
9785
9863
  idempotencyKey: params.idempotencyKey,
@@ -9878,17 +9956,6 @@ async function collabFinalizeTurn(params) {
9878
9956
  });
9879
9957
  }
9880
9958
  }
9881
- const pendingReAnchor = await findPendingAsyncJob({
9882
- repoRoot,
9883
- branchName: binding.branchName ?? null,
9884
- kind: "re_anchor"
9885
- });
9886
- if (pendingReAnchor) {
9887
- throw new RemixError("Cannot finalize a turn while a re-anchor is still processing.", {
9888
- exitCode: 2,
9889
- hint: `Re-anchor job ${pendingReAnchor.id} is still in the background queue. Run \`remix collab status\` to check progress.`
9890
- });
9891
- }
9892
9959
  const detected = await collabDetectRepoState({
9893
9960
  api: params.api,
9894
9961
  cwd: repoRoot,
@@ -9929,9 +9996,16 @@ async function collabFinalizeTurn(params) {
9929
9996
  hint: detected.hint
9930
9997
  });
9931
9998
  }
9999
+ if (detected.repoState === "both_changed") {
10000
+ throw new RemixError("Local and server changes must be reconciled before finalizing this turn.", {
10001
+ code: "reconcile_required",
10002
+ exitCode: 2,
10003
+ hint: detected.hint || "Run `remix collab reconcile --dry-run` to inspect recovery options before retrying."
10004
+ });
10005
+ }
9932
10006
  if (detected.repoState === "external_local_base_changed") {
9933
- throw new RemixError("The local checkout must be re-anchored before finalizing this turn.", {
9934
- code: "re_anchor_required",
10007
+ throw new RemixError("The local checkout is missing a Remix revision baseline for this lane.", {
10008
+ code: "baseline_missing",
9935
10009
  exitCode: 2,
9936
10010
  hint: detected.hint
9937
10011
  });
@@ -9943,8 +10017,9 @@ async function collabFinalizeTurn(params) {
9943
10017
  });
9944
10018
  if (!baseline) {
9945
10019
  throw new RemixError("Local Remix baseline is missing for this lane.", {
10020
+ code: "baseline_missing",
9946
10021
  exitCode: 2,
9947
- hint: "Run `remix collab re-anchor` to create a fresh baseline."
10022
+ hint: "Run `remix collab init` or sync this lane to create a fresh revision baseline."
9948
10023
  });
9949
10024
  }
9950
10025
  const snapshot = await captureLocalSnapshot({
@@ -9955,10 +10030,11 @@ async function collabFinalizeTurn(params) {
9955
10030
  });
9956
10031
  const mode = snapshot.snapshotHash === baseline.lastSnapshotHash ? "no_diff_turn" : "changed_turn";
9957
10032
  const idempotencyKey = params.idempotencyKey?.trim() || buildDeterministicIdempotencyKey({
9958
- kind: "collab_finalize_turn_boundary_v1",
10033
+ kind: "collab_finalize_turn_boundary_v2",
9959
10034
  appId: binding.currentAppId,
9960
10035
  laneId: binding.laneId,
9961
10036
  baselineSnapshotId: baseline.lastSnapshotId,
10037
+ baselineServerRevisionId: baseline.lastServerRevisionId,
9962
10038
  baselineServerHeadHash: baseline.lastServerHeadHash,
9963
10039
  currentSnapshotId: snapshot.id,
9964
10040
  currentSnapshotHash: snapshot.snapshotHash,
@@ -9978,6 +10054,7 @@ async function collabFinalizeTurn(params) {
9978
10054
  prompt,
9979
10055
  assistantResponse,
9980
10056
  baselineSnapshotId: baseline.lastSnapshotId,
10057
+ baselineServerRevisionId: baseline.lastServerRevisionId,
9981
10058
  baselineServerHeadHash: baseline.lastServerHeadHash,
9982
10059
  currentSnapshotId: snapshot.id,
9983
10060
  idempotencyKey,
@@ -10024,9 +10101,10 @@ var FINALIZE_PREFLIGHT_FAILURE_CODES = [
10024
10101
  // Server has commits we don't. Fix: `remix collab sync` (safe to
10025
10102
  // auto-run for fast-forward; non-FF refused by the command itself).
10026
10103
  "pull_required",
10027
- // Local base hash doesn't match the recorded baseline (force-push,
10028
- // hard reset, rebase). Fix: `remix collab re-anchor`.
10029
- "re_anchor_required"
10104
+ // Both local and server changed. Fix: inspect and apply reconcile.
10105
+ "reconcile_required",
10106
+ // Local revision baseline is missing. Fix: `remix collab init` or sync.
10107
+ "baseline_missing"
10030
10108
  ];
10031
10109
  var CODE_SET = new Set(FINALIZE_PREFLIGHT_FAILURE_CODES);
10032
10110
  function isFinalizePreflightFailureCode(value) {
@@ -10438,7 +10516,7 @@ async function clearPendingTurnState(sessionId) {
10438
10516
  // package.json
10439
10517
  var package_default = {
10440
10518
  name: "@remixhq/claude-plugin",
10441
- version: "0.1.22",
10519
+ version: "0.1.23",
10442
10520
  description: "Claude Code plugin for Remix collaboration workflows",
10443
10521
  homepage: "https://github.com/RemixDotOne/remix-claude-plugin",
10444
10522
  license: "MIT",
@@ -10476,8 +10554,8 @@ var package_default = {
10476
10554
  prepack: "npm run build"
10477
10555
  },
10478
10556
  dependencies: {
10479
- "@remixhq/core": "^0.1.17",
10480
- "@remixhq/mcp": "^0.1.17"
10557
+ "@remixhq/core": "^0.1.18",
10558
+ "@remixhq/mcp": "^0.1.18"
10481
10559
  },
10482
10560
  devDependencies: {
10483
10561
  "@types/node": "^25.4.0",
@@ -10583,11 +10661,9 @@ var AUTO_FIX_COMMAND = {
10583
10661
  // include it here too so a finalize-time failure (e.g. binding got
10584
10662
  // deleted between init and the next finalize) also self-heals.
10585
10663
  branch_binding_missing: ["collab", "init"],
10586
- // External base diverged. Re-anchor declares the new local state as
10587
- // truth. Risky if the user hard-reset to the wrong commit, but the
10588
- // worst case is "Remix forgets the previous anchor" — recorded turns
10589
- // are preserved on the server, so this is recoverable.
10590
- re_anchor_required: ["collab", "re-anchor"],
10664
+ // Local revision baseline is missing. Init seeds the branch/lane baseline
10665
+ // without requiring the user to know about the recording internals.
10666
+ baseline_missing: ["collab", "init"],
10591
10667
  // Server moved ahead. `collab sync` is fast-forward-safe by default;
10592
10668
  // it refuses non-FF on its own, so we don't need to gate here.
10593
10669
  pull_required: ["collab", "sync"]
@@ -10604,7 +10680,7 @@ var RECOMMENDED_USER_COMMAND = {
10604
10680
  missing_head: "remix collab status",
10605
10681
  remote_error: "remix collab status",
10606
10682
  pull_required: "remix collab sync",
10607
- re_anchor_required: "remix collab re-anchor"
10683
+ baseline_missing: "remix collab init"
10608
10684
  };
10609
10685
  var SPAWN_LOCK_REL = (cmdSlug) => import_node_path9.default.join(".remix", `.${cmdSlug}-spawning`);
10610
10686
  var SPAWN_LOG_REL = (cmdSlug) => import_node_path9.default.join(".remix", `${cmdSlug}.log`);
@@ -10759,6 +10835,7 @@ var import_promises21 = __toESM(require("fs/promises"), 1);
10759
10835
  var import_node_os6 = __toESM(require("os"), 1);
10760
10836
  var import_node_path10 = __toESM(require("path"), 1);
10761
10837
  var DEFERRED_TURN_SCHEMA_VERSION = 1;
10838
+ var DEFERRED_TURN_MAX_ATTEMPTS = 10;
10762
10839
  var DEFERRED_TURN_TTL_MS = 24 * 60 * 60 * 1e3;
10763
10840
  var DEFERRED_TURN_DIR = "deferred-turns";
10764
10841
  function stateRoot2() {
@@ -10802,7 +10879,7 @@ async function readDeferredTurnFile(filePath) {
10802
10879
  if (!parsed || typeof parsed !== "object") return null;
10803
10880
  const record = parsed;
10804
10881
  if (record.schemaVersion !== DEFERRED_TURN_SCHEMA_VERSION) return null;
10805
- if (typeof record.sessionId !== "string" || typeof record.turnId !== "string" || typeof record.repoRoot !== "string" || typeof record.prompt !== "string" || typeof record.assistantResponse !== "string" || typeof record.submittedAt !== "string" || typeof record.deferredAt !== "string" || record.reason !== "current_branch_unbound" && record.reason !== "recovery_in_progress") {
10882
+ if (typeof record.sessionId !== "string" || typeof record.turnId !== "string" || typeof record.repoRoot !== "string" || typeof record.prompt !== "string" || typeof record.assistantResponse !== "string" || typeof record.submittedAt !== "string" || typeof record.deferredAt !== "string" || record.reason !== "current_branch_unbound" && record.reason !== "recovery_in_progress" && record.reason !== "transient_recording_failure") {
10806
10883
  return null;
10807
10884
  }
10808
10885
  return {
@@ -10815,7 +10892,17 @@ async function readDeferredTurnFile(filePath) {
10815
10892
  submittedAt: record.submittedAt,
10816
10893
  deferredAt: record.deferredAt,
10817
10894
  reason: record.reason,
10818
- branchAtDefer: typeof record.branchAtDefer === "string" || record.branchAtDefer === null ? record.branchAtDefer : null
10895
+ branchAtDefer: typeof record.branchAtDefer === "string" || record.branchAtDefer === null ? record.branchAtDefer : null,
10896
+ // Additive fields: pre-appId-aware records on disk won't have these
10897
+ // keys at all. Coerce missing/invalid to `null` (drainer treats
10898
+ // null as "legacy, drain as today" — see drainer for the policy).
10899
+ appIdAtDefer: typeof record.appIdAtDefer === "string" ? record.appIdAtDefer : null,
10900
+ projectIdAtDefer: typeof record.projectIdAtDefer === "string" ? record.projectIdAtDefer : null,
10901
+ // Pre-attemptCount records coerce to 0 — they've never been
10902
+ // counted against the cap, so giving them the cap's full budget
10903
+ // is correct (we'd rather over-retry a legacy record than drop it
10904
+ // unexpectedly). Negative or non-finite values also coerce to 0.
10905
+ attemptCount: typeof record.attemptCount === "number" && Number.isFinite(record.attemptCount) && record.attemptCount >= 0 ? Math.floor(record.attemptCount) : 0
10819
10906
  };
10820
10907
  }
10821
10908
  async function listDeferredTurnsForRepo(repoRoot) {
@@ -10878,16 +10965,35 @@ function buildDeferredTurnRecord(params) {
10878
10965
  submittedAt: params.submittedAt,
10879
10966
  deferredAt: (/* @__PURE__ */ new Date()).toISOString(),
10880
10967
  reason: params.reason ?? "current_branch_unbound",
10881
- branchAtDefer: params.branchAtDefer
10968
+ branchAtDefer: params.branchAtDefer,
10969
+ appIdAtDefer: params.appIdAtDefer ?? null,
10970
+ projectIdAtDefer: params.projectIdAtDefer ?? null,
10971
+ // Fresh records start at zero attempts — the next drain pass will
10972
+ // be the first attempt and bump this to 1 if it fails.
10973
+ attemptCount: 0
10882
10974
  };
10883
10975
  }
10976
+ async function recordDeferredTurnFailedAttempt(filePath) {
10977
+ const current = await readDeferredTurnFile(filePath);
10978
+ if (!current) {
10979
+ return { promoted: true, finalAttemptCount: DEFERRED_TURN_MAX_ATTEMPTS };
10980
+ }
10981
+ const newAttemptCount = current.attemptCount + 1;
10982
+ if (newAttemptCount >= DEFERRED_TURN_MAX_ATTEMPTS) {
10983
+ await deleteDeferredTurnFile(filePath);
10984
+ return { promoted: true, finalAttemptCount: newAttemptCount };
10985
+ }
10986
+ const next = { ...current, attemptCount: newAttemptCount };
10987
+ await writeDeferredTurn(next);
10988
+ return { promoted: false, newAttemptCount };
10989
+ }
10884
10990
 
10885
10991
  // src/deferred-turn-drainer.ts
10886
10992
  var import_promises23 = __toESM(require("fs/promises"), 1);
10887
10993
  var import_node_path11 = __toESM(require("path"), 1);
10888
10994
  var import_node_crypto3 = require("crypto");
10889
10995
 
10890
- // node_modules/@remixhq/core/dist/chunk-US5SM7ZC.js
10996
+ // node_modules/@remixhq/core/dist/chunk-RCNOSZP6.js
10891
10997
  async function readJsonSafe(res) {
10892
10998
  const ct = res.headers.get("content-type") ?? "";
10893
10999
  if (!ct.toLowerCase().includes("application/json")) return null;
@@ -10900,8 +11006,13 @@ async function readJsonSafe(res) {
10900
11006
  function createApiClient(config, opts) {
10901
11007
  const apiKey = (opts?.apiKey ?? "").trim();
10902
11008
  const tokenProvider = opts?.tokenProvider;
11009
+ const defaultTimeoutMs = typeof opts?.defaultRequestTimeoutMs === "number" && opts.defaultRequestTimeoutMs > 0 ? opts.defaultRequestTimeoutMs : null;
10903
11010
  const CLIENT_KEY_HEADER = "x-comerge-api-key";
10904
- async function request(path16, init) {
11011
+ function makeTimeoutSignal(timeoutMs) {
11012
+ const ms = typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : defaultTimeoutMs;
11013
+ return ms != null ? AbortSignal.timeout(ms) : void 0;
11014
+ }
11015
+ async function request(path16, init, opts2) {
10905
11016
  if (!tokenProvider) {
10906
11017
  throw new RemixError("API client is missing a token provider.", {
10907
11018
  exitCode: 1,
@@ -10912,6 +11023,7 @@ function createApiClient(config, opts) {
10912
11023
  const url = new URL(path16, config.apiUrl).toString();
10913
11024
  const doFetch = async (bearer) => fetch(url, {
10914
11025
  ...init,
11026
+ signal: makeTimeoutSignal(opts2?.timeoutMs),
10915
11027
  headers: {
10916
11028
  Accept: "application/json",
10917
11029
  "Content-Type": "application/json",
@@ -10928,12 +11040,16 @@ function createApiClient(config, opts) {
10928
11040
  if (!res.ok) {
10929
11041
  const body = await readJsonSafe(res);
10930
11042
  const msg = (body && typeof body === "object" && body && "message" in body && typeof body.message === "string" ? body.message : null) ?? `Request failed (status ${res.status})`;
10931
- throw new RemixError(msg, { exitCode: 1, hint: body ? JSON.stringify(body, null, 2) : null });
11043
+ throw new RemixError(msg, {
11044
+ exitCode: 1,
11045
+ hint: body ? JSON.stringify(body, null, 2) : null,
11046
+ statusCode: res.status
11047
+ });
10932
11048
  }
10933
11049
  const json = await readJsonSafe(res);
10934
11050
  return json ?? null;
10935
11051
  }
10936
- async function requestBinary(path16, init) {
11052
+ async function requestBinary(path16, init, opts2) {
10937
11053
  if (!tokenProvider) {
10938
11054
  throw new RemixError("API client is missing a token provider.", {
10939
11055
  exitCode: 1,
@@ -10944,6 +11060,7 @@ function createApiClient(config, opts) {
10944
11060
  const url = new URL(path16, config.apiUrl).toString();
10945
11061
  const doFetch = async (bearer) => fetch(url, {
10946
11062
  ...init,
11063
+ signal: makeTimeoutSignal(opts2?.timeoutMs),
10947
11064
  headers: {
10948
11065
  Accept: "*/*",
10949
11066
  ...init?.headers ?? {},
@@ -10959,7 +11076,11 @@ function createApiClient(config, opts) {
10959
11076
  if (!res.ok) {
10960
11077
  const body = await readJsonSafe(res);
10961
11078
  const msg = (body && typeof body === "object" && body && "message" in body && typeof body.message === "string" ? body.message : null) ?? `Request failed (status ${res.status})`;
10962
- throw new RemixError(msg, { exitCode: 1, hint: body ? JSON.stringify(body, null, 2) : null });
11079
+ throw new RemixError(msg, {
11080
+ exitCode: 1,
11081
+ hint: body ? JSON.stringify(body, null, 2) : null,
11082
+ statusCode: res.status
11083
+ });
10963
11084
  }
10964
11085
  const contentDisposition = res.headers.get("content-disposition") ?? "";
10965
11086
  const fileNameMatch = contentDisposition.match(/filename=\"([^\"]+)\"/i);
@@ -15354,7 +15475,7 @@ var coerce = {
15354
15475
  };
15355
15476
  var NEVER = INVALID;
15356
15477
 
15357
- // node_modules/@remixhq/core/dist/chunk-P6JHXOV4.js
15478
+ // node_modules/@remixhq/core/dist/chunk-XETDXVGM.js
15358
15479
  var import_promises22 = __toESM(require("fs/promises"), 1);
15359
15480
  var import_os3 = __toESM(require("os"), 1);
15360
15481
  var import_path7 = __toESM(require("path"), 1);
@@ -35299,7 +35420,7 @@ function shouldShowDeprecationWarning() {
35299
35420
  }
35300
35421
  if (shouldShowDeprecationWarning()) console.warn("\u26A0\uFE0F Node.js 18 and below are deprecated and will no longer be supported in future versions of @supabase/supabase-js. Please upgrade to Node.js 20 or later. For more information, visit: https://github.com/orgs/supabase/discussions/37217");
35301
35422
 
35302
- // node_modules/@remixhq/core/dist/chunk-P6JHXOV4.js
35423
+ // node_modules/@remixhq/core/dist/chunk-XETDXVGM.js
35303
35424
  var storedSessionSchema = external_exports.object({
35304
35425
  access_token: external_exports.string().min(1),
35305
35426
  refresh_token: external_exports.string().min(1),
@@ -35513,7 +35634,7 @@ function createSupabaseAuthHelpers(config) {
35513
35634
  };
35514
35635
  }
35515
35636
 
35516
- // node_modules/@remixhq/core/dist/chunk-VM3CGCNX.js
35637
+ // node_modules/@remixhq/core/dist/chunk-XCZRNB35.js
35517
35638
  var DEFAULT_API_URL = "https://api.remix.one";
35518
35639
  var DEFAULT_SUPABASE_URL = "https://xtfxwbckjpfmqubnsusu.supabase.co";
35519
35640
  var DEFAULT_SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inh0Znh3YmNranBmbXF1Ym5zdXN1Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjA2MDEyMzAsImV4cCI6MjA3NjE3NzIzMH0.dzWGAWrK4CvrmHVHzf8w7JlUZohdap0ZPnLZnABMV8s";
@@ -35551,6 +35672,7 @@ async function resolveConfig(_opts) {
35551
35672
  }
35552
35673
 
35553
35674
  // src/hook-auth.ts
35675
+ var HOOK_API_REQUEST_TIMEOUT_MS = 6e4;
35554
35676
  async function createHookCollabApiClient() {
35555
35677
  const config = await resolveConfig();
35556
35678
  const sessionStore = createLocalSessionStore();
@@ -35563,7 +35685,8 @@ async function createHookCollabApiClient() {
35563
35685
  }
35564
35686
  });
35565
35687
  return createApiClient(config, {
35566
- tokenProvider
35688
+ tokenProvider,
35689
+ defaultRequestTimeoutMs: HOOK_API_REQUEST_TIMEOUT_MS
35567
35690
  });
35568
35691
  }
35569
35692
 
@@ -35576,6 +35699,16 @@ var HOOK_ACTOR = {
35576
35699
  version: pluginMetadata.version,
35577
35700
  provider: "anthropic"
35578
35701
  };
35702
+ function getDrainerErrorDetails(error) {
35703
+ if (error instanceof Error) {
35704
+ const hint = typeof error.hint === "string" ? String(error.hint) : null;
35705
+ const codeRaw = error.code;
35706
+ const preflightCode = isFinalizePreflightFailureCode(codeRaw) ? codeRaw : null;
35707
+ return { message: error.message || "Deferred turn recording failed.", hint, preflightCode };
35708
+ }
35709
+ const message = typeof error === "string" && error.trim() ? error.trim() : "Deferred turn recording failed.";
35710
+ return { message, hint: null, preflightCode: null };
35711
+ }
35579
35712
  var DEFERRED_TURN_DRAIN_POLL_INTERVAL_MS = 3e3;
35580
35713
  var DEFERRED_TURN_DRAIN_MAX_WAIT_MS = 15 * 60 * 1e3;
35581
35714
  var DEFERRED_TURN_DRAIN_LOCK_HEARTBEAT_MS = 3e4;
@@ -35741,6 +35874,7 @@ async function runStandaloneDeferredTurnDrainer(repoRoot) {
35741
35874
  let api = null;
35742
35875
  let recordedTotal = 0;
35743
35876
  let failedTotal = 0;
35877
+ let droppedTotal = 0;
35744
35878
  let exitReason = "queue_empty";
35745
35879
  try {
35746
35880
  while (true) {
@@ -35771,7 +35905,49 @@ async function runStandaloneDeferredTurnDrainer(repoRoot) {
35771
35905
  const bindingState = await readCollabBindingState(repoRoot).catch(() => null);
35772
35906
  const currentBranch = bindingState?.currentBranch ?? null;
35773
35907
  const isCurrentBranchBound = bindingState?.binding != null;
35774
- const attemptable = entries.filter(
35908
+ const currentAppId = bindingState?.binding?.currentAppId ?? null;
35909
+ const currentProjectId = bindingState?.binding?.projectId ?? bindingState?.projectId ?? null;
35910
+ let droppedThisPass = 0;
35911
+ const liveEntries = [];
35912
+ for (const entry of entries) {
35913
+ const appIdMismatch = entry.record.appIdAtDefer != null && currentAppId != null && entry.record.appIdAtDefer !== currentAppId;
35914
+ const projectIdMismatch = entry.record.projectIdAtDefer != null && currentProjectId != null && entry.record.projectIdAtDefer !== currentProjectId;
35915
+ if (appIdMismatch || projectIdMismatch) {
35916
+ await deleteDeferredTurnFile(entry.filePath);
35917
+ droppedThisPass += 1;
35918
+ await appendHookDiagnosticsEvent({
35919
+ hook: "deferredTurnDrainer",
35920
+ sessionId: sessionMarker,
35921
+ stage: "deferred_turn_dropped",
35922
+ result: "info",
35923
+ reason: appIdMismatch ? "app_id_mismatch" : "project_id_mismatch",
35924
+ repoRoot,
35925
+ fields: {
35926
+ deferredTurnId: entry.record.turnId,
35927
+ deferredSessionId: entry.record.sessionId,
35928
+ appIdAtDefer: entry.record.appIdAtDefer,
35929
+ projectIdAtDefer: entry.record.projectIdAtDefer,
35930
+ currentAppId,
35931
+ currentProjectId
35932
+ }
35933
+ });
35934
+ continue;
35935
+ }
35936
+ liveEntries.push(entry);
35937
+ }
35938
+ if (droppedThisPass > 0) {
35939
+ droppedTotal += droppedThisPass;
35940
+ }
35941
+ if (liveEntries.length === 0) {
35942
+ const remaining = await listDeferredTurnsForRepo(repoRoot).catch(() => []);
35943
+ if (remaining.length === 0) {
35944
+ exitReason = "queue_empty";
35945
+ break;
35946
+ }
35947
+ await sleep5(DEFERRED_TURN_DRAIN_POLL_INTERVAL_MS);
35948
+ continue;
35949
+ }
35950
+ const attemptable = liveEntries.filter(
35775
35951
  (e) => isCurrentBranchBound && (!e.record.branchAtDefer || e.record.branchAtDefer === currentBranch)
35776
35952
  );
35777
35953
  if (attemptable.length === 0) {
@@ -35820,6 +35996,8 @@ async function runStandaloneDeferredTurnDrainer(repoRoot) {
35820
35996
  } else {
35821
35997
  failedThisPass += 1;
35822
35998
  failedTotal += 1;
35999
+ const outcome = await recordDeferredTurnFailedAttempt(entry.filePath).catch(() => null);
36000
+ const promoted = outcome?.promoted === true;
35823
36001
  await appendHookDiagnosticsEvent({
35824
36002
  hook: "deferredTurnDrainer",
35825
36003
  sessionId: sessionMarker,
@@ -35830,9 +36008,43 @@ async function runStandaloneDeferredTurnDrainer(repoRoot) {
35830
36008
  message: result.error instanceof Error ? result.error.message : String(result.error ?? ""),
35831
36009
  fields: {
35832
36010
  deferredTurnId: entry.record.turnId,
35833
- deferredSessionId: entry.record.sessionId
36011
+ deferredSessionId: entry.record.sessionId,
36012
+ attemptCount: outcome?.promoted === false ? outcome.newAttemptCount : outcome?.promoted === true ? outcome.finalAttemptCount : null,
36013
+ promoted
35834
36014
  }
35835
36015
  });
36016
+ if (promoted) {
36017
+ const errorDetails = getDrainerErrorDetails(result.error);
36018
+ await dispatchFinalizeFailure({
36019
+ // The dispatcher only knows about the two real Claude hook
36020
+ // entrypoints. The standalone drainer is logically a
36021
+ // post-Stop background process and the marker we're about
36022
+ // to write is consumed by the next prompt's UserPromptSubmit
36023
+ // hook, so attributing the failure to "Stop" matches what
36024
+ // the user will see.
36025
+ hook: "Stop",
36026
+ sessionId: sessionMarker,
36027
+ turnId: entry.record.turnId,
36028
+ repoRoot,
36029
+ preflightCode: errorDetails.preflightCode,
36030
+ message: `Deferred turn could not be recorded after ${outcome?.finalAttemptCount ?? "max"} attempts: ${errorDetails.message}`,
36031
+ hint: errorDetails.hint
36032
+ }).catch(async (dispatchErr) => {
36033
+ await appendHookDiagnosticsEvent({
36034
+ hook: "deferredTurnDrainer",
36035
+ sessionId: sessionMarker,
36036
+ stage: "deferred_turn_promotion_dispatch_failed",
36037
+ result: "error",
36038
+ reason: "exception",
36039
+ repoRoot,
36040
+ message: dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr),
36041
+ fields: {
36042
+ deferredTurnId: entry.record.turnId,
36043
+ deferredSessionId: entry.record.sessionId
36044
+ }
36045
+ });
36046
+ });
36047
+ }
35836
36048
  }
35837
36049
  }
35838
36050
  if (recordedThisPass > 0) {
@@ -35885,6 +36097,7 @@ async function runStandaloneDeferredTurnDrainer(repoRoot) {
35885
36097
  fields: {
35886
36098
  recordedTotal,
35887
36099
  failedTotal,
36100
+ droppedTotal,
35888
36101
  elapsedMs: Date.now() - startedAt
35889
36102
  }
35890
36103
  });
@@ -35931,6 +36144,20 @@ function spawnDeferredTurnDrainer(repoRoot) {
35931
36144
  child.unref();
35932
36145
  }
35933
36146
 
36147
+ // src/transient-failure.ts
36148
+ function isTransientRecordingFailure(error) {
36149
+ if (!error || typeof error !== "object") return false;
36150
+ if (error instanceof Error) {
36151
+ if (error.name === "AbortError" || error.name === "TimeoutError") return true;
36152
+ if (error instanceof TypeError && /fetch failed/i.test(error.message)) return true;
36153
+ }
36154
+ const candidate = error;
36155
+ if (typeof candidate.statusCode === "number" && candidate.statusCode >= 500 && candidate.statusCode < 600) {
36156
+ return true;
36157
+ }
36158
+ return false;
36159
+ }
36160
+
35934
36161
  // node_modules/@remixhq/core/dist/history.js
35935
36162
  var import_promises24 = __toESM(require("fs/promises"), 1);
35936
36163
  async function readAndParseTranscript(transcriptPath) {
@@ -36618,11 +36845,12 @@ function getErrorDetails(error) {
36618
36845
  return {
36619
36846
  message: error.message || "Fallback Remix turn recording failed.",
36620
36847
  hint,
36621
- preflightCode
36848
+ preflightCode,
36849
+ isTransient: isTransientRecordingFailure(error)
36622
36850
  };
36623
36851
  }
36624
36852
  const message = typeof error === "string" && error.trim() ? error.trim() : "Fallback Remix turn recording failed.";
36625
- return { message, hint: null, preflightCode: null };
36853
+ return { message, hint: null, preflightCode: null, isTransient: false };
36626
36854
  }
36627
36855
  function buildRepoIdempotencyKey(turnId, repo) {
36628
36856
  const repoToken = repo.currentAppId?.trim() || repo.repoRoot;
@@ -36934,7 +37162,12 @@ async function recordTouchedRepo(params) {
36934
37162
  // Equivalent to the not_bound preflight code — the binding
36935
37163
  // disappeared between touch-time and finalize-time. Reusing the
36936
37164
  // code lets the dispatcher route this through the same recovery.
36937
- preflightCode: "not_bound"
37165
+ preflightCode: "not_bound",
37166
+ // Missing-binding is a permanent state mismatch (the user
37167
+ // unbinded mid-flight); not transient. Spell it out so the
37168
+ // upstream loop routes via dispatchFinalizeFailure instead of
37169
+ // silent defer.
37170
+ isTransient: false
36938
37171
  };
36939
37172
  await markTouchedRepoRecordingFailure(sessionId, repo.repoRoot, {
36940
37173
  message: failure.message,
@@ -36994,7 +37227,11 @@ async function recordTouchedRepo(params) {
36994
37227
  message: details.message,
36995
37228
  fields: {
36996
37229
  hint: details.hint,
36997
- preflightCode: details.preflightCode
37230
+ preflightCode: details.preflightCode,
37231
+ // Logged so a hung backend or DNS hiccup is greppable in the
37232
+ // diagnostics file alongside the Cursor mirror — the next
37233
+ // prompt's drain log will pair with this for the recovery.
37234
+ isTransient: details.isTransient
36998
37235
  }
36999
37236
  });
37000
37237
  return {
@@ -37004,7 +37241,8 @@ async function recordTouchedRepo(params) {
37004
37241
  repoRoot: repo.repoRoot,
37005
37242
  message: details.message,
37006
37243
  hint: details.hint,
37007
- preflightCode: details.preflightCode
37244
+ preflightCode: details.preflightCode,
37245
+ isTransient: details.isTransient
37008
37246
  }
37009
37247
  };
37010
37248
  }
@@ -37042,6 +37280,8 @@ async function drainDeferredTurnsForRepo(params) {
37042
37280
  const bindingState = await readCollabBindingState(repoRoot).catch(() => null);
37043
37281
  const currentBranch = bindingState?.currentBranch ?? null;
37044
37282
  const isCurrentBranchBound = bindingState?.binding != null;
37283
+ const currentAppId = bindingState?.binding?.currentAppId ?? null;
37284
+ const currentProjectId = bindingState?.binding?.projectId ?? bindingState?.projectId ?? null;
37045
37285
  await appendHookDiagnosticsEvent({
37046
37286
  hook,
37047
37287
  sessionId,
@@ -37052,14 +37292,41 @@ async function drainDeferredTurnsForRepo(params) {
37052
37292
  fields: {
37053
37293
  candidateCount: entries.length,
37054
37294
  currentBranch,
37055
- currentBranchBound: isCurrentBranchBound
37295
+ currentBranchBound: isCurrentBranchBound,
37296
+ currentAppId,
37297
+ currentProjectId
37056
37298
  }
37057
37299
  });
37058
37300
  let recordedCount = 0;
37059
37301
  let skippedCount = 0;
37060
37302
  let failedCount = 0;
37303
+ let droppedCount = 0;
37061
37304
  for (const entry of entries) {
37062
37305
  const { record, filePath } = entry;
37306
+ const appIdMismatch = record.appIdAtDefer != null && currentAppId != null && record.appIdAtDefer !== currentAppId;
37307
+ const projectIdMismatch = record.projectIdAtDefer != null && currentProjectId != null && record.projectIdAtDefer !== currentProjectId;
37308
+ if (appIdMismatch || projectIdMismatch) {
37309
+ droppedCount += 1;
37310
+ await deleteDeferredTurnFile(filePath);
37311
+ await appendHookDiagnosticsEvent({
37312
+ hook,
37313
+ sessionId,
37314
+ turnId: triggerTurnId,
37315
+ stage: "deferred_turn_dropped",
37316
+ result: "info",
37317
+ reason: appIdMismatch ? "app_id_mismatch" : "project_id_mismatch",
37318
+ repoRoot,
37319
+ fields: {
37320
+ deferredTurnId: record.turnId,
37321
+ deferredSessionId: record.sessionId,
37322
+ appIdAtDefer: record.appIdAtDefer,
37323
+ projectIdAtDefer: record.projectIdAtDefer,
37324
+ currentAppId,
37325
+ currentProjectId
37326
+ }
37327
+ });
37328
+ continue;
37329
+ }
37063
37330
  if (!isCurrentBranchBound || record.branchAtDefer && record.branchAtDefer !== currentBranch) {
37064
37331
  skippedCount += 1;
37065
37332
  await appendHookDiagnosticsEvent({
@@ -37110,6 +37377,8 @@ async function drainDeferredTurnsForRepo(params) {
37110
37377
  });
37111
37378
  } catch (recordErr) {
37112
37379
  failedCount += 1;
37380
+ const outcome = await recordDeferredTurnFailedAttempt(filePath).catch(() => null);
37381
+ const promoted = outcome?.promoted === true;
37113
37382
  await appendHookDiagnosticsEvent({
37114
37383
  hook,
37115
37384
  sessionId,
@@ -37121,9 +37390,38 @@ async function drainDeferredTurnsForRepo(params) {
37121
37390
  message: recordErr instanceof Error ? recordErr.message : String(recordErr),
37122
37391
  fields: {
37123
37392
  deferredTurnId: record.turnId,
37124
- deferredSessionId: record.sessionId
37393
+ deferredSessionId: record.sessionId,
37394
+ attemptCount: outcome?.promoted === false ? outcome.newAttemptCount : outcome?.promoted === true ? outcome.finalAttemptCount : null,
37395
+ promoted
37125
37396
  }
37126
37397
  });
37398
+ if (promoted) {
37399
+ const errorDetails = getErrorDetails(recordErr);
37400
+ await dispatchFinalizeFailure({
37401
+ hook,
37402
+ sessionId,
37403
+ turnId: triggerTurnId,
37404
+ repoRoot,
37405
+ preflightCode: errorDetails.preflightCode,
37406
+ message: `Deferred turn could not be recorded after ${outcome?.finalAttemptCount ?? "max"} attempts: ${errorDetails.message}`,
37407
+ hint: errorDetails.hint
37408
+ }).catch(async (dispatchErr) => {
37409
+ await appendHookDiagnosticsEvent({
37410
+ hook,
37411
+ sessionId,
37412
+ turnId: triggerTurnId,
37413
+ stage: "deferred_turn_promotion_dispatch_failed",
37414
+ result: "error",
37415
+ reason: "exception",
37416
+ repoRoot,
37417
+ message: dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr),
37418
+ fields: {
37419
+ deferredTurnId: record.turnId,
37420
+ deferredSessionId: record.sessionId
37421
+ }
37422
+ });
37423
+ });
37424
+ }
37127
37425
  }
37128
37426
  }
37129
37427
  try {
@@ -37167,14 +37465,75 @@ async function drainDeferredTurnsForRepo(params) {
37167
37465
  fields: {
37168
37466
  recordedCount,
37169
37467
  skippedCount,
37170
- failedCount
37468
+ failedCount,
37469
+ droppedCount
37171
37470
  }
37172
37471
  });
37173
37472
  }
37473
+ async function deferTurnForTransientFailure(params) {
37474
+ const { hook, sessionId, turnId, repoRoot, prompt, assistantResponse, submittedAt, failureMessage } = params;
37475
+ const bindingState = await readCollabBindingState(repoRoot).catch(() => null);
37476
+ const branchAtDefer = bindingState?.currentBranch ?? null;
37477
+ const appIdAtDefer = bindingState?.binding?.currentAppId ?? null;
37478
+ const projectIdAtDefer = bindingState?.binding?.projectId ?? bindingState?.projectId ?? null;
37479
+ try {
37480
+ const deferredFilePath = await writeDeferredTurn(
37481
+ buildDeferredTurnRecord({
37482
+ sessionId,
37483
+ turnId,
37484
+ repoRoot,
37485
+ prompt,
37486
+ assistantResponse,
37487
+ submittedAt,
37488
+ branchAtDefer,
37489
+ appIdAtDefer,
37490
+ projectIdAtDefer,
37491
+ reason: "transient_recording_failure"
37492
+ })
37493
+ );
37494
+ await appendHookDiagnosticsEvent({
37495
+ hook,
37496
+ sessionId,
37497
+ turnId,
37498
+ stage: "turn_deferred",
37499
+ result: "success",
37500
+ reason: "transient_recording_failure",
37501
+ repoRoot,
37502
+ fields: {
37503
+ deferredFilePath,
37504
+ promptLength: prompt.length,
37505
+ assistantResponseLength: assistantResponse.length,
37506
+ branchAtDefer,
37507
+ // Forwarded so the diagnostics timeline pairs the defer with
37508
+ // the originating recording_failed event without needing a
37509
+ // join across stages.
37510
+ failureMessage
37511
+ }
37512
+ });
37513
+ return deferredFilePath;
37514
+ } catch (deferErr) {
37515
+ await appendHookDiagnosticsEvent({
37516
+ hook,
37517
+ sessionId,
37518
+ turnId,
37519
+ stage: "deferred_turn_write_failed",
37520
+ result: "error",
37521
+ reason: "exception",
37522
+ repoRoot,
37523
+ message: deferErr instanceof Error ? deferErr.message : String(deferErr),
37524
+ fields: {
37525
+ triggeredBy: "transient_recording_failure"
37526
+ }
37527
+ });
37528
+ return null;
37529
+ }
37530
+ }
37174
37531
  async function deferTurnForRecoveryInProgress(params) {
37175
37532
  const { hook, sessionId, turnId, repoRoot, prompt, assistantResponse, submittedAt, preflightCode } = params;
37176
37533
  const bindingState = await readCollabBindingState(repoRoot).catch(() => null);
37177
37534
  const branchAtDefer = bindingState?.currentBranch ?? null;
37535
+ const appIdAtDefer = bindingState?.binding?.currentAppId ?? null;
37536
+ const projectIdAtDefer = bindingState?.binding?.projectId ?? bindingState?.projectId ?? null;
37178
37537
  try {
37179
37538
  const deferredFilePath = await writeDeferredTurn(
37180
37539
  buildDeferredTurnRecord({
@@ -37185,6 +37544,8 @@ async function deferTurnForRecoveryInProgress(params) {
37185
37544
  assistantResponse,
37186
37545
  submittedAt,
37187
37546
  branchAtDefer,
37547
+ appIdAtDefer,
37548
+ projectIdAtDefer,
37188
37549
  reason: "recovery_in_progress"
37189
37550
  })
37190
37551
  );
@@ -37299,6 +37660,7 @@ async function runHookStopCollab(payload) {
37299
37660
  let unboundBranchRepoRoot = null;
37300
37661
  let unboundBranchName = null;
37301
37662
  let unboundBranchKnownCount = 0;
37663
+ let unboundProjectIdAtDefer = null;
37302
37664
  const candidateRepoRoot = await findBoundRepo(state.initialCwd).catch(() => null);
37303
37665
  if (candidateRepoRoot) {
37304
37666
  const bindingState = await readCollabBindingState(candidateRepoRoot).catch(() => null);
@@ -37308,6 +37670,7 @@ async function runHookStopCollab(payload) {
37308
37670
  unboundBranchRepoRoot = candidateRepoRoot;
37309
37671
  unboundBranchName = bindingState.currentBranch;
37310
37672
  unboundBranchKnownCount = knownBoundBranches.length;
37673
+ unboundProjectIdAtDefer = bindingState.projectId ?? null;
37311
37674
  }
37312
37675
  }
37313
37676
  const promptTextForDefer = state.prompt.trim();
@@ -37323,7 +37686,12 @@ async function runHookStopCollab(payload) {
37323
37686
  prompt: promptTextForDefer,
37324
37687
  assistantResponse: assistantResponseForDefer,
37325
37688
  submittedAt: state.submittedAt,
37326
- branchAtDefer: unboundBranchName
37689
+ branchAtDefer: unboundBranchName,
37690
+ // No appId for an unbound lane (the binding is null
37691
+ // by construction); project id still anchors against
37692
+ // `force-new`-style identity rotations.
37693
+ appIdAtDefer: null,
37694
+ projectIdAtDefer: unboundProjectIdAtDefer
37327
37695
  })
37328
37696
  );
37329
37697
  } catch (deferErr) {
@@ -37539,6 +37907,22 @@ async function runHookStopCollab(payload) {
37539
37907
  let deferredFailureCount = 0;
37540
37908
  let dispatchFailureCount = 0;
37541
37909
  for (const failure of failures) {
37910
+ if (failure.isTransient) {
37911
+ const deferredFilePath = await deferTurnForTransientFailure({
37912
+ hook,
37913
+ sessionId,
37914
+ turnId: state.turnId,
37915
+ repoRoot: failure.repoRoot,
37916
+ prompt,
37917
+ assistantResponse,
37918
+ submittedAt: state.submittedAt,
37919
+ failureMessage: failure.message
37920
+ });
37921
+ if (deferredFilePath) {
37922
+ deferredFailureCount += 1;
37923
+ }
37924
+ continue;
37925
+ }
37542
37926
  const outcome = await dispatchFinalizeFailure({
37543
37927
  hook: "Stop",
37544
37928
  sessionId,