opencode-codeops 1.10.3 → 2.0.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/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  All notable changes to CodeOps are recorded here.
4
4
 
5
+ ## 2.0.0 — 2026-10-06
6
+
7
+ ### Breaking Changes
8
+
9
+ - port plugin to the OpenCode 2 plugin API
10
+
11
+ ### Fixes
12
+
13
+ - docs: use a placeholder version in the plugin CLI example
14
+
15
+ ## 1.11.0 — 2026-10-04
16
+
17
+ ### Features
18
+
19
+ - effort: map missing levels to the nearest exposed variant
20
+
21
+ ### Documentation
22
+
23
+ - effort: note the exposed model variant set
24
+ - trace: record live verification and close T-03
25
+
5
26
  ## 1.10.3 — 2026-10-04
6
27
 
7
28
  ### Fixes
package/README.md CHANGED
@@ -46,8 +46,8 @@ npx -y opencode-codeops@latest update # alias of install
46
46
  ```
47
47
 
48
48
  `install`/`update` writes the skills and subagents onto the filesystem (OpenCode discovers those
49
- only from disk), then registers the plugin in the OpenCode config by calling OpenCode's own
50
- `opencode plugin` command, so standards injection and `CODEOPS_PLUGIN_ROOT` are enabled. Restart
49
+ only from disk), then registers the plugin in the OpenCode config by calling OpenCode 2's own
50
+ `opencode plugin add` command, so standards injection and `CODEOPS_PLUGIN_ROOT` are enabled. Restart
51
51
  OpenCode after installing for the plugin to load. Pass `--no-plugin` to manage the config yourself.
52
52
 
53
53
  The installer also places the shared `_shared/` and `references/` documents beside the installed
@@ -56,16 +56,18 @@ them resolve at their installed location. An existing directory with either name
56
56
  with `--force`.
57
57
 
58
58
  The scope is auto-detected: inside a CodeOps project (a git repo with `.opencode/` or
59
- `codeops/.codeops.yml`) it installs into `./.opencode`, and registers the plugin in the project
59
+ `codeops/.codeops.yml`) it installs into `./.opencode` and registers the plugin in the project
60
60
  config; anywhere else it installs globally into `~/.config/opencode` and the global config. Pass
61
- `--project` or `--global` to force one.
61
+ `--project` or `--global` to force one. OpenCode 2's CLI manages package plugins globally, so a
62
+ project-scope install registers the plugin globally with a note while the skills and agents stay
63
+ project-scoped.
62
64
 
63
- If you prefer to register the plugin manually instead, add it to your `opencode.json`:
65
+ If you prefer to register the plugin manually instead, add it to your `opencode.jsonc`:
64
66
 
65
67
  ```json
66
68
  {
67
69
  "$schema": "https://opencode.ai/config.json",
68
- "plugin": ["opencode-codeops"]
70
+ "plugins": ["opencode-codeops"]
69
71
  }
70
72
  ```
71
73
 
@@ -92,23 +94,26 @@ npx -y opencode-codeops@latest uninstall
92
94
  `status` reports the installed skills and agents versions and the configured plugin entry, so a
93
95
  mismatch is visible. Use `--dry-run` to preview an install; a same-named file the package does not
94
96
  own is skipped with a warning, and `--force` replaces it. `uninstall` removes the skills and
95
- subagents but leaves the plugin entry in your config; remove `opencode-codeops` from the `plugin`
97
+ subagents but leaves the plugin entry in your config; remove `opencode-codeops` from the `plugins`
96
98
  array by hand to fully disable it.
97
99
 
98
100
  ### Local development
99
101
 
100
- Symlink the plugin into your OpenCode plugin directory and link the installed files to a checkout,
101
- so edits are picked up without reinstalling. Pass `--no-plugin` so the checkout is not overwritten
102
- by a registered npm plugin:
102
+ OpenCode 2's `opencode plugin add` accepts npm or Git package specifiers only, so a checkout is
103
+ loaded through a local plugin file. Place a one-line re-export in a plugin directory and link the
104
+ installed files to the checkout so edits are picked up without reinstalling:
103
105
 
104
106
  ```bash
105
- # Plugin (project or global plugin directory)
106
- ln -s /path/to/opencode-codeops/plugin/index.ts ~/.config/opencode/plugins/codeops.ts
107
+ # Plugin — project scope (use ~/.config/opencode/plugins/ for every project)
108
+ mkdir -p .opencode/plugins
109
+ printf 'export { default } from "%s/plugin/index.ts"\n' "$PWD" > .opencode/plugins/codeops.ts
107
110
 
108
111
  # Skills and agents — link instead of copy
109
- node /path/to/opencode-codeops/bin/index.mjs install --link --global --no-plugin
112
+ node bin/index.mjs install --link --global --no-plugin
110
113
  ```
111
114
 
115
+ Pass `--no-plugin` so the checkout entry is not overwritten by a registered npm plugin.
116
+
112
117
  ## Setup
113
118
 
114
119
  After installing the plugin and the files, initialize CodeOps in your project:
@@ -128,7 +133,8 @@ git commit -m "chore: initialize CodeOps"
128
133
 
129
134
  ## What the plugin does automatically
130
135
 
131
- On every OpenCode session start and after every compaction, the plugin injects:
136
+ The plugin adds the CodeOps standards to every model request as system instructions, and also to
137
+ every compaction summary, so they are always active:
132
138
  - `standards/coding-standards.md` — coding quality, security, testing, and working-style rules
133
139
  - `standards/output-style.md` — how to report findings, format tables, and recommend next steps
134
140
 
@@ -136,17 +142,24 @@ These standards are active without any user action. They do not need to be copie
136
142
 
137
143
  The plugin also warns (non-blocking) if any tool attempts to edit `codeops/.codeops.yml` directly — that file is managed exclusively by the `setup-codeops` skill — and if the installed skills version differs from the plugin version, so a stale install is visible.
138
144
 
139
- Every session also gets a workspace-hygiene guard: the plugin exports `CODEOPS_TMPDIR`, a per-session scratch directory under the OS temp directory. Skills and agents put temporary files there and delete them when the run completes; the plugin removes the directory when the session is deleted and sweeps directories abandoned by interrupted runs at session start, so CodeOps does not accumulate scratch data. The full protocol is in `_shared/workspace-hygiene.md`.
145
+ Every session also gets a workspace-hygiene guard: the plugin exports `CODEOPS_TMPDIR`, a scratch
146
+ directory under the OS temp directory that belongs to the running OpenCode plugin instance. Skills
147
+ and agents put temporary files there and delete them when the run completes; the plugin removes the
148
+ directory when it unloads and sweeps directories abandoned by interrupted runs at startup, so
149
+ CodeOps does not accumulate scratch data. Because OpenCode 2 does not expose a session id to shell
150
+ hooks, concurrent sessions served by one instance share this directory. The full protocol is in
151
+ `_shared/workspace-hygiene.md`.
140
152
 
141
153
  ## Agent model configuration
142
154
 
143
155
  All CodeOps subagents inherit the model of the primary agent that invoked them. No provider-specific configuration is required out of the box.
144
156
 
145
- To pin specific models per role, use the `setup-routing` skill or add overrides directly in `opencode.json`:
157
+ To pin specific models per role, use the `setup-routing` skill or add overrides directly in
158
+ `opencode.jsonc`:
146
159
 
147
160
  ```json
148
161
  {
149
- "agent": {
162
+ "agents": {
150
163
  "demanding-executor": { "model": "anthropic/claude-opus-4-5" },
151
164
  "executor": { "model": "anthropic/claude-haiku-4" }
152
165
  }
@@ -166,8 +179,10 @@ Reasoning-heavy skills also accept `--auto-effort` (use the skill's recommended
166
179
  `--auto-effort=high` (explicit level), announce it, and clear it before the final run summary.
167
180
  Resolution order: dispatch marker, then session `--auto-effort`, then
168
181
  `routing.roles.<agent>.reasoning` in `codeops/codeops.json`, then the inherited parent variant.
169
- The levels are suggestions: no permission, verification step, or review gate ever reads them. The
170
- full contract is in `_shared/reasoning-effort.md`.
182
+ A level the active model does not expose resolves to the nearest exposed variant (`medium`
183
+ becomes `high` on a `low`/`high`/`max` model). The levels are suggestions: no permission,
184
+ verification step, or review gate ever reads them. The full contract is in
185
+ `_shared/reasoning-effort.md`.
171
186
 
172
187
  ## Project specialists
173
188
 
@@ -202,7 +217,8 @@ updates the index.
202
217
 
203
218
  ## Requirements
204
219
 
205
- - OpenCode (current)
220
+ - OpenCode 2.0+ (this release uses the OpenCode 2 plugin API; on OpenCode 1, use
221
+ `opencode-codeops@1`)
206
222
  - Bash
207
223
  - Python 3.8+
208
224
  - Git
@@ -16,13 +16,20 @@ contract consumed by the plugin and the skills. It is advisory by design and nev
16
16
  | `high` | Correctness- or security-sensitive, cross-cutting, or planning/review work | Requirements, planning, correctness/security review, ambiguous implementation |
17
17
  | `max` | Adversarial or high-risk analysis where a missed detail is costly | Thorough preflight, complex or sensitive phases |
18
18
 
19
- The four levels are the complete suggestion vocabulary. A level is applied only when the
20
- runtime model exposes a matching variant, or when the model reports reasoning support but has no
21
- variant record (the level is then written directly as the provider reasoning option). A level
22
- the model does not expose leaves the request unchanged and never raises a provider error.
23
- Routing policy is project configuration, not a suggestion:
24
- `routing.roles.<agent>.reasoning` may name any value from the provider enum
25
- (`none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`).
19
+ The four levels are the complete suggestion vocabulary. A level is applied through the model's
20
+ own variant options: the exact variant when it exists, otherwise the nearest exposed level on the
21
+ provider scale, with ties resolving to the higher level. On OpenCode 2 the plugin reads those
22
+ options from the registered model's `variants` collection and merges the chosen entry's settings
23
+ into the request. `none` stays exact-match only, because mapping it to a reasoning level would
24
+ enable reasoning the caller disabled. When no variant can be chosen — the model exposes no
25
+ matching variant — the request is left unchanged, and it never raises a provider error. Routing
26
+ policy is project configuration, not a suggestion:
27
+ `routing.roles.<agent>.reasoning` may name any value from the provider enum (`none`, `minimal`,
28
+ `low`, `medium`, `high`, `xhigh`, `max`).
29
+
30
+ The deepseek flash model in this environment exposes `low`, `high`, and `max`, so a `medium`
31
+ suggestion resolves to `high` there. Confirm the active model's set by enabling
32
+ `CODEOPS_EFFORT_TRACE=1` and reading `variantLevels` on an apply line.
26
33
 
27
34
  ## Marker grammar
28
35
 
@@ -99,6 +106,9 @@ already records:
99
106
  | Docs/config/rename-only phase with deterministic verification | `low` |
100
107
  | Any other non-trivial phase | `medium` |
101
108
 
109
+ The runtime maps a missing level to the nearest exposed variant, so the four levels remain valid
110
+ suggestions on every reasoning-capable model.
111
+
102
112
  The line is written as `> **Reasoning**: <level> — <one-line reason>`. It is a suggestion: the
103
113
  user may edit or delete it, and `exec-plan` never blocks on it. A plan without the line keeps
104
114
  the inherited behavior.
@@ -119,10 +129,12 @@ invalid `=<level>` is an argument error.
119
129
  Semantics:
120
130
 
121
131
  1. Announce `Auto-effort active — reasoning <level> applied for this run`.
122
- 2. Record the level for the session run through `scripts/codeops_effort.py`.
132
+ 2. Record the level for the run in the runtime scratch directory through
133
+ `scripts/codeops_effort.py`. When one runtime serves more than one concurrent session, the
134
+ most recently recorded level applies to all of them.
123
135
  3. Clear it at run completion, before the final summary.
124
136
  4. Run-scoped: the level applies from the point it is set until cleared or the session ends.
125
- 5. Fail-open: when the session temp directory is unavailable or the helper fails, print an
137
+ 5. Fail-open: when the runtime scratch directory is unavailable or the helper fails, print an
126
138
  advise-only note and continue; the skill never blocks.
127
139
  6. Never a gate: effort affects cost and latency only. No readiness check, verification step,
128
140
  reviewer requirement, or finding gate may read it.
@@ -134,10 +146,10 @@ forces that level for the whole run, including every dispatch marker composed du
134
146
 
135
147
  Set `CODEOPS_EFFORT_TRACE=1` (or `true`) in the environment that starts OpenCode to record what
136
148
  the plugin does. One content-free JSON line is appended per marker capture and per request to
137
- `reasoning-effort-trace.jsonl` inside the session's scratch directory. Lines carry only a
138
- timestamp, event name, message id, agent name, level, source, and applied flag — never prompt
139
- text or file content. The trace lives with the rest of the session scratch and is removed with
140
- it. Tracing is off by default and never affects a request.
149
+ `reasoning-effort-trace.jsonl` inside the runtime's scratch directory. Lines carry only a
150
+ timestamp, session id, event name, message id, agent name, level, source, chosen variant, and
151
+ applied flag — never prompt text or file content. The trace lives with the rest of the runtime
152
+ scratch and is removed with it. Tracing is off by default and never affects a request.
141
153
 
142
154
  ## Suggestion-only guarantee
143
155
 
@@ -15,13 +15,17 @@ outputs are deliverables — not temporary artifacts — and they are never dele
15
15
 
16
16
  ## Where temporary artifacts live
17
17
 
18
- 1. `$CODEOPS_TMPDIR` when the CodeOps plugin exports it (one directory per session). Use it for
19
- every scratch file.
18
+ 1. `$CODEOPS_TMPDIR` when the CodeOps plugin exports it (one directory per OpenCode runtime).
19
+ Use it for every scratch file.
20
20
  2. Otherwise, the operating system's temp directory.
21
21
  3. Never inside the repository. A repository path is durable space; scratch does not belong there.
22
22
 
23
23
  Create subdirectories under the temp root lazily, only when the task needs them.
24
24
 
25
+ OpenCode 2 does not expose a session identifier to shell hooks, so the plugin owns one scratch
26
+ directory per runtime instead of one per session. Sessions served by the same runtime share it;
27
+ keep scratch file names specific to the run to avoid collisions.
28
+
25
29
  ## Lifecycle
26
30
 
27
31
  1. **Create lazily** — write a temporary file only when the task needs it.
@@ -47,7 +51,7 @@ Create subdirectories under the temp root lazily, only when the task needs them.
47
51
 
48
52
  ## Automatic safety net
49
53
 
50
- The CodeOps plugin exports `$CODEOPS_TMPDIR` for each session and removes that session's directory
51
- when the session is deleted. At session start it also removes CodeOps temp directories older than
52
- seven days, so an interrupted run cannot leak storage forever. The safety net is a fallback, not a
53
- substitute: the run itself still performs the cleanup pass above.
54
+ The CodeOps plugin exports `$CODEOPS_TMPDIR` for each runtime and removes that runtime's
55
+ directory when the plugin unloads. At startup it also removes CodeOps temp directories older
56
+ than seven days, so an interrupted run cannot leak storage forever. The safety net is a
57
+ fallback, not a substitute: the run itself still performs the cleanup pass above.
package/bin/index.mjs CHANGED
@@ -135,11 +135,17 @@ async function runCombined(command, rest, io) {
135
135
  const result = registerPlugin({ scope, version, cwd, run: io.run })
136
136
  if (result.ok) {
137
137
  console.log(`Plugin: registered ${result.spec} in the ${scope} OpenCode config.`)
138
+ if (scope === "project" && result.dialect === "add") {
139
+ console.log(
140
+ "Note: the OpenCode 2 CLI manages package plugins globally; " +
141
+ "skills and agents remain project-scoped."
142
+ )
143
+ }
138
144
  console.log("Restart OpenCode to load the plugin.")
139
145
  } else {
140
146
  console.log(
141
147
  `Plugin: not registered (${result.reason}). ` +
142
- `Add "${PLUGIN_NAME}" to the "plugin" array in your opencode.json.`
148
+ `Add "${PLUGIN_NAME}" to the "plugins" array in your opencode.jsonc.`
143
149
  )
144
150
  }
145
151
  }
@@ -2,13 +2,16 @@
2
2
  /**
3
3
  * Registers (or inspects) the CodeOps plugin in OpenCode's own config.
4
4
  *
5
- * OpenCode ships a command that installs a plugin and updates the config
6
- * safely, including JSON/JSONC formatting and version replacement:
5
+ * OpenCode 2 ships a command that installs a plugin and updates the config
6
+ * safely, including JSON/JSONC formatting and version replacement. Two CLI
7
+ * shapes exist across releases:
7
8
  *
8
- * opencode plugin <module> [--global] [--force]
9
+ * opencode plugin add <module> (OpenCode 2 subcommand form)
10
+ * opencode plugin <module> [--global] (older form; still used by some builds)
9
11
  *
10
- * The installer delegates to that command instead of editing `opencode.json`
11
- * itself, so it never has to parse JSONC or risk clobbering a user's config.
12
+ * The installer probes the installed CLI once and uses the shape it accepts.
13
+ * This CodeOps version requires OpenCode 2; running it against OpenCode 1
14
+ * fails with a direct pointer to the 1.x line, because the plugin API changed.
12
15
  * All calls are best-effort: if the `opencode` executable is missing or the
13
16
  * command fails, the caller still completes the file install and tells the user
14
17
  * how to register the plugin manually.
@@ -21,6 +24,9 @@ import { spawnSync } from "node:child_process"
21
24
  /** The npm package name of the plugin. */
22
25
  export const PLUGIN_NAME = "opencode-codeops"
23
26
 
27
+ /** The OpenCode major version this package requires. */
28
+ export const REQUIRED_OPENCODE_MAJOR = 2
29
+
24
30
  /**
25
31
  * Runs a command and normalizes the result.
26
32
  *
@@ -39,37 +45,84 @@ export function defaultRun(command, args, options = {}) {
39
45
  }
40
46
  }
41
47
 
48
+ /**
49
+ * Extract the OpenCode major version from `opencode --version` output.
50
+ *
51
+ * @param versionText - Raw version output
52
+ * @returns The major version, or `undefined` when no semver is present
53
+ *
54
+ * @example
55
+ * parseOpenCodeMajor("2.0.24") // 2
56
+ */
57
+ export function parseOpenCodeMajor(versionText) {
58
+ const match = /(\d+)\.\d+\.\d+/.exec(String(versionText ?? ""))
59
+ return match ? Number(match[1]) : undefined
60
+ }
61
+
42
62
  /**
43
63
  * Builds the arguments for `opencode plugin`.
44
64
  *
45
65
  * @param details - Plugin registration inputs
46
- * @param details.scope - `"global"` adds `--global`; `"project"` omits it
66
+ * @param details.scope - `"global"` adds `--global` when the CLI supports it
47
67
  * @param details.version - Exact version to pin, or `undefined` for the bare name
68
+ * @param details.dialect - `"add"` for `plugin add <spec>`, `"positional"` otherwise
69
+ * @param details.supportsGlobal - Whether the chosen dialect accepts `--global`
48
70
  * @returns Arguments after the `opencode` executable
71
+ *
49
72
  * @example
50
- * buildPluginArgs({ scope: "global", version: "1.0.0" })
51
- * // ["plugin", "opencode-codeops@1.0.0", "--global", "--force"]
73
+ * buildPluginArgs({ scope: "global", version: "1.2.3", dialect: "add", supportsGlobal: true })
74
+ * // ["plugin", "add", "opencode-codeops@1.2.3", "--global"]
52
75
  */
53
- export function buildPluginArgs({ scope, version }) {
76
+ export function buildPluginArgs({ scope, version, dialect = "positional", supportsGlobal = true }) {
54
77
  const module = version ? `${PLUGIN_NAME}@${version}` : PLUGIN_NAME
78
+ const global = scope === "global" && supportsGlobal
79
+
80
+ if (dialect === "add") {
81
+ const args = ["plugin", "add", module]
82
+ if (global) args.push("--global")
83
+ return args
84
+ }
85
+
55
86
  const args = ["plugin", module]
56
- if (scope === "global") args.push("--global")
87
+ if (global) args.push("--global")
57
88
  args.push("--force")
58
89
  return args
59
90
  }
60
91
 
92
+ /**
93
+ * Detect which `opencode plugin` shape the installed CLI accepts.
94
+ *
95
+ * The subcommand form answers `opencode plugin add --help`; older builds treat
96
+ * `add` as a module name and fail. The help text also tells whether the
97
+ * subcommand accepts an explicit `--global` flag.
98
+ *
99
+ * @param details - Detection inputs
100
+ * @param details.cwd - Working directory
101
+ * @param details.run - Command runner, injectable for tests
102
+ * @returns The dialect and whether it supports `--global`
103
+ */
104
+ export function detectPluginDialect({ cwd, run = defaultRun }) {
105
+ const help = run("opencode", ["plugin", "add", "--help"], { cwd })
106
+ if (!help.error && help.status === 0) {
107
+ const text = `${help.stdout}\n${help.stderr}`
108
+ return { dialect: "add", supportsGlobal: /--global\b/.test(text) }
109
+ }
110
+ return { dialect: "positional", supportsGlobal: true }
111
+ }
112
+
61
113
  /**
62
114
  * Registers the plugin in the OpenCode config.
63
115
  *
64
- * Tries the exact version first so plugin and files stay in sync; if the CLI
65
- * rejects the versioned spec, falls back to the bare package name. Never throws.
116
+ * Requires OpenCode 2. Tries the exact version first so plugin and files stay
117
+ * in sync; if the CLI rejects the versioned spec, falls back to the bare
118
+ * package name. Never throws.
66
119
  *
67
120
  * @param details - Registration inputs
68
121
  * @param details.scope - `"global"` or `"project"`
69
122
  * @param details.version - Version to pin
70
123
  * @param details.cwd - Working directory (used for project scope)
71
124
  * @param details.run - Command runner, injectable for tests
72
- * @returns Whether registration succeeded, the spec used, and a reason on failure
125
+ * @returns Whether registration succeeded, the spec used, the dialect, and a reason on failure
73
126
  */
74
127
  export function registerPlugin({ scope, version, cwd, run = defaultRun }) {
75
128
  try {
@@ -78,30 +131,69 @@ export function registerPlugin({ scope, version, cwd, run = defaultRun }) {
78
131
  return { ok: false, spec: null, reason: "opencode CLI not found on PATH" }
79
132
  }
80
133
 
81
- const pinned = run("opencode", buildPluginArgs({ scope, version }), { cwd })
82
- if (!pinned.error && pinned.status === 0) {
83
- return { ok: true, spec: `${PLUGIN_NAME}@${version}` }
134
+ const major = parseOpenCodeMajor(probe.stdout)
135
+ if (major !== undefined && major < REQUIRED_OPENCODE_MAJOR) {
136
+ return {
137
+ ok: false,
138
+ spec: null,
139
+ reason:
140
+ `OpenCode ${REQUIRED_OPENCODE_MAJOR} is required (found ${probe.stdout.trim()}); ` +
141
+ "on OpenCode 1, use opencode-codeops@1",
142
+ }
84
143
  }
85
144
 
86
- const bare = run("opencode", buildPluginArgs({ scope, version: null }), { cwd })
87
- if (!bare.error && bare.status === 0) {
88
- return { ok: true, spec: PLUGIN_NAME }
145
+ const { dialect, supportsGlobal } = detectPluginDialect({ cwd, run })
146
+ let lastMessage = "opencode plugin command failed"
147
+
148
+ for (const candidate of [version, null]) {
149
+ const args = buildPluginArgs({ scope, version: candidate, dialect, supportsGlobal })
150
+ const result = run("opencode", args, { cwd })
151
+ if (!result.error && result.status === 0) {
152
+ return {
153
+ ok: true,
154
+ spec: candidate ? `${PLUGIN_NAME}@${candidate}` : PLUGIN_NAME,
155
+ dialect,
156
+ }
157
+ }
158
+ const message = (result.stderr || result.stdout || "").trim()
159
+ lastMessage = message.split("\n").pop() || lastMessage
89
160
  }
90
161
 
91
- const message = (bare.stderr || bare.stdout || pinned.stderr || pinned.stdout || "").trim()
92
- return { ok: false, spec: null, reason: message.split("\n").pop() || "opencode plugin failed" }
162
+ return { ok: false, spec: null, reason: lastMessage }
93
163
  } catch (caught) {
94
164
  return { ok: false, spec: null, reason: caught.message }
95
165
  }
96
166
  }
97
167
 
98
168
  /**
99
- * Reads the resolved `plugin` list from OpenCode's effective config.
169
+ * Normalize one configured plugin entry to its package spec.
170
+ *
171
+ * OpenCode accepts plain spec strings, `[spec, options]` tuples, and
172
+ * `{ package, options }` objects across versions, so the status readout must
173
+ * understand all three.
174
+ *
175
+ * @param item - Raw config entry
176
+ * @returns The spec string, or `undefined` when the entry is unusable
177
+ */
178
+ function entrySpec(item) {
179
+ if (typeof item === "string") return item
180
+ if (Array.isArray(item) && typeof item[0] === "string") return item[0]
181
+ if (item && typeof item === "object" && typeof item.package === "string") {
182
+ return item.package
183
+ }
184
+ return undefined
185
+ }
186
+
187
+ /**
188
+ * Reads the resolved plugin list from OpenCode's effective config.
189
+ *
190
+ * OpenCode 2 uses `plugins`; the legacy singular `plugin` key is still read as
191
+ * a fallback so mixed configs are reported correctly.
100
192
  *
101
193
  * @param details - Inspection inputs
102
194
  * @param details.cwd - Working directory
103
195
  * @param details.run - Command runner, injectable for tests
104
- * @returns The plugin entries, or `undefined` when the config cannot be read
196
+ * @returns The plugin spec strings, or `undefined` when the config cannot be read
105
197
  */
106
198
  export function readConfiguredPlugin({ cwd, run = defaultRun } = {}) {
107
199
  try {
@@ -109,7 +201,13 @@ export function readConfiguredPlugin({ cwd, run = defaultRun } = {}) {
109
201
  if (result.error || result.status !== 0) return undefined
110
202
 
111
203
  const config = JSON.parse(result.stdout)
112
- return Array.isArray(config.plugin) ? config.plugin : []
204
+ const raw = Array.isArray(config.plugins)
205
+ ? config.plugins
206
+ : Array.isArray(config.plugin)
207
+ ? config.plugin
208
+ : []
209
+
210
+ return raw.map(entrySpec).filter((spec) => typeof spec === "string")
113
211
  } catch {
114
212
  return undefined
115
213
  }
@@ -81,9 +81,20 @@ export declare function extractModelVariants(
81
81
  model: unknown
82
82
  ): Record<string, unknown> | undefined
83
83
 
84
+ /** Normalize a host variant collection (v2 array or v1 record) into a record. */
85
+ export declare function normalizeModelVariants(
86
+ variants: unknown
87
+ ): Record<string, unknown>
88
+
84
89
  /** Check whether a model advertises reasoning support. */
85
90
  export declare function modelSupportsReasoning(model: unknown): boolean
86
91
 
92
+ /** Choose the model variant key to apply for a requested level. */
93
+ export declare function selectEffortVariant(
94
+ level: unknown,
95
+ model: unknown
96
+ ): RoutingReasoning | undefined
97
+
87
98
  /** Merge the model's variant options for a level into the request options. */
88
99
  export declare function applyEffort(
89
100
  options: Record<string, unknown>,