@khalilgharbaoui/opencode-claude-code-plugin 0.41.0 → 0.42.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,5 +1,5 @@
1
1
  // src/claude-code-language-model.ts
2
- import { join as join11 } from "path";
2
+ import { join as join12 } from "path";
3
3
 
4
4
  // src/ids.ts
5
5
  import { randomInt } from "crypto";
@@ -1719,6 +1719,10 @@ function cliSupportsPermissionPrompts(v) {
1719
1719
  if (!v) return false;
1720
1720
  return gte(v, { major: 2, minor: 1, patch: 263 });
1721
1721
  }
1722
+ function cliSupportsDontAsk(v) {
1723
+ if (!v) return false;
1724
+ return gte(v, { major: 2, minor: 1, patch: 263 });
1725
+ }
1722
1726
  function cliSupportsSideQuestion(v) {
1723
1727
  if (!v) return false;
1724
1728
  return gte(v, { major: 2, minor: 1, patch: 258 });
@@ -4533,6 +4537,20 @@ function summarizePermissionPreset(provider, options, defaultProxyTools = DEFAUL
4533
4537
  overrides: resolved.overridden
4534
4538
  };
4535
4539
  }
4540
+ function interactivePermissionPosture(input) {
4541
+ if (!isReadOnlyPermissionMode(input.permissionMode)) {
4542
+ return { permissionMode: input.permissionMode, restricted: false, allow: input.allow };
4543
+ }
4544
+ if (!input.supportsReadOnly) return null;
4545
+ const refused = new Set(READ_ONLY_DISALLOWED_CLI_TOOLS);
4546
+ return {
4547
+ permissionMode: "dontAsk",
4548
+ restricted: true,
4549
+ allow: input.allow.filter(
4550
+ (rule) => !rule.startsWith("mcp__") && !refused.has(rule.split("(")[0].trim())
4551
+ )
4552
+ };
4553
+ }
4536
4554
  function isReadOnlyPermissionMode(mode) {
4537
4555
  return mode === READ_ONLY_PERMISSION_MODE;
4538
4556
  }
@@ -4612,7 +4630,25 @@ async function requestSideQuestion(activeProcess, question, options) {
4612
4630
  if (!question) return { response: SIDE_QUESTION_USAGE, synthetic: true };
4613
4631
  options.abortSignal?.throwIfAborted();
4614
4632
  const { proc, lineEmitter } = activeProcess;
4615
- if (options.interactive || !proc.stdout) {
4633
+ if (options.interactive) {
4634
+ const askAside = activeProcess.interactiveControl?.askAside;
4635
+ if (!askAside) throw new Error("/btw needs a live interactive Claude Code session.");
4636
+ if (pendingProcesses.has(proc)) {
4637
+ throw new Error("Wait for the current /btw to finish before asking another.");
4638
+ }
4639
+ pendingProcesses.add(proc);
4640
+ try {
4641
+ const response = await askAside(question, {
4642
+ ...options.history === void 0 ? {} : { history: options.history },
4643
+ timeoutMs: options.timeoutMs ?? 12e4,
4644
+ ...options.abortSignal ? { abortSignal: options.abortSignal } : {}
4645
+ });
4646
+ return { response, synthetic: false };
4647
+ } finally {
4648
+ pendingProcesses.delete(proc);
4649
+ }
4650
+ }
4651
+ if (!proc.stdout) {
4616
4652
  throw new Error("/btw requires the headless Claude Code transport; interactive sessions are not supported.");
4617
4653
  }
4618
4654
  if (!cliSupportsSideQuestion(options.cliVersion)) {
@@ -5762,6 +5798,8 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
5762
5798
  "interactive system prompt disabled; opencode agent prompts will not be appended",
5763
5799
  "interactive transport cancelled the usage-limit auto-continue",
5764
5800
  "interactive transport cannot continue",
5801
+ "interactive transport could not answer the plan approval; stopping the turn",
5802
+ "interactive transport could not stage an image",
5765
5803
  "interactive transport denied a permission prompt",
5766
5804
  "interactive transport dropped non-text content blocks",
5767
5805
  "interactive turn failed",
@@ -8554,8 +8592,10 @@ async function resolveSkillPluginDirs(opts) {
8554
8592
 
8555
8593
  // src/claude-session-wrapper.ts
8556
8594
  import { EventEmitter as EventEmitter4 } from "events";
8557
- import { existsSync as existsSync4 } from "fs";
8595
+ import { randomUUID as randomUUID5 } from "crypto";
8596
+ import { existsSync as existsSync4, writeFileSync as writeFileSync5 } from "fs";
8558
8597
  import { unlink as unlink2 } from "fs/promises";
8598
+ import { join as join10 } from "path";
8559
8599
 
8560
8600
  // src/claude-session-bun.ts
8561
8601
  import * as os4 from "os";
@@ -8635,6 +8675,17 @@ var SCREEN_PATTERNS = [
8635
8675
  "i"
8636
8676
  )
8637
8677
  },
8678
+ {
8679
+ // Measured verbatim on 2.1.288: "Claude has written up a plan and is ready
8680
+ // to execute. Would you like to proceed? ❯ 1. Yes, auto-accept edits
8681
+ // 2. Yes, manually approve edits 3. Tell Claude what to change". The
8682
+ // numbered last choice is what a reply quoting the question never has.
8683
+ kind: "plan-approval",
8684
+ pattern: new RegExp(
8685
+ `${words("Would you like to proceed?")}[\\s\\S]{0,400}?\\d\\s*\\.\\s*(?:${words("Tell Claude what to change")}|${words("No, keep planning")})`,
8686
+ "i"
8687
+ )
8688
+ },
8638
8689
  {
8639
8690
  // The question alone is not enough: Claude can write "Do you want to
8640
8691
  // proceed?" in a reply. The dialog always numbers its options.
@@ -8665,6 +8716,13 @@ function highlightedChoice(text) {
8665
8716
  }
8666
8717
  return last === "yes" || last === "no" ? last : null;
8667
8718
  }
8719
+ function planApprovalKeys(text) {
8720
+ const approve = /(\d)\s*\.\s*Yes,?\s*(?:and\s*)?manually\s*approve\s*edits/i.exec(text)?.[1] ?? /(\d)\s*\.\s*Yes,?\s*(?:and\s*)?auto-?\s*accept\s*edits/i.exec(text)?.[1] ?? null;
8721
+ const tell = /(\d)\s*\.\s*Tell\s*Claude\s*what\s*to\s*change/i.exec(text)?.[1];
8722
+ if (tell) return { approve, reject: tell, rejectTakesText: true };
8723
+ const keep = /(\d)\s*\.\s*No,?\s*keep\s*planning/i.exec(text)?.[1] ?? null;
8724
+ return { approve, reject: keep, rejectTakesText: false };
8725
+ }
8668
8726
  function classifyScreen(text) {
8669
8727
  for (const { kind, pattern } of SCREEN_PATTERNS) {
8670
8728
  const match = pattern.exec(text);
@@ -8756,7 +8814,7 @@ var TurnUsageAccumulator = class {
8756
8814
  var TERMINAL_STOP = /* @__PURE__ */ new Set(["end_turn", "stop_sequence", "max_tokens"]);
8757
8815
  var delay = (ms) => new Promise((r) => setTimeout(r, ms));
8758
8816
  var RAW_KEEP_CHARS = 64 * 1024;
8759
- var SCREEN_WINDOW_CHARS = 16 * 1024;
8817
+ var SCREEN_WINDOW_CHARS = 64 * 1024;
8760
8818
  var INTERRUPT_MARKER = "[Request interrupted by user";
8761
8819
  function isInterruptRecord(rec) {
8762
8820
  if (!rec || rec.type !== "user" || !rec.message) return false;
@@ -8770,6 +8828,11 @@ function isInterruptRecord(rec) {
8770
8828
  function isTurnDurationRecord(rec) {
8771
8829
  return rec?.type === "system" && rec.subtype === "turn_duration";
8772
8830
  }
8831
+ var normalizedPrompt = (text) => text.replace(/\s+/g, " ").trim();
8832
+ function isOwnPromptRecord(rec, normalized) {
8833
+ const content = rec?.type === "user" ? rec.message?.content : void 0;
8834
+ return typeof content === "string" && normalizedPrompt(content) === normalized;
8835
+ }
8773
8836
  function resolveConfigDir(configDir) {
8774
8837
  const value = configDir ?? process.env.CLAUDE_CONFIG_DIR;
8775
8838
  if (!value) return path9.join(os4.homedir(), ".claude");
@@ -8804,6 +8867,10 @@ var ClaudeSession = class {
8804
8867
  aborted = false;
8805
8868
  signal;
8806
8869
  resumeSessionId;
8870
+ forkOf;
8871
+ /** A fork whose first turn has not started: its transcript opens with the
8872
+ * parent's copied conversation, which belongs to no turn of this session. */
8873
+ forkPending = false;
8807
8874
  env;
8808
8875
  onScreen;
8809
8876
  onExit;
@@ -8822,6 +8889,15 @@ var ClaudeSession = class {
8822
8889
  /** The running turn's read state, shared with `flushTranscript`. */
8823
8890
  scan = null;
8824
8891
  turnDenied = [];
8892
+ /** The running turn's `ExitPlanMode` call that has no result yet. */
8893
+ planApprovalToolUseId = null;
8894
+ /** The approval dialog as drawn for one `ExitPlanMode` call, until it is
8895
+ * answered. Keyed by the call, so a redraw of a dialog already answered
8896
+ * (the TUI repaints it while it takes the key, measured on 2.1.288) can
8897
+ * never stand in for the next call's dialog. */
8898
+ planDialog = null;
8899
+ /** This turn's `ExitPlanMode` calls already answered. */
8900
+ answeredPlans = /* @__PURE__ */ new Set();
8825
8901
  abandonTurn = false;
8826
8902
  o;
8827
8903
  constructor(opts = {}) {
@@ -8829,7 +8905,9 @@ var ClaudeSession = class {
8829
8905
  this.configDir = resolveConfigDir(opts.configDir);
8830
8906
  this.explicitConfigDir = opts.configDir ? this.configDir : void 0;
8831
8907
  this.signal = opts.signal;
8832
- this.resumeSessionId = opts.resumeSessionId;
8908
+ this.resumeSessionId = opts.forkOf ? void 0 : opts.resumeSessionId;
8909
+ this.forkOf = opts.forkOf;
8910
+ this.forkPending = !!opts.forkOf;
8833
8911
  this.env = opts.env;
8834
8912
  this.onScreen = opts.onScreen;
8835
8913
  this.onExit = opts.onExit;
@@ -8872,12 +8950,16 @@ var ClaudeSession = class {
8872
8950
  // Shorter than `bootQuietMs`, so a trust dialog is answered before boot
8873
8951
  // could declare the TUI ready and paste a prompt into it.
8874
8952
  permissionQuietMs: opts.permissionQuietMs ?? 1e3,
8875
- stopSettleMs: opts.stopSettleMs ?? 750
8953
+ stopSettleMs: opts.stopSettleMs ?? 750,
8954
+ // The decision arrives after opencode's own question round trip, by
8955
+ // which time the dialog has long been drawn; this only bounds a TUI
8956
+ // that never draws it.
8957
+ planDialogWaitMs: opts.planDialogWaitMs ?? 3e4
8876
8958
  };
8877
8959
  }
8878
8960
  /** The CLI arguments this session spawns with. */
8879
8961
  spawnArgs() {
8880
- const args = this.resumeSessionId ? ["--resume", this.resumeSessionId] : ["--session-id", this.sessionId];
8962
+ const args = this.forkOf ? ["--session-id", this.sessionId, "--resume", this.forkOf, "--fork-session"] : this.resumeSessionId ? ["--resume", this.resumeSessionId] : ["--session-id", this.sessionId];
8881
8963
  if (this.o.model) args.push("--model", this.o.model);
8882
8964
  if (this.o.settingSources !== null && this.o.settingSources !== void 0) {
8883
8965
  args.push("--setting-sources", this.o.settingSources);
@@ -8946,6 +9028,9 @@ var ClaudeSession = class {
8946
9028
  this.raw = (this.raw + chunk).slice(-RAW_KEEP_CHARS);
8947
9029
  this.screen = (this.screen + chunk).slice(-SCREEN_WINDOW_CHARS);
8948
9030
  if (this.o.debug) process.stdout.write(chunk);
9031
+ this.scheduleScreenCheck();
9032
+ }
9033
+ scheduleScreenCheck() {
8949
9034
  this.clearScreenTimer();
8950
9035
  this.screenTimer = setTimeout(() => {
8951
9036
  this.screenTimer = null;
@@ -9001,6 +9086,13 @@ var ClaudeSession = class {
9001
9086
  this.write("\x1B");
9002
9087
  action = "cancelled";
9003
9088
  break;
9089
+ case "plan-approval": {
9090
+ const id = this.planApprovalToolUseId;
9091
+ if (!this.turn || !id || this.answeredPlans.has(id) || this.planDialog?.id === id) return null;
9092
+ this.planDialog = { id, text };
9093
+ action = "parked";
9094
+ break;
9095
+ }
9004
9096
  case "login":
9005
9097
  case "onboarding":
9006
9098
  action = "fatal";
@@ -9139,8 +9231,10 @@ var ClaudeSession = class {
9139
9231
  stopSeenAt: 0,
9140
9232
  end: null,
9141
9233
  sawUserRecord: false,
9142
- lastBeat: Date.now()
9234
+ lastBeat: Date.now(),
9235
+ awaitingOwnPrompt: this.forkPending ? normalizedPrompt(prompt) : null
9143
9236
  };
9237
+ this.forkPending = false;
9144
9238
  this.scan = scan;
9145
9239
  const deadline = Date.now() + timeout;
9146
9240
  while (Date.now() < deadline) {
@@ -9174,6 +9268,9 @@ var ClaudeSession = class {
9174
9268
  this.turn = null;
9175
9269
  this.scan = null;
9176
9270
  this.abandonTurn = false;
9271
+ this.planApprovalToolUseId = null;
9272
+ this.planDialog = null;
9273
+ this.answeredPlans.clear();
9177
9274
  settle();
9178
9275
  }
9179
9276
  }
@@ -9199,6 +9296,14 @@ var ClaudeSession = class {
9199
9296
  rec = JSON.parse(s);
9200
9297
  } catch {
9201
9298
  }
9299
+ if (scan.awaitingOwnPrompt !== null) {
9300
+ if (!isOwnPromptRecord(rec, scan.awaitingOwnPrompt)) {
9301
+ this.cursor = i + 1;
9302
+ continue;
9303
+ }
9304
+ scan.awaitingOwnPrompt = null;
9305
+ }
9306
+ if (rec) this.notePlanApproval(rec);
9202
9307
  scan.onRecord(s, rec);
9203
9308
  if (rec) {
9204
9309
  if (rec.type === "assistant" && rec.message) {
@@ -9230,6 +9335,74 @@ var ClaudeSession = class {
9230
9335
  flushTranscript() {
9231
9336
  this.scanTranscript();
9232
9337
  }
9338
+ /** Track the turn's `ExitPlanMode` call from the transcript: opened by the
9339
+ * assistant record that makes it, closed by the result that answers it. */
9340
+ notePlanApproval(rec) {
9341
+ const content = rec?.message?.content;
9342
+ if (!Array.isArray(content)) return;
9343
+ if (rec.type === "assistant") {
9344
+ for (const block of content) {
9345
+ if (block?.type === "tool_use" && block.name === "ExitPlanMode" && typeof block.id === "string") {
9346
+ this.planApprovalToolUseId = block.id;
9347
+ this.scheduleScreenCheck();
9348
+ }
9349
+ }
9350
+ } else if (rec.type === "user" && this.planApprovalToolUseId) {
9351
+ const answered = content.some(
9352
+ (block) => block?.type === "tool_result" && block.tool_use_id === this.planApprovalToolUseId
9353
+ );
9354
+ if (answered) {
9355
+ if (this.planDialog?.id === this.planApprovalToolUseId) this.planDialog = null;
9356
+ this.planApprovalToolUseId = null;
9357
+ }
9358
+ }
9359
+ }
9360
+ /**
9361
+ * The running turn's unanswered `ExitPlanMode` call id, or null. While it is
9362
+ * set the TUI is parked on (or about to draw) the approval dialog, and the
9363
+ * next message is the operator's decision, not a new turn.
9364
+ */
9365
+ get pendingPlanApproval() {
9366
+ return this.turn ? this.planApprovalToolUseId : null;
9367
+ }
9368
+ /**
9369
+ * Answer the plan approval dialog the running turn is parked on. Waits up to
9370
+ * `planDialogWaitMs` for it to be drawn, because the transcript records the
9371
+ * call before the TUI draws the dialog, and resolves false when there is
9372
+ * none to answer or the drawn dialog has no matching choice.
9373
+ */
9374
+ async answerPlanApproval(answer, timeoutMs = this.o.planDialogWaitMs) {
9375
+ const deadline = Date.now() + timeoutMs;
9376
+ let dialog = this.planDialog;
9377
+ while (!dialog || dialog.id !== this.planApprovalToolUseId) {
9378
+ if (!this.turn || this.exited || Date.now() >= deadline) return false;
9379
+ if (Date.now() - this.lastDataAt >= this.o.permissionQuietMs) this.checkScreen();
9380
+ await delay(this.o.pollMs);
9381
+ dialog = this.planDialog;
9382
+ }
9383
+ const keys = planApprovalKeys(dialog.text);
9384
+ const key = answer.approved ? keys.approve : keys.reject;
9385
+ if (!key) return false;
9386
+ this.answeredPlans.add(dialog.id);
9387
+ this.planDialog = null;
9388
+ this.screen = "";
9389
+ this.write(key);
9390
+ if (answer.approved || !keys.rejectTakesText) return true;
9391
+ await this.waitForQuiet();
9392
+ const text = (answer.feedback ?? "").replace(/\s+/g, " ").trim() || "no";
9393
+ this.write(text);
9394
+ await delay(this.o.submitMinMs);
9395
+ this.write("\r");
9396
+ return true;
9397
+ }
9398
+ /** Wait until the PTY has been quiet for `permissionQuietMs`, at most 3 s. */
9399
+ async waitForQuiet() {
9400
+ const until = Date.now() + 3e3;
9401
+ await delay(Math.min(this.o.permissionQuietMs, 3e3));
9402
+ while (Date.now() < until && Date.now() - this.lastDataAt < this.o.permissionQuietMs) {
9403
+ await delay(25);
9404
+ }
9405
+ }
9233
9406
  /**
9234
9407
  * Stop the running turn the way the TUI's own Esc does, and keep the
9235
9408
  * session alive for the next one. Resolves true once the turn ended, false
@@ -9358,7 +9531,14 @@ var ClaudeSession = class {
9358
9531
  };
9359
9532
 
9360
9533
  // src/claude-session-wrapper.ts
9361
- function decodeUserEnvelope(chunk) {
9534
+ var IMAGE_EXTENSIONS = {
9535
+ "image/png": "png",
9536
+ "image/jpeg": "jpg",
9537
+ "image/jpg": "jpg",
9538
+ "image/gif": "gif",
9539
+ "image/webp": "webp"
9540
+ };
9541
+ function decodeUserEnvelope(chunk, saveImage) {
9362
9542
  let parsed;
9363
9543
  try {
9364
9544
  parsed = JSON.parse(chunk);
@@ -9370,16 +9550,22 @@ function decodeUserEnvelope(chunk) {
9370
9550
  if (typeof content === "string") return content;
9371
9551
  if (!Array.isArray(content)) return chunk;
9372
9552
  const parts = [];
9553
+ const images = [];
9373
9554
  let dropped = 0;
9374
9555
  for (const block of content) {
9556
+ const extension = block?.type === "image" && block.source?.type === "base64" && typeof block.source.data === "string" ? IMAGE_EXTENSIONS[String(block.source.media_type).toLowerCase()] : void 0;
9375
9557
  if (block?.type === "text" && typeof block.text === "string") {
9376
9558
  parts.push(block.text);
9559
+ } else if (extension && saveImage) {
9560
+ const file = saveImage(Buffer.from(block.source.data, "base64"), extension);
9561
+ if (file) images.push(file);
9562
+ else dropped++;
9377
9563
  } else if (block?.type === "tool_result") {
9378
9564
  const v = block.content;
9379
- const text = typeof v === "string" ? v : Array.isArray(v) ? v.map((i) => i?.type === "text" ? i.text : "").filter(Boolean).join("\n") : "";
9565
+ const text2 = typeof v === "string" ? v : Array.isArray(v) ? v.map((i) => i?.type === "text" ? i.text : "").filter(Boolean).join("\n") : "";
9380
9566
  parts.push(
9381
9567
  `[Tool result${block.tool_use_id ? ` ${block.tool_use_id}` : ""}]
9382
- ${text}`
9568
+ ${text2}`
9383
9569
  );
9384
9570
  } else {
9385
9571
  dropped++;
@@ -9390,11 +9576,61 @@ ${text}`
9390
9576
  dropped
9391
9577
  });
9392
9578
  }
9393
- return parts.join("\n\n");
9579
+ const text = parts.join("\n\n");
9580
+ return images.length > 0 ? [...images, text].filter(Boolean).join("\n") : text;
9581
+ }
9582
+ function stageImage(data, extension) {
9583
+ try {
9584
+ const file = join10(pluginTmpDir(), `image-${randomUUID5()}.${extension}`);
9585
+ writeFileSync5(file, data, { mode: 384, flag: "wx" });
9586
+ return file;
9587
+ } catch (err) {
9588
+ log.warn("interactive transport could not stage an image", {
9589
+ error: err instanceof Error ? err.message : String(err)
9590
+ });
9591
+ return null;
9592
+ }
9593
+ }
9594
+ var APPROVAL_WORDS = /^(?:y|yes|yep|yeah|ok|okay|sure|approve|approved|proceed|go|go ahead|do it|lgtm)[.!\s]*$/i;
9595
+ var HOST_ANNOTATIONS = /<(?:dcp-)?system-reminder>[\s\S]*?<\/(?:dcp-)?system-reminder>|<dcp-message-id>[^<]*<\/dcp-message-id>/g;
9596
+ function toolResultText(content) {
9597
+ if (typeof content === "string") return content;
9598
+ if (!Array.isArray(content)) return "";
9599
+ return content.map((item) => item?.type === "text" && typeof item.text === "string" ? item.text : "").join("\n");
9600
+ }
9601
+ function planApprovalAnswer(raw, toolUseId) {
9602
+ let parsed;
9603
+ try {
9604
+ parsed = JSON.parse(raw);
9605
+ } catch {
9606
+ }
9607
+ const content = parsed?.type === "user" ? parsed.message?.content : void 0;
9608
+ if (Array.isArray(content)) {
9609
+ const result = content.find(
9610
+ (block) => block?.type === "tool_result" && block.tool_use_id === toolUseId
9611
+ );
9612
+ if (result) {
9613
+ if (result.is_error !== true) return { approved: true };
9614
+ const text = toolResultText(result.content);
9615
+ const feedback = text.startsWith(REJECTED_EXIT_PLAN_MODE_PREFIX) ? text.slice(REJECTED_EXIT_PLAN_MODE_PREFIX.length) : text;
9616
+ return { approved: false, feedback: feedback.trim() || "no" };
9617
+ }
9618
+ }
9619
+ const typed = decodeUserEnvelope(raw).replace(HOST_ANNOTATIONS, "").trim();
9620
+ return APPROVAL_WORDS.test(typed) ? { approved: true } : { approved: false, feedback: typed || "no" };
9621
+ }
9622
+ function asidePrompt(question, history = []) {
9623
+ return [
9624
+ "(Side question, asked while the main task continues elsewhere. Answer it from this conversation alone, briefly and without using any tools. Do not continue or change the main task.)",
9625
+ ...history.map((exchange) => `Earlier side question: ${exchange.question}
9626
+ Your answer: ${exchange.response}`),
9627
+ question.trim()
9628
+ ].join("\n\n");
9394
9629
  }
9395
9630
  function interactiveExtraArgs(opts) {
9396
9631
  const extraArgs = [];
9397
9632
  if (opts.tools) extraArgs.push("--tools", opts.tools.join(","));
9633
+ if (opts.restricted) extraArgs.push("--restricted");
9398
9634
  if (opts.mcpConfigPaths && opts.mcpConfigPaths.length > 0) {
9399
9635
  extraArgs.push(
9400
9636
  "--mcp-config",
@@ -9552,6 +9788,7 @@ function spawnInteractiveProcess(opts) {
9552
9788
  if (event.action === "accepted") log.info("interactive transport accepted a prompt", data);
9553
9789
  else if (event.action === "fatal") log.error("interactive transport cannot continue", data);
9554
9790
  else if (event.action === "denied") log.warn("interactive transport denied a permission prompt", data);
9791
+ else if (event.action === "parked") log.info("interactive transport is waiting on a plan approval", data);
9555
9792
  else log.warn("interactive transport cancelled the usage-limit auto-continue", data);
9556
9793
  },
9557
9794
  onExit: (code) => {
@@ -9582,7 +9819,7 @@ function spawnInteractiveProcess(opts) {
9582
9819
  let cancelledThrough = 0;
9583
9820
  let queued = 0;
9584
9821
  let chain = Promise.resolve();
9585
- const runTurn = async (userMsg, turnNumber) => {
9822
+ const runTurn = async (userMsg, turnNumber, images) => {
9586
9823
  const current = () => turnNumber === writes;
9587
9824
  const emit2 = (line) => {
9588
9825
  if (!current()) return;
@@ -9655,12 +9892,62 @@ function spawnInteractiveProcess(opts) {
9655
9892
  );
9656
9893
  if (proc.listenerCount("error") > 0) proc.emit("error", e);
9657
9894
  else lineEmitter.emit("close");
9895
+ } finally {
9896
+ for (const file of images) void unlink2(file).catch(() => {
9897
+ });
9898
+ }
9899
+ };
9900
+ const answerPlan = async (answer) => {
9901
+ log.info("interactive transport answering a plan approval", {
9902
+ sessionId: session.sessionId,
9903
+ approved: answer.approved
9904
+ });
9905
+ const answered = await session.answerPlanApproval(answer).catch(() => false);
9906
+ if (answered) return;
9907
+ log.warn("interactive transport could not answer the plan approval; stopping the turn", {
9908
+ sessionId: session.sessionId
9909
+ });
9910
+ cancelledThrough = writes;
9911
+ await session.interrupt().catch(() => false);
9912
+ };
9913
+ const askAside = async (question, options) => {
9914
+ const forked = existsSync4(session.jsonlPath);
9915
+ const aside = new ClaudeSession({
9916
+ ...opts.tuning,
9917
+ cwd: opts.cwd,
9918
+ cliPath: opts.cliPath,
9919
+ configDir: opts.configDir,
9920
+ model: opts.model,
9921
+ settingSources: opts.settingSources === void 0 ? null : opts.settingSources,
9922
+ extraArgs: interactiveExtraArgs({ ...opts, permissionsAllow: [], permissionMode: "dontAsk" }),
9923
+ ignoreAnthropicApiKey: opts.ignoreAnthropicApiKey,
9924
+ effort: opts.effort ? cliEffortLevel(opts.effort) : void 0,
9925
+ env: opts.env,
9926
+ spawnPty: opts.spawnPty,
9927
+ signal: options.abortSignal,
9928
+ ...forked ? { forkOf: session.sessionId } : {}
9929
+ });
9930
+ log.info("btw: asking a fork of the interactive session", {
9931
+ sessionId: session.sessionId,
9932
+ asideSessionId: aside.sessionId,
9933
+ forked
9934
+ });
9935
+ try {
9936
+ await aside.start();
9937
+ const result = await aside.ask(asidePrompt(question, options.history), options.timeoutMs);
9938
+ const text = result.text.trim();
9939
+ if (!text) throw new Error("the side question got no answer");
9940
+ return text;
9941
+ } finally {
9942
+ aside.dispose();
9943
+ void unlink2(aside.jsonlPath).catch(() => {
9944
+ });
9658
9945
  }
9659
9946
  };
9660
- const enqueueTurn = (userMsg) => {
9947
+ const enqueueTurn = (userMsg, images = []) => {
9661
9948
  const turnNumber = ++writes;
9662
9949
  queued++;
9663
- chain = chain.then(() => runTurn(userMsg, turnNumber)).finally(() => {
9950
+ chain = chain.then(() => runTurn(userMsg, turnNumber, images)).finally(() => {
9664
9951
  queued--;
9665
9952
  });
9666
9953
  };
@@ -9669,7 +9956,18 @@ function spawnInteractiveProcess(opts) {
9669
9956
  writable: true,
9670
9957
  write(chunk) {
9671
9958
  const raw = typeof chunk === "string" && chunk.endsWith("\n") ? chunk.slice(0, -1) : chunk;
9672
- enqueueTurn(decodeUserEnvelope(raw));
9959
+ const planToolUseId = session.pendingPlanApproval;
9960
+ if (planToolUseId) {
9961
+ void answerPlan(planApprovalAnswer(raw, planToolUseId));
9962
+ return true;
9963
+ }
9964
+ const images = [];
9965
+ const text = decodeUserEnvelope(raw, (data, extension) => {
9966
+ const file = stageImage(data, extension);
9967
+ if (file) images.push(file);
9968
+ return file;
9969
+ });
9970
+ enqueueTurn(text, images);
9673
9971
  return true;
9674
9972
  },
9675
9973
  end() {
@@ -9720,7 +10018,9 @@ function spawnInteractiveProcess(opts) {
9720
10018
  cancelledThrough = writes;
9721
10019
  return session.interrupt(timeoutMs);
9722
10020
  },
9723
- flushTranscript: () => session.flushTranscript()
10021
+ flushTranscript: () => session.flushTranscript(),
10022
+ planApprovalPending: () => session.pendingPlanApproval !== null,
10023
+ askAside
9724
10024
  }
9725
10025
  };
9726
10026
  return ap;
@@ -9753,10 +10053,10 @@ async function selectTransport(requested, cliPath, available = hasInteractiveTra
9753
10053
  }
9754
10054
 
9755
10055
  // src/prompts.ts
9756
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
10056
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "fs";
9757
10057
  import { homedir as homedir8 } from "os";
9758
- import { randomUUID as randomUUID5 } from "crypto";
9759
- import { dirname as dirname6, join as join10 } from "path";
10058
+ import { randomUUID as randomUUID6 } from "crypto";
10059
+ import { dirname as dirname6, join as join11 } from "path";
9760
10060
  function readPromptFileIfPresent(path11) {
9761
10061
  try {
9762
10062
  const content = readFileSync7(path11, "utf8").trim();
@@ -9768,7 +10068,7 @@ function readPromptFileIfPresent(path11) {
9768
10068
  function nearestWorkspaceAgentsPrompt(cwd) {
9769
10069
  let dir = cwd;
9770
10070
  while (true) {
9771
- const content = readPromptFileIfPresent(join10(dir, "AGENTS.md"));
10071
+ const content = readPromptFileIfPresent(join11(dir, "AGENTS.md"));
9772
10072
  if (content) return content;
9773
10073
  const parent = dirname6(dir);
9774
10074
  if (parent === dir) return void 0;
@@ -9877,8 +10177,8 @@ ${options.compressionSummary.trim()}`
9877
10177
  for (const s of extraSystemContent) {
9878
10178
  if (s.trim()) parts.push(s.trim());
9879
10179
  }
9880
- const configRoot = process.env.XDG_CONFIG_HOME ?? join10(homedir8(), ".config");
9881
- const globalAgents = readPromptFileIfPresent(join10(configRoot, "opencode", "AGENTS.md"));
10180
+ const configRoot = process.env.XDG_CONFIG_HOME ?? join11(homedir8(), ".config");
10181
+ const globalAgents = readPromptFileIfPresent(join11(configRoot, "opencode", "AGENTS.md"));
9882
10182
  const workspaceAgents = nearestWorkspaceAgentsPrompt(cwd);
9883
10183
  const forwarded = extraSystemContent.join("\n\n");
9884
10184
  const pushGlobal = !!globalAgents && !forwarded.includes(globalAgents);
@@ -9889,9 +10189,9 @@ ${options.compressionSummary.trim()}`
9889
10189
  if (includeMultiStepHint) parts.push(MULTI_STEP_TASK_HINT);
9890
10190
  const content = parts.join("\n\n");
9891
10191
  if (!content) return void 0;
9892
- const path11 = join10(pluginTmpDir(), `opencode-cc-sys-${randomUUID5()}.md`);
10192
+ const path11 = join11(pluginTmpDir(), `opencode-cc-sys-${randomUUID6()}.md`);
9893
10193
  try {
9894
- writeFileSync5(path11, content, { encoding: "utf8", mode: 384 });
10194
+ writeFileSync6(path11, content, { encoding: "utf8", mode: 384 });
9895
10195
  return path11;
9896
10196
  } catch (err) {
9897
10197
  log.warn("failed to write system prompt file", { error: String(err) });
@@ -10506,7 +10806,6 @@ function decideMcpHotReload(input) {
10506
10806
  if (!input.hasActiveProcess) return decide("no-process");
10507
10807
  const previousHash = input.previousHash ?? null;
10508
10808
  if (previousHash === input.currentHash) return decide("unchanged");
10509
- if (input.interactive) return decide("skipped-interactive");
10510
10809
  if (input.pendingProxyCalls > 0) return decide("deferred-proxy-calls");
10511
10810
  if (input.turnInFlight) return decide("deferred-turn-in-flight");
10512
10811
  if (input.planQuestionPending) return decide("deferred-plan-question");
@@ -11305,6 +11604,24 @@ function finishWithQuestionCall(state, call) {
11305
11604
  } catch {
11306
11605
  }
11307
11606
  }
11607
+ function finishAwaitingPlanApproval(state) {
11608
+ if (state.controllerClosed) return;
11609
+ state.endTextBlock();
11610
+ state.controller.enqueue({
11611
+ type: "finish",
11612
+ finishReason: state.toFinishReason("stop"),
11613
+ usage: state.toUsage(lastCallContextUsage(state.lastCallUsage, state.resultMeta.usage)),
11614
+ providerMetadata: {
11615
+ "claude-code": state.resultMeta
11616
+ }
11617
+ });
11618
+ state.controllerClosed = true;
11619
+ state.cleanupTurn();
11620
+ try {
11621
+ state.controller.close();
11622
+ } catch {
11623
+ }
11624
+ }
11308
11625
  function drainNow(state) {
11309
11626
  if (state.drainTimer) {
11310
11627
  clearTimeout(state.drainTimer);
@@ -11901,6 +12218,10 @@ ${plan}
11901
12218
  `
11902
12219
  });
11903
12220
  state.endTextBlock();
12221
+ if (ctx.interactive) {
12222
+ finishAwaitingPlanApproval(state);
12223
+ return;
12224
+ }
11904
12225
  } else if (isWebSearchTool(block2.name) && isWebSearchHandledByCli(ctx.config.webSearch)) {
11905
12226
  state.toolCallsById.delete(block2.id);
11906
12227
  const query = typeof parsedInput?.query === "string" ? parsedInput.query : JSON.stringify(parsedInput);
@@ -12625,9 +12946,6 @@ var ClaudeCodeLanguageModel = class {
12625
12946
  const selectedTransport = doctor ? transport === "interactive" ? "interactive" : "headless" : this.config.transport === void 0 && !hasInteractiveTransport() ? "headless" : this.isTitleRequest(scope, options) && !compactionMode ? "headless" : await Promise.race([selectTransport(transport, this.config.cliPath), turnAbort.whenAborted]);
12626
12947
  if (turnAbort.aborted) return abortedBeforeWork("selecting the transport");
12627
12948
  const useInteractive = selectedTransport === "interactive";
12628
- if (useInteractive && !compactionMode && !doctor && (this.config.permissionPreset === "read-only" || this.config.permissionMode === "plan")) {
12629
- throw new Error("Interactive Claude transport cannot enforce the read-only preset or plan mode. Use the headless transport for this permission posture.");
12630
- }
12631
12949
  const interactiveBypassRequested = this.config.interactiveBypass ?? flagOn(process.env.CLAUDE_CODE_INTERACTIVE_BYPASS);
12632
12950
  const modelFallbackArmed = attempt?.armed === true && !compactionMode && !useInteractive;
12633
12951
  const sourceAccount = normalizeAccountName(
@@ -12912,7 +13230,7 @@ var ClaudeCodeLanguageModel = class {
12912
13230
  sessionKey: sk,
12913
13231
  prompt: options.prompt,
12914
13232
  cliPath,
12915
- transcriptPath: (id) => join11(configDir, "projects", encodeCwd(cwd), `${id}.jsonl`)
13233
+ transcriptPath: (id) => join12(configDir, "projects", encodeCwd(cwd), `${id}.jsonl`)
12916
13234
  });
12917
13235
  if (resumePoint) {
12918
13236
  setClaudeSessionId(sk, resumePoint.claudeSessionId);
@@ -12939,7 +13257,8 @@ var ClaudeCodeLanguageModel = class {
12939
13257
  cliToolCallIds: new Set(previousPendingProxyCalls.map((c) => c.toolCallId)),
12940
13258
  stripContextReminders: this.stripContextRemindersEnabled()
12941
13259
  });
12942
- const resolvedProxy = compactionMode ? null : this.resolvedProxyTools();
13260
+ const ptyPlanMode = useInteractive && !compactionMode && this.config.permissionMode === "plan";
13261
+ const resolvedProxy = compactionMode ? null : ptyPlanMode ? this.resolvedProxyTools()?.filter((def) => def.name !== "write" && def.name !== "edit") ?? null : this.resolvedProxyTools();
12943
13262
  const loadLiveToolInfo = this.createLiveToolInfoLoader();
12944
13263
  const planModeQuestionActive = await this.resolvePlanModeQuestion(
12945
13264
  compactionMode,
@@ -12975,6 +13294,13 @@ var ClaudeCodeLanguageModel = class {
12975
13294
  return abortedBeforeWork("waiting for opencode's MCP status and the CLI version");
12976
13295
  }
12977
13296
  const [runtimeStatus, cliVersion] = prologue.value;
13297
+ const ptyReadOnly = useInteractive && !compactionMode && !doctor && isReadOnlyPermissionMode(this.config.permissionMode);
13298
+ const ptyReadOnlySupported = cliSupportsRestricted(cliVersion) && cliSupportsDontAsk(cliVersion);
13299
+ if (ptyReadOnly && !ptyReadOnlySupported) {
13300
+ throw new Error(
13301
+ "The read-only preset on the interactive transport needs Claude Code 2.1.263 or newer (--restricted and --permission-mode dontAsk). Update claude, or use the headless transport."
13302
+ );
13303
+ }
12978
13304
  const failoverAccounts = failoverCandidates(
12979
13305
  this.config.failoverAccounts,
12980
13306
  sourceAccount
@@ -13379,6 +13705,12 @@ var ClaudeCodeLanguageModel = class {
13379
13705
  });
13380
13706
  if (stoppedBeforeWork()) return;
13381
13707
  const resumeSessionId = getClaudeSessionId(sk);
13708
+ const posture = interactivePermissionPosture({
13709
+ permissionMode: self.config.permissionMode,
13710
+ allow,
13711
+ supportsReadOnly: ptyReadOnlySupported
13712
+ });
13713
+ if (!posture) throw new Error("the interactive transport cannot hold this permission posture");
13382
13714
  const ap = spawnInteractiveProcess({
13383
13715
  cwd,
13384
13716
  cliPath,
@@ -13387,7 +13719,9 @@ var ClaudeCodeLanguageModel = class {
13387
13719
  fastMode,
13388
13720
  mcpConfigPaths: mcp.paths,
13389
13721
  pluginDirs: skillPluginDirs,
13390
- permissionsAllow: allow,
13722
+ permissionsAllow: posture.allow,
13723
+ permissionMode: posture.permissionMode,
13724
+ restricted: posture.restricted,
13391
13725
  disallowedTools: wiring.allDisallowed,
13392
13726
  proxyServer: state.proxyServer,
13393
13727
  systemPromptFile,
@@ -13519,7 +13853,7 @@ var ClaudeCodeLanguageModel = class {
13519
13853
  state.activeProcess = ap;
13520
13854
  }
13521
13855
  }
13522
- if (state.activeProcess && !hasMatchedPendingResults && isTurnInFlight(state.activeProcess)) {
13856
+ if (state.activeProcess && !hasMatchedPendingResults && !state.activeProcess.interactiveControl?.planApprovalPending?.() && isTurnInFlight(state.activeProcess)) {
13523
13857
  log.warn("previous turn still in flight; interrupting it", { sk });
13524
13858
  const idle = await interruptTurn(state.activeProcess);
13525
13859
  if (!idle) {
@@ -13689,7 +14023,7 @@ var ClaudeCodeLanguageModel = class {
13689
14023
  });
13690
14024
  state.controllerClosed = true;
13691
14025
  cleanupTurn();
13692
- if (!useInteractive && !compactionMode) {
14026
+ if (!compactionMode) {
13693
14027
  scheduleIdleProcessEviction(
13694
14028
  sk,
13695
14029
  resolveIdleProcessTimeoutMs(self.config.idleProcessTimeoutMs)
@@ -13705,6 +14039,7 @@ var ClaudeCodeLanguageModel = class {
13705
14039
  compactionMode,
13706
14040
  fastMode,
13707
14041
  planModeQuestionActive,
14042
+ interactive: useInteractive,
13708
14043
  sourceAccount,
13709
14044
  failoverAskActive,
13710
14045
  usageLimitNoteActive,
@@ -14013,10 +14348,10 @@ import {
14013
14348
  readFileSync as readFileSync8,
14014
14349
  realpathSync as realpathSync4,
14015
14350
  rmSync as rmSync3,
14016
- writeFileSync as writeFileSync6
14351
+ writeFileSync as writeFileSync7
14017
14352
  } from "fs";
14018
14353
  import { homedir as homedir9 } from "os";
14019
- import { dirname as dirname7, join as join12, resolve as resolve5 } from "path";
14354
+ import { dirname as dirname7, join as join13, resolve as resolve5 } from "path";
14020
14355
  import { fileURLToPath as fileURLToPath4 } from "url";
14021
14356
  var STALE_PACKAGE_NAME = "opencode-claude-code-plugin";
14022
14357
  var SUSPECT_DESCRIPTION_TOKEN = "Claude Code";
@@ -14024,14 +14359,14 @@ var alreadyRan = false;
14024
14359
  function candidateCacheRoots() {
14025
14360
  const xdg = process.env.XDG_CACHE_HOME;
14026
14361
  return [
14027
- xdg ? join12(xdg, "opencode") : null,
14028
- join12(homedir9(), ".cache", "opencode"),
14029
- join12(homedir9(), "Library", "Caches", "opencode")
14362
+ xdg ? join13(xdg, "opencode") : null,
14363
+ join13(homedir9(), ".cache", "opencode"),
14364
+ join13(homedir9(), "Library", "Caches", "opencode")
14030
14365
  ].filter((p) => Boolean(p));
14031
14366
  }
14032
14367
  function userOpencodeJsonPath() {
14033
- const xdgConfig = process.env.XDG_CONFIG_HOME ?? join12(homedir9(), ".config");
14034
- return join12(xdgConfig, "opencode", "opencode.json");
14368
+ const xdgConfig = process.env.XDG_CONFIG_HOME ?? join13(homedir9(), ".config");
14369
+ return join13(xdgConfig, "opencode", "opencode.json");
14035
14370
  }
14036
14371
  function userIntendsToUseUnscoped() {
14037
14372
  const cfg = userOpencodeJsonPath();
@@ -14058,15 +14393,15 @@ function ourLoadedDir() {
14058
14393
  function ourVersion(dir) {
14059
14394
  if (!dir) return null;
14060
14395
  try {
14061
- const pkg = JSON.parse(readFileSync8(join12(dir, "package.json"), "utf8"));
14396
+ const pkg = JSON.parse(readFileSync8(join13(dir, "package.json"), "utf8"));
14062
14397
  return typeof pkg.version === "string" && pkg.version ? pkg.version : null;
14063
14398
  } catch {
14064
14399
  return null;
14065
14400
  }
14066
14401
  }
14067
14402
  function markerPath() {
14068
- const stateRoot = process.env.XDG_STATE_HOME ?? join12(homedir9(), ".local", "state");
14069
- return join12(stateRoot, "opencode-claude-code-plugin", "cleanup-stale.json");
14403
+ const stateRoot = process.env.XDG_STATE_HOME ?? join13(homedir9(), ".local", "state");
14404
+ return join13(stateRoot, "opencode-claude-code-plugin", "cleanup-stale.json");
14070
14405
  }
14071
14406
  function markerVersion() {
14072
14407
  try {
@@ -14080,7 +14415,7 @@ function writeMarker(version) {
14080
14415
  const file = markerPath();
14081
14416
  try {
14082
14417
  mkdirSync5(dirname7(file), { recursive: true });
14083
- writeFileSync6(
14418
+ writeFileSync7(
14084
14419
  file,
14085
14420
  JSON.stringify({ version, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2) + "\n",
14086
14421
  "utf8"
@@ -14115,7 +14450,7 @@ function cleanupStaleUnscopedInstall() {
14115
14450
  }
14116
14451
  function cleanupOne(cacheRoot, ourDir) {
14117
14452
  if (!existsSync5(cacheRoot)) return;
14118
- const stalePath = join12(cacheRoot, "node_modules", STALE_PACKAGE_NAME);
14453
+ const stalePath = join13(cacheRoot, "node_modules", STALE_PACKAGE_NAME);
14119
14454
  if (!existsSync5(stalePath)) return;
14120
14455
  let realStalePath = stalePath;
14121
14456
  try {
@@ -14123,7 +14458,7 @@ function cleanupOne(cacheRoot, ourDir) {
14123
14458
  } catch {
14124
14459
  }
14125
14460
  if (ourDir && realStalePath === ourDir) return;
14126
- const pkgJsonPath = join12(stalePath, "package.json");
14461
+ const pkgJsonPath = join13(stalePath, "package.json");
14127
14462
  if (!existsSync5(pkgJsonPath)) return;
14128
14463
  let pkg = {};
14129
14464
  try {
@@ -14143,13 +14478,13 @@ function cleanupOne(cacheRoot, ourDir) {
14143
14478
  });
14144
14479
  return;
14145
14480
  }
14146
- const cachePkgJson = join12(cacheRoot, "package.json");
14481
+ const cachePkgJson = join13(cacheRoot, "package.json");
14147
14482
  if (!existsSync5(cachePkgJson)) return;
14148
14483
  try {
14149
14484
  const cfg = JSON.parse(readFileSync8(cachePkgJson, "utf8"));
14150
14485
  if (cfg?.dependencies?.[STALE_PACKAGE_NAME]) {
14151
14486
  delete cfg.dependencies[STALE_PACKAGE_NAME];
14152
- writeFileSync6(cachePkgJson, JSON.stringify(cfg, null, 2) + "\n");
14487
+ writeFileSync7(cachePkgJson, JSON.stringify(cfg, null, 2) + "\n");
14153
14488
  log.info("cleanup-stale: pruned dep from cache package.json");
14154
14489
  }
14155
14490
  } catch (err) {
@@ -14720,8 +15055,10 @@ function warnIfAnthropicApiKey(ignore) {
14720
15055
  function _resetPlanModeWarningForTests() {
14721
15056
  warnedPlanModeNoExit = false;
14722
15057
  }
14723
- function warnIfPlanModeCannotExit(permissionMode) {
15058
+ function warnIfPlanModeCannotExit(permissionMode, transport) {
14724
15059
  if (permissionMode !== "plan") return;
15060
+ const interactive = transport?.transport === "interactive" || transport?.transport === void 0 && transport?.interactive === true;
15061
+ if (interactive) return;
14725
15062
  if (warnedPlanModeNoExit) return;
14726
15063
  warnedPlanModeNoExit = true;
14727
15064
  log.warn(
@@ -14762,7 +15099,7 @@ function createClaudeCode(settings = {}) {
14762
15099
  }
14763
15100
  warnIfAnthropicApiKey(settings.ignoreAnthropicApiKey);
14764
15101
  const preset = applyPermissionPreset(settings, DEFAULT_PROXY_TOOL_NAMES);
14765
- warnIfPlanModeCannotExit(preset ? void 0 : settings.permissionMode);
15102
+ warnIfPlanModeCannotExit(preset ? void 0 : settings.permissionMode, settings);
14766
15103
  const cliPath = settings.cliPath ?? process.env.CLAUDE_CLI_PATH ?? "claude";
14767
15104
  const providerName = settings.providerID ?? settings.name ?? "claude-code";
14768
15105
  const proxyTools = preset?.proxyTools ?? settings.proxyTools ?? [...DEFAULT_PROXY_TOOL_NAMES];