@khalilgharbaoui/opencode-claude-code-plugin 0.38.0 → 0.39.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.js CHANGED
@@ -1,3 +1,6 @@
1
+ // src/claude-code-language-model.ts
2
+ import { join as join11 } from "path";
3
+
1
4
  // src/ids.ts
2
5
  import { randomInt } from "crypto";
3
6
  var ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
@@ -72,6 +75,14 @@ function parseInput(raw) {
72
75
  return {};
73
76
  }
74
77
  }
78
+ function hostedToolResult(part) {
79
+ const result = part.result;
80
+ if (part.providerExecuted !== true) return part.result;
81
+ if (!result || typeof result !== "object" || typeof result.output !== "string") {
82
+ return part.result;
83
+ }
84
+ return result.output;
85
+ }
75
86
  function createHostToolPartTranslator(dialect = "v1") {
76
87
  if (dialect !== "v2") return (part) => part;
77
88
  const dropped = /* @__PURE__ */ new Set();
@@ -118,7 +129,8 @@ function createHostToolPartTranslator(dialect = "v1") {
118
129
  case "tool-result": {
119
130
  const tool = classify2(String(part.toolCallId), part.toolName);
120
131
  if (tool === null) return null;
121
- return tool ? { ...part, toolName: tool.name } : part;
132
+ const result = hostedToolResult(part);
133
+ return tool ? { ...part, toolName: tool.name, result } : { ...part, result };
122
134
  }
123
135
  default:
124
136
  return part;
@@ -4259,6 +4271,11 @@ function findForkParent(opts) {
4259
4271
  }
4260
4272
  return best;
4261
4273
  }
4274
+ function continuesRecordedConversation(recorded, prompt) {
4275
+ const { history, forkable } = splitForkHistory(prompt);
4276
+ if (!forkable || recorded.length === 0 || recorded.length > history.length) return false;
4277
+ return isPrefix(recorded, history) && tailIsReplyOnly(history, recorded.length);
4278
+ }
4262
4279
  function isPrefix(recorded, history) {
4263
4280
  for (let i = 0; i < recorded.length; i++) {
4264
4281
  if (recorded[i].role !== history[i].role) return false;
@@ -4273,6 +4290,109 @@ function tailIsReplyOnly(history, from) {
4273
4290
  return true;
4274
4291
  }
4275
4292
 
4293
+ // src/session-resume-store.ts
4294
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync2, renameSync as renameSync2, writeFileSync as writeFileSync3 } from "fs";
4295
+ import { homedir as homedir4 } from "os";
4296
+ import { join as join5 } from "path";
4297
+ var MAX_RESUME_RECORDS = 256;
4298
+ var RESUME_RECORD_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
4299
+ var records = null;
4300
+ var pathOverride = null;
4301
+ function resumeStorePath() {
4302
+ if (pathOverride) return pathOverride;
4303
+ const stateRoot = process.env.XDG_STATE_HOME || join5(homedir4(), ".local", "state");
4304
+ return join5(stateRoot, "opencode-claude-code-plugin", "claude-sessions.json");
4305
+ }
4306
+ function isDigest(value) {
4307
+ const entry = value;
4308
+ return !!entry && (entry.role === "user" || entry.role === "assistant" || entry.role === "tool") && typeof entry.digest === "string";
4309
+ }
4310
+ function isRecord2(value) {
4311
+ const record = value;
4312
+ return !!record && typeof record.claudeSessionId === "string" && typeof record.cliPath === "string" && typeof record.updatedAt === "number" && Array.isArray(record.chain) && record.chain.every(isDigest);
4313
+ }
4314
+ function load() {
4315
+ if (records) return records;
4316
+ records = /* @__PURE__ */ new Map();
4317
+ try {
4318
+ const parsed = JSON.parse(readFileSync2(resumeStorePath(), "utf8"));
4319
+ const entries = parsed && typeof parsed === "object" ? parsed.sessions : null;
4320
+ if (entries && typeof entries === "object") {
4321
+ const cutoff = Date.now() - RESUME_RECORD_MAX_AGE_MS;
4322
+ const valid = Object.entries(entries).filter((entry) => isRecord2(entry[1])).filter(([, record]) => record.updatedAt >= cutoff).sort((a, b) => a[1].updatedAt - b[1].updatedAt);
4323
+ for (const [key, record] of valid) records.set(key, record);
4324
+ }
4325
+ } catch {
4326
+ }
4327
+ return records;
4328
+ }
4329
+ function save(update) {
4330
+ const file = resumeStorePath();
4331
+ const merged = /* @__PURE__ */ new Map();
4332
+ try {
4333
+ const onDisk = JSON.parse(readFileSync2(file, "utf8"))?.sessions;
4334
+ if (onDisk && typeof onDisk === "object") {
4335
+ for (const [key, record] of Object.entries(onDisk)) {
4336
+ if (isRecord2(record)) merged.set(key, record);
4337
+ }
4338
+ }
4339
+ } catch {
4340
+ }
4341
+ update(merged);
4342
+ const cutoff = Date.now() - RESUME_RECORD_MAX_AGE_MS;
4343
+ const kept = [...merged.entries()].filter(([, record]) => record.updatedAt >= cutoff).sort((a, b) => a[1].updatedAt - b[1].updatedAt).slice(-MAX_RESUME_RECORDS);
4344
+ records = new Map(kept);
4345
+ try {
4346
+ mkdirSync3(join5(file, ".."), { recursive: true, mode: 448 });
4347
+ const tmp = `${file}.${process.pid}.tmp`;
4348
+ writeFileSync3(tmp, JSON.stringify({ version: 1, sessions: Object.fromEntries(kept) }), {
4349
+ mode: 384
4350
+ });
4351
+ renameSync2(tmp, file);
4352
+ } catch (err) {
4353
+ log.debug("could not write the claude session resume store", {
4354
+ error: err instanceof Error ? err.message : String(err)
4355
+ });
4356
+ }
4357
+ }
4358
+ function recordResumePoint(sessionKey2, claudeSessionId, prompt, cliPath) {
4359
+ const chain = conversationDigests(prompt);
4360
+ if (chain.length === 0) return;
4361
+ const current = load().get(sessionKey2);
4362
+ if (current && current.claudeSessionId === claudeSessionId && current.cliPath === cliPath && current.chain.length === chain.length && current.chain.every((entry, i) => entry.digest === chain[i].digest)) {
4363
+ return;
4364
+ }
4365
+ save((merged) => {
4366
+ merged.delete(sessionKey2);
4367
+ merged.set(sessionKey2, { claudeSessionId, cliPath, chain, updatedAt: Date.now() });
4368
+ });
4369
+ }
4370
+ function forgetResumePoint(sessionKey2) {
4371
+ if (!load().has(sessionKey2)) return;
4372
+ save((merged) => {
4373
+ merged.delete(sessionKey2);
4374
+ });
4375
+ }
4376
+ function findResumePoint(opts) {
4377
+ const record = load().get(opts.sessionKey);
4378
+ if (!record) return void 0;
4379
+ const refuse = (reason) => {
4380
+ log.info("not resuming the claude session from before the restart", {
4381
+ sessionKey: opts.sessionKey,
4382
+ reason
4383
+ });
4384
+ return void 0;
4385
+ };
4386
+ if (record.cliPath !== opts.cliPath) return refuse("another claude binary");
4387
+ if (!continuesRecordedConversation(record.chain, opts.prompt)) {
4388
+ return refuse("the conversation changed");
4389
+ }
4390
+ if (!existsSync2(opts.transcriptPath(record.claudeSessionId))) {
4391
+ return refuse("the transcript is gone");
4392
+ }
4393
+ return { claudeSessionId: record.claudeSessionId, matched: record.chain.length };
4394
+ }
4395
+
4276
4396
  // src/permission-presets.ts
4277
4397
  var READ_ONLY_DISALLOWED_CLI_TOOLS = [
4278
4398
  "Bash",
@@ -4385,7 +4505,7 @@ import { randomUUID as randomUUID2 } from "crypto";
4385
4505
  var MAX_HISTORY_EXCHANGES = 20;
4386
4506
  var SIDE_QUESTION_USAGE = "Usage: /btw <question>. Ask a side question about the current conversation without adding it to the main context.";
4387
4507
  var pendingProcesses = /* @__PURE__ */ new WeakSet();
4388
- function isRecord2(value) {
4508
+ function isRecord3(value) {
4389
4509
  return value !== null && typeof value === "object" && !Array.isArray(value);
4390
4510
  }
4391
4511
  var SYSTEM_REMINDER_BLOCK = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
@@ -4396,7 +4516,7 @@ function parseSideQuestionContent(content) {
4396
4516
  } else if (Array.isArray(content)) {
4397
4517
  const parts = [];
4398
4518
  for (const part of content) {
4399
- if (!isRecord2(part) || part.type !== "text" || typeof part.text !== "string") return null;
4519
+ if (!isRecord3(part) || part.type !== "text" || typeof part.text !== "string") return null;
4400
4520
  parts.push(part.text);
4401
4521
  }
4402
4522
  text = parts.join("\n");
@@ -4415,7 +4535,7 @@ function assistantText(content) {
4415
4535
  if (!Array.isArray(content)) return "";
4416
4536
  const parts = [];
4417
4537
  for (const part of content) {
4418
- if (isRecord2(part) && part.type === "text" && typeof part.text === "string") parts.push(part.text);
4538
+ if (isRecord3(part) && part.type === "text" && typeof part.text === "string") parts.push(part.text);
4419
4539
  }
4420
4540
  return parts.join("\n").trim();
4421
4541
  }
@@ -4445,9 +4565,9 @@ function dispatchSideQuestionResponse(activeProcess, line) {
4445
4565
  } catch {
4446
4566
  return false;
4447
4567
  }
4448
- if (!isRecord2(message) || message.type !== "control_response") return false;
4568
+ if (!isRecord3(message) || message.type !== "control_response") return false;
4449
4569
  const response = message.response;
4450
- if (!isRecord2(response) || typeof response.request_id !== "string") return false;
4570
+ if (!isRecord3(response) || typeof response.request_id !== "string") return false;
4451
4571
  return activeProcess.lineEmitter.emit(`side-question:${response.request_id}`, response);
4452
4572
  }
4453
4573
  async function requestSideQuestion(activeProcess, question, options) {
@@ -4532,7 +4652,7 @@ async function requestSideQuestion(activeProcess, question, options) {
4532
4652
  return;
4533
4653
  }
4534
4654
  const result = response.response;
4535
- if (response.subtype !== "success" || !isRecord2(result) || typeof result.response !== "string" || typeof result.synthetic !== "boolean") {
4655
+ if (response.subtype !== "success" || !isRecord3(result) || typeof result.response !== "string" || typeof result.synthetic !== "boolean") {
4536
4656
  fail(new Error("Claude Code returned an invalid /btw response."));
4537
4657
  return;
4538
4658
  }
@@ -4702,6 +4822,7 @@ function noteTurnLine(ap, line) {
4702
4822
  if (isTerminalResultLine(line)) settleTurn(ap);
4703
4823
  }
4704
4824
  function isTurnInFlight(ap) {
4825
+ if (ap.interactiveControl) return ap.interactiveControl.turnRunning();
4705
4826
  return ap.turnInFlight === true;
4706
4827
  }
4707
4828
  function awaitTurnIdle(ap, timeoutMs) {
@@ -4721,6 +4842,7 @@ function awaitTurnIdle(ap, timeoutMs) {
4721
4842
  });
4722
4843
  }
4723
4844
  function interruptTurn(ap, timeoutMs = TURN_INTERRUPT_TIMEOUT_MS) {
4845
+ if (ap.interactiveControl) return ap.interactiveControl.interrupt(timeoutMs);
4724
4846
  if (!ap.turnInFlight) return Promise.resolve(true);
4725
4847
  const stdin = ap.proc.stdin;
4726
4848
  if (ap.asideTransport?.interactive || !stdin || !stdin.writable) {
@@ -4761,6 +4883,15 @@ function setActiveProcess(key, ap) {
4761
4883
  cancelIdleProcessEviction(key);
4762
4884
  activeProcesses.set(key, ap);
4763
4885
  }
4886
+ function noteInteractiveProcessExit(sessionKey2, ap, code) {
4887
+ if (activeProcesses.get(sessionKey2) !== ap) return;
4888
+ cancelIdleProcessEviction(sessionKey2);
4889
+ activeProcesses.delete(sessionKey2);
4890
+ if (code !== 0 && code !== null) {
4891
+ claudeSessions.delete(sessionKey2);
4892
+ forgetResumePoint(sessionKey2);
4893
+ }
4894
+ }
4764
4895
  function scheduleIdleProcessEviction(key, timeoutMs) {
4765
4896
  cancelIdleProcessEviction(key);
4766
4897
  if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_IDLE_TIMEOUT_MS) {
@@ -4906,6 +5037,7 @@ function deleteClaudeSessionId(key) {
4906
5037
  if (claudeSessionId) clearLedger(claudeSessionId);
4907
5038
  claudeSessions.delete(key);
4908
5039
  forgetForkFingerprint(key);
5040
+ forgetResumePoint(key);
4909
5041
  }
4910
5042
  function effortSessionKey(baseKey, effort) {
4911
5043
  return effort ? `${baseKey}::effort=${effort}` : baseKey;
@@ -5018,6 +5150,7 @@ function spawnClaudeProcess(cliPath, cliArgs, cwd, sessionKey2, proxyServer, mcp
5018
5150
  sessionKey: sessionKey2
5019
5151
  });
5020
5152
  claudeSessions.delete(sessionKey2);
5153
+ forgetResumePoint(sessionKey2);
5021
5154
  }
5022
5155
  });
5023
5156
  proc.stderr?.on("data", (data) => {
@@ -5032,6 +5165,7 @@ function spawnClaudeProcess(cliPath, cliArgs, cwd, sessionKey2, proxyServer, mcp
5032
5165
  error: stderr.slice(0, 200)
5033
5166
  });
5034
5167
  claudeSessions.delete(sessionKey2);
5168
+ forgetResumePoint(sessionKey2);
5035
5169
  } else {
5036
5170
  log.debug("ignoring session ID error from stale claude process", {
5037
5171
  sessionKey: sessionKey2
@@ -5510,7 +5644,7 @@ async function handleBtwCommand(client, input, options = {}) {
5510
5644
  // src/diagnostic-bundle.ts
5511
5645
  import { createHash as createHash4, randomBytes as randomBytes2 } from "crypto";
5512
5646
  import { openSync, readSync, closeSync, fstatSync, realpathSync } from "fs";
5513
- import { homedir as homedir4 } from "os";
5647
+ import { homedir as homedir5 } from "os";
5514
5648
 
5515
5649
  // src/log-messages.ts
5516
5650
  var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
@@ -5586,6 +5720,9 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
5586
5720
  "ignoring unknown proxyTools entries",
5587
5721
  "interactive permissionMode bypassPermissions ignored: Claude Code prompts for confirmation in the TUI",
5588
5722
  "interactive system prompt disabled; opencode agent prompts will not be appended",
5723
+ "interactive transport cancelled the usage-limit auto-continue",
5724
+ "interactive transport cannot continue",
5725
+ "interactive transport denied a permission prompt",
5589
5726
  "interactive transport dropped non-text content blocks",
5590
5727
  "interactive turn failed",
5591
5728
  "interactiveBypass ignored: Claude Code prompts for bypassPermissions confirmation in the interactive TUI",
@@ -5617,6 +5754,7 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
5617
5754
  "remote URL images are not supported by Claude CLI, skipping",
5618
5755
  "replaying stdout the child emitted between turns",
5619
5756
  "result fallback timer fired \u2014 closing stream without result event",
5757
+ "resuming the claude session from before the restart instead of replaying it",
5620
5758
  "shared Claude capability already exists; leaving untouched",
5621
5759
  "skipping MCP server with unknown type",
5622
5760
  "skipping local MCP server with no command",
@@ -5840,7 +5978,7 @@ function createIdHasher(salt = randomBytes2(16).toString("hex")) {
5840
5978
  return (raw) => createHash4("sha256").update(salt).update("\0").update(raw).digest("hex").slice(0, 8);
5841
5979
  }
5842
5980
  function createRedactionContext(options = {}) {
5843
- const home = options.home ?? homedir4();
5981
+ const home = options.home ?? homedir5();
5844
5982
  const homes = /* @__PURE__ */ new Set();
5845
5983
  if (home) homes.add(home);
5846
5984
  if (home) {
@@ -6514,7 +6652,7 @@ function staleBuildWatch() {
6514
6652
  var DOCTOR_COMMAND = "claude-code-doctor";
6515
6653
  var DOCTOR_COMMAND_DESCRIPTION = "Report what the Claude Code plugin sees: versions, cwd, live processes, pending proxy calls. Add `usage` for plan windows, or `bundle` for a redacted log you can paste into an issue";
6516
6654
  var DOCTOR_MARKER = "\u258C **claude-code doctor**";
6517
- function isRecord3(value) {
6655
+ function isRecord4(value) {
6518
6656
  return value !== null && typeof value === "object" && !Array.isArray(value);
6519
6657
  }
6520
6658
  var SYSTEM_REMINDER_BLOCK2 = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
@@ -6525,7 +6663,7 @@ function parseDoctorCommandContent(content) {
6525
6663
  } else if (Array.isArray(content)) {
6526
6664
  const parts = [];
6527
6665
  for (const part of content) {
6528
- if (!isRecord3(part) || part.type !== "text" || typeof part.text !== "string") return null;
6666
+ if (!isRecord4(part) || part.type !== "text" || typeof part.text !== "string") return null;
6529
6667
  parts.push(part.text);
6530
6668
  }
6531
6669
  text = parts.join("\n");
@@ -7266,8 +7404,8 @@ var registry = {};
7266
7404
  var defaultSubagentModel;
7267
7405
  var defaultSubagentCacheTtl;
7268
7406
  var providerFallbackModels = [];
7269
- function setAgentRegistry(records) {
7270
- registry = records;
7407
+ function setAgentRegistry(records2) {
7408
+ registry = records2;
7271
7409
  }
7272
7410
  function getAgentRegistry() {
7273
7411
  return registry;
@@ -7407,7 +7545,7 @@ function parseFallbackModelList(value) {
7407
7545
  return value.trim().replace(/^\[|\]$/g, "").split(/[,\s]+/).map((entry) => entry.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
7408
7546
  }
7409
7547
  async function readAgentMarkdownRecords(directories) {
7410
- const records = {};
7548
+ const records2 = {};
7411
7549
  for (const directory of directories) {
7412
7550
  let entries;
7413
7551
  try {
@@ -7418,10 +7556,10 @@ async function readAgentMarkdownRecords(directories) {
7418
7556
  for (const entry of entries) {
7419
7557
  if (!entry.endsWith(".md")) continue;
7420
7558
  const name = entry.slice(0, -3);
7421
- if (records[name]) continue;
7559
+ if (records2[name]) continue;
7422
7560
  try {
7423
7561
  const text = await readFile(path7.join(directory, entry), "utf8");
7424
- records[name] = parseAgentFrontmatter(text);
7562
+ records2[name] = parseAgentFrontmatter(text);
7425
7563
  } catch (err) {
7426
7564
  log.debug("failed to read agent markdown", {
7427
7565
  file: path7.join(directory, entry),
@@ -7430,7 +7568,7 @@ async function readAgentMarkdownRecords(directories) {
7430
7568
  }
7431
7569
  }
7432
7570
  }
7433
- return records;
7571
+ return records2;
7434
7572
  }
7435
7573
  function agentDirectories(home, projectDirectory) {
7436
7574
  const directories = [];
@@ -8369,6 +8507,7 @@ async function resolveSkillPluginDirs(opts) {
8369
8507
 
8370
8508
  // src/claude-session-wrapper.ts
8371
8509
  import { EventEmitter as EventEmitter4 } from "events";
8510
+ import { existsSync as existsSync4 } from "fs";
8372
8511
  import { unlink as unlink2 } from "fs/promises";
8373
8512
 
8374
8513
  // src/claude-session-bun.ts
@@ -8402,10 +8541,99 @@ function encodeCwd(cwd) {
8402
8541
  }
8403
8542
  return real.replace(/[^a-zA-Z0-9]/g, "-");
8404
8543
  }
8544
+ var bunPtySpawner = (argv, opts) => {
8545
+ const [command, ...args] = argv;
8546
+ return Bun.spawn([resolveClaude(command ?? "claude"), ...args], {
8547
+ cwd: opts.cwd,
8548
+ env: opts.env,
8549
+ terminal: {
8550
+ cols: opts.cols,
8551
+ rows: opts.rows,
8552
+ data: (_term, data) => opts.onData(Buffer.from(data).toString("utf8"))
8553
+ }
8554
+ });
8555
+ };
8556
+ function words(text) {
8557
+ return text.split(/\s+/).map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s*");
8558
+ }
8559
+ var SCREEN_PATTERNS = [
8560
+ {
8561
+ kind: "login",
8562
+ pattern: new RegExp(
8563
+ [
8564
+ words("Select login method"),
8565
+ words("Please run /login"),
8566
+ words("Invalid API key"),
8567
+ words("OAuth token has expired"),
8568
+ words("Not logged in")
8569
+ ].join("|"),
8570
+ "i"
8571
+ )
8572
+ },
8573
+ {
8574
+ kind: "onboarding",
8575
+ pattern: new RegExp(
8576
+ [words("Choose the text style that looks best"), words("Let's get started.")].join("|"),
8577
+ "i"
8578
+ )
8579
+ },
8580
+ {
8581
+ kind: "trust",
8582
+ pattern: new RegExp(
8583
+ [
8584
+ words("Do you trust the files in this folder?"),
8585
+ words("Is this a project you created or one you trust?"),
8586
+ words("Yes, I trust this folder")
8587
+ ].join("|"),
8588
+ "i"
8589
+ )
8590
+ },
8591
+ {
8592
+ // The question alone is not enough: Claude can write "Do you want to
8593
+ // proceed?" in a reply. The dialog always numbers its options.
8594
+ kind: "permission",
8595
+ pattern: new RegExp(
8596
+ `${words("Do you want to")}\\s*(?:proceed|make\\s*this\\s*edit|create|overwrite|allow|run)[^?]{0,160}\\?[\\s\\S]{0,600}?1\\.\\s*Yes`,
8597
+ "i"
8598
+ )
8599
+ },
8600
+ {
8601
+ kind: "auto-continue",
8602
+ pattern: new RegExp(
8603
+ `${words("continuing automatically at")}[^\xB7\\n]{1,40}\xB7\\s*${words("esc to cancel")}`,
8604
+ "i"
8605
+ )
8606
+ }
8607
+ ];
8608
+ function stripTerminal(raw) {
8609
+ return raw.replace(
8610
+ /\x1B\[(\d*)C/g,
8611
+ (_match, count) => " ".repeat(Math.min(Number(count) || 1, 200))
8612
+ ).replace(/\x1B\[[0-9;?]*[ABDEFGHJKfd]/g, "\n").replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)/g, "").replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "").replace(/[\x00-\x08\x0B-\x1F\x7F]/g, "").replace(/[ \t]+/g, " ");
8613
+ }
8614
+ function highlightedChoice(text) {
8615
+ let last = null;
8616
+ for (const match of text.matchAll(/❯\s*(?:\d+\s*\.\s*)?(Yes|No)\b/gi)) {
8617
+ last = match[1].toLowerCase();
8618
+ }
8619
+ return last === "yes" || last === "no" ? last : null;
8620
+ }
8621
+ function classifyScreen(text) {
8622
+ for (const { kind, pattern } of SCREEN_PATTERNS) {
8623
+ const match = pattern.exec(text);
8624
+ if (match) {
8625
+ return { kind, detail: match[0].replace(/\s+/g, " ").trim().slice(0, 200) };
8626
+ }
8627
+ }
8628
+ return null;
8629
+ }
8630
+ function configDirEnv(configDir) {
8631
+ return configDir ? { CLAUDE_CONFIG_DIR: configDir } : {};
8632
+ }
8405
8633
  function interactiveSpawnEnv(opts) {
8406
8634
  return {
8407
8635
  ...process.env,
8408
- CLAUDE_CONFIG_DIR: opts.configDir,
8636
+ ...configDirEnv(opts.configDir),
8409
8637
  TERM: "xterm-256color",
8410
8638
  // Pin the binary so a mid-session autoupdate cannot invalidate the
8411
8639
  // detected version the flag gates read, and skip non-essential traffic.
@@ -8480,6 +8708,21 @@ var TurnUsageAccumulator = class {
8480
8708
  };
8481
8709
  var TERMINAL_STOP = /* @__PURE__ */ new Set(["end_turn", "stop_sequence", "max_tokens"]);
8482
8710
  var delay = (ms) => new Promise((r) => setTimeout(r, ms));
8711
+ var RAW_KEEP_CHARS = 64 * 1024;
8712
+ var SCREEN_WINDOW_CHARS = 16 * 1024;
8713
+ var INTERRUPT_MARKER = "[Request interrupted by user";
8714
+ function isInterruptRecord(rec) {
8715
+ if (!rec || rec.type !== "user" || !rec.message) return false;
8716
+ const content = rec.message.content;
8717
+ if (typeof content === "string") return content.startsWith(INTERRUPT_MARKER);
8718
+ if (!Array.isArray(content)) return false;
8719
+ return content.some(
8720
+ (block) => block?.type === "text" && typeof block.text === "string" && block.text.startsWith(INTERRUPT_MARKER)
8721
+ );
8722
+ }
8723
+ function isTurnDurationRecord(rec) {
8724
+ return rec?.type === "system" && rec.subtype === "turn_duration";
8725
+ }
8483
8726
  function resolveConfigDir(configDir) {
8484
8727
  const value = configDir ?? process.env.CLAUDE_CONFIG_DIR;
8485
8728
  if (!value) return path9.join(os4.homedir(), ".claude");
@@ -8489,11 +8732,21 @@ function resolveConfigDir(configDir) {
8489
8732
  }
8490
8733
  return path9.resolve(value);
8491
8734
  }
8735
+ function fatalScreenMessage(kind, configDir, cwd) {
8736
+ if (kind === "trust") {
8737
+ return `Claude Code's folder trust dialog for ${cwd} could not be answered. Run \`claude\` in that folder once by hand and trust it, then retry.`;
8738
+ }
8739
+ if (kind === "login") {
8740
+ return `Claude Code is not logged in for ${configDir}. Run \`claude\` there once by hand and log in (or \`claude auth login\`), then retry.`;
8741
+ }
8742
+ return `Claude Code has not finished its first-run setup for ${configDir}. Run \`claude\` there once by hand, then retry.`;
8743
+ }
8492
8744
  var ClaudeSession = class {
8493
8745
  sessionId;
8494
8746
  cwd;
8495
8747
  configDir;
8496
8748
  jsonlPath;
8749
+ /** The newest terminal output, ANSI included, for diagnostics. */
8497
8750
  raw = "";
8498
8751
  proc = null;
8499
8752
  cursor = 0;
@@ -8503,12 +8756,36 @@ var ClaudeSession = class {
8503
8756
  exitCode = null;
8504
8757
  aborted = false;
8505
8758
  signal;
8759
+ resumeSessionId;
8760
+ env;
8761
+ onScreen;
8762
+ onExit;
8763
+ spawnPty;
8764
+ /** The configured config dir, which alone is exported to the child. */
8765
+ explicitConfigDir;
8766
+ /** Output since the screen was last acted on, read by `checkScreen`. */
8767
+ screen = "";
8768
+ screenTimer = null;
8769
+ fatal = null;
8770
+ /** The folder trust dialog is up and not answered yet. */
8771
+ trustPending = false;
8772
+ trustMoves = 0;
8773
+ trustMovedAt = 0;
8774
+ turn = null;
8775
+ turnDenied = [];
8776
+ abandonTurn = false;
8506
8777
  o;
8507
8778
  constructor(opts = {}) {
8508
8779
  this.cwd = path9.resolve(opts.cwd ?? process.cwd());
8509
8780
  this.configDir = resolveConfigDir(opts.configDir);
8781
+ this.explicitConfigDir = opts.configDir ? this.configDir : void 0;
8510
8782
  this.signal = opts.signal;
8511
- this.sessionId = randomUUID4();
8783
+ this.resumeSessionId = opts.resumeSessionId;
8784
+ this.env = opts.env;
8785
+ this.onScreen = opts.onScreen;
8786
+ this.onExit = opts.onExit;
8787
+ this.spawnPty = opts.spawnPty ?? bunPtySpawner;
8788
+ this.sessionId = opts.resumeSessionId ?? randomUUID4();
8512
8789
  this.jsonlPath = path9.join(
8513
8790
  this.configDir,
8514
8791
  "projects",
@@ -8538,9 +8815,47 @@ var ClaudeSession = class {
8538
8815
  submitMinMs: opts.submitMinMs ?? 200,
8539
8816
  submitConfirmMs: opts.submitConfirmMs ?? 1500,
8540
8817
  submitMaxRetries: opts.submitMaxRetries ?? 8,
8541
- debug: opts.debug ?? false
8818
+ debug: opts.debug ?? false,
8819
+ // The headless CLI's `tool_progress` heartbeat runs every 30 s, and the
8820
+ // wire-inactivity watchdog it keeps quiet fires at 60 s.
8821
+ heartbeatMs: opts.heartbeatMs ?? 3e4,
8822
+ interruptGraceMs: opts.interruptGraceMs ?? 5e3,
8823
+ // Shorter than `bootQuietMs`, so a trust dialog is answered before boot
8824
+ // could declare the TUI ready and paste a prompt into it.
8825
+ permissionQuietMs: opts.permissionQuietMs ?? 1e3,
8826
+ stopSettleMs: opts.stopSettleMs ?? 750
8542
8827
  };
8543
8828
  }
8829
+ /** The CLI arguments this session spawns with. */
8830
+ spawnArgs() {
8831
+ const args = this.resumeSessionId ? ["--resume", this.resumeSessionId] : ["--session-id", this.sessionId];
8832
+ if (this.o.model) args.push("--model", this.o.model);
8833
+ if (this.o.settingSources !== null && this.o.settingSources !== void 0) {
8834
+ args.push("--setting-sources", this.o.settingSources);
8835
+ }
8836
+ if (this.o.extraArgs && this.o.extraArgs.length) args.push(...this.o.extraArgs);
8837
+ return args;
8838
+ }
8839
+ /** The child environment: the caller's (or the default one) plus `TERM`,
8840
+ * and `CLAUDE_CONFIG_DIR` only for a configured one (`configDirEnv`). */
8841
+ spawnEnv() {
8842
+ if (!this.env) {
8843
+ return interactiveSpawnEnv({
8844
+ configDir: this.explicitConfigDir,
8845
+ ignoreAnthropicApiKey: this.o.ignoreAnthropicApiKey,
8846
+ effort: this.o.effort
8847
+ });
8848
+ }
8849
+ return { ...this.env, ...configDirEnv(this.explicitConfigDir), TERM: "xterm-256color" };
8850
+ }
8851
+ /** True while a turn is between its paste and its end. */
8852
+ get turnRunning() {
8853
+ return this.turn !== null;
8854
+ }
8855
+ /** True once the child is gone (exited, killed or never started). */
8856
+ get hasExited() {
8857
+ return this.exited;
8858
+ }
8544
8859
  async start() {
8545
8860
  if (this.signal?.aborted) throw new Error("aborted before start");
8546
8861
  this.signal?.addEventListener(
@@ -8551,61 +8866,147 @@ var ClaudeSession = class {
8551
8866
  },
8552
8867
  { once: true }
8553
8868
  );
8554
- const claude = resolveClaude(this.o.cliPath ?? "claude");
8555
- const args = ["--session-id", this.sessionId];
8556
- if (this.o.model) args.push("--model", this.o.model);
8557
- if (this.o.settingSources !== null && this.o.settingSources !== void 0) {
8558
- args.push("--setting-sources", this.o.settingSources);
8559
- }
8560
- if (this.o.extraArgs && this.o.extraArgs.length) args.push(...this.o.extraArgs);
8869
+ const claude = this.o.cliPath ?? "claude";
8870
+ const args = this.spawnArgs();
8561
8871
  if (this.o.debug)
8562
8872
  process.stderr.write(`[session] spawn: ${claude} ${args.join(" ")}
8563
8873
  `);
8564
8874
  this.lastDataAt = Date.now();
8565
- this.proc = Bun.spawn([claude, ...args], {
8875
+ const proc = this.spawnPty([claude, ...args], {
8566
8876
  cwd: this.cwd,
8567
- env: interactiveSpawnEnv({
8568
- // The resolved field, not `this.o.configDir`: same value (the
8569
- // constructor copies it in) but typed as always present.
8570
- configDir: this.configDir,
8571
- ignoreAnthropicApiKey: this.o.ignoreAnthropicApiKey,
8572
- effort: this.o.effort
8573
- }),
8574
- terminal: {
8575
- cols: this.o.cols,
8576
- rows: this.o.rows,
8577
- data: (_term, d) => {
8578
- this.lastDataAt = Date.now();
8579
- const chunk = Buffer.from(d).toString("utf8");
8580
- this.raw += chunk;
8581
- if (this.o.debug) process.stdout.write(chunk);
8582
- }
8583
- }
8877
+ env: this.spawnEnv(),
8878
+ cols: this.o.cols,
8879
+ rows: this.o.rows,
8880
+ onData: (chunk) => this.onData(chunk)
8584
8881
  });
8585
- this.proc.exited.then((code) => {
8586
- this.exitCode = typeof code === "number" ? code : null;
8587
- this.exited = true;
8588
- this.proc = null;
8589
- }).catch(() => {
8882
+ this.proc = proc;
8883
+ const markExited = (code) => {
8884
+ if (this.exited) return;
8885
+ this.exitCode = code;
8590
8886
  this.exited = true;
8591
8887
  this.proc = null;
8592
- });
8888
+ this.clearScreenTimer();
8889
+ this.onExit?.(code);
8890
+ };
8891
+ proc.exited.then((code) => markExited(typeof code === "number" ? code : null)).catch(() => markExited(null));
8593
8892
  await this.waitForBoot();
8594
8893
  this.cursor = this.lineCount();
8595
8894
  }
8895
+ onData(chunk) {
8896
+ this.lastDataAt = Date.now();
8897
+ this.raw = (this.raw + chunk).slice(-RAW_KEEP_CHARS);
8898
+ this.screen = (this.screen + chunk).slice(-SCREEN_WINDOW_CHARS);
8899
+ if (this.o.debug) process.stdout.write(chunk);
8900
+ this.clearScreenTimer();
8901
+ this.screenTimer = setTimeout(() => {
8902
+ this.screenTimer = null;
8903
+ this.checkScreen();
8904
+ }, this.o.permissionQuietMs);
8905
+ }
8906
+ clearScreenTimer() {
8907
+ if (this.screenTimer) {
8908
+ clearTimeout(this.screenTimer);
8909
+ this.screenTimer = null;
8910
+ }
8911
+ }
8912
+ /**
8913
+ * Answer the screen the TUI is blocked on, if it is one of the few this
8914
+ * session recognises. Returns what it did, or null. Each screen is acted on
8915
+ * once: the window is cleared afterwards, so the next check only reads what
8916
+ * the TUI drew since.
8917
+ */
8918
+ checkScreen() {
8919
+ if (!this.proc || this.exited) return null;
8920
+ const text = stripTerminal(this.screen);
8921
+ const state = this.trustPending ? { kind: "trust", detail: "folder trust dialog" } : classifyScreen(text);
8922
+ if (!state) return null;
8923
+ let action;
8924
+ switch (state.kind) {
8925
+ case "trust": {
8926
+ this.trustPending = true;
8927
+ const choice = highlightedChoice(text);
8928
+ if (choice === "yes") {
8929
+ this.write("\r");
8930
+ this.trustPending = false;
8931
+ action = "accepted";
8932
+ break;
8933
+ }
8934
+ if (choice === null) return null;
8935
+ if (this.trustMovedAt && this.lastDataAt <= this.trustMovedAt) return null;
8936
+ if (this.trustMoves >= 4) {
8937
+ action = "fatal";
8938
+ break;
8939
+ }
8940
+ this.trustMoves++;
8941
+ this.trustMovedAt = Date.now();
8942
+ this.write("\x1B[B");
8943
+ return null;
8944
+ }
8945
+ case "permission":
8946
+ if (!this.turn) return null;
8947
+ this.write("\x1B");
8948
+ this.turnDenied.push(state);
8949
+ action = "denied";
8950
+ break;
8951
+ case "auto-continue":
8952
+ this.write("\x1B");
8953
+ action = "cancelled";
8954
+ break;
8955
+ case "login":
8956
+ case "onboarding":
8957
+ action = "fatal";
8958
+ break;
8959
+ }
8960
+ this.screen = "";
8961
+ const event = { ...state, action };
8962
+ if (action === "fatal") {
8963
+ this.fatal = event;
8964
+ this.killProcess();
8965
+ }
8966
+ try {
8967
+ this.onScreen?.(event);
8968
+ } catch {
8969
+ }
8970
+ return event;
8971
+ }
8972
+ write(data) {
8973
+ try {
8974
+ this.proc?.terminal.write(data);
8975
+ } catch {
8976
+ }
8977
+ }
8978
+ fatalError() {
8979
+ if (!this.fatal) return null;
8980
+ return new Error(
8981
+ this.failureMessage(fatalScreenMessage(this.fatal.kind, this.configDir, this.cwd), true)
8982
+ );
8983
+ }
8596
8984
  /** Wait until the TUI has been quiet for bootQuietMs (Ink ready), bounded by
8597
- * bootMinMs..bootMaxMs. */
8985
+ * bootMinMs..bootMaxMs. A screen still waiting to be answered when the TUI
8986
+ * goes quiet is answered first, and the wait goes on. */
8598
8987
  async waitForBoot() {
8599
8988
  const start = Date.now();
8600
8989
  while (Date.now() - start < this.o.bootMaxMs) {
8601
8990
  await delay(150);
8602
8991
  if (this.aborted) throw new Error("aborted during boot");
8992
+ const fatal = this.fatalError();
8993
+ if (fatal) throw fatal;
8603
8994
  if (this.exited) {
8604
8995
  throw new Error(this.failureMessage("claude exited during boot", true));
8605
8996
  }
8606
8997
  const elapsed = Date.now() - start;
8607
8998
  const sinceData = Date.now() - this.lastDataAt;
8608
- if (elapsed >= this.o.bootMinMs && sinceData >= this.o.bootQuietMs) return;
8999
+ if (elapsed >= this.o.bootMinMs && sinceData >= this.o.bootQuietMs) {
9000
+ const acted = this.checkScreen();
9001
+ if (!acted && !this.trustPending) return;
9002
+ const fatalNow = this.fatalError();
9003
+ if (fatalNow) throw fatalNow;
9004
+ }
9005
+ }
9006
+ if (this.trustPending) {
9007
+ this.fatal = { kind: "trust", detail: "folder trust dialog", action: "fatal" };
9008
+ this.killProcess();
9009
+ throw this.fatalError();
8609
9010
  }
8610
9011
  }
8611
9012
  /** Submit the freshly-injected prompt and confirm the turn was actually
@@ -8643,7 +9044,7 @@ var ClaudeSession = class {
8643
9044
  return lines.length > 0 ? lines.length - 1 : 0;
8644
9045
  }
8645
9046
  rawTail(max = 600) {
8646
- const clean = this.raw.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "").replace(/\s+/g, " ").trim();
9047
+ const clean = stripTerminal(this.raw).replace(/\s+/g, " ").trim();
8647
9048
  return clean.length > max ? clean.slice(-max) : clean;
8648
9049
  }
8649
9050
  failureMessage(reason, includeRaw = false) {
@@ -8657,61 +9058,133 @@ var ClaudeSession = class {
8657
9058
  return parts.join("; ");
8658
9059
  }
8659
9060
  /**
8660
- * Inject a turn into the live session and return the assistant reply once a
8661
- * terminal stop_reason is observed in the transcript.
9061
+ * Paste a turn into the live session and follow the transcript until the
9062
+ * turn ends, handing every new record to `onRecord` (raw line and parsed
9063
+ * record, the latter null when the line is not JSON). One turn at a time:
9064
+ * a second call while one runs is refused, because both would read the
9065
+ * same cursor.
8662
9066
  */
8663
- async ask(prompt, perTurnTimeoutMs) {
9067
+ async runTurnLoop(prompt, onRecord, timeout, onHeartbeat) {
8664
9068
  if (this.aborted) throw new Error("aborted");
9069
+ const fatal = this.fatalError();
9070
+ if (fatal) throw fatal;
8665
9071
  if (!this.proc || this.exited)
8666
9072
  throw new Error("session not started or already exited");
8667
- const timeout = perTurnTimeoutMs ?? this.o.turnTimeoutMs;
8668
- const t0 = Date.now();
8669
- if (this.o.bracketedPaste) {
8670
- this.proc.terminal.write("\x1B[200~" + prompt + "\x1B[201~");
8671
- } else {
8672
- this.proc.terminal.write(prompt);
8673
- }
8674
- await this.submitTurn();
8675
- const collected2 = [];
8676
- const usage = new TurnUsageAccumulator();
8677
- let stopReason = null;
8678
- const deadline = Date.now() + timeout;
8679
- while (Date.now() < deadline) {
8680
- await delay(this.o.pollMs);
8681
- if (this.aborted) throw new Error("aborted mid-turn");
8682
- const lines = this.readRawLines();
8683
- const lastComplete = lines.length - 1;
8684
- if (lastComplete <= this.cursor) {
8685
- if (this.exited) throw new Error(this.failureMessage("claude exited mid-turn", true));
8686
- continue;
9073
+ if (this.turn) throw new Error("a turn is already running in this session");
9074
+ let settle;
9075
+ this.turn = new Promise((resolve6) => settle = resolve6);
9076
+ this.turnDenied = [];
9077
+ this.abandonTurn = false;
9078
+ this.cursor = Math.max(this.cursor, this.lineCount());
9079
+ this.screen = "";
9080
+ try {
9081
+ if (this.o.bracketedPaste) {
9082
+ this.proc.terminal.write("\x1B[200~" + prompt + "\x1B[201~");
9083
+ } else {
9084
+ this.proc.terminal.write(prompt);
8687
9085
  }
8688
- for (let i = this.cursor; i < lastComplete; i++) {
8689
- const s = lines[i];
8690
- if (!s || !s.trim()) continue;
8691
- let rec;
8692
- try {
8693
- rec = JSON.parse(s);
8694
- } catch {
9086
+ await this.submitTurn();
9087
+ let stopReason = null;
9088
+ let stopSeenAt = 0;
9089
+ let end = null;
9090
+ let sawUserRecord = false;
9091
+ let lastBeat = Date.now();
9092
+ const deadline = Date.now() + timeout;
9093
+ while (Date.now() < deadline) {
9094
+ await delay(this.o.pollMs);
9095
+ if (this.aborted) throw new Error("aborted mid-turn");
9096
+ const fatalNow = this.fatalError();
9097
+ if (fatalNow) throw fatalNow;
9098
+ const lines = this.readRawLines();
9099
+ const lastComplete = lines.length - 1;
9100
+ if (lastComplete > this.cursor) {
9101
+ for (let i = this.cursor; i < lastComplete && !end; i++) {
9102
+ const s = lines[i];
9103
+ if (!s || !s.trim()) continue;
9104
+ let rec = null;
9105
+ try {
9106
+ rec = JSON.parse(s);
9107
+ } catch {
9108
+ }
9109
+ onRecord(s, rec);
9110
+ if (!rec) continue;
9111
+ if (rec.type === "assistant" && rec.message) {
9112
+ const reason = rec.message.stop_reason;
9113
+ if (typeof reason === "string") {
9114
+ stopReason = TERMINAL_STOP.has(reason) ? reason : null;
9115
+ }
9116
+ } else if (rec.type === "user") {
9117
+ if (sawUserRecord && isInterruptRecord(rec)) end = "interrupted";
9118
+ sawUserRecord = true;
9119
+ } else if ((sawUserRecord || stopReason) && isTurnDurationRecord(rec)) {
9120
+ end = stopReason ? "stop" : "ended";
9121
+ }
9122
+ this.cursor = i + 1;
9123
+ }
9124
+ if (!end) this.cursor = lastComplete;
9125
+ lastBeat = Date.now();
9126
+ stopSeenAt = Date.now();
9127
+ if (end) return { stopReason: end === "stop" ? stopReason : null, end, denied: this.turnDenied };
8695
9128
  continue;
8696
9129
  }
8697
- if (rec.type === "assistant" && rec.message) {
8698
- for (const b of rec.message.content ?? []) {
8699
- if (b?.type === "text" && typeof b.text === "string")
8700
- collected2.push(b.text);
8701
- }
8702
- usage.add(rec);
8703
- if (rec.message.stop_reason && TERMINAL_STOP.has(rec.message.stop_reason)) {
8704
- stopReason = rec.message.stop_reason;
8705
- }
9130
+ if (stopReason && Date.now() - stopSeenAt >= this.o.stopSettleMs) {
9131
+ return { stopReason, end: "stop", denied: this.turnDenied };
9132
+ }
9133
+ if (this.exited) throw new Error(this.failureMessage("claude exited mid-turn", true));
9134
+ if (this.abandonTurn) return { stopReason: null, end: "interrupted", denied: this.turnDenied };
9135
+ const now = Date.now();
9136
+ if (onHeartbeat && now - lastBeat >= this.o.heartbeatMs) {
9137
+ lastBeat = now;
9138
+ if (now - this.lastDataAt < this.o.heartbeatMs) onHeartbeat();
8706
9139
  }
8707
9140
  }
8708
- this.cursor = lastComplete;
8709
- if (stopReason) break;
9141
+ return { stopReason: null, end: null, denied: this.turnDenied };
9142
+ } finally {
9143
+ this.turn = null;
9144
+ this.abandonTurn = false;
9145
+ settle();
8710
9146
  }
9147
+ }
9148
+ /**
9149
+ * Stop the running turn the way the TUI's own Esc does, and keep the
9150
+ * session alive for the next one. Resolves true once the turn ended, false
9151
+ * when it was abandoned after `interruptGraceMs` without the TUI saying so.
9152
+ */
9153
+ async interrupt(graceMs = this.o.interruptGraceMs) {
9154
+ const turn = this.turn;
9155
+ if (!turn) return true;
9156
+ this.write("\x1B");
9157
+ const ended = await Promise.race([turn.then(() => true), delay(graceMs).then(() => false)]);
9158
+ if (ended) return true;
9159
+ this.write("\x1B");
9160
+ this.abandonTurn = true;
9161
+ await Promise.race([turn, delay(this.o.pollMs * 4)]);
9162
+ return false;
9163
+ }
9164
+ /**
9165
+ * Inject a turn into the live session and return the assistant reply once a
9166
+ * terminal stop_reason is observed in the transcript.
9167
+ */
9168
+ async ask(prompt, perTurnTimeoutMs) {
9169
+ const timeout = perTurnTimeoutMs ?? this.o.turnTimeoutMs;
9170
+ const t0 = Date.now();
9171
+ const collected2 = [];
9172
+ const usage = new TurnUsageAccumulator();
9173
+ const { stopReason } = await this.runTurnLoop(
9174
+ prompt,
9175
+ (_raw, rec) => {
9176
+ if (rec?.type !== "assistant" || !rec.message) return;
9177
+ for (const b of rec.message.content ?? []) {
9178
+ if (b?.type === "text" && typeof b.text === "string") collected2.push(b.text);
9179
+ }
9180
+ usage.add(rec);
9181
+ },
9182
+ timeout
9183
+ );
8711
9184
  if (!stopReason) {
8712
9185
  throw new Error(
8713
9186
  this.failureMessage(
8714
- `turn timed out after ${timeout}ms (no terminal assistant record; collected ${collected2.length} text block(s))`
9187
+ `turn ended without a terminal assistant record after ${Date.now() - t0}ms (collected ${collected2.length} text block(s))`
8715
9188
  )
8716
9189
  );
8717
9190
  }
@@ -8733,9 +9206,9 @@ var ClaudeSession = class {
8733
9206
  }
8734
9207
  /**
8735
9208
  * Like ask(), but instead of collecting the reply text it re-emits each NEW
8736
- * raw JSONL transcript line via onLine (verbatim) until a terminal
8737
- * stop_reason. Used by the opencode plugin transport shim, which feeds
8738
- * these raw lines into the existing stream-json line handler unchanged.
9209
+ * raw JSONL transcript line via onLine (verbatim) until the turn ends. Used
9210
+ * by the opencode plugin transport shim, which feeds these raw lines into
9211
+ * the existing stream-json line handler unchanged.
8739
9212
  *
8740
9213
  * `usage` is the turn summed over DISTINCT API calls, which is exactly what
8741
9214
  * a headless `result` frame reports, so the shim can synthesize one and
@@ -8743,53 +9216,24 @@ var ClaudeSession = class {
8743
9216
  * `turnStats`) behaves as it does on the headless path. `lastCallUsage` is
8744
9217
  * the newest real call, returned because a caller with no stream parser
8745
9218
  * (`askOnce`) has no other way to get the context side.
9219
+ *
9220
+ * A turn that ends any way other than a terminal stop (an interrupt, a
9221
+ * denied permission dialog, `turn_duration`) resolves with that `end` and a
9222
+ * null `stopReason`; only a timeout throws.
8746
9223
  */
8747
- async tailTurn(prompt, onLine, perTurnTimeoutMs) {
8748
- if (this.aborted) throw new Error("aborted");
8749
- if (!this.proc || this.exited)
8750
- throw new Error("session not started or already exited");
9224
+ async tailTurn(prompt, onLine, perTurnTimeoutMs, onHeartbeat) {
8751
9225
  const timeout = perTurnTimeoutMs ?? this.o.turnTimeoutMs;
8752
- if (this.o.bracketedPaste) {
8753
- this.proc.terminal.write("\x1B[200~" + prompt + "\x1B[201~");
8754
- } else {
8755
- this.proc.terminal.write(prompt);
8756
- }
8757
- await this.submitTurn();
8758
9226
  const usage = new TurnUsageAccumulator();
8759
- let stopReason = null;
8760
- const deadline = Date.now() + timeout;
8761
- while (Date.now() < deadline) {
8762
- await delay(this.o.pollMs);
8763
- if (this.aborted) throw new Error("aborted mid-turn");
8764
- const lines = this.readRawLines();
8765
- const lastComplete = lines.length - 1;
8766
- if (lastComplete <= this.cursor) {
8767
- if (this.exited) {
8768
- throw new Error(this.failureMessage("claude exited mid-turn", true));
8769
- }
8770
- continue;
8771
- }
8772
- for (let i = this.cursor; i < lastComplete; i++) {
8773
- const s = lines[i];
8774
- if (!s || !s.trim()) continue;
8775
- onLine(s);
8776
- let rec;
8777
- try {
8778
- rec = JSON.parse(s);
8779
- } catch {
8780
- continue;
8781
- }
8782
- usage.add(rec);
8783
- if (rec.type === "assistant" && rec.message) {
8784
- if (rec.message.stop_reason && TERMINAL_STOP.has(rec.message.stop_reason)) {
8785
- stopReason = rec.message.stop_reason;
8786
- }
8787
- }
8788
- }
8789
- this.cursor = lastComplete;
8790
- if (stopReason) break;
8791
- }
8792
- if (!stopReason) {
9227
+ const { stopReason, end, denied } = await this.runTurnLoop(
9228
+ prompt,
9229
+ (raw, rec) => {
9230
+ onLine(raw);
9231
+ if (rec) usage.add(rec);
9232
+ },
9233
+ timeout,
9234
+ onHeartbeat
9235
+ );
9236
+ if (!end) {
8793
9237
  throw new Error(
8794
9238
  this.failureMessage(
8795
9239
  `turn timed out after ${timeout}ms (no terminal assistant record)`
@@ -8798,26 +9242,32 @@ var ClaudeSession = class {
8798
9242
  }
8799
9243
  return {
8800
9244
  stopReason,
9245
+ end,
8801
9246
  usage: usage.turnTotal,
8802
9247
  lastCallUsage: usage.lastCall,
8803
- callCount: usage.callCount
9248
+ callCount: usage.callCount,
9249
+ denied
8804
9250
  };
8805
9251
  }
8806
- dispose() {
8807
- if (this.proc) {
8808
- try {
8809
- this.proc.terminal.write("");
8810
- } catch {
8811
- }
8812
- try {
8813
- this.proc.kill();
8814
- } catch {
8815
- }
8816
- try {
8817
- this.proc.terminal.close();
8818
- } catch {
8819
- }
9252
+ killProcess() {
9253
+ const proc = this.proc;
9254
+ if (!proc) return;
9255
+ try {
9256
+ proc.terminal.write("");
9257
+ } catch {
9258
+ }
9259
+ try {
9260
+ proc.kill();
9261
+ } catch {
8820
9262
  }
9263
+ try {
9264
+ proc.terminal.close();
9265
+ } catch {
9266
+ }
9267
+ }
9268
+ dispose() {
9269
+ this.clearScreenTimer();
9270
+ this.killProcess();
8821
9271
  this.proc = null;
8822
9272
  }
8823
9273
  };
@@ -8891,9 +9341,88 @@ function interactiveExtraArgs(opts) {
8891
9341
  }
8892
9342
  return extraArgs;
8893
9343
  }
9344
+ var OpenToolCalls = class {
9345
+ open = /* @__PURE__ */ new Map();
9346
+ add(rec, now = Date.now()) {
9347
+ const content = rec?.message?.content;
9348
+ if (!Array.isArray(content)) return;
9349
+ for (const block of content) {
9350
+ if (rec.type === "assistant" && block?.type === "tool_use" && typeof block.id === "string") {
9351
+ this.open.set(block.id, { name: String(block.name ?? "tool"), startedAt: now });
9352
+ } else if (rec.type === "user" && block?.type === "tool_result") {
9353
+ this.open.delete(block.tool_use_id);
9354
+ }
9355
+ }
9356
+ }
9357
+ newest() {
9358
+ let newest = null;
9359
+ for (const [id, call] of this.open) newest = { id, ...call };
9360
+ return newest;
9361
+ }
9362
+ clear() {
9363
+ this.open.clear();
9364
+ }
9365
+ };
9366
+ function heartbeatFrame(sessionId, openCall, now = Date.now()) {
9367
+ if (openCall) {
9368
+ return JSON.stringify({
9369
+ type: "tool_progress",
9370
+ tool_use_id: `${openCall.id}-heartbeat`,
9371
+ tool_name: openCall.name,
9372
+ parent_tool_use_id: openCall.id,
9373
+ heartbeat: true,
9374
+ elapsed_time_seconds: Math.round((now - openCall.startedAt) / 1e3),
9375
+ session_id: sessionId
9376
+ });
9377
+ }
9378
+ return JSON.stringify({
9379
+ type: "system",
9380
+ subtype: "status",
9381
+ status: "requesting",
9382
+ session_id: sessionId
9383
+ });
9384
+ }
9385
+ function interactiveResultFrame(opts) {
9386
+ const completed = opts.end === "stop" && !!opts.stopReason;
9387
+ let result;
9388
+ let terminalReason = "completed";
9389
+ if (!completed) {
9390
+ if (opts.end === "failed") {
9391
+ terminalReason = "error_during_execution";
9392
+ result = `Interactive transport failed: ${opts.error ?? "unknown error"}`;
9393
+ } else if (opts.end === "interrupted") {
9394
+ terminalReason = "aborted";
9395
+ result = "Interactive transport: the turn was interrupted.";
9396
+ } else {
9397
+ terminalReason = "error_during_execution";
9398
+ result = "Interactive transport: the turn ended without a terminal stop_reason. Output above may be incomplete.";
9399
+ }
9400
+ }
9401
+ return JSON.stringify({
9402
+ type: "result",
9403
+ subtype: completed ? "success" : "error_during_execution",
9404
+ is_error: !completed,
9405
+ stop_reason: completed ? opts.stopReason : null,
9406
+ terminal_reason: terminalReason,
9407
+ result,
9408
+ session_id: opts.sessionId,
9409
+ usage: opts.usage ?? {},
9410
+ // The TUI has no `can_use_tool` channel, so a permission dialog is the
9411
+ // only way it asks; each one the session denied is reported in the
9412
+ // headless field's place, names and ids only (h #g109).
9413
+ permission_denials: opts.denials ?? [],
9414
+ total_cost_usd: null,
9415
+ duration_ms: 0
9416
+ });
9417
+ }
8894
9418
  function spawnInteractiveProcess(opts) {
8895
9419
  const extraArgs = interactiveExtraArgs(opts);
9420
+ const openCalls = new OpenToolCalls();
9421
+ let denials = [];
9422
+ const lineEmitter = new EventEmitter4();
9423
+ const proc = new EventEmitter4();
8896
9424
  const session = new ClaudeSession({
9425
+ ...opts.tuning,
8897
9426
  cwd: opts.cwd,
8898
9427
  cliPath: opts.cliPath,
8899
9428
  configDir: opts.configDir,
@@ -8903,7 +9432,28 @@ function spawnInteractiveProcess(opts) {
8903
9432
  settingSources: opts.settingSources === void 0 ? null : opts.settingSources,
8904
9433
  extraArgs,
8905
9434
  ignoreAnthropicApiKey: opts.ignoreAnthropicApiKey,
8906
- effort: opts.effort ? cliEffortLevel(opts.effort) : void 0
9435
+ effort: opts.effort ? cliEffortLevel(opts.effort) : void 0,
9436
+ resumeSessionId: opts.resumeSessionId,
9437
+ env: opts.env,
9438
+ spawnPty: opts.spawnPty,
9439
+ onScreen: (event) => {
9440
+ if (event.action === "denied") {
9441
+ const call = openCalls.newest();
9442
+ denials.push(call ? { tool_name: call.name, tool_use_id: call.id } : { tool_name: "unknown" });
9443
+ }
9444
+ const data = { sessionId: session.sessionId, screen: event.kind, detail: event.detail };
9445
+ if (event.action === "accepted") log.info("interactive transport accepted a prompt", data);
9446
+ else if (event.action === "fatal") log.error("interactive transport cannot continue", data);
9447
+ else if (event.action === "denied") log.warn("interactive transport denied a permission prompt", data);
9448
+ else log.warn("interactive transport cancelled the usage-limit auto-continue", data);
9449
+ },
9450
+ onExit: (code) => {
9451
+ proc.exitCode = code;
9452
+ proc.signalCode = code === null ? "SIGTERM" : null;
9453
+ log.info("interactive claude exited", { sessionId: session.sessionId, code });
9454
+ proc.emit("exit", proc.exitCode, proc.signalCode);
9455
+ proc.emit("close", proc.exitCode, proc.signalCode);
9456
+ }
8907
9457
  });
8908
9458
  log.info("prepared interactive claude session", {
8909
9459
  cwd: opts.cwd,
@@ -8912,80 +9462,95 @@ function spawnInteractiveProcess(opts) {
8912
9462
  model: opts.model,
8913
9463
  effort: opts.effort,
8914
9464
  sessionId: session.sessionId,
9465
+ resumed: !!opts.resumeSessionId,
8915
9466
  jsonlPath: session.jsonlPath
8916
9467
  });
8917
- const lineEmitter = new EventEmitter4();
8918
- const errorHandlers = /* @__PURE__ */ new Set();
8919
9468
  let startPromise = null;
8920
9469
  const ensureStarted = () => {
8921
9470
  if (!startPromise) startPromise = session.start();
8922
9471
  return startPromise;
8923
9472
  };
8924
- const emitResult = (opts2) => {
8925
- lineEmitter.emit(
8926
- "line",
8927
- JSON.stringify({
8928
- type: "result",
8929
- subtype: opts2.subtype,
8930
- is_error: opts2.isError,
8931
- stop_reason: opts2.stopReason,
8932
- terminal_reason: opts2.terminalReason,
8933
- result: opts2.result,
8934
- session_id: session.sessionId,
8935
- usage: opts2.usage ?? {},
8936
- total_cost_usd: null,
8937
- duration_ms: 0
8938
- })
8939
- );
8940
- };
8941
- const runTurn = (userMsg) => {
8942
- void (async () => {
8943
- try {
8944
- await ensureStarted();
8945
- const { stopReason, usage, lastCallUsage, callCount } = await session.tailTurn(userMsg, (raw) => {
8946
- lineEmitter.emit("line", raw);
8947
- });
8948
- log.info("interactive turn usage", {
8949
- apiCalls: callCount,
8950
- turnOutputTokens: usage?.output_tokens,
8951
- turnInputTokens: usage?.input_tokens,
8952
- turnCacheReadTokens: usage?.cache_read_input_tokens,
8953
- lastCallInputTokens: lastCallUsage?.input_tokens,
8954
- lastCallCacheReadTokens: lastCallUsage?.cache_read_input_tokens,
8955
- lastCallCacheWriteTokens: lastCallUsage?.cache_creation_input_tokens
8956
- });
8957
- const timedOut = !stopReason;
8958
- emitResult({
8959
- subtype: timedOut ? "error_during_execution" : "success",
8960
- isError: timedOut,
8961
- stopReason: timedOut ? null : stopReason,
8962
- terminalReason: timedOut ? "error_during_execution" : "completed",
8963
- result: timedOut ? "Interactive transport: the turn ended without a terminal stop_reason (turn timeout or claude exit). Output above may be incomplete." : void 0,
8964
- usage
8965
- });
8966
- } catch (err) {
8967
- const e = err instanceof Error ? err : new Error(String(err));
8968
- log.error("interactive turn failed", { error: e.message });
8969
- emitResult({
8970
- subtype: "error_during_execution",
8971
- isError: true,
8972
- stopReason: null,
8973
- terminalReason: "error_during_execution",
8974
- result: `Interactive transport failed: ${e.message}`
8975
- });
8976
- if (errorHandlers.size > 0) {
8977
- for (const h of errorHandlers) h(e);
8978
- } else {
8979
- lineEmitter.emit("close");
8980
- }
9473
+ let writes = 0;
9474
+ let cancelledThrough = 0;
9475
+ let queued = 0;
9476
+ let chain = Promise.resolve();
9477
+ const runTurn = async (userMsg, turnNumber) => {
9478
+ const current = () => turnNumber === writes;
9479
+ const emit2 = (line) => {
9480
+ if (current()) lineEmitter.emit("line", line);
9481
+ };
9482
+ try {
9483
+ await ensureStarted();
9484
+ if (turnNumber <= cancelledThrough) {
9485
+ log.info("interactive turn cancelled before it started", { sessionId: session.sessionId });
9486
+ return;
8981
9487
  }
8982
- })();
9488
+ openCalls.clear();
9489
+ denials = [];
9490
+ const { stopReason, end, usage, lastCallUsage, callCount, denied } = await session.tailTurn(
9491
+ userMsg,
9492
+ (raw) => {
9493
+ try {
9494
+ openCalls.add(JSON.parse(raw));
9495
+ } catch {
9496
+ }
9497
+ emit2(raw);
9498
+ },
9499
+ void 0,
9500
+ () => emit2(heartbeatFrame(session.sessionId, openCalls.newest()))
9501
+ );
9502
+ log.info("interactive turn ended", {
9503
+ end,
9504
+ stopReason,
9505
+ superseded: !current(),
9506
+ apiCalls: callCount,
9507
+ turnOutputTokens: usage?.output_tokens,
9508
+ turnInputTokens: usage?.input_tokens,
9509
+ turnCacheReadTokens: usage?.cache_read_input_tokens,
9510
+ lastCallInputTokens: lastCallUsage?.input_tokens,
9511
+ lastCallCacheReadTokens: lastCallUsage?.cache_read_input_tokens,
9512
+ lastCallCacheWriteTokens: lastCallUsage?.cache_creation_input_tokens,
9513
+ deniedPrompts: denied.length,
9514
+ cancelled: turnNumber <= cancelledThrough
9515
+ });
9516
+ emit2(
9517
+ interactiveResultFrame({
9518
+ sessionId: session.sessionId,
9519
+ end,
9520
+ stopReason,
9521
+ usage,
9522
+ denials
9523
+ })
9524
+ );
9525
+ } catch (err) {
9526
+ const e = err instanceof Error ? err : new Error(String(err));
9527
+ log.error("interactive turn failed", { error: e.message, superseded: !current() });
9528
+ if (!current()) return;
9529
+ emit2(
9530
+ interactiveResultFrame({
9531
+ sessionId: existsSync4(session.jsonlPath) ? session.sessionId : void 0,
9532
+ end: "failed",
9533
+ stopReason: null,
9534
+ error: e.message
9535
+ })
9536
+ );
9537
+ if (proc.listenerCount("error") > 0) proc.emit("error", e);
9538
+ else lineEmitter.emit("close");
9539
+ }
9540
+ };
9541
+ const enqueueTurn = (userMsg) => {
9542
+ const turnNumber = ++writes;
9543
+ queued++;
9544
+ chain = chain.then(() => runTurn(userMsg, turnNumber)).finally(() => {
9545
+ queued--;
9546
+ });
8983
9547
  };
8984
- const proc = {
9548
+ Object.assign(proc, {
8985
9549
  stdin: {
9550
+ writable: true,
8986
9551
  write(chunk) {
8987
9552
  const raw = typeof chunk === "string" && chunk.endsWith("\n") ? chunk.slice(0, -1) : chunk;
8988
- runTurn(decodeUserEnvelope(raw));
9553
+ enqueueTurn(decodeUserEnvelope(raw));
8989
9554
  return true;
8990
9555
  },
8991
9556
  end() {
@@ -8995,47 +9560,56 @@ function spawnInteractiveProcess(opts) {
8995
9560
  stderr: null,
8996
9561
  pid: -1,
8997
9562
  killed: false,
8998
- on(event, fn) {
8999
- if (event === "error") errorHandlers.add(fn);
9000
- return proc;
9001
- },
9002
- once() {
9003
- return proc;
9004
- },
9005
- off(event, fn) {
9006
- if (event === "error") errorHandlers.delete(fn);
9007
- return proc;
9008
- },
9563
+ exitCode: null,
9564
+ signalCode: null,
9009
9565
  kill() {
9566
+ if (!proc.killed) {
9567
+ proc.killed = true;
9568
+ if (opts.systemPromptFile) {
9569
+ void unlink2(opts.systemPromptFile).catch(() => {
9570
+ });
9571
+ }
9572
+ }
9573
+ const started = startPromise !== null;
9010
9574
  try {
9011
9575
  session.dispose();
9012
9576
  } catch {
9013
9577
  }
9014
- if (opts.systemPromptFile) {
9015
- void unlink2(opts.systemPromptFile).catch(() => {
9016
- });
9578
+ if (!started && proc.exitCode === null && proc.signalCode === null) {
9579
+ proc.signalCode = "SIGTERM";
9580
+ proc.emit("exit", null, "SIGTERM");
9581
+ proc.emit("close", null, "SIGTERM");
9017
9582
  }
9018
- proc.killed = true;
9019
9583
  return true;
9020
9584
  }
9021
- };
9585
+ });
9022
9586
  return {
9023
9587
  proc,
9024
9588
  lineEmitter,
9025
9589
  proxyServer: null,
9026
9590
  mcpHash: void 0,
9027
- systemPromptFile: opts.systemPromptFile
9591
+ systemPromptFile: opts.systemPromptFile,
9592
+ cliPath: opts.cliPath,
9593
+ interactiveControl: {
9594
+ turnRunning: () => queued > 0,
9595
+ // Stops the running turn and every queued one: they were all written
9596
+ // by the turn being aborted, or by one before it.
9597
+ interrupt: (timeoutMs) => {
9598
+ cancelledThrough = writes;
9599
+ return session.interrupt(timeoutMs);
9600
+ }
9601
+ }
9028
9602
  };
9029
9603
  }
9030
9604
 
9031
9605
  // src/prompts.ts
9032
- import { readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
9033
- import { homedir as homedir7 } from "os";
9606
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
9607
+ import { homedir as homedir8 } from "os";
9034
9608
  import { randomUUID as randomUUID5 } from "crypto";
9035
- import { dirname as dirname6, join as join9 } from "path";
9609
+ import { dirname as dirname6, join as join10 } from "path";
9036
9610
  function readPromptFileIfPresent(path11) {
9037
9611
  try {
9038
- const content = readFileSync6(path11, "utf8").trim();
9612
+ const content = readFileSync7(path11, "utf8").trim();
9039
9613
  return content || void 0;
9040
9614
  } catch {
9041
9615
  return void 0;
@@ -9044,7 +9618,7 @@ function readPromptFileIfPresent(path11) {
9044
9618
  function nearestWorkspaceAgentsPrompt(cwd) {
9045
9619
  let dir = cwd;
9046
9620
  while (true) {
9047
- const content = readPromptFileIfPresent(join9(dir, "AGENTS.md"));
9621
+ const content = readPromptFileIfPresent(join10(dir, "AGENTS.md"));
9048
9622
  if (content) return content;
9049
9623
  const parent = dirname6(dir);
9050
9624
  if (parent === dir) return void 0;
@@ -9153,8 +9727,8 @@ ${options.compressionSummary.trim()}`
9153
9727
  for (const s of extraSystemContent) {
9154
9728
  if (s.trim()) parts.push(s.trim());
9155
9729
  }
9156
- const configRoot = process.env.XDG_CONFIG_HOME ?? join9(homedir7(), ".config");
9157
- const globalAgents = readPromptFileIfPresent(join9(configRoot, "opencode", "AGENTS.md"));
9730
+ const configRoot = process.env.XDG_CONFIG_HOME ?? join10(homedir8(), ".config");
9731
+ const globalAgents = readPromptFileIfPresent(join10(configRoot, "opencode", "AGENTS.md"));
9158
9732
  const workspaceAgents = nearestWorkspaceAgentsPrompt(cwd);
9159
9733
  const forwarded = extraSystemContent.join("\n\n");
9160
9734
  const pushGlobal = !!globalAgents && !forwarded.includes(globalAgents);
@@ -9165,9 +9739,9 @@ ${options.compressionSummary.trim()}`
9165
9739
  if (includeMultiStepHint) parts.push(MULTI_STEP_TASK_HINT);
9166
9740
  const content = parts.join("\n\n");
9167
9741
  if (!content) return void 0;
9168
- const path11 = join9(pluginTmpDir(), `opencode-cc-sys-${randomUUID5()}.md`);
9742
+ const path11 = join10(pluginTmpDir(), `opencode-cc-sys-${randomUUID5()}.md`);
9169
9743
  try {
9170
- writeFileSync4(path11, content, { encoding: "utf8", mode: 384 });
9744
+ writeFileSync5(path11, content, { encoding: "utf8", mode: 384 });
9171
9745
  return path11;
9172
9746
  } catch (err) {
9173
9747
  log.warn("failed to write system prompt file", { error: String(err) });
@@ -9885,8 +10459,8 @@ function synthesizeTitle(prompt) {
9885
10459
  "summary",
9886
10460
  "project"
9887
10461
  ]);
9888
- const words = source.split(" ").map((word) => word.trim()).filter(Boolean).filter((word) => !stop.has(word.toLowerCase()));
9889
- const picked = (words.length > 0 ? words : source.split(" ").filter(Boolean)).slice(0, 6).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
10462
+ const words2 = source.split(" ").map((word) => word.trim()).filter(Boolean).filter((word) => !stop.has(word.toLowerCase()));
10463
+ const picked = (words2.length > 0 ? words2 : source.split(" ").filter(Boolean)).slice(0, 6).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
9890
10464
  return picked || "New Session";
9891
10465
  }
9892
10466
 
@@ -10350,6 +10924,11 @@ function clearStartWatchdog(state) {
10350
10924
  function onStartWatchdogFire(state) {
10351
10925
  state.startWatchdog = null;
10352
10926
  if (state.controllerClosed || state.hasReceivedContent || state.hasReceivedProgress) return;
10927
+ const interactive = state.activeProcess?.interactiveControl;
10928
+ if (interactive) {
10929
+ if (interactive.turnRunning()) armStartWatchdog(state);
10930
+ return;
10931
+ }
10353
10932
  if (state.respawnAttempted) {
10354
10933
  log.error(
10355
10934
  "claude process still silent after respawn; ending turn",
@@ -12170,6 +12749,23 @@ var ClaudeCodeLanguageModel = class {
12170
12749
  }
12171
12750
  }
12172
12751
  }
12752
+ if (this.config.resumeAfterRestart !== false && includeHistoryContext && !compactionMode && !forkFromClaudeSessionId && failoverAnswer?.kind !== "switch") {
12753
+ const configDir = resolveConfigDir(this.config.configDir);
12754
+ const resumePoint = findResumePoint({
12755
+ sessionKey: sk,
12756
+ prompt: options.prompt,
12757
+ cliPath,
12758
+ transcriptPath: (id) => join11(configDir, "projects", encodeCwd(cwd), `${id}.jsonl`)
12759
+ });
12760
+ if (resumePoint) {
12761
+ setClaudeSessionId(sk, resumePoint.claudeSessionId);
12762
+ includeHistoryContext = false;
12763
+ log.notice("resuming the claude session from before the restart instead of replaying it", {
12764
+ sessionKey: sk,
12765
+ matchedMessages: resumePoint.matched
12766
+ });
12767
+ }
12768
+ }
12173
12769
  if (this.config.forkSessions === true && !compactionMode && !useInteractive) {
12174
12770
  recordForkFingerprint(sk, options.prompt, cliPath);
12175
12771
  }
@@ -12463,6 +13059,7 @@ var ClaudeCodeLanguageModel = class {
12463
13059
  ...self.skillBridgeSpawn(failover)
12464
13060
  });
12465
13061
  if (stoppedBeforeWork()) return;
13062
+ const resumeSessionId = getClaudeSessionId(sk);
12466
13063
  const ap = spawnInteractiveProcess({
12467
13064
  cwd,
12468
13065
  cliPath,
@@ -12474,8 +13071,20 @@ var ClaudeCodeLanguageModel = class {
12474
13071
  permissionsAllow: allow,
12475
13072
  systemPromptFile,
12476
13073
  ignoreAnthropicApiKey: self.config.ignoreAnthropicApiKey,
12477
- effort: reasoningEffort
13074
+ effort: reasoningEffort,
13075
+ resumeSessionId,
13076
+ // The headless spawn's env, so hygiene, effort and the
13077
+ // agent's prompt cache TTL reach both transports alike.
13078
+ env: claudeSpawnEnv({
13079
+ ignoreAnthropicApiKey: self.config.ignoreAnthropicApiKey,
13080
+ effort: reasoningEffort,
13081
+ promptCacheTtl
13082
+ })
12478
13083
  });
13084
+ ap.proc.once(
13085
+ "exit",
13086
+ (code) => noteInteractiveProcessExit(sk, ap, code)
13087
+ );
12479
13088
  ap.mcpHash = mcp.bridgedHash;
12480
13089
  ap.mcpServers = mcp.allEnabledServerNames;
12481
13090
  setActiveProcess(sk, ap);
@@ -12484,6 +13093,7 @@ var ClaudeCodeLanguageModel = class {
12484
13093
  state.activeProcess = ap;
12485
13094
  log.info("spawned interactive claude session", {
12486
13095
  sk,
13096
+ resumed: !!resumeSessionId,
12487
13097
  cliPath,
12488
13098
  configDir: self.config.configDir,
12489
13099
  model: effectiveModelId
@@ -12723,6 +13333,9 @@ var ClaudeCodeLanguageModel = class {
12723
13333
  state.autoContinueState.startedAt = Date.now();
12724
13334
  const completeResult = (msg) => {
12725
13335
  if (state.controllerClosed) return;
13336
+ if (self.config.resumeAfterRestart !== false && !compactionMode && msg.is_error !== true && typeof msg.session_id === "string") {
13337
+ recordResumePoint(sk, msg.session_id, options.prompt, cliPath);
13338
+ }
12726
13339
  if (deliverPendingCompletions(state)) {
12727
13340
  if (state.drainBuffer.length > 0) drainNow(state);
12728
13341
  return;
@@ -13179,15 +13792,15 @@ var ClaudeCodeLanguageModel = class {
13179
13792
 
13180
13793
  // src/cleanup-stale.ts
13181
13794
  import {
13182
- existsSync as existsSync3,
13183
- mkdirSync as mkdirSync4,
13184
- readFileSync as readFileSync7,
13795
+ existsSync as existsSync5,
13796
+ mkdirSync as mkdirSync5,
13797
+ readFileSync as readFileSync8,
13185
13798
  realpathSync as realpathSync4,
13186
13799
  rmSync as rmSync3,
13187
- writeFileSync as writeFileSync5
13800
+ writeFileSync as writeFileSync6
13188
13801
  } from "fs";
13189
- import { homedir as homedir8 } from "os";
13190
- import { dirname as dirname7, join as join10, resolve as resolve5 } from "path";
13802
+ import { homedir as homedir9 } from "os";
13803
+ import { dirname as dirname7, join as join12, resolve as resolve5 } from "path";
13191
13804
  import { fileURLToPath as fileURLToPath4 } from "url";
13192
13805
  var STALE_PACKAGE_NAME = "opencode-claude-code-plugin";
13193
13806
  var SUSPECT_DESCRIPTION_TOKEN = "Claude Code";
@@ -13195,20 +13808,20 @@ var alreadyRan = false;
13195
13808
  function candidateCacheRoots() {
13196
13809
  const xdg = process.env.XDG_CACHE_HOME;
13197
13810
  return [
13198
- xdg ? join10(xdg, "opencode") : null,
13199
- join10(homedir8(), ".cache", "opencode"),
13200
- join10(homedir8(), "Library", "Caches", "opencode")
13811
+ xdg ? join12(xdg, "opencode") : null,
13812
+ join12(homedir9(), ".cache", "opencode"),
13813
+ join12(homedir9(), "Library", "Caches", "opencode")
13201
13814
  ].filter((p) => Boolean(p));
13202
13815
  }
13203
13816
  function userOpencodeJsonPath() {
13204
- const xdgConfig = process.env.XDG_CONFIG_HOME ?? join10(homedir8(), ".config");
13205
- return join10(xdgConfig, "opencode", "opencode.json");
13817
+ const xdgConfig = process.env.XDG_CONFIG_HOME ?? join12(homedir9(), ".config");
13818
+ return join12(xdgConfig, "opencode", "opencode.json");
13206
13819
  }
13207
13820
  function userIntendsToUseUnscoped() {
13208
13821
  const cfg = userOpencodeJsonPath();
13209
- if (!existsSync3(cfg)) return false;
13822
+ if (!existsSync5(cfg)) return false;
13210
13823
  try {
13211
- const json = JSON.parse(readFileSync7(cfg, "utf8"));
13824
+ const json = JSON.parse(readFileSync8(cfg, "utf8"));
13212
13825
  const plugins = json.plugin;
13213
13826
  if (!Array.isArray(plugins)) return false;
13214
13827
  return plugins.some(
@@ -13229,19 +13842,19 @@ function ourLoadedDir() {
13229
13842
  function ourVersion(dir) {
13230
13843
  if (!dir) return null;
13231
13844
  try {
13232
- const pkg = JSON.parse(readFileSync7(join10(dir, "package.json"), "utf8"));
13845
+ const pkg = JSON.parse(readFileSync8(join12(dir, "package.json"), "utf8"));
13233
13846
  return typeof pkg.version === "string" && pkg.version ? pkg.version : null;
13234
13847
  } catch {
13235
13848
  return null;
13236
13849
  }
13237
13850
  }
13238
13851
  function markerPath() {
13239
- const stateRoot = process.env.XDG_STATE_HOME ?? join10(homedir8(), ".local", "state");
13240
- return join10(stateRoot, "opencode-claude-code-plugin", "cleanup-stale.json");
13852
+ const stateRoot = process.env.XDG_STATE_HOME ?? join12(homedir9(), ".local", "state");
13853
+ return join12(stateRoot, "opencode-claude-code-plugin", "cleanup-stale.json");
13241
13854
  }
13242
13855
  function markerVersion() {
13243
13856
  try {
13244
- const marker = JSON.parse(readFileSync7(markerPath(), "utf8"));
13857
+ const marker = JSON.parse(readFileSync8(markerPath(), "utf8"));
13245
13858
  return typeof marker.version === "string" ? marker.version : null;
13246
13859
  } catch {
13247
13860
  return null;
@@ -13250,8 +13863,8 @@ function markerVersion() {
13250
13863
  function writeMarker(version) {
13251
13864
  const file = markerPath();
13252
13865
  try {
13253
- mkdirSync4(dirname7(file), { recursive: true });
13254
- writeFileSync5(
13866
+ mkdirSync5(dirname7(file), { recursive: true });
13867
+ writeFileSync6(
13255
13868
  file,
13256
13869
  JSON.stringify({ version, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2) + "\n",
13257
13870
  "utf8"
@@ -13285,20 +13898,20 @@ function cleanupStaleUnscopedInstall() {
13285
13898
  if (version) writeMarker(version);
13286
13899
  }
13287
13900
  function cleanupOne(cacheRoot, ourDir) {
13288
- if (!existsSync3(cacheRoot)) return;
13289
- const stalePath = join10(cacheRoot, "node_modules", STALE_PACKAGE_NAME);
13290
- if (!existsSync3(stalePath)) return;
13901
+ if (!existsSync5(cacheRoot)) return;
13902
+ const stalePath = join12(cacheRoot, "node_modules", STALE_PACKAGE_NAME);
13903
+ if (!existsSync5(stalePath)) return;
13291
13904
  let realStalePath = stalePath;
13292
13905
  try {
13293
13906
  realStalePath = realpathSync4(stalePath);
13294
13907
  } catch {
13295
13908
  }
13296
13909
  if (ourDir && realStalePath === ourDir) return;
13297
- const pkgJsonPath = join10(stalePath, "package.json");
13298
- if (!existsSync3(pkgJsonPath)) return;
13910
+ const pkgJsonPath = join12(stalePath, "package.json");
13911
+ if (!existsSync5(pkgJsonPath)) return;
13299
13912
  let pkg = {};
13300
13913
  try {
13301
- pkg = JSON.parse(readFileSync7(pkgJsonPath, "utf8"));
13914
+ pkg = JSON.parse(readFileSync8(pkgJsonPath, "utf8"));
13302
13915
  } catch {
13303
13916
  return;
13304
13917
  }
@@ -13314,13 +13927,13 @@ function cleanupOne(cacheRoot, ourDir) {
13314
13927
  });
13315
13928
  return;
13316
13929
  }
13317
- const cachePkgJson = join10(cacheRoot, "package.json");
13318
- if (!existsSync3(cachePkgJson)) return;
13930
+ const cachePkgJson = join12(cacheRoot, "package.json");
13931
+ if (!existsSync5(cachePkgJson)) return;
13319
13932
  try {
13320
- const cfg = JSON.parse(readFileSync7(cachePkgJson, "utf8"));
13933
+ const cfg = JSON.parse(readFileSync8(cachePkgJson, "utf8"));
13321
13934
  if (cfg?.dependencies?.[STALE_PACKAGE_NAME]) {
13322
13935
  delete cfg.dependencies[STALE_PACKAGE_NAME];
13323
- writeFileSync5(cachePkgJson, JSON.stringify(cfg, null, 2) + "\n");
13936
+ writeFileSync6(cachePkgJson, JSON.stringify(cfg, null, 2) + "\n");
13324
13937
  log.info("cleanup-stale: pruned dep from cache package.json");
13325
13938
  }
13326
13939
  } catch (err) {
@@ -13981,6 +14594,7 @@ function createClaudeCode(settings = {}) {
13981
14594
  bridgeSkipNativeSkills: settings.bridgeSkipNativeSkills !== false,
13982
14595
  turnStats: settings.turnStats === true,
13983
14596
  forkSessions: settings.forkSessions === true,
14597
+ resumeAfterRestart: settings.resumeAfterRestart !== false,
13984
14598
  interactive: settings.interactive,
13985
14599
  interactiveBypass: settings.interactiveBypass,
13986
14600
  interactiveAllowTools: settings.interactiveAllowTools,
@@ -14066,12 +14680,12 @@ function configModelsForProvider(providerModels, providerID, modelSuffix) {
14066
14680
  }
14067
14681
  return models;
14068
14682
  }
14069
- function isRecord4(value) {
14683
+ function isRecord5(value) {
14070
14684
  return typeof value === "object" && value !== null && !Array.isArray(value);
14071
14685
  }
14072
14686
  function completeUserModel(id, entry, providerID) {
14073
- const record = isRecord4(entry) ? entry : {};
14074
- const runtimeShaped = isRecord4(record.capabilities) && isRecord4(record.capabilities.input) && isRecord4(record.capabilities.output) && isRecord4(record.cost) && isRecord4(record.cost.cache) && isRecord4(record.api);
14687
+ const record = isRecord5(entry) ? entry : {};
14688
+ const runtimeShaped = isRecord5(record.capabilities) && isRecord5(record.capabilities.input) && isRecord5(record.capabilities.output) && isRecord5(record.cost) && isRecord5(record.cost.cache) && isRecord5(record.api);
14075
14689
  if (runtimeShaped) {
14076
14690
  return toConfigModel({ ...record, providerID });
14077
14691
  }
@@ -14084,8 +14698,8 @@ function completeUserModel(id, entry, providerID) {
14084
14698
  const merged = { ...base, ...record, id };
14085
14699
  for (const key of ["cost", "limit", "variants"]) {
14086
14700
  merged[key] = {
14087
- ...isRecord4(base[key]) ? base[key] : {},
14088
- ...isRecord4(record[key]) ? record[key] : {}
14701
+ ...isRecord5(base[key]) ? base[key] : {},
14702
+ ...isRecord5(record[key]) ? record[key] : {}
14089
14703
  };
14090
14704
  }
14091
14705
  return merged;
@@ -14182,7 +14796,7 @@ async function buildAgentRegistry(config) {
14182
14796
  typeof configuredTtl === "string" ? configuredTtl : void 0
14183
14797
  );
14184
14798
  setProviderFallbackModels(parseFallbackModelList(options?.fallbackModels));
14185
- const records = await readAgentMarkdownRecords(
14799
+ const records2 = await readAgentMarkdownRecords(
14186
14800
  agentDirectories(
14187
14801
  process.env.HOME ?? process.env.USERPROFILE,
14188
14802
  getOpencodeProjectDirectory()
@@ -14197,18 +14811,18 @@ async function buildAgentRegistry(config) {
14197
14811
  const declaredChain = parseFallbackModelList(
14198
14812
  agent.fallbackModels ?? bag.fallbackModels
14199
14813
  );
14200
- records[name] = {
14201
- mode: pick("mode") ?? records[name]?.mode,
14202
- model: pick("model") ?? records[name]?.model,
14203
- forceModel: pick("forceModel") ?? records[name]?.forceModel,
14204
- reasoningEffort: pick("reasoningEffort") ?? records[name]?.reasoningEffort,
14205
- cacheTtl: pick("cacheTtl") ?? records[name]?.cacheTtl,
14206
- fallbackModels: declaredChain.length ? declaredChain : records[name]?.fallbackModels
14814
+ records2[name] = {
14815
+ mode: pick("mode") ?? records2[name]?.mode,
14816
+ model: pick("model") ?? records2[name]?.model,
14817
+ forceModel: pick("forceModel") ?? records2[name]?.forceModel,
14818
+ reasoningEffort: pick("reasoningEffort") ?? records2[name]?.reasoningEffort,
14819
+ cacheTtl: pick("cacheTtl") ?? records2[name]?.cacheTtl,
14820
+ fallbackModels: declaredChain.length ? declaredChain : records2[name]?.fallbackModels
14207
14821
  };
14208
14822
  }
14209
- setAgentRegistry(records);
14823
+ setAgentRegistry(records2);
14210
14824
  log.debug("agent registry built", {
14211
- agents: Object.keys(records).length,
14825
+ agents: Object.keys(records2).length,
14212
14826
  defaultSubagentModel: getDefaultSubagentModel()
14213
14827
  });
14214
14828
  }