@khalilgharbaoui/opencode-claude-code-plugin 0.50.0 → 0.52.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khalilgharbaoui/opencode-claude-code-plugin",
3
- "version": "0.50.0",
3
+ "version": "0.52.0",
4
4
  "description": "Claude Code CLI provider plugin for opencode",
5
5
  "homepage": "https://opencode-claude-code-plugin.dev/",
6
6
  "funding": {
@@ -113,9 +113,12 @@ Defaults below describe normal headless opencode use when the key is absent.
113
113
  | `cliPath` | string | `"claude"` | Executable, not a shell command with flags. Use an absolute path for a non-PATH install. The opencode config hook supplies this default; only direct `createClaudeCode()` use falls back to `CLAUDE_CLI_PATH`. Account providers wrap it; never select a generated wrapper yourself. |
114
114
  | `accounts` | string[] | unset | Unset keeps provider `claude-code`. Any array, including `[]`, expands to `claude-code-default` plus normalized, deduplicated names. Non-default accounts use `~/.claude-<name>`; default uses the CLI's normal environment/auth. |
115
115
  | `accountFailover` | `"ask"` / `"off"` | `"off"` | **Opt-in: only an explicit `"ask"` opens the switch form**, so unset and `"off"` behave identically. `"ask"` ends a usage-limited turn on opencode's native `question` form listing the other configured accounts, and continues the task on the pick inside the same opencode turn. Only ever fires with more than one account configured. The pick is sticky for the LIMITED account until the limit's reset time (or until opencode restarts when the CLI reported none), so it covers every session on that account and subagents follow their parent; child sessions are never shown the form. Leaving it unanswered waits and costs nothing. `stop`, a dismissal, or text that is not one of the offered accounts ends the turn the way a limited turn ends without the form. Triggered only by a rejected `rate_limit_event`, one of the two known account-limit error texts, or one of the five account-level failure kinds the CLI names on its own error reply (`authentication_failed`, `oauth_org_not_allowed`, `account_on_hold`, `verification_required`, `billing_error`); never by a generic failure. Never on compaction turns; both transports (the TUI's limit reply carries the same `error: "rate_limit"` kind). A switch carries the Claude conversation to the target account's config dir and resumes it there (`crossAccountResume`, default on) and only replays the thread as text when that copy is refused; either way, MCP servers configured only in the limited account's Claude profile are gone. With the default `"off"`, a limited turn ends on one `▌ **usage limit:**` note instead (see "When an account runs out of usage"). |
116
+ | `accountGroups` | object | – | **Off unless set**, and unset means every account behaves as one group, so nothing changes on an install that does not set it. A map from an account name in `accounts` to a group name, e.g. `{"work": "work"}`; every account not named, `default` included, is in one implicit group. With it set the plugin only moves a conversation between accounts in the SAME group, which covers all four ways one can move: the by-hand switch (picking a model under another account's provider in the same opencode session), the `accountFailover` form, the override that form sets, and `crossAccountResume`. A switch ACROSS groups sends the other account nothing at all, neither the Claude transcript nor a text replay of the thread (the replay is the same history by another route), starts fresh with only the current user message, and writes one `▌ **account group:**` note naming both accounts and both groups. The source account keeps the conversation and its transcript, so switching back continues it. The switch form offers only same-group accounts, and with none left behaves as a single-account install; `describeOtherAccounts` in the usage-limit and account-block notes is filtered the same way. An account name that is not configured is ignored with one WARN, as is a non-object value or a non-string group. Compaction is exempt and a child session follows its parent. Shown resolved (never echoed) in the startup block and `/claude-code-doctor` as `accountGroups`, names only. Log: `starting fresh on this account: it is in another account group` (NOTICE, reason `another-account-group`). It guards what the plugin moves on its own; it is not a permission system. |
116
117
  | `failoverAccounts` | string[] | unset/derived | Account expansion supplies the resolved account list so a limited account can offer the others. Do not hand-wire it; set `accounts` instead. |
117
118
  | `baseCliPath` | string | unset/derived | The `cliPath` before the per-account wrapper substitution, so a failover can build another account's wrapper on the same binary. Supplied by the config hook. Do not hand-wire it. |
118
119
  | `accountInProcess` | boolean | unset/derived | Set by the config hook on Windows, where there is no per-account wrapper script: the spawn exports `CLAUDE_CONFIG_DIR` itself and strips the `@<account>` marker off `--model`. Do not hand-wire it. |
120
+ | `subagentDispatch` | `"ask"` / `"off"` | `"off"` | **Opt-in: only an explicit `"ask"` raises the form**, so unset and `"off"` are identical and a dispatch is handed to opencode exactly as it is today. With `"ask"`, a proxied `task` or `task_batch` call is held at the drain and the turn ends on opencode's native `question` form asking how the subagents should run; the real `task` calls are released, with the answer applied, on the next step of the SAME opencode turn. Always exactly one question first (`Same as last time` when this conversation has one, `Default`, four curated model/effort combos, `Customise…`), so the common answer is one click. `Customise…` opens a second form with one row per agent type plus one account row when more than one account is offered, and a type row can ask for a third form with one row per task in that group. Every row takes a typed answer too: a model id, an effort level, or both (`claude-opus-5-5 max`); anything unrecognised keeps that row's default with a NOTICE. The answer applies to each child's own `claude` spawn (`--model`, `CLAUDE_CODE_EFFORT_LEVEL`, and the account's wrapper and `CLAUDE_CONFIG_DIR`) and beats `forceModel`, `defaultSubagentModel` and the inherited effort, because it is about this dispatch. A child is matched to its task by the dispatching session, the subagent type and the task prompt, verified against `parentID`. A dismissed or unanswered form dispatches with the defaults and writes one `▌ **subagent dispatch:**` note; the dispatch is never lost. Never on compaction turns, never inside a child session (a subagent follows the choice its own parent made for it), and only where opencode's registry has the `question` entry. `Same as last time` is per conversation and per agent type, in memory, so it is not offered again after an opencode restart. Both transports, both opencode majors. |
121
+ | `subagentDispatchCrossGroup` | boolean | `false` | Let `subagentDispatch`'s account row offer accounts outside the dispatching account's own `accountGroups` group. **Off by default and never implied by anything else**: a subagent is handed the task text the main agent writes, which can quote the conversation, and it reads the repository, so offering another group is offering that group the conversation by another route. Does nothing when `accountGroups` is unset, where every account is already one group, or when `subagentDispatch` is off. |
119
122
  | `defaultSubagentModel` | string | unset | Seed-config default for discovered `mode: subagent` agents without a full `provider/model` pin; `forceModel` takes precedence. Keeps the caller's account. Unknown ids warn and keep the inherited model. Not independently read per expanded account. |
120
123
  | `defaultSubagentCacheTtl` | string | unset | Prompt cache TTL (`5m` / `1h`) for discovered `mode: subagent` agents that declare no `cacheTtl`; the agent's own value takes precedence. Unset leaves the CLI's default (1 hour on a subscription). Unknown values warn and change nothing. Headless and interactive spawns, never compaction. |
121
124
  | `fallbackModels` | string[] | unset | Ordered models to try when the model a turn would run on is refused. Default for agents declaring no `fallbackModels`; a per-agent list replaces it rather than extending it. Same account throughout, never a switch. Armed only by the CLI refusing the model (`model_not_found`) or by a usage limit when the `accountFailover` form is not taking the turn, which is the case whenever it is `"off"` (its default) or has no other account to offer; with `"ask"` and another account the switch form wins. Entries must be registered model ids, unknown ones warn and are skipped, the current model is dropped from its own chain, each entry is tried at most once per turn, and an exhausted chain surfaces the original error. Never on compaction or title stubs; both transports. Writes a `▌ **model fallback:**` note that transcript rebuilds strip. Not independently read per expanded account. |
@@ -149,7 +152,7 @@ Defaults below describe normal headless opencode use when the key is absent.
149
152
  | `forkSessions` | boolean | `false` | When a new opencode session turns out to be a fork of one this provider already served, branch the parent's Claude conversation with `claude --resume <parent> --fork-session` instead of re-rendering the whole thread as text into the first message. Measured on CLI 2.1.280 with haiku 4.5 over a ~13k-token thread: 814 cache tokens written and 39,710 read, against 22,355 written and 17,385 read for the replay, so $0.0058 against $0.0467 for that turn; the parent's transcript is byte-identical afterwards. Neither opencode major tells a provider that a session is a fork, so the parent is found by matching this prompt's history against what each sibling session key was last asked to continue. Off by default because a resumed Claude conversation reuses the system prompt recorded on its FIRST request (`--system-prompt-snapshot`, default `on`), so a forked session answers under the parent's appended system prompt rather than this turn's; measured directly, a parent seeded with codename ZEBRA and forked while passing QUAIL answered ZEBRA. Falls back to the replay, unchanged, for: another account, an unknown or released parent session id, a busy parent (live process, proxied call in flight, unanswered plan-mode question), a fork cut mid-conversation, a fork taken mid tool round trip, a different cwd / model / agent / effort / prompt-cache TTL, compaction, an account-failover switch, and a `claude` whose `--help` does not advertise `--fork-session`. Recording costs nothing while the option is off. |
150
153
  | `resumeAfterRestart` | boolean | `true` | After an opencode restart, resume the conversation's Claude session (`--resume`) instead of replaying the thread as text. Persists session id + conversation digest per session key in `$XDG_STATE_HOME/opencode-claude-code-plugin/claude-sessions.json` (0600, 256 entries, 30 days) after each successful turn. Resumes only on the same session key and binary, with the transcript on disk and the history equal to the recorded conversation plus Claude's reply; anything else (edit, revert, compaction, account switch, open tool round trip) replays. Not on compaction. Log: `resuming the claude session from before the restart` (NOTICE) or `not resuming ...` with a reason (INFO). `false` disables reading and writing. |
151
154
  | `resumeAcrossModelChanges` | boolean | `true` | When the operator changes a conversation's model or reasoning effort mid-conversation, continue the same Claude session (`--resume <id> --model <new>`) instead of replaying the thread as text. Both are in the session key, so changing either used to start a fresh Claude conversation (issue #91); neither needs one, because `--model` and `CLAUDE_CODE_EFFORT_LEVEL` are spawn-time and the CLI applies either to a resumed transcript (measured on 2.1.288). The session is MOVED off the old key, so one Claude conversation has exactly one owner. Refused on every term `resumeAfterRestart` is refused on (another account, a transcript that is gone, a conversation that changed) plus two more: a sibling whose transcript may still be written to, and anything that is not the same cwd, request scope, opencode session, agent and prompt-cache TTL. Not on compaction, not on an account-failover switch. It reads the record `resumeAfterRestart` writes, so `resumeAfterRestart: false` turns it off too. Log: `continuing this conversation's claude session under the new model or effort` (NOTICE). |
152
- | `crossAccountResume` | boolean | `true` | On an account-failover switch (and on the switch back when the override expires), continue the same Claude conversation on the other account instead of replaying the thread as text. An account is a `CLAUDE_CONFIG_DIR` and a conversation is one `projects/<encoded cwd>/<id>.jsonl` inside it, so the switch copies that file into the target account's own projects dir (0600, dir 0700) and keeps the session id for the `--resume` both transports already build. Measured on CLI 2.1.288: a copied transcript resumes under another config dir with its context intact, the CLI resolves a session by FILENAME (not the `sessionId` in the records), it appends to the copy it found rather than to the cwd's path, the source comes out byte-identical, and no record holds a user, org or account id. Never overwrites: an older copy already at the target path (what a switch back finds) makes the carry take a fresh session id, and the live copy is the longest one. Refuses on nothing to carry, a non-file at the target path, or a failed copy, and each refusal falls back to the replay. Not on compaction, and inert with one account. Log: `carried this conversation's claude transcript to the other account` or `replaying this conversation as text on the other account` with a reason (both NOTICE). What is NOT verified live is a different Anthropic login answering another's conversation; `false` keeps the replay. |
155
+ | `crossAccountResume` | boolean | `true` | On any account switch (a by-hand pick of another account's model in the same session, the switch form, and the switch back when the override expires), and only between accounts in the same `accountGroups` group, continue the same Claude conversation on the other account instead of replaying the thread as text. An account is a `CLAUDE_CONFIG_DIR` and a conversation is one `projects/<encoded cwd>/<id>.jsonl` inside it, so the switch copies that file into the target account's own projects dir (0600, dir 0700) and keeps the session id for the `--resume` both transports already build. Measured on CLI 2.1.288: a copied transcript resumes under another config dir with its context intact, the CLI resolves a session by FILENAME (not the `sessionId` in the records), it appends to the copy it found rather than to the cwd's path, the source comes out byte-identical, and no record holds a user, org or account id. Never overwrites: an older copy already at the target path (what a switch back finds) makes the carry take a fresh session id, and the live copy is the longest one. Refuses on nothing to carry, a non-file at the target path, or a failed copy, and each refusal falls back to the replay. Not on compaction, and inert with one account. Log: `carried this conversation's claude transcript to the other account` or `replaying this conversation as text on the other account` with a reason (both NOTICE). What is NOT verified live is a different Anthropic login answering another's conversation; `false` keeps the replay. |
153
156
  | `bridgeOpencodeSkills` | boolean | `false` | Stage the user's opencode skills for Claude's native Skill tool as `opencode-skills:<name>`, on the headless and interactive spawns (never compaction). Covers every root opencode reads: project `.opencode/`, `.claude/`, `.agents/` walking up, the opencode config dirs (`skill/` and `skills/`), and global `~/.claude/skills` and `~/.agents/skills` under opencode's own `OPENCODE_DISABLE_EXTERNAL_SKILLS` / `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` switches. Requires the CLI's `--help` to advertise `--plugin-dir`; otherwise no-op. Bridged skills are also listed in opencode's forwarded system prompt, so a large skill set costs prompt tokens twice, which is why it is off by default; `true` opts the user's skills in. Bundled skill staging ignores this option, but still requires flag support and successful discovery/staging. |
154
157
  | `bridgeSkipNativeSkills` | boolean | `true` | Leave a skill unbridged when the Claude session already loads it: from `<CLAUDE_CONFIG_DIR>/skills`, the project's `.claude/skills`, or an installed plugin's `skills/`. Matched by resolved directory, by byte-identical SKILL.md, or (user/project scope only, since plugin skills are namespaced `<plugin>:<name>`) by name. A name match means `Skill("<name>")` answers from Claude's copy, not opencode's, so it is logged at WARN with both paths. The plugin scan reads `installed_plugins.json` and does not check whether the plugin is enabled. `false` bridges everything and reinstates the duplicates. |
155
158
  | `transport` | `"auto"` / `"headless"` / `"interactive"` | unset (headless) | Explicit choice wins over `interactive` and its env var. `auto` prefers headless and probes required headless flags with free help/argument parsing and no prompt. Only definitive unsupported flags select the PTY; timeout, auth failure, missing binary and unknown output stay headless. Never retries a submitted request. Explicit or automatic PTY selection requires `Bun.Terminal`, otherwise errors. Title stubs do not probe. `/compact` follows the selected transport. On the PTY, `read-only` is `--restricted` plus `dontAsk` (CLI 2.1.263+, refused below rather than weakened) and plan mode parks `ExitPlanMode`'s dialog for the operator. `/claude-code-doctor usage` reports the free headless check unavailable when those flags are absent; it does not run a PTY inference prompt. |
@@ -432,17 +435,18 @@ anything else: this note is recent and a long-lived opencode window may predate
432
435
 
433
436
  **Opt-in.** `{ "accountFailover": "ask" }` and more than one account configured makes a
434
437
  usage-rejected turn end on opencode's `question` form instead of that note: one option
435
- per other configured account, plus `stop`. Picking an account applies it inside the same
438
+ per other configured account in the limited account's `accountGroups` group (every other
439
+ account when no groups are set), plus `stop`. Picking an account applies it inside the same
436
440
  opencode turn, with no new user message, and the task carries on. Leaving the form
437
441
  unanswered waits and costs nothing.
438
442
 
439
443
  It is opt-in rather than on by default because of what was measured against a real
440
- five-hour limit on 2026-10-03. Two of the three findings are not the plugin's to fix:
441
- typing a message instead of picking **dismisses** the form in opencode, and that message
442
- then runs on the still-limited account and raises a second form (the "I had to send two
443
- messages before anything ran" report); and a switch is always a full replay. The third,
444
- every pick being refused as `unrecognised answer` because another plugin appends a
445
- routing tag to tool results, is fixed.
444
+ five-hour limit on 2026-10-03. One finding is not the plugin's to fix: typing a message
445
+ instead of picking **dismisses** the form in opencode, and that message then runs on the
446
+ still-limited account and raises a second form (the "I had to send two messages before
447
+ anything ran" report). The other two are fixed: every pick was refused as `unrecognised
448
+ answer` because another plugin appends a routing tag to tool results, and a switch was
449
+ always a full replay (since 0.47.0 it carries the conversation over, see below).
446
450
 
447
451
  Tell the user what a pick actually does before recommending one:
448
452
 
@@ -473,10 +477,43 @@ Tell the user what a pick actually does before recommending one:
473
477
  fix; a switch made from that form lasts until opencode restarts. With the form off and
474
478
  another account configured the note ends with "Or pick a model from the
475
479
  `"<account>"` account and resend." instead of offering the form.
476
- - Not available on the interactive transport or on compaction turns.
480
+ - Works on both transports; not available on compaction turns.
477
481
 
478
482
  `{ "accountFailover": "off" }`, which is also the default, keeps the note.
479
483
 
484
+ ### Switching account by hand, and keeping two accounts apart
485
+
486
+ Picking a model under another account's provider in the **same** opencode session
487
+ continues the conversation on that account rather than starting over: the Claude
488
+ transcript is copied into the other account's `CLAUDE_CONFIG_DIR` and resumed with
489
+ `--resume`. Both transports, either direction, and it composes with a model or effort
490
+ change in the same step. It refuses (and replays as text, saying which in one NOTICE)
491
+ when the thread is no longer the conversation that account was last asked to continue,
492
+ when the transcript is gone or still being written, or with
493
+ `"crossAccountResume": false`.
494
+
495
+ When two accounts must NOT see each other's work, name them into groups:
496
+
497
+ ```json
498
+ {
499
+ "provider": {
500
+ "claude-code": {
501
+ "options": {
502
+ "accounts": ["work", "hobby"],
503
+ "accountGroups": { "work": "work" }
504
+ }
505
+ }
506
+ }
507
+ }
508
+ ```
509
+
510
+ `work` is alone in the group `work`; `hobby` and `default` share the implicit `default`
511
+ group. Moving a conversation from `work` to `hobby` now sends `hobby` nothing: no
512
+ transcript copy and no text replay, one `▌ **account group:**` note, and the
513
+ conversation left intact on `work` to switch back to. Recommend one group name per
514
+ real-world boundary (employer, client, personal), and name only the accounts that have
515
+ to stay apart, since everything unnamed is already together.
516
+
480
517
  ### Subagents on one model, on the caller's account
481
518
 
482
519
  opencode's agent config cannot say "inherit the account, change the model", because the
@@ -542,6 +579,52 @@ the opencode schema requires it. Markdown fallback reads top-level scalar fields
542
579
  | `cacheTtl` | `5m` or `1h`; anything else warns and leaves the CLI's default alone. Exported as `CLAUDE_CODE_PROMPT_CACHE_TTL`, beating a shell export of the same name. Works for any discovered agent mode; compaction skips it. |
543
580
  | `fallbackModels` | Ordered registered bare model ids to try when this agent's model is refused. Both YAML spellings (`[a, b]` or a `- ` block). Replaces the provider-level `fallbackModels` rather than extending it. Keeps the caller's account; an entry carrying `@account` has it stripped. Unknown ids warn and are skipped. |
544
581
 
582
+ ### Ask before each subagent dispatch
583
+
584
+ Opt-in; off by default. Turns a fan-out into one question instead of a static agent file.
585
+
586
+ ```json
587
+ {
588
+ "provider": {
589
+ "claude-code": {
590
+ "options": {
591
+ "proxyTools": ["Bash", "Write", "Edit", "WebFetch", "Task"],
592
+ "subagentDispatch": "ask"
593
+ }
594
+ }
595
+ }
596
+ }
597
+ ```
598
+
599
+ What happens, in order:
600
+
601
+ 1. The model calls `mcp__opencode_proxy__task` or `mcp__opencode_proxy__task_batch`.
602
+ 2. The plugin holds the call and ends the step on opencode's `question` form with ONE
603
+ question: `Same as last time` (when this conversation has one), `Default`, four
604
+ curated model/effort combos, and `Customise…`.
605
+ 3. Answering anything but `Customise…` releases the subagents immediately, with that
606
+ answer applied, on the next step of the SAME opencode turn.
607
+ 4. `Customise…` opens one row per agent type, plus an `Account` row when more than one
608
+ account is offered. A type with more than one task also offers `Per task…`, which
609
+ opens one row per task in that group.
610
+ 5. Each subagent opencode starts spawns its own `claude` with the chosen `--model`,
611
+ `CLAUDE_CODE_EFFORT_LEVEL` and account wrapper.
612
+
613
+ Rules worth stating to a user:
614
+
615
+ - A dispatch answer beats `forceModel`, `defaultSubagentModel` and the inherited effort.
616
+ - Any row takes a typed answer: a registered model id, an effort level, or both
617
+ (`claude-opus-5-5 max`). Unrecognised text keeps that row's default with a NOTICE.
618
+ - Dismissing or ignoring the form dispatches with the defaults and writes one
619
+ `▌ **subagent dispatch:**` note. The dispatch is never lost.
620
+ - The account row lists only accounts in the dispatching account's own `accountGroups`
621
+ group. `subagentDispatchCrossGroup: true` is the explicit, default-off opt-in for the
622
+ rest; it does nothing when `accountGroups` is unset.
623
+ - Never on compaction, never inside a child session, and only where opencode's registry
624
+ has the `question` entry.
625
+ - `Same as last time` is per conversation and per agent type, in memory, so an opencode
626
+ restart offers `Default` again.
627
+
545
628
  ### Degrade to another model instead of failing
546
629
 
547
630
  ```yaml