@khalilgharbaoui/opencode-claude-code-plugin 0.44.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",
@@ -5897,7 +6005,7 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
5897
6005
  "two different skills share a name; bridging the one the nearest root claimed",
5898
6006
  "unknown permissionPreset; no preset applied and every permission setting is left exactly as configured",
5899
6007
  "unsupported file part data type",
5900
- "unsupported media type for Claude image block, skipping"
6008
+ "unsupported media type for Claude attachment block, skipping"
5901
6009
  ]);
5902
6010
 
5903
6011
  // src/diagnostic-bundle.ts
@@ -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",
@@ -7253,7 +7370,7 @@ var baseCapabilities = {
7253
7370
  temperature: false,
7254
7371
  attachment: true,
7255
7372
  toolcall: true,
7256
- input: { text: true, audio: false, image: true, video: false, pdf: false },
7373
+ input: { text: true, audio: false, image: true, video: false, pdf: true },
7257
7374
  output: { text: true, audio: false, image: false, video: false, pdf: false },
7258
7375
  interleaved: false
7259
7376
  };
@@ -8010,13 +8127,14 @@ function stripContextReminders(prompt) {
8010
8127
  });
8011
8128
  return removed > 0 ? { prompt: out, removed } : { prompt, removed: 0 };
8012
8129
  }
8130
+ var PDF_MEDIA_TYPE = "application/pdf";
8013
8131
  var SUPPORTED_IMAGE_TYPES = /* @__PURE__ */ new Set([
8014
8132
  "image/jpeg",
8015
8133
  "image/png",
8016
8134
  "image/gif",
8017
8135
  "image/webp"
8018
8136
  ]);
8019
- function toImageBlock(part) {
8137
+ function toAttachmentBlock(part) {
8020
8138
  const raw = part.image ?? part.data ?? part.url ?? part.source?.data;
8021
8139
  if (!raw) {
8022
8140
  log.warn("file part without data, skipping");
@@ -8048,8 +8166,14 @@ function toImageBlock(part) {
8048
8166
  log.warn("unsupported file part data type", { dataType: typeof raw });
8049
8167
  return null;
8050
8168
  }
8169
+ if (resolvedMediaType.toLowerCase() === PDF_MEDIA_TYPE) {
8170
+ return {
8171
+ type: "document",
8172
+ source: { type: "base64", media_type: PDF_MEDIA_TYPE, data: base64 }
8173
+ };
8174
+ }
8051
8175
  if (!resolvedMediaType || !SUPPORTED_IMAGE_TYPES.has(resolvedMediaType)) {
8052
- log.warn("unsupported media type for Claude image block, skipping", {
8176
+ log.warn("unsupported media type for Claude attachment block, skipping", {
8053
8177
  mediaType: resolvedMediaType
8054
8178
  });
8055
8179
  return null;
@@ -8269,7 +8393,7 @@ The complete prior conversation appears above. The synthesis instructions follow
8269
8393
  content.push({
8270
8394
  type: "text",
8271
8395
  text: `<conversation_history>
8272
- 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:
8273
8397
 
8274
8398
  ${historyContext}
8275
8399
 
@@ -8301,11 +8425,11 @@ Now continuing with the current message:
8301
8425
  content.push({ type: "text", text: part.text });
8302
8426
  }
8303
8427
  } else if (part.type === "file" || part.type === "image") {
8304
- const block = toImageBlock(part);
8428
+ const block = toAttachmentBlock(part);
8305
8429
  if (block) {
8306
8430
  content.push(block);
8307
8431
  } else {
8308
- log.debug("skipped non-image file part", {
8432
+ log.debug("skipped unsupported file part", {
8309
8433
  mediaType: part.mediaType
8310
8434
  });
8311
8435
  }
@@ -8365,7 +8489,7 @@ function getTrailingUserMessages(prompt, opts = {}) {
8365
8489
  if (part.type === "text") {
8366
8490
  if (part.text && part.text.trim()) blocks.push({ type: "text", text: part.text });
8367
8491
  } else if (part.type === "file" || part.type === "image") {
8368
- const block = toImageBlock(part);
8492
+ const block = toAttachmentBlock(part);
8369
8493
  if (block) blocks.push(block);
8370
8494
  }
8371
8495
  }
@@ -9780,12 +9904,14 @@ function decodeUserEnvelope(chunk, saveImage) {
9780
9904
  const images = [];
9781
9905
  let dropped = 0;
9782
9906
  for (const block of content) {
9783
- const extension = block?.type === "image" && block.source?.type === "base64" && typeof block.source.data === "string" ? IMAGE_EXTENSIONS[String(block.source.media_type).toLowerCase()] : void 0;
9907
+ const base64 = block?.source?.type === "base64" && typeof block.source.data === "string";
9908
+ const mediaType = String(block?.source?.media_type).toLowerCase();
9909
+ const extension = base64 && block.type === "image" ? IMAGE_EXTENSIONS[mediaType] : base64 && block.type === "document" && mediaType === "application/pdf" ? "pdf" : void 0;
9784
9910
  if (block?.type === "text" && typeof block.text === "string") {
9785
9911
  parts.push(block.text);
9786
9912
  } else if (extension && saveImage) {
9787
9913
  const file = saveImage(Buffer.from(block.source.data, "base64"), extension);
9788
- if (file) images.push(file);
9914
+ if (file) images.push(extension === "pdf" ? `@${file}` : file);
9789
9915
  else dropped++;
9790
9916
  } else if (block?.type === "tool_result") {
9791
9917
  const v = block.content;
@@ -9808,7 +9934,7 @@ ${text2}`
9808
9934
  }
9809
9935
  function stageImage(data, extension) {
9810
9936
  try {
9811
- const file = join10(pluginTmpDir(), `image-${randomUUID5()}.${extension}`);
9937
+ const file = join10(pluginTmpDir(), `attachment-${randomUUID5()}.${extension}`);
9812
9938
  writeFileSync5(file, data, { mode: 384, flag: "wx" });
9813
9939
  return file;
9814
9940
  } catch (err) {
@@ -11317,6 +11443,7 @@ function createTurnState(init) {
11317
11443
  accountLimitHit: null,
11318
11444
  apiErrorTextShown: null,
11319
11445
  usageLimitNote: false,
11446
+ compactionFailureText: null,
11320
11447
  accountBlock: null,
11321
11448
  modelRefusal: null,
11322
11449
  drainBuffer: [],
@@ -12411,6 +12538,7 @@ ${plan}
12411
12538
  resultText: msg.message.content.filter((b) => b.type === "text" && b.text).map((b) => String(b.text)).join("\n")
12412
12539
  }));
12413
12540
  const limitErrorReply = limitFromReply && ctx.usageLimitNoteActive;
12541
+ const compactionApiError = ctx.compactionMode && (apiErrorReply || typeof msg.error === "string");
12414
12542
  if (limitFromReply) {
12415
12543
  state.accountLimitHit ??= recallAccountLimit(ctx.sourceAccount);
12416
12544
  const said = limitFactsFromText(
@@ -12432,6 +12560,10 @@ ${plan}
12432
12560
  for (const block2 of msg.message.content) {
12433
12561
  if (block2.type === "text" && block2.text) {
12434
12562
  if (limitErrorReply) continue;
12563
+ if (compactionApiError) {
12564
+ state.compactionFailureText ??= block2.text;
12565
+ continue;
12566
+ }
12435
12567
  if (apiErrorReply) state.apiErrorTextShown = block2.text;
12436
12568
  resetLastVisibleTextBlock(state);
12437
12569
  const blockId = state.startTextBlock();
@@ -12657,7 +12789,14 @@ ${plan}
12657
12789
  state.usageLimitNote = ctx.usageLimitNoteActive && !!state.accountLimitHit && // An expired login or a billing hold writes its own note just below
12658
12790
  // and says what to run; two notes about one failure is one too many.
12659
12791
  !state.accountBlock && msg.is_error === true;
12660
- 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.
12661
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) {
12662
12801
  const errId = state.startTextBlock();
12663
12802
  state.controller.enqueue({
@@ -12672,21 +12811,23 @@ ${plan}
12672
12811
  if (state.accountBlock && msg.is_error) {
12673
12812
  state.resultFailure ??= state.accountBlock;
12674
12813
  const offeringSwitch = ctx.failoverAskActive;
12675
- state.controller.enqueue({
12676
- type: "text-delta",
12677
- id: state.startTextBlock(),
12678
- delta: formatAccountBlockNote({
12679
- kind: state.accountBlock,
12680
- account: ctx.sourceAccount,
12681
- configDir: ctx.config.configDir,
12682
- offeringSwitch,
12683
- // Read only when the form is not taking the turn, which is the
12684
- // default: moving the work by hand is then the operator's only
12685
- // route and nothing else on screen says it exists (h #g194).
12686
- candidates: ctx.failoverAccounts
12687
- })
12688
- });
12689
- 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
+ }
12690
12831
  log.warn(`Claude account "${ctx.sourceAccount}" cannot serve requests`, {
12691
12832
  sessionKey: state.sessionKey,
12692
12833
  kind: state.accountBlock,
@@ -12696,11 +12837,14 @@ ${plan}
12696
12837
  const failure = describeResultFailure(msg);
12697
12838
  if (failure) {
12698
12839
  state.resultFailure = msg.subtype;
12699
- state.controller.enqueue({
12700
- type: "text-delta",
12701
- id: state.startTextBlock(),
12702
- delta: formatResultFailureNote(failure)
12703
- });
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;
12704
12848
  log.warn(failure, { sessionKey: state.sessionKey, subtype: msg.subtype });
12705
12849
  }
12706
12850
  const turnStats = extractTurnStats(msg);
@@ -13510,13 +13654,18 @@ var ClaudeCodeLanguageModel = class {
13510
13654
  }
13511
13655
  }
13512
13656
  }
13657
+ let replayReason = "no-session-for-key";
13513
13658
  if (this.config.resumeAfterRestart !== false && includeHistoryContext && !compactionMode && !forkFromClaudeSessionId && failoverAnswer?.kind !== "switch") {
13514
13659
  const configDir = resolveConfigDir(this.config.configDir);
13660
+ const transcriptPath = (id) => join12(configDir, "projects", encodeCwd(cwd), `${id}.jsonl`);
13515
13661
  const resumePoint = findResumePoint({
13516
13662
  sessionKey: sk,
13517
13663
  prompt: options.prompt,
13518
13664
  cliPath,
13519
- transcriptPath: (id) => join12(configDir, "projects", encodeCwd(cwd), `${id}.jsonl`)
13665
+ transcriptPath,
13666
+ onRefused: (reason) => {
13667
+ replayReason = reason;
13668
+ }
13520
13669
  });
13521
13670
  if (resumePoint) {
13522
13671
  setClaudeSessionId(sk, resumePoint.claudeSessionId);
@@ -13525,8 +13674,47 @@ var ClaudeCodeLanguageModel = class {
13525
13674
  sessionKey: sk,
13526
13675
  matchedMessages: resumePoint.matched
13527
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
+ }
13528
13709
  }
13529
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
+ }
13530
13718
  if (this.config.forkSessions === true && !compactionMode) {
13531
13719
  recordForkFingerprint(sk, options.prompt, cliPath);
13532
13720
  }
@@ -14279,6 +14467,7 @@ var ClaudeCodeLanguageModel = class {
14279
14467
  })
14280
14468
  });
14281
14469
  state.endTextBlock();
14470
+ state.resultFailure ??= "usage_limit";
14282
14471
  }
14283
14472
  if (runAutoContinue(state, msg)) return;
14284
14473
  for (const [idx, reasoningId] of state.reasoningIds) {
@@ -14312,6 +14501,18 @@ var ClaudeCodeLanguageModel = class {
14312
14501
  });
14313
14502
  state.endTextBlock();
14314
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
+ }
14315
14516
  const usage = toUsage2(lastCallContextUsage(state.lastCallUsage, msg.usage));
14316
14517
  controller.enqueue({
14317
14518
  type: "finish",
@@ -15510,6 +15711,7 @@ function createClaudeCode(settings = {}) {
15510
15711
  turnStats: settings.turnStats === true,
15511
15712
  forkSessions: settings.forkSessions === true,
15512
15713
  resumeAfterRestart: settings.resumeAfterRestart !== false,
15714
+ resumeAcrossModelChanges: settings.resumeAcrossModelChanges !== false,
15513
15715
  transport: settings.transport,
15514
15716
  interactive: settings.interactive,
15515
15717
  interactiveBypass: settings.interactiveBypass,