@khalilgharbaoui/opencode-claude-code-plugin 0.32.0 → 0.33.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/README.md CHANGED
@@ -541,7 +541,7 @@ Anything you supply is merged on top of the defaults; you don't need to redeclar
541
541
 
542
542
  ## Interactive transport (experimental)
543
543
 
544
- By default the plugin spawns `claude --print` (headless). The interactive transport instead drives the real interactive `claude` TUI under a native PTY inside opencode's Bun runtime, types your prompt into it, and streams the session transcript (`~/.claude/projects/<cwd>/<session-id>.jsonl`) back through the same pipeline the headless transport uses. It was built as insurance for the day headless usage is billed differently from interactive usage; today both draw from the same plan usage limits (see [Billing](#billing)), so it is not a way to change what a turn costs.
544
+ By default the plugin spawns `claude --print` (headless). The interactive transport instead drives the real interactive `claude` TUI under a native PTY inside opencode's Bun runtime, types your prompt into it, and streams the session transcript (`~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`) back through the same pipeline the headless transport uses. Claude Code names that directory from the cwd's **resolved real path** with every non-alphanumeric character replaced by `-`, so a working directory reached through a symlink (on macOS `/tmp` is a symlink to `/private/tmp`) is named after the target: `/tmp/scratch` becomes `-private-tmp-scratch`. It was built as insurance for the day headless usage is billed differently from interactive usage; today both draw from the same plan usage limits (see [Billing](#billing)), so it is not a way to change what a turn costs.
545
545
 
546
546
  ```json
547
547
  "options": { "interactive": true }
@@ -577,6 +577,7 @@ This is the part to read before turning it on. Three whole features of this plug
577
577
  - **Input is text-only:** images and other non-text blocks are dropped (with a logged warning); tool results are rendered as labeled text.
578
578
  - **Output granularity:** text arrives per transcript record, not token-by-token, so it can feel chunkier than headless streaming.
579
579
  - **Token counts come from the transcript, one count per API call.** The session JSONL writes one record per content block (thinking, text, tool_use) and every record of a call repeats that call's final usage, so the transport counts each call once, keyed by its message id. The numbers then mean exactly what they do on the headless transport: [`turnStats`](#per-turn-stats) gets the turn's totals and opencode gets the last call's context plus the turn's output. Before this was fixed a four-tool turn reported 1,306 output tokens against a real 653, and its input and cache counts were one call's instead of the turn's. An all-zero `<synthetic>` record (how the CLI writes "Login expired" or a session limit into the transcript) is not counted as a call.
580
+ - **How a turn finishes:** a turn that reaches a terminal stop reason (`end_turn`, `stop_sequence`, `max_tokens`) finishes exactly as a headless turn does, so it is an ordinary completed reply and [`turnStats`](#per-turn-stats) applies to it. `max_tokens` is deliberately a completed turn rather than a failure: the call happened and billed, and the truncation is what auto-continue reads. Before this was fixed every interactive turn finished as an error instead, which also suppressed the stats footer.
580
581
  - **Turn timeout:** a turn that produces no terminal stop within 30 minutes is reported honestly as an error result (visible truncation), not silently ended.
581
582
  - **No idle eviction:** `idleProcessTimeoutMs` does not apply to interactive sessions.
582
583
  - `/compact` always uses the headless transport regardless of this setting.
@@ -646,6 +647,14 @@ The task is working in the background. You will be notified automatically when i
646
647
 
647
648
  Claude keeps working. When the subagent finishes, opencode prompts the same conversation with the result as a new message, so it arrives as its own turn rather than as that call's result. Measured end to end on opencode 1.18.33 with claude-haiku-4-5: the dispatch returned in 14 s while the child's 30-second command was still running, Claude ran another tool and ended its turn 18 s in, and the `<task ... state="completed">` message landed 43 s later. That notification is automatic, so the right thing after a background dispatch is to end the turn, not to wait or poll.
648
649
 
650
+ **opencode 2 uses different envelopes for the same thing**, so the plugin tells the model about its own host's. There a background dispatch answers in prose rather than XML:
651
+
652
+ ```text
653
+ The subagent is working in the background (sessionID: ses_f0cb9005fffekrDPNa1Px8Jp0J). You will be notified automatically when it finishes.
654
+ ```
655
+
656
+ and the completion arrives as `<subagent sessionID="…" state="completed" description="…">`. That `sessionID` is the `task_id` for the two tools below. Measured on opencode 2.0.16.
657
+
649
658
  **The gate is enforced at the schema, not at the call.** On a host without the flag opencode rejects `background: true` outright with `Background subagents require OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true`, which costs a whole dispatch. So the plugin reads the host's own `task` schema (the same registry fetch that supplies the agent-type list) and, when it has no `background` property, strips the field before Claude ever sees it. Nothing about a default install changes: the model is shown exactly `description`, `prompt`, `subagent_type`, `task_id`, `command`. Either way `plugin.log` says which:
650
659
 
651
660
  ```
@@ -659,7 +668,11 @@ background subagent gate {"supported":true,"registryResolved":true,"hostApi":"v1
659
668
  | `mcp__opencode_proxy__task_status` | Reads the state of a background subagent by its `task_id` and returns its result if it has finished. A recovery path, not a progress poll: a healthy background task delivers its own result. A result is handed over once, so asking again reports the state without repeating the output. |
660
669
  | `mcp__opencode_proxy__task_cancel` | Stops a background subagent. A cancelled subagent sends no completion notification. |
661
670
 
662
- The `task_id` is the `id` in the `<task …>` envelope, which is the child's own opencode session id. Both tools are answered inside the plugin rather than executed by opencode, because opencode has no tools of these names, and both refuse any session whose parent is not the conversation doing the asking. Neither can be named in `proxyTools`: they appear only when the host advertises background support, so upgrading changes nothing about what the model can do or spend on a default install.
671
+ The `task_id` is the child's own opencode session id: the `id` in the `<task …>` envelope on opencode 1.x, the `sessionID` the dispatch reported on opencode 2. Both tools are answered inside the plugin rather than executed by opencode, because opencode has no tools of these names, and both refuse any session whose parent is not the conversation doing the asking. Neither can be named in `proxyTools`: they appear only when the host advertises background support, so upgrading changes nothing about what the model can do or spend on a default install.
672
+
673
+ Both work on opencode 1.x and on opencode 2. On opencode 2 they run over the session routes a plugin is actually given there (`session.context` and `session.interrupt`); opencode 2 gives a plugin no all-sessions run-state map, so "still running" is read off the child's own transcript instead. Verified live on 2.0.16: start, `task_status` answering `running`, `task_cancel` answering `Stopped.`, and a finished child collected once.
674
+
675
+ **What `/claude-code-doctor` says about it.** The report has a **Background subagents** section: whether `background` was offered to Claude and the two tools registered, which opencode major, and what decided it (the live `task` schema, a registry that did not answer, or opencode 2 offering it unconditionally), plus the background tasks this process has collected or cancelled. The gate is read while a turn plans its proxy tools, so in a fresh process the section reads `Not read yet this process`: send one message and run it again.
663
676
 
664
677
  ### Proxy endpoint security
665
678
 
package/dist/index.js CHANGED
@@ -1633,12 +1633,12 @@ function setProxyDeadlineGuard(guard) {
1633
1633
  var PROXY_DEADLINE_RECHECK_MS = 6e4;
1634
1634
  var deadlineRecheckMs = PROXY_DEADLINE_RECHECK_MS;
1635
1635
  function armProxyDeadline(opts) {
1636
- let cancelled = false;
1636
+ let cancelled2 = false;
1637
1637
  let extended = false;
1638
1638
  let timer = null;
1639
1639
  const check = async () => {
1640
1640
  timer = null;
1641
- if (cancelled) return;
1641
+ if (cancelled2) return;
1642
1642
  let keep = false;
1643
1643
  const guard = proxyDeadlineGuard;
1644
1644
  if (guard) {
@@ -1648,7 +1648,7 @@ function armProxyDeadline(opts) {
1648
1648
  keep = false;
1649
1649
  }
1650
1650
  }
1651
- if (cancelled) return;
1651
+ if (cancelled2) return;
1652
1652
  if (keep) {
1653
1653
  if (!extended && opts.logExtension) {
1654
1654
  log.warn("proxy call past its deadline, but opencode is still serving it; waiting", {
@@ -1667,7 +1667,7 @@ function armProxyDeadline(opts) {
1667
1667
  timer = setTimeout(() => void check(), opts.deadlineMs);
1668
1668
  return {
1669
1669
  cancel() {
1670
- cancelled = true;
1670
+ cancelled2 = true;
1671
1671
  if (timer) clearTimeout(timer);
1672
1672
  timer = null;
1673
1673
  }
@@ -1685,6 +1685,7 @@ function buildProxyTimeoutError(toolName, ms) {
1685
1685
  }
1686
1686
  var TASK_PROXY_NOTE = "This and task_batch are the ONLY tools that dispatch opencode subagents (including user @-mentions). Claude Code's built-in TaskCreate/TaskUpdate manage a local todo list and cannot dispatch subagents. Do not search config files to verify a subagent type exists: invalid types fail fast with a clear error. Calls block until the subagent finishes. For two or more independent subagents in one response use task_batch, not several task calls: those run one after another. Task calls have no proxy deadline by default: the call waits for the subagent to finish (a positive proxyToolTimeoutMs override adds a deadline).";
1687
1687
  var TASK_BACKGROUND_NOTE = 'Fire-and-collect: set `background: true` and the call returns at once with `<task id="ses_..." state="running">` instead of the subagent\'s answer. Keep working on something that does not overlap it, then end your turn: when the subagent finishes, opencode delivers its result to this conversation on its own as a new message. Do NOT poll, sleep or loop waiting for it. The `id` in that envelope is the task_id: pass it to task_status to read a result the notification did not deliver, or to task_cancel to stop a background subagent you no longer want.';
1688
+ var TASK_BACKGROUND_NOTE_V2 = 'Fire-and-collect: set `background: true` and the call returns at once with `The subagent is working in the background (sessionID: ses_...)` instead of the subagent\'s answer. Keep working on something that does not overlap it, then end your turn: when the subagent finishes, opencode delivers its result to this conversation on its own, as a `<subagent sessionID="..." state="completed">` message. Do NOT poll, sleep or loop waiting for it. That sessionID is the task_id: pass it to task_status to read a result the notification did not deliver, or to task_cancel to stop a background subagent you no longer want.';
1688
1689
  var TASK_BATCH_TOOL_NAME = "task_batch";
1689
1690
  var TASK_BATCH_PROXY_NOTE = "Use this instead of several task calls in one response: Claude Code runs MCP tool calls one at a time, so separate task calls run serially even when emitted together, while one task_batch call fans them out as parallel opencode task calls. Each task takes the same fields as the task tool. Results come back in task order, each labelled. Like task it has no proxy deadline by default (a positive proxyToolTimeoutMs override adds one).";
1690
1691
  var TASK_INPUT_REQUIRED = ["description", "prompt", "subagent_type"];
@@ -1785,7 +1786,7 @@ var BACKGROUND_TASK_TOOL_DEFS = [
1785
1786
  properties: {
1786
1787
  task_id: {
1787
1788
  type: "string",
1788
- description: 'The id from the `<task id="..." state="running">` envelope the background dispatch returned.'
1789
+ description: 'The background subagent\'s own opencode session id: the `id` in the `<task id="..." state="running">` envelope on opencode 1.x, or the `sessionID` the background dispatch reported on opencode 2.'
1789
1790
  }
1790
1791
  },
1791
1792
  required: ["task_id"]
@@ -1799,21 +1800,22 @@ var BACKGROUND_TASK_TOOL_DEFS = [
1799
1800
  properties: {
1800
1801
  task_id: {
1801
1802
  type: "string",
1802
- description: 'The id from the `<task id="..." state="running">` envelope the background dispatch returned.'
1803
+ description: 'The background subagent\'s own opencode session id: the `id` in the `<task id="..." state="running">` envelope on opencode 1.x, or the `sessionID` the background dispatch reported on opencode 2.'
1803
1804
  }
1804
1805
  },
1805
1806
  required: ["task_id"]
1806
1807
  }
1807
1808
  }
1808
1809
  ];
1809
- function applyBackgroundSubagentSupport(tools, supported) {
1810
+ function applyBackgroundSubagentSupport(tools, supported, dialect = "v1") {
1810
1811
  if (!tools.some((t) => t.name === "task")) return tools;
1812
+ const note = dialect === "v2" ? TASK_BACKGROUND_NOTE_V2 : TASK_BACKGROUND_NOTE;
1811
1813
  const adjusted = tools.map((t) => {
1812
1814
  if (t.name !== "task" && t.name !== TASK_BATCH_TOOL_NAME) return t;
1813
1815
  if (supported) {
1814
1816
  return { ...t, description: `${t.description}
1815
1817
 
1816
- ${TASK_BACKGROUND_NOTE}` };
1818
+ ${note}` };
1817
1819
  }
1818
1820
  return { ...t, inputSchema: stripBackgroundFromSchema(t.name, t.inputSchema) };
1819
1821
  });
@@ -3132,8 +3134,8 @@ async function abortSession(sessionID) {
3132
3134
  const client = opencodeClient;
3133
3135
  if (!client?.session?.abort) return false;
3134
3136
  try {
3135
- await client.session.abort({ path: { id: sessionID } });
3136
- return true;
3137
+ const res = await client.session.abort({ path: { id: sessionID } });
3138
+ return res.data !== false;
3137
3139
  } catch (err) {
3138
3140
  log.warn("failed to abort opencode session", {
3139
3141
  sessionID,
@@ -3149,21 +3151,22 @@ var TASK_CANCEL_TOOL_NAME = "task_cancel";
3149
3151
  var MAX_LEDGERS = 32;
3150
3152
  var MAX_COLLECTED_PER_SESSION = 64;
3151
3153
  var collected = /* @__PURE__ */ new Map();
3152
- function ledgerFor(sessionKey2) {
3153
- let ids = collected.get(sessionKey2);
3154
+ var cancelled = /* @__PURE__ */ new Map();
3155
+ function ledgerIn(ledgers2, sessionKey2) {
3156
+ let ids = ledgers2.get(sessionKey2);
3154
3157
  if (!ids) {
3155
3158
  ids = /* @__PURE__ */ new Set();
3156
- collected.set(sessionKey2, ids);
3157
- while (collected.size > MAX_LEDGERS) {
3158
- const oldest = collected.keys().next();
3159
+ ledgers2.set(sessionKey2, ids);
3160
+ while (ledgers2.size > MAX_LEDGERS) {
3161
+ const oldest = ledgers2.keys().next();
3159
3162
  if (oldest.done) break;
3160
- collected.delete(oldest.value);
3163
+ ledgers2.delete(oldest.value);
3161
3164
  }
3162
3165
  }
3163
3166
  return ids;
3164
3167
  }
3165
- function markCollected(sessionKey2, taskId) {
3166
- const ids = ledgerFor(sessionKey2);
3168
+ function mark(ledgers2, sessionKey2, taskId) {
3169
+ const ids = ledgerIn(ledgers2, sessionKey2);
3167
3170
  ids.add(taskId);
3168
3171
  while (ids.size > MAX_COLLECTED_PER_SESSION) {
3169
3172
  const oldest = ids.values().next();
@@ -3171,11 +3174,30 @@ function markCollected(sessionKey2, taskId) {
3171
3174
  ids.delete(oldest.value);
3172
3175
  }
3173
3176
  }
3177
+ function markCollected(sessionKey2, taskId) {
3178
+ mark(collected, sessionKey2, taskId);
3179
+ }
3174
3180
  function hasCollectedBackgroundTask(sessionKey2, taskId) {
3175
3181
  return collected.get(sessionKey2)?.has(taskId) === true;
3176
3182
  }
3177
3183
  function clearBackgroundTasks(sessionKey2) {
3178
3184
  collected.delete(sessionKey2);
3185
+ cancelled.delete(sessionKey2);
3186
+ }
3187
+ var lastGate;
3188
+ function recordBackgroundSubagentGate(gate, now = Date.now()) {
3189
+ lastGate = { ...gate, at: now };
3190
+ }
3191
+ function snapshotBackgroundSubagentGate() {
3192
+ return lastGate ? { ...lastGate } : void 0;
3193
+ }
3194
+ function snapshotBackgroundTasks() {
3195
+ const keys = /* @__PURE__ */ new Set([...collected.keys(), ...cancelled.keys()]);
3196
+ return [...keys].map((sessionKey2) => ({
3197
+ sessionKey: sessionKey2,
3198
+ collected: [...collected.get(sessionKey2) ?? []],
3199
+ cancelled: [...cancelled.get(sessionKey2) ?? []]
3200
+ }));
3179
3201
  }
3180
3202
  function readTaskId(input) {
3181
3203
  const raw = input.task_id ?? input.taskId ?? input.sessionID;
@@ -3183,14 +3205,20 @@ function readTaskId(input) {
3183
3205
  const id = raw.trim();
3184
3206
  return id.length > 0 ? id : null;
3185
3207
  }
3186
- var MISSING_TASK_ID = 'task_id is required: pass the id from the `<task id="...">` envelope the background dispatch returned.';
3208
+ var MISSING_TASK_ID = 'task_id is required: pass the background subagent\'s own opencode session id, from the `<task id="...">` envelope on opencode 1.x or the `sessionID` the background dispatch reported on opencode 2.';
3187
3209
  async function guardParent(taskId, callerSessionId) {
3188
3210
  if (!callerSessionId || callerSessionId === "default") {
3189
3211
  return `Cannot check that task_id ${taskId} belongs to this conversation (this turn has no opencode session id), so it is not inspected or cancelled.`;
3190
3212
  }
3191
3213
  const parent = await fetchSessionParentId(taskId);
3192
3214
  if (parent === callerSessionId) return null;
3193
- return `task_id ${taskId} is not a subagent of this conversation, so it cannot be inspected or cancelled from here. Use the id from a \`<task id="...">\` envelope this conversation received.`;
3215
+ return `task_id ${taskId} is not a subagent of this conversation, so it cannot be inspected or cancelled from here. Use the session id from a background dispatch this conversation itself made.`;
3216
+ }
3217
+ function isBackgroundTaskRunning(runState, replies) {
3218
+ if (runState === "busy") return true;
3219
+ if (runState === "idle") return false;
3220
+ const reply = lastAssistantReply(replies);
3221
+ return reply !== void 0 && !reply.completed && reply.error === void 0;
3194
3222
  }
3195
3223
  function lastAssistantReply(replies) {
3196
3224
  for (let index = replies.length - 1; index >= 0; index--) {
@@ -3212,7 +3240,7 @@ async function collectBackgroundTask(input, options) {
3212
3240
  };
3213
3241
  }
3214
3242
  const runState = await fetchSessionRunState(taskId);
3215
- if (runState === "busy") {
3243
+ if (isBackgroundTaskRunning(runState, replies)) {
3216
3244
  return {
3217
3245
  kind: "text",
3218
3246
  text: `<task id="${taskId}" state="running">
@@ -3267,7 +3295,10 @@ async function cancelBackgroundTask(input, options) {
3267
3295
  if (!taskId) return { kind: "error", message: MISSING_TASK_ID };
3268
3296
  const refused = await guardParent(taskId, options.callerSessionId);
3269
3297
  if (refused) return { kind: "error", message: refused };
3270
- const before = await fetchSessionRunState(taskId);
3298
+ const running = isBackgroundTaskRunning(
3299
+ await fetchSessionRunState(taskId),
3300
+ await fetchSessionReplies(taskId) ?? []
3301
+ );
3271
3302
  const aborted = await abortSession(taskId);
3272
3303
  if (!aborted) {
3273
3304
  return {
@@ -3276,15 +3307,16 @@ async function cancelBackgroundTask(input, options) {
3276
3307
  };
3277
3308
  }
3278
3309
  collected.get(options.sessionKey)?.delete(taskId);
3310
+ mark(cancelled, options.sessionKey, taskId);
3279
3311
  log.info("cancelled background subagent", {
3280
3312
  sessionKey: options.sessionKey,
3281
3313
  taskId,
3282
- wasRunning: before === "busy"
3314
+ wasRunning: running
3283
3315
  });
3284
3316
  return {
3285
3317
  kind: "text",
3286
3318
  text: `<task id="${taskId}" state="cancelled">
3287
- ` + (before === "busy" ? "Stopped. No completion notification will arrive for it." : "It was not running when the cancel was sent, so nothing was interrupted. No completion notification will arrive for it.") + "\n</task>"
3319
+ ` + (running ? "Stopped. No completion notification will arrive for it." : "It was not running when the cancel was sent, so nothing was interrupted. No completion notification will arrive for it.") + "\n</task>"
3288
3320
  };
3289
3321
  }
3290
3322
 
@@ -5219,6 +5251,10 @@ function formatDoctorReport(report) {
5219
5251
  }
5220
5252
  }
5221
5253
  lines.push("");
5254
+ lines.push("**Background subagents**");
5255
+ lines.push("");
5256
+ lines.push(...formatBackgroundSubagents(report.backgroundSubagents));
5257
+ lines.push("");
5222
5258
  lines.push("**Plan usage**");
5223
5259
  lines.push("");
5224
5260
  switch (report.planUsage.status) {
@@ -5251,6 +5287,59 @@ function formatDoctorReport(report) {
5251
5287
  }
5252
5288
  return lines.join("\n");
5253
5289
  }
5290
+ function formatBackgroundSubagents(state) {
5291
+ const lines = [];
5292
+ const gate = state.gate;
5293
+ if (!gate) {
5294
+ lines.push(
5295
+ "Not read yet this process. The gate is resolved the first time a turn plans its proxy tools, so send one message and run this again."
5296
+ );
5297
+ } else {
5298
+ lines.push("| Field | Value |");
5299
+ lines.push("|---|---|");
5300
+ lines.push(`| \`background\` offered to Claude | ${gate.supported ? "yes" : "no"} |`);
5301
+ lines.push(
5302
+ `| \`task_status\` / \`task_cancel\` | ${gate.supported ? "registered" : "not registered"} |`
5303
+ );
5304
+ lines.push(`| opencode API | ${gate.hostApi} |`);
5305
+ lines.push(`| decided from | ${describeBackgroundGateSource(gate)} |`);
5306
+ lines.push(`| read | ${formatAge(Math.max(0, Date.now() - gate.at))} ago |`);
5307
+ if (!gate.supported && gate.hostApi === "v1") {
5308
+ lines.push("");
5309
+ lines.push(
5310
+ "opencode 1.x keeps background subagents behind a flag on its OWN process: set `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true` (or the blanket `OPENCODE_EXPERIMENTAL`) in opencode's environment and restart it. The plugin never reads that variable itself; it reads whether opencode's advertised `task` schema carries a `background` property, which is how opencode publishes the flag."
5311
+ );
5312
+ }
5313
+ }
5314
+ const active = state.ledgers.filter(
5315
+ (ledger) => ledger.collected.length > 0 || ledger.cancelled.length > 0
5316
+ );
5317
+ lines.push("");
5318
+ if (active.length === 0) {
5319
+ lines.push("No background task has been collected or cancelled by this process.");
5320
+ } else {
5321
+ lines.push("| session | collected | cancelled |");
5322
+ lines.push("|---|---|---|");
5323
+ for (const ledger of active) {
5324
+ lines.push(
5325
+ `| ${describeSessionKey(ledger.sessionKey).session} | ${list(ledger.collected)} | ${list(
5326
+ ledger.cancelled
5327
+ )} |`
5328
+ );
5329
+ }
5330
+ lines.push("");
5331
+ lines.push(
5332
+ "A collected task is one this conversation read back with `task_status`, or was told about by opencode's own completion notification; a result is handed over once."
5333
+ );
5334
+ }
5335
+ return lines;
5336
+ }
5337
+ function describeBackgroundGateSource(gate) {
5338
+ if (gate.hostApi === "v2") {
5339
+ return "opencode 2 offers `background` unconditionally, so the registry is not consulted";
5340
+ }
5341
+ return gate.registryResolved ? "opencode's live `task` schema" : "opencode's live tool registry did not answer, so the answer defaulted to no";
5342
+ }
5254
5343
  async function checkProxyAuth(url, fetchImpl = fetch, timeoutMs = 3e3) {
5255
5344
  let authority;
5256
5345
  try {
@@ -5317,7 +5406,11 @@ async function gatherDoctorReport(options) {
5317
5406
  proxyServers,
5318
5407
  mcpServerErrors: snapshotMcpServerErrors(),
5319
5408
  pluginLoadFailures: snapshotPluginLoadFailures(),
5320
- planUsage
5409
+ planUsage,
5410
+ backgroundSubagents: {
5411
+ gate: snapshotBackgroundSubagentGate(),
5412
+ ledgers: snapshotBackgroundTasks()
5413
+ }
5321
5414
  };
5322
5415
  }
5323
5416
  async function buildDoctorReport(options) {
@@ -5332,7 +5425,8 @@ async function buildDoctorReport(options) {
5332
5425
  proxyServers: report.proxyServers.map((server2) => server2.auth.status),
5333
5426
  mcpServerErrors: report.mcpServerErrors.length,
5334
5427
  pluginLoadFailures: report.pluginLoadFailures.length,
5335
- planUsage: report.planUsage.status
5428
+ planUsage: report.planUsage.status,
5429
+ backgroundSubagents: report.backgroundSubagents.gate?.supported ?? "not read"
5336
5430
  });
5337
5431
  return formatDoctorReport(report);
5338
5432
  } catch (error) {
@@ -6820,7 +6914,13 @@ function resolveClaude(cmd = "claude") {
6820
6914
  throw new Error(`Could not resolve command on PATH: ${cmd}`);
6821
6915
  }
6822
6916
  function encodeCwd(cwd) {
6823
- return path8.resolve(cwd).replace(/[^a-zA-Z0-9]/g, "-");
6917
+ const resolved = path8.resolve(cwd);
6918
+ let real = resolved;
6919
+ try {
6920
+ real = fs6.realpathSync.native(resolved);
6921
+ } catch {
6922
+ }
6923
+ return real.replace(/[^a-zA-Z0-9]/g, "-");
6824
6924
  }
6825
6925
  function interactiveSpawnEnv(opts) {
6826
6926
  return {
@@ -7341,16 +7441,18 @@ function spawnInteractiveProcess(opts) {
7341
7441
  if (!startPromise) startPromise = session.start();
7342
7442
  return startPromise;
7343
7443
  };
7344
- const emitResult = (subtype, isError, result, usage) => {
7444
+ const emitResult = (opts2) => {
7345
7445
  lineEmitter.emit(
7346
7446
  "line",
7347
7447
  JSON.stringify({
7348
7448
  type: "result",
7349
- subtype,
7350
- is_error: isError,
7351
- result,
7449
+ subtype: opts2.subtype,
7450
+ is_error: opts2.isError,
7451
+ stop_reason: opts2.stopReason,
7452
+ terminal_reason: opts2.terminalReason,
7453
+ result: opts2.result,
7352
7454
  session_id: session.sessionId,
7353
- usage: usage ?? {},
7455
+ usage: opts2.usage ?? {},
7354
7456
  total_cost_usd: null,
7355
7457
  duration_ms: 0
7356
7458
  })
@@ -7373,20 +7475,24 @@ function spawnInteractiveProcess(opts) {
7373
7475
  lastCallCacheWriteTokens: lastCallUsage?.cache_creation_input_tokens
7374
7476
  });
7375
7477
  const timedOut = !stopReason;
7376
- emitResult(
7377
- timedOut ? "error_during_execution" : stopReason,
7378
- timedOut,
7379
- timedOut ? "Interactive transport: the turn ended without a terminal stop_reason (turn timeout or claude exit). Output above may be incomplete." : void 0,
7478
+ emitResult({
7479
+ subtype: timedOut ? "error_during_execution" : "success",
7480
+ isError: timedOut,
7481
+ stopReason: timedOut ? null : stopReason,
7482
+ terminalReason: timedOut ? "error_during_execution" : "completed",
7483
+ result: timedOut ? "Interactive transport: the turn ended without a terminal stop_reason (turn timeout or claude exit). Output above may be incomplete." : void 0,
7380
7484
  usage
7381
- );
7485
+ });
7382
7486
  } catch (err) {
7383
7487
  const e = err instanceof Error ? err : new Error(String(err));
7384
7488
  log.error("interactive turn failed", { error: e.message });
7385
- emitResult(
7386
- "error_during_execution",
7387
- true,
7388
- `Interactive transport failed: ${e.message}`
7389
- );
7489
+ emitResult({
7490
+ subtype: "error_during_execution",
7491
+ isError: true,
7492
+ stopReason: null,
7493
+ terminalReason: "error_during_execution",
7494
+ result: `Interactive transport failed: ${e.message}`
7495
+ });
7390
7496
  if (errorHandlers.size > 0) {
7391
7497
  for (const h of errorHandlers) h(e);
7392
7498
  } else {
@@ -10547,18 +10653,25 @@ var ClaudeCodeLanguageModel = class {
10547
10653
  )
10548
10654
  )
10549
10655
  });
10656
+ const hostDialect = self.config.hostApi === "v2" ? "v2" : "v1";
10550
10657
  backgroundSubagentsSupported = liveTaskSupportsBackground(
10551
10658
  liveToolInfo.taskParameters,
10552
- self.config.hostApi === "v2" ? "v2" : "v1"
10659
+ hostDialect
10553
10660
  );
10554
10661
  enrichedProxy = applyBackgroundSubagentSupport(
10555
10662
  enrichedProxy,
10556
- backgroundSubagentsSupported
10663
+ backgroundSubagentsSupported,
10664
+ hostDialect
10557
10665
  );
10666
+ recordBackgroundSubagentGate({
10667
+ supported: backgroundSubagentsSupported,
10668
+ hostApi: hostDialect,
10669
+ registryResolved: liveToolInfo.resolved
10670
+ });
10558
10671
  log.info("background subagent gate", {
10559
10672
  supported: backgroundSubagentsSupported,
10560
10673
  registryResolved: liveToolInfo.resolved,
10561
- hostApi: self.config.hostApi ?? "v1",
10674
+ hostApi: hostDialect,
10562
10675
  note: backgroundSubagentsSupported ? "task accepts `background`; task_status and task_cancel are registered" : "`background` stripped from the task schema; set OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true in opencode's environment to enable it"
10563
10676
  });
10564
10677
  }
@@ -11203,7 +11316,7 @@ import {
11203
11316
  existsSync as existsSync3,
11204
11317
  mkdirSync as mkdirSync4,
11205
11318
  readFileSync as readFileSync6,
11206
- realpathSync as realpathSync2,
11319
+ realpathSync as realpathSync3,
11207
11320
  rmSync as rmSync3,
11208
11321
  writeFileSync as writeFileSync5
11209
11322
  } from "fs";
@@ -11242,7 +11355,7 @@ function userIntendsToUseUnscoped() {
11242
11355
  function ourLoadedDir() {
11243
11356
  try {
11244
11357
  const filePath = fileURLToPath3(import.meta.url);
11245
- return realpathSync2(resolve5(filePath, "..", ".."));
11358
+ return realpathSync3(resolve5(filePath, "..", ".."));
11246
11359
  } catch {
11247
11360
  return null;
11248
11361
  }
@@ -11311,7 +11424,7 @@ function cleanupOne(cacheRoot, ourDir) {
11311
11424
  if (!existsSync3(stalePath)) return;
11312
11425
  let realStalePath = stalePath;
11313
11426
  try {
11314
- realStalePath = realpathSync2(stalePath);
11427
+ realStalePath = realpathSync3(stalePath);
11315
11428
  } catch {
11316
11429
  }
11317
11430
  if (ourDir && realStalePath === ourDir) return;
@@ -11384,6 +11497,30 @@ function toV1Session(info) {
11384
11497
  const directory = typeof root === "string" ? typeof session.subpath === "string" && session.subpath.length > 0 ? path9.resolve(root, session.subpath) : root : void 0;
11385
11498
  return { ...info, directory, parentID: session.parentID };
11386
11499
  }
11500
+ function toV1Messages(messages) {
11501
+ if (!Array.isArray(messages)) return [];
11502
+ return messages.flatMap((entry) => {
11503
+ if (!entry || typeof entry !== "object") return [];
11504
+ const message = entry;
11505
+ const parts = Array.isArray(message.content) ? message.content.flatMap((part) => {
11506
+ if (!part || typeof part !== "object") return [];
11507
+ const piece = part;
11508
+ return piece.type === "text" && typeof piece.text === "string" ? [{ type: "text", text: piece.text }] : [];
11509
+ }) : typeof message.text === "string" ? [{ type: "text", text: message.text }] : [];
11510
+ const error = message.error;
11511
+ const info = {
11512
+ role: typeof message.type === "string" ? message.type : "",
11513
+ time: message.time && typeof message.time === "object" ? message.time : {},
11514
+ ...error && typeof error === "object" ? {
11515
+ error: {
11516
+ name: typeof error.type === "string" ? error.type : "error",
11517
+ data: { message: typeof error.message === "string" ? error.message : void 0 }
11518
+ }
11519
+ } : {}
11520
+ };
11521
+ return [{ info, parts }];
11522
+ });
11523
+ }
11387
11524
  function createV1ClientShim(ctx) {
11388
11525
  const client = {};
11389
11526
  const mcpList = ctx.mcp?.list;
@@ -11436,13 +11573,27 @@ ${agentList}` : subagent.description;
11436
11573
  };
11437
11574
  }
11438
11575
  const sessionGet = ctx.session?.get;
11576
+ const sessionContext = ctx.session?.context;
11577
+ const sessionInterrupt = ctx.session?.interrupt;
11578
+ const session = {};
11439
11579
  if (typeof sessionGet === "function") {
11440
- client.session = {
11441
- get: async (options) => ({
11442
- data: toV1Session(await sessionGet.call(ctx.session, { sessionID: options.path.id }))
11443
- })
11580
+ session.get = async (options) => ({
11581
+ data: toV1Session(await sessionGet.call(ctx.session, { sessionID: options.path.id }))
11582
+ });
11583
+ }
11584
+ if (typeof sessionContext === "function") {
11585
+ session.messages = async (options) => ({
11586
+ data: toV1Messages(await sessionContext.call(ctx.session, { sessionID: options.path.id }))
11587
+ });
11588
+ }
11589
+ if (typeof sessionInterrupt === "function") {
11590
+ session.abort = async (options) => {
11591
+ const result = await sessionInterrupt.call(ctx.session, { sessionID: options.path.id });
11592
+ const interrupted = result?.interrupted;
11593
+ return { data: typeof interrupted === "boolean" ? interrupted : true };
11444
11594
  };
11445
11595
  }
11596
+ if (Object.keys(session).length > 0) client.session = session;
11446
11597
  return client;
11447
11598
  }
11448
11599