@khalilgharbaoui/opencode-claude-code-plugin 0.45.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -433,6 +433,8 @@ interface ClaudeCodeConfig {
433
433
  forkSessions?: boolean;
434
434
  /** Resume a conversation's Claude session after opencode restarts. Default true. */
435
435
  resumeAfterRestart?: boolean;
436
+ /** Carry a conversation's Claude session over when its model or effort changes. Default true. */
437
+ resumeAcrossModelChanges?: boolean;
436
438
  logging?: LoggingConfig;
437
439
  }
438
440
  interface LoggingConfig {
@@ -829,6 +831,31 @@ interface ClaudeCodeProviderSettings {
829
831
  * before. `false` never reads or writes the file.
830
832
  */
831
833
  resumeAfterRestart?: boolean;
834
+ /**
835
+ * Carry a conversation's Claude session over when the operator changes its
836
+ * model or its reasoning effort mid-conversation, instead of replaying the
837
+ * whole thread as text into a fresh one. Default `true`.
838
+ *
839
+ * Both the model and the effort level are in the session key, so changing
840
+ * either lands the same opencode conversation on a key nothing has ever
841
+ * answered. That was the other half of issue #91: at least 18 replays in
842
+ * one ~15-hour session. Neither is a reason to start a new Claude
843
+ * conversation, because both are spawn-time choices (`--model`,
844
+ * `CLAUDE_CODE_EFFORT_LEVEL`) that the CLI applies to a transcript it
845
+ * resumes, measured on 2.1.288.
846
+ *
847
+ * It takes the Claude session off the sibling key rather than sharing it, so
848
+ * one conversation is owned by exactly one key, and it is refused on every
849
+ * term `resumeAfterRestart` is refused on (another account, a transcript
850
+ * that is gone, a conversation that has changed) plus two of its own: a
851
+ * sibling whose transcript may still be written to, and anything that is not
852
+ * the same cwd, request scope, opencode session, agent and prompt-cache TTL.
853
+ * A compaction turn and an account-failover switch never take one.
854
+ *
855
+ * It reads the record `resumeAfterRestart` writes, so `resumeAfterRestart:
856
+ * false` turns this off too.
857
+ */
858
+ resumeAcrossModelChanges?: boolean;
832
859
  /**
833
860
  * Routing for Claude's built-in `WebSearch` tool.
834
861
  *
package/dist/index.js CHANGED
@@ -916,6 +916,10 @@ function formatResultFailureNote(message) {
916
916
  ${RESULT_ERROR_MARKER} ${message}
917
917
  `;
918
918
  }
919
+ function formatCompactionFailure(cause, detail) {
920
+ const said = detail?.trim();
921
+ return `Claude Code could not compact this conversation (${cause}), so no summary was produced.` + (said ? ` The CLI said: ${said}` : "");
922
+ }
919
923
  var STREAM_TIMEOUT_MARKER = "\u258C **stream timeout:**";
920
924
  function formatStreamTimeoutNote(silenceMs) {
921
925
  const seconds = Math.max(1, Math.round(silenceMs / 1e3));
@@ -4250,7 +4254,8 @@ function messageDigest(msg) {
4250
4254
  }
4251
4255
  return hash.digest("hex").slice(0, 32);
4252
4256
  }
4253
- function conversationDigests(prompt) {
4257
+ var ASSISTANT_TURN_DIGEST = "assistant-turn";
4258
+ function conversationDigests(prompt, opts) {
4254
4259
  const out = [];
4255
4260
  for (const msg of prompt) {
4256
4261
  if (msg.role !== "user" && msg.role !== "assistant" && msg.role !== "tool") {
@@ -4258,7 +4263,7 @@ function conversationDigests(prompt) {
4258
4263
  }
4259
4264
  out.push({
4260
4265
  role: msg.role,
4261
- digest: messageDigest(msg)
4266
+ digest: opts?.assistantContent === false && msg.role === "assistant" ? ASSISTANT_TURN_DIGEST : messageDigest(msg)
4262
4267
  });
4263
4268
  }
4264
4269
  return out;
@@ -4285,7 +4290,24 @@ function forkSiblingSignature(sessionKey2) {
4285
4290
  blanked[3] = "*";
4286
4291
  return blanked.join("::");
4287
4292
  }
4288
- function splitForkHistory(prompt) {
4293
+ function modelSiblingSignature(sessionKey2) {
4294
+ const parts = sessionKey2.split("::");
4295
+ if (parts.length < 5) return null;
4296
+ if (parts[2] === "compaction") return null;
4297
+ const withoutEffort = parts[parts.length - 1].startsWith("effort=") ? parts.slice(0, -1) : parts;
4298
+ if (withoutEffort.length < 5) return null;
4299
+ const blanked = [...withoutEffort];
4300
+ blanked[1] = "*";
4301
+ return blanked.join("::");
4302
+ }
4303
+ function moveForkFingerprint(fromKey, toKey) {
4304
+ const record = fingerprints.get(fromKey);
4305
+ if (!record) return;
4306
+ fingerprints.delete(fromKey);
4307
+ fingerprints.delete(toKey);
4308
+ fingerprints.set(toKey, { ...record, seq: ++fingerprintSeq });
4309
+ }
4310
+ function splitForkHistory(prompt, opts) {
4289
4311
  const messages = prompt;
4290
4312
  let lastAssistant = -1;
4291
4313
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -4304,7 +4326,7 @@ function splitForkHistory(prompt) {
4304
4326
  }
4305
4327
  }
4306
4328
  return {
4307
- history: conversationDigests(messages.slice(0, lastAssistant + 1)),
4329
+ history: conversationDigests(messages.slice(0, lastAssistant + 1), opts),
4308
4330
  forkable: true
4309
4331
  };
4310
4332
  }
@@ -4343,8 +4365,8 @@ function findForkParent(opts) {
4343
4365
  }
4344
4366
  return best;
4345
4367
  }
4346
- function continuesRecordedConversation(recorded, prompt) {
4347
- const { history, forkable } = splitForkHistory(prompt);
4368
+ function continuesRecordedConversation(recorded, prompt, opts) {
4369
+ const { history, forkable } = splitForkHistory(prompt, opts);
4348
4370
  if (!forkable || recorded.length === 0 || recorded.length > history.length) return false;
4349
4371
  return isPrefix(recorded, history) && tailIsReplyOnly(history, recorded.length);
4350
4372
  }
@@ -4381,7 +4403,7 @@ function isDigest(value) {
4381
4403
  }
4382
4404
  function isRecord2(value) {
4383
4405
  const record = value;
4384
- return !!record && typeof record.claudeSessionId === "string" && typeof record.cliPath === "string" && typeof record.updatedAt === "number" && Array.isArray(record.chain) && record.chain.every(isDigest);
4406
+ return !!record && typeof record.claudeSessionId === "string" && typeof record.cliPath === "string" && typeof record.updatedAt === "number" && Array.isArray(record.chain) && record.chain.every(isDigest) && (record.shape === void 0 || Array.isArray(record.shape) && record.shape.every(isDigest));
4385
4407
  }
4386
4408
  function load() {
4387
4409
  if (records) return records;
@@ -4430,13 +4452,14 @@ function save(update) {
4430
4452
  function recordResumePoint(sessionKey2, claudeSessionId, prompt, cliPath) {
4431
4453
  const chain = conversationDigests(prompt);
4432
4454
  if (chain.length === 0) return;
4455
+ const shape2 = conversationDigests(prompt, { assistantContent: false });
4433
4456
  const current = load().get(sessionKey2);
4434
- if (current && current.claudeSessionId === claudeSessionId && current.cliPath === cliPath && current.chain.length === chain.length && current.chain.every((entry, i) => entry.digest === chain[i].digest)) {
4457
+ if (current && current.claudeSessionId === claudeSessionId && current.cliPath === cliPath && current.shape !== void 0 && current.chain.length === chain.length && current.chain.every((entry, i) => entry.digest === chain[i].digest)) {
4435
4458
  return;
4436
4459
  }
4437
4460
  save((merged) => {
4438
4461
  merged.delete(sessionKey2);
4439
- merged.set(sessionKey2, { claudeSessionId, cliPath, chain, updatedAt: Date.now() });
4462
+ merged.set(sessionKey2, { claudeSessionId, cliPath, chain, shape: shape2, updatedAt: Date.now() });
4440
4463
  });
4441
4464
  }
4442
4465
  function forgetResumePoint(sessionKey2) {
@@ -4445,25 +4468,84 @@ function forgetResumePoint(sessionKey2) {
4445
4468
  merged.delete(sessionKey2);
4446
4469
  });
4447
4470
  }
4471
+ function moveResumePoint(fromKey, toKey) {
4472
+ const record = load().get(fromKey);
4473
+ if (!record) return;
4474
+ save((merged) => {
4475
+ merged.delete(fromKey);
4476
+ if (merged.has(toKey)) return;
4477
+ merged.set(toKey, { ...record, updatedAt: Date.now() });
4478
+ });
4479
+ }
4448
4480
  function findResumePoint(opts) {
4449
4481
  const record = load().get(opts.sessionKey);
4450
- if (!record) return void 0;
4482
+ if (!record) {
4483
+ opts.onRefused?.("no-session-recorded");
4484
+ return void 0;
4485
+ }
4451
4486
  const refuse = (reason) => {
4452
4487
  log.info("not resuming the claude session from before the restart", {
4453
4488
  sessionKey: opts.sessionKey,
4454
4489
  reason
4455
4490
  });
4491
+ opts.onRefused?.(reason);
4456
4492
  return void 0;
4457
4493
  };
4458
- if (record.cliPath !== opts.cliPath) return refuse("another claude binary");
4494
+ if (record.cliPath !== opts.cliPath) return refuse("another-claude-binary");
4459
4495
  if (!continuesRecordedConversation(record.chain, opts.prompt)) {
4460
- return refuse("the conversation changed");
4496
+ return refuse("conversation-changed");
4461
4497
  }
4462
4498
  if (!existsSync2(opts.transcriptPath(record.claudeSessionId))) {
4463
- return refuse("the transcript is gone");
4499
+ return refuse("transcript-gone");
4464
4500
  }
4465
4501
  return { claudeSessionId: record.claudeSessionId, matched: record.chain.length };
4466
4502
  }
4503
+ function findSiblingResumePoint(opts) {
4504
+ const signature = modelSiblingSignature(opts.sessionKey);
4505
+ if (!signature) {
4506
+ opts.onRefused?.("no-sibling-possible");
4507
+ return void 0;
4508
+ }
4509
+ let best;
4510
+ let bestUpdatedAt = -1;
4511
+ let refusal;
4512
+ for (const [key, record] of load()) {
4513
+ if (key === opts.sessionKey) continue;
4514
+ if (modelSiblingSignature(key) !== signature) continue;
4515
+ if (record.cliPath !== opts.cliPath) {
4516
+ refusal = "sibling-another-claude-binary";
4517
+ continue;
4518
+ }
4519
+ if (!record.shape || !continuesRecordedConversation(record.shape, opts.prompt, {
4520
+ assistantContent: false
4521
+ })) {
4522
+ refusal = "sibling-conversation-changed";
4523
+ continue;
4524
+ }
4525
+ if (opts.isBusy(key)) {
4526
+ refusal = "sibling-still-writing";
4527
+ continue;
4528
+ }
4529
+ if (!existsSync2(opts.transcriptPath(record.claudeSessionId))) {
4530
+ refusal = "sibling-transcript-gone";
4531
+ continue;
4532
+ }
4533
+ if (record.updatedAt >= bestUpdatedAt) {
4534
+ bestUpdatedAt = record.updatedAt;
4535
+ best = {
4536
+ claudeSessionId: record.claudeSessionId,
4537
+ siblingKey: key,
4538
+ matched: record.chain.length
4539
+ };
4540
+ }
4541
+ }
4542
+ if (!best) {
4543
+ opts.onRefused?.(
4544
+ refusal ?? "no-sibling-recorded"
4545
+ );
4546
+ }
4547
+ return best;
4548
+ }
4467
4549
 
4468
4550
  // src/permission-presets.ts
4469
4551
  var READ_ONLY_DISALLOWED_CLI_TOOLS = [
@@ -5164,6 +5246,23 @@ function deleteClaudeSessionId(key) {
5164
5246
  forgetForkFingerprint(key);
5165
5247
  forgetResumePoint(key);
5166
5248
  }
5249
+ function transferClaudeSession(fromKey, toKey) {
5250
+ if (fromKey === toKey) return void 0;
5251
+ const claudeSessionId = claudeSessions.get(fromKey);
5252
+ if (!claudeSessionId) return void 0;
5253
+ if (claudeSessions.has(toKey)) return void 0;
5254
+ claudeSessions.delete(fromKey);
5255
+ setClaudeSessionId(toKey, claudeSessionId);
5256
+ moveResumePoint(fromKey, toKey);
5257
+ moveForkFingerprint(fromKey, toKey);
5258
+ clearExitPlanModeQuestions(fromKey);
5259
+ clearAccountFailoverQuestions(fromKey);
5260
+ log.notice("carrying the claude conversation over to the new session key", {
5261
+ fromKey,
5262
+ toKey
5263
+ });
5264
+ return claudeSessionId;
5265
+ }
5167
5266
  function effortSessionKey(baseKey, effort) {
5168
5267
  return effort ? `${baseKey}::effort=${effort}` : baseKey;
5169
5268
  }
@@ -5186,8 +5285,14 @@ function invalidateOtherEffortSessions(baseKey, effort) {
5186
5285
  );
5187
5286
  }
5188
5287
  }
5288
+ const currentKey = effortSessionKey(baseKey, effort);
5189
5289
  for (const key of staleKeys) {
5190
5290
  deleteActiveProcess(key);
5291
+ if (transferClaudeSession(key, currentKey)) {
5292
+ clearCompression(key);
5293
+ clearBackgroundTasks(key);
5294
+ continue;
5295
+ }
5191
5296
  deleteClaudeSessionId(key);
5192
5297
  clearCompression(key);
5193
5298
  clearBackgroundTasks(key);
@@ -5270,12 +5375,11 @@ function spawnClaudeProcess(cliPath, cliArgs, cwd, sessionKey2, proxyServer, mcp
5270
5375
  }
5271
5376
  }
5272
5377
  if (ownsSessionKey && code !== 0 && code !== null) {
5273
- log.info("process exited with error, clearing session", {
5378
+ log.info("process exited with error; keeping the claude session to resume", {
5274
5379
  code,
5275
- sessionKey: sessionKey2
5380
+ sessionKey: sessionKey2,
5381
+ resumable: claudeSessions.has(sessionKey2)
5276
5382
  });
5277
- claudeSessions.delete(sessionKey2);
5278
- forgetResumePoint(sessionKey2);
5279
5383
  }
5280
5384
  });
5281
5385
  proc.stderr?.on("data", (data) => {
@@ -5788,6 +5892,7 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
5788
5892
  "btw: a turn is running but no claude process appeared for it",
5789
5893
  "btw: early aside failed; the message will ask again",
5790
5894
  "cannot interrupt this transport; waiting for the turn to end",
5895
+ "carrying the claude conversation over to the new session key",
5791
5896
  "claude --version output unparseable",
5792
5897
  "claude account is out of usage; the turn ends with a note",
5793
5898
  "claude already registers a different skill under this name; not bridging ours",
@@ -5795,6 +5900,7 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
5795
5900
  "claude cli does not support --plugin-dir; opencode skills will not be bridged. Run `npm i -g @anthropic-ai/claude-code` to upgrade.",
5796
5901
  "claude code compacted its own context",
5797
5902
  "claude code reset its conversation",
5903
+ "claude could not compact the conversation; no summary was stored",
5798
5904
  "claude finished the turn without a reply",
5799
5905
  "claude kept missing the probe deadline; keeping the conservative answer for this process",
5800
5906
  "claude process belongs to another account; starting fresh",
@@ -5810,6 +5916,7 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
5810
5916
  "cleanup-stale: could not record the sweep marker",
5811
5917
  "cleanup-stale: error processing cache root",
5812
5918
  "cleanup-stale: rmSync failed",
5919
+ "continuing this conversation's claude session under the new model or effort instead of replaying it",
5813
5920
  "delivering proxy results after interrupted continuation",
5814
5921
  "draining proxy calls queued between turns",
5815
5922
  "empty user content; sending sentinel to satisfy CLI",
@@ -5881,6 +5988,7 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
5881
5988
  "rejected pending proxy call",
5882
5989
  "remote URL images are not supported by Claude CLI, skipping",
5883
5990
  "replaying stdout the child emitted between turns",
5991
+ "replaying the conversation as text: no claude session was available to continue",
5884
5992
  "result fallback timer fired \u2014 closing stream without result event",
5885
5993
  "resuming the claude session from before the restart instead of replaying it",
5886
5994
  "shared Claude capability already exists; leaving untouched",
@@ -5936,6 +6044,7 @@ var BUNDLE_DATA_ALLOWLIST = {
5936
6044
  inFlight: "flag",
5937
6045
  anthropicApiKeyInEnv: "flag",
5938
6046
  includeHistoryContext: "flag",
6047
+ resumable: "flag",
5939
6048
  interactive: "flag",
5940
6049
  interactiveTransport: "flag",
5941
6050
  isError: "flag",
@@ -5957,6 +6066,8 @@ var BUNDLE_DATA_ALLOWLIST = {
5957
6066
  dropped: "count",
5958
6067
  exitCode: "count",
5959
6068
  historyLength: "count",
6069
+ matchedMessages: "count",
6070
+ messages: "count",
5960
6071
  inputTokens: "count",
5961
6072
  lastTextLength: "count",
5962
6073
  lines: "count",
@@ -6058,6 +6169,12 @@ var BUNDLE_DATA_ALLOWLIST = {
6058
6169
  sessionId: "id",
6059
6170
  sessionID: "id",
6060
6171
  sessionKey: "id",
6172
+ // The two ends of a conversation carried from one session key to another
6173
+ // (h #g215), and the fork lookup's parent. All three are session keys.
6174
+ fromKey: "id",
6175
+ toKey: "id",
6176
+ siblingKey: "id",
6177
+ parentKey: "id",
6061
6178
  toolCallId: "id",
6062
6179
  toolCallIds: "id",
6063
6180
  toolUseId: "id",
@@ -8276,7 +8393,7 @@ The complete prior conversation appears above. The synthesis instructions follow
8276
8393
  content.push({
8277
8394
  type: "text",
8278
8395
  text: `<conversation_history>
8279
- The following is a summary of our conversation so far (from a previous session that couldn't be resumed):
8396
+ The following is a summary of our conversation so far, replayed as text because no Claude session was available to continue it:
8280
8397
 
8281
8398
  ${historyContext}
8282
8399
 
@@ -11326,6 +11443,7 @@ function createTurnState(init) {
11326
11443
  accountLimitHit: null,
11327
11444
  apiErrorTextShown: null,
11328
11445
  usageLimitNote: false,
11446
+ compactionFailureText: null,
11329
11447
  accountBlock: null,
11330
11448
  modelRefusal: null,
11331
11449
  drainBuffer: [],
@@ -12420,6 +12538,7 @@ ${plan}
12420
12538
  resultText: msg.message.content.filter((b) => b.type === "text" && b.text).map((b) => String(b.text)).join("\n")
12421
12539
  }));
12422
12540
  const limitErrorReply = limitFromReply && ctx.usageLimitNoteActive;
12541
+ const compactionApiError = ctx.compactionMode && (apiErrorReply || typeof msg.error === "string");
12423
12542
  if (limitFromReply) {
12424
12543
  state.accountLimitHit ??= recallAccountLimit(ctx.sourceAccount);
12425
12544
  const said = limitFactsFromText(
@@ -12441,6 +12560,10 @@ ${plan}
12441
12560
  for (const block2 of msg.message.content) {
12442
12561
  if (block2.type === "text" && block2.text) {
12443
12562
  if (limitErrorReply) continue;
12563
+ if (compactionApiError) {
12564
+ state.compactionFailureText ??= block2.text;
12565
+ continue;
12566
+ }
12444
12567
  if (apiErrorReply) state.apiErrorTextShown = block2.text;
12445
12568
  resetLastVisibleTextBlock(state);
12446
12569
  const blockId = state.startTextBlock();
@@ -12666,7 +12789,14 @@ ${plan}
12666
12789
  state.usageLimitNote = ctx.usageLimitNoteActive && !!state.accountLimitHit && // An expired login or a billing hold writes its own note just below
12667
12790
  // and says what to run; two notes about one failure is one too many.
12668
12791
  !state.accountBlock && msg.is_error === true;
12669
- if (!state.currentTextId && msg.is_error && // Already on screen from the reply frame, word for word.
12792
+ const compactionFailure = ctx.compactionMode && (msg.is_error === true || typeof msg.subtype === "string" && msg.subtype !== "success");
12793
+ if (compactionFailure) {
12794
+ if (typeof msg.result === "string" && msg.result.trim()) {
12795
+ state.compactionFailureText ??= msg.result.trim();
12796
+ }
12797
+ state.resultFailure ??= state.accountBlock ?? (state.accountLimitHit ? "usage_limit" : void 0) ?? (typeof msg.subtype === "string" && msg.subtype !== "success" ? msg.subtype : "error");
12798
+ }
12799
+ if (!state.currentTextId && msg.is_error && !compactionFailure && // Already on screen from the reply frame, word for word.
12670
12800
  !(typeof msg.result === "string" && state.apiErrorTextShown !== null && msg.result.trim() === state.apiErrorTextShown.trim()) && !state.usageLimitNote && typeof msg.result === "string" && msg.result.trim().length > 0) {
12671
12801
  const errId = state.startTextBlock();
12672
12802
  state.controller.enqueue({
@@ -12681,21 +12811,23 @@ ${plan}
12681
12811
  if (state.accountBlock && msg.is_error) {
12682
12812
  state.resultFailure ??= state.accountBlock;
12683
12813
  const offeringSwitch = ctx.failoverAskActive;
12684
- state.controller.enqueue({
12685
- type: "text-delta",
12686
- id: state.startTextBlock(),
12687
- delta: formatAccountBlockNote({
12688
- kind: state.accountBlock,
12689
- account: ctx.sourceAccount,
12690
- configDir: ctx.config.configDir,
12691
- offeringSwitch,
12692
- // Read only when the form is not taking the turn, which is the
12693
- // default: moving the work by hand is then the operator's only
12694
- // route and nothing else on screen says it exists (h #g194).
12695
- candidates: ctx.failoverAccounts
12696
- })
12697
- });
12698
- state.endTextBlock();
12814
+ if (!compactionFailure) {
12815
+ state.controller.enqueue({
12816
+ type: "text-delta",
12817
+ id: state.startTextBlock(),
12818
+ delta: formatAccountBlockNote({
12819
+ kind: state.accountBlock,
12820
+ account: ctx.sourceAccount,
12821
+ configDir: ctx.config.configDir,
12822
+ offeringSwitch,
12823
+ // Read only when the form is not taking the turn, which is the
12824
+ // default: moving the work by hand is then the operator's only
12825
+ // route and nothing else on screen says it exists (h #g194).
12826
+ candidates: ctx.failoverAccounts
12827
+ })
12828
+ });
12829
+ state.endTextBlock();
12830
+ }
12699
12831
  log.warn(`Claude account "${ctx.sourceAccount}" cannot serve requests`, {
12700
12832
  sessionKey: state.sessionKey,
12701
12833
  kind: state.accountBlock,
@@ -12705,11 +12837,14 @@ ${plan}
12705
12837
  const failure = describeResultFailure(msg);
12706
12838
  if (failure) {
12707
12839
  state.resultFailure = msg.subtype;
12708
- state.controller.enqueue({
12709
- type: "text-delta",
12710
- id: state.startTextBlock(),
12711
- delta: formatResultFailureNote(failure)
12712
- });
12840
+ if (!compactionFailure) {
12841
+ state.controller.enqueue({
12842
+ type: "text-delta",
12843
+ id: state.startTextBlock(),
12844
+ delta: formatResultFailureNote(failure)
12845
+ });
12846
+ }
12847
+ if (compactionFailure) state.compactionFailureText ??= failure;
12713
12848
  log.warn(failure, { sessionKey: state.sessionKey, subtype: msg.subtype });
12714
12849
  }
12715
12850
  const turnStats = extractTurnStats(msg);
@@ -13519,13 +13654,18 @@ var ClaudeCodeLanguageModel = class {
13519
13654
  }
13520
13655
  }
13521
13656
  }
13657
+ let replayReason = "no-session-for-key";
13522
13658
  if (this.config.resumeAfterRestart !== false && includeHistoryContext && !compactionMode && !forkFromClaudeSessionId && failoverAnswer?.kind !== "switch") {
13523
13659
  const configDir = resolveConfigDir(this.config.configDir);
13660
+ const transcriptPath = (id) => join12(configDir, "projects", encodeCwd(cwd), `${id}.jsonl`);
13524
13661
  const resumePoint = findResumePoint({
13525
13662
  sessionKey: sk,
13526
13663
  prompt: options.prompt,
13527
13664
  cliPath,
13528
- transcriptPath: (id) => join12(configDir, "projects", encodeCwd(cwd), `${id}.jsonl`)
13665
+ transcriptPath,
13666
+ onRefused: (reason) => {
13667
+ replayReason = reason;
13668
+ }
13529
13669
  });
13530
13670
  if (resumePoint) {
13531
13671
  setClaudeSessionId(sk, resumePoint.claudeSessionId);
@@ -13534,8 +13674,47 @@ var ClaudeCodeLanguageModel = class {
13534
13674
  sessionKey: sk,
13535
13675
  matchedMessages: resumePoint.matched
13536
13676
  });
13677
+ } else if (this.config.resumeAcrossModelChanges !== false) {
13678
+ const sibling = findSiblingResumePoint({
13679
+ sessionKey: sk,
13680
+ prompt: options.prompt,
13681
+ cliPath,
13682
+ transcriptPath,
13683
+ // `claudeSessionIsWriting`, not `getActiveProcess`: this walks every
13684
+ // sibling key, and that one refreshes LRU order and cancels idle
13685
+ // timers on everything it touches (the same reason the fork lookup
13686
+ // above uses it).
13687
+ isBusy: claudeSessionIsWriting,
13688
+ onRefused: (reason) => {
13689
+ replayReason = reason;
13690
+ }
13691
+ });
13692
+ if (sibling) {
13693
+ deleteActiveProcess(sibling.siblingKey);
13694
+ const carried = transferClaudeSession(sibling.siblingKey, sk);
13695
+ if (carried) {
13696
+ includeHistoryContext = false;
13697
+ log.notice(
13698
+ "continuing this conversation's claude session under the new model or effort instead of replaying it",
13699
+ {
13700
+ sessionKey: sk,
13701
+ siblingKey: sibling.siblingKey,
13702
+ matchedMessages: sibling.matched
13703
+ }
13704
+ );
13705
+ } else {
13706
+ replayReason = "sibling-carry-over-failed";
13707
+ }
13708
+ }
13537
13709
  }
13538
13710
  }
13711
+ if (includeHistoryContext && !compactionMode) {
13712
+ log.notice("replaying the conversation as text: no claude session was available to continue", {
13713
+ sessionKey: sk,
13714
+ reason: replayReason,
13715
+ messages: options.prompt.length
13716
+ });
13717
+ }
13539
13718
  if (this.config.forkSessions === true && !compactionMode) {
13540
13719
  recordForkFingerprint(sk, options.prompt, cliPath);
13541
13720
  }
@@ -14288,6 +14467,7 @@ var ClaudeCodeLanguageModel = class {
14288
14467
  })
14289
14468
  });
14290
14469
  state.endTextBlock();
14470
+ state.resultFailure ??= "usage_limit";
14291
14471
  }
14292
14472
  if (runAutoContinue(state, msg)) return;
14293
14473
  for (const [idx, reasoningId] of state.reasoningIds) {
@@ -14321,6 +14501,18 @@ var ClaudeCodeLanguageModel = class {
14321
14501
  });
14322
14502
  state.endTextBlock();
14323
14503
  }
14504
+ if (compactionMode && state.resultFailure) {
14505
+ log.warn("claude could not compact the conversation; no summary was stored", {
14506
+ sessionKey: sk,
14507
+ cause: state.resultFailure
14508
+ });
14509
+ controller.enqueue({
14510
+ type: "error",
14511
+ error: new Error(
14512
+ formatCompactionFailure(state.resultFailure, state.compactionFailureText)
14513
+ )
14514
+ });
14515
+ }
14324
14516
  const usage = toUsage2(lastCallContextUsage(state.lastCallUsage, msg.usage));
14325
14517
  controller.enqueue({
14326
14518
  type: "finish",
@@ -15519,6 +15711,7 @@ function createClaudeCode(settings = {}) {
15519
15711
  turnStats: settings.turnStats === true,
15520
15712
  forkSessions: settings.forkSessions === true,
15521
15713
  resumeAfterRestart: settings.resumeAfterRestart !== false,
15714
+ resumeAcrossModelChanges: settings.resumeAcrossModelChanges !== false,
15522
15715
  transport: settings.transport,
15523
15716
  interactive: settings.interactive,
15524
15717
  interactiveBypass: settings.interactiveBypass,