opencode-codeops 1.11.0 → 2.0.1

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,22 @@
2
2
 
3
3
  All notable changes to CodeOps are recorded here.
4
4
 
5
+ ## 2.0.1 — 2026-10-06
6
+
7
+ ### Fixes
8
+
9
+ - installer: read plugins from the v2 configuration source list
10
+
11
+ ## 2.0.0 — 2026-10-06
12
+
13
+ ### Breaking Changes
14
+
15
+ - port plugin to the OpenCode 2 plugin API
16
+
17
+ ### Fixes
18
+
19
+ - docs: use a placeholder version in the plugin CLI example
20
+
5
21
  ## 1.11.0 — 2026-10-04
6
22
 
7
23
  ### Features
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
  }
@@ -204,7 +217,8 @@ updates the index.
204
217
 
205
218
  ## Requirements
206
219
 
207
- - OpenCode (current)
220
+ - OpenCode 2.0+ (this release uses the OpenCode 2 plugin API; on OpenCode 1, use
221
+ `opencode-codeops@1`)
208
222
  - Bash
209
223
  - Python 3.8+
210
224
  - Git
@@ -17,13 +17,15 @@ contract consumed by the plugin and the skills. It is advisory by design and nev
17
17
  | `max` | Adversarial or high-risk analysis where a missed detail is costly | Thorough preflight, complex or sensitive phases |
18
18
 
19
19
  The four levels are the complete suggestion vocabulary. A level is applied through the model's
20
- own variant record: the exact variant when it exists, otherwise the nearest exposed level on the
21
- provider scale, with ties resolving to the higher level. `none` stays exact-match only, because
22
- mapping it to a reasoning level would enable reasoning the caller disabled. When no variant can
23
- be chosen — or when the model exposes no variants and does not report reasoning support — the
24
- request is left unchanged, and it never raises a provider error. Routing policy is project
25
- configuration, not a suggestion: `routing.roles.<agent>.reasoning` may name any value from the
26
- provider enum (`none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`).
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`).
27
29
 
28
30
  The deepseek flash model in this environment exposes `low`, `high`, and `max`, so a `medium`
29
31
  suggestion resolves to `high` there. Confirm the active model's set by enabling
@@ -127,10 +129,12 @@ invalid `=<level>` is an argument error.
127
129
  Semantics:
128
130
 
129
131
  1. Announce `Auto-effort active — reasoning <level> applied for this run`.
130
- 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.
131
135
  3. Clear it at run completion, before the final summary.
132
136
  4. Run-scoped: the level applies from the point it is set until cleared or the session ends.
133
- 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
134
138
  advise-only note and continue; the skill never blocks.
135
139
  6. Never a gate: effort affects cost and latency only. No readiness check, verification step,
136
140
  reviewer requirement, or finding gate may read it.
@@ -142,10 +146,10 @@ forces that level for the whole run, including every dispatch marker composed du
142
146
 
143
147
  Set `CODEOPS_EFFORT_TRACE=1` (or `true`) in the environment that starts OpenCode to record what
144
148
  the plugin does. One content-free JSON line is appended per marker capture and per request to
145
- `reasoning-effort-trace.jsonl` inside the session's scratch directory. Lines carry only a
146
- timestamp, event name, message id, agent name, level, source, chosen variant, and applied flag —
147
- never prompt text or file content. The trace lives with the rest of the session scratch and is
148
- removed with 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.
149
153
 
150
154
  ## Suggestion-only guarantee
151
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,38 +131,108 @@ 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
+ * Normalize the plugin entries from one configuration document.
189
+ *
190
+ * A document's `info` may carry the native `plugins` array or the legacy
191
+ * singular `plugin` array; both are read so mixed configurations are reported
192
+ * correctly.
193
+ *
194
+ * @param info - Configuration document info object
195
+ * @returns The document's plugin spec strings
196
+ */
197
+ function documentPlugins(info) {
198
+ if (!info || typeof info !== "object") return []
199
+ const raw = Array.isArray(info.plugins)
200
+ ? info.plugins
201
+ : Array.isArray(info.plugin)
202
+ ? info.plugin
203
+ : []
204
+ return raw.map(entrySpec).filter((spec) => typeof spec === "string")
205
+ }
206
+
207
+ /**
208
+ * Reads the resolved plugin list from OpenCode's effective config.
209
+ *
210
+ * OpenCode 2 prints the list of configuration sources, each with its document
211
+ * under `info`; OpenCode 1 printed one resolved config object. Both shapes are
212
+ * understood, and the legacy `plugin` key is read as a fallback so mixed
213
+ * configurations are reported correctly.
100
214
  *
101
215
  * @param details - Inspection inputs
102
216
  * @param details.cwd - Working directory
103
217
  * @param details.run - Command runner, injectable for tests
104
- * @returns The plugin entries, or `undefined` when the config cannot be read
218
+ * @returns The plugin spec strings, or `undefined` when the config cannot be read
105
219
  */
106
220
  export function readConfiguredPlugin({ cwd, run = defaultRun } = {}) {
107
221
  try {
108
222
  const result = run("opencode", ["debug", "config"], { cwd })
109
223
  if (result.error || result.status !== 0) return undefined
110
224
 
111
- const config = JSON.parse(result.stdout)
112
- return Array.isArray(config.plugin) ? config.plugin : []
225
+ const parsed = JSON.parse(result.stdout)
226
+ const sources = Array.isArray(parsed) ? parsed : [parsed]
227
+ const specs = []
228
+ for (const source of sources) {
229
+ if (source && typeof source === "object" && "info" in source) {
230
+ specs.push(...documentPlugins(source.info))
231
+ }
232
+ }
233
+ if (specs.length > 0) return specs
234
+
235
+ return documentPlugins(parsed)
113
236
  } catch {
114
237
  return undefined
115
238
  }
@@ -81,6 +81,11 @@ 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
 
@@ -406,6 +406,44 @@ export function applyEffort(options, level, model) {
406
406
  return { ...options, reasoningEffort: level }
407
407
  }
408
408
 
409
+ /**
410
+ * Normalize a host model's variant collection into a keyed record.
411
+ *
412
+ * OpenCode 2 exposes model variants as an array of entries shaped
413
+ * `{ id, settings }`, while {@link applyEffort} consumes a record keyed by
414
+ * variant id. This adapter bridges the two without changing the core logic.
415
+ * A record input is passed through unchanged, so v1-style callers and tests
416
+ * keep working. Malformed entries — non-objects, missing ids, non-object
417
+ * settings — are skipped; anything that is not an array or plain object yields
418
+ * an empty record.
419
+ *
420
+ * @param variants - Variant collection from the host (any shape)
421
+ * @returns A record mapping variant id to its settings object
422
+ *
423
+ * @example
424
+ * normalizeModelVariants([{ id: "high", settings: { reasoningEffort: "high" } }])
425
+ * // { high: { reasoningEffort: "high" } }
426
+ */
427
+ export function normalizeModelVariants(variants) {
428
+ if (isPlainObject(variants)) return { ...variants }
429
+ if (!Array.isArray(variants)) return {}
430
+
431
+ const record = {}
432
+ for (const entry of variants) {
433
+ if (!isPlainObject(entry)) continue
434
+ const id = entry.id
435
+ if (typeof id !== "string" || id.length === 0) continue
436
+ const settings = isPlainObject(entry.settings) ? entry.settings : {}
437
+ Object.defineProperty(record, id, {
438
+ value: settings,
439
+ writable: true,
440
+ enumerable: true,
441
+ configurable: true,
442
+ })
443
+ }
444
+ return record
445
+ }
446
+
409
447
  /**
410
448
  * Recursively merge plain objects into a new object.
411
449
  *
package/package.json CHANGED
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "name": "opencode-codeops",
3
- "version": "1.11.0",
3
+ "version": "2.0.1",
4
4
  "description": "Specification-first engineering for complex systems — CodeOps plugin for OpenCode",
5
5
  "type": "module",
6
6
  "engines": {
7
- "node": ">=18"
7
+ "node": ">=18",
8
+ "opencode": ">=2 <3"
8
9
  },
9
10
  "main": "./plugin/index.ts",
10
11
  "exports": {
11
- ".": "./plugin/index.ts"
12
+ ".": "./plugin/index.ts",
13
+ "./server": "./plugin/index.ts"
12
14
  },
13
15
  "bin": {
14
16
  "opencode-codeops": "bin/index.mjs"
@@ -62,11 +64,10 @@
62
64
  "bugs": {
63
65
  "url": "https://github.com/blendsdk/opencode-codeops/issues"
64
66
  },
65
- "peerDependencies": {
66
- "@opencode-ai/plugin": "^1.18.30"
67
+ "dependencies": {
68
+ "@opencode/plugin": "^2.0.24"
67
69
  },
68
70
  "devDependencies": {
69
- "@opencode-ai/plugin": "1.18.30",
70
71
  "@types/node": "^22.20.2",
71
72
  "typescript": "^5.0.0"
72
73
  }
package/plugin/index.ts CHANGED
@@ -1,7 +1,8 @@
1
- import type { Plugin } from "@opencode-ai/plugin"
1
+ import { Plugin } from "@opencode/plugin"
2
+ import { randomUUID } from "node:crypto"
2
3
  import { readFileSync } from "node:fs"
3
4
  import { homedir } from "node:os"
4
- import { join, dirname } from "node:path"
5
+ import { dirname, join } from "node:path"
5
6
  import { fileURLToPath } from "node:url"
6
7
 
7
8
  import {
@@ -17,7 +18,7 @@ import {
17
18
  isEffortLevel,
18
19
  isEffortTraceEnabled,
19
20
  isRoutingReasoning,
20
- modelSupportsReasoning,
21
+ normalizeModelVariants,
21
22
  readRoutingReasoning,
22
23
  readSessionEffort,
23
24
  resolveEffort,
@@ -52,7 +53,8 @@ function readPackageVersion(): string {
52
53
  }
53
54
 
54
55
  // ---------------------------------------------------------------------------
55
- // Load standards at startup (once). Both files are injected into every session.
56
+ // Load standards at startup (once). Both files are injected into every request
57
+ // as system instructions, so they also survive context compaction.
56
58
  // ---------------------------------------------------------------------------
57
59
  const codingStandards = readFileSync(
58
60
  join(PACKAGE_ROOT, "standards", "coding-standards.md"),
@@ -65,50 +67,49 @@ const outputStyle = readFileSync(
65
67
  const standardsText = `${codingStandards}\n\n${outputStyle}`
66
68
 
67
69
  // ---------------------------------------------------------------------------
68
- // Workspace hygiene — the plugin owns one temp directory per session (exported
69
- // as CODEOPS_TMPDIR) and sweeps directories abandoned by earlier interrupted
70
- // runs. The sweep runs at most once per plugin process, so spawning many child
71
- // sessions does not repeat the directory walk.
70
+ // OpenCode 2 does not expose a session identifier to shell hooks, so the plugin
71
+ // owns one scratch directory per plugin runtime instead of one per session.
72
+ // Skills and subagents still receive it as CODEOPS_TMPDIR and delete their own
73
+ // scratch; the plugin removes the runtime directory on unload and sweeps
74
+ // directories abandoned by earlier runs. Two sessions served by the same
75
+ // runtime share this directory; see _shared/workspace-hygiene.md.
72
76
  // ---------------------------------------------------------------------------
73
- let sweptStaleTmpDirs = false
77
+ const runtimeID = `runtime-${randomUUID()}`
78
+
79
+ /** Heading prefixed to the standards block injected into compaction summaries. */
80
+ const STANDARDS_HEADING = "## CodeOps Standards (always active)"
74
81
 
75
82
  /**
76
- * Sweep temp directories abandoned by earlier interrupted runs, once per
77
- * process. Cleanup is best effort: a failure must never block a session.
83
+ * Check whether a value is a plain object (not an array, class instance, or
84
+ * `null`). Used to read unknown hook payloads without unsafe casts.
85
+ *
86
+ * @param value - Value to inspect
87
+ * @returns True for `{}`-shaped objects
78
88
  */
79
- function sweepStaleTmpDirsOnce(): void {
80
- if (sweptStaleTmpDirs) return
81
- sweptStaleTmpDirs = true
82
- try {
83
- cleanStaleTmpDirs()
84
- } catch {
85
- // Best effort only.
86
- }
89
+ function isRecord(value: unknown): value is Record<string, unknown> {
90
+ return typeof value === "object" && value !== null && !Array.isArray(value)
87
91
  }
88
92
 
89
- // ---------------------------------------------------------------------------
90
- // Helper — inject standards into a session without triggering an AI reply.
91
- // Uses client.session.prompt with noReply: true (confirmed from OpenCode SDK).
92
- // ---------------------------------------------------------------------------
93
- async function injectStandards(
94
- client: Parameters<Plugin>[0]["client"],
95
- sessionId: string
96
- ): Promise<void> {
97
- await client.session.prompt({
98
- path: { id: sessionId },
99
- body: {
100
- noReply: true,
101
- parts: [{ type: "text", text: standardsText }],
102
- },
103
- })
93
+ /**
94
+ * Log one content-free warning. Logging is best effort: a failed log must never
95
+ * break a request.
96
+ *
97
+ * @param message - Warning text that never contains prompt or file content
98
+ */
99
+ function warnContentFree(message: string): void {
100
+ console.warn(`CodeOps: ${message}`)
104
101
  }
105
102
 
106
- // ---------------------------------------------------------------------------
107
- // Helper — read the version recorded in an installed skills marker, if any.
108
- // The marker (`.opencode-codeops.json`) is written by the skills installer.
109
- // Its absence means the skills are not managed — for example a development
110
- // symlink — so there is no version to compare against.
111
- // ---------------------------------------------------------------------------
103
+ /**
104
+ * Read the version recorded in an installed skills marker, if any.
105
+ *
106
+ * The marker (`.opencode-codeops.json`) is written by the skills installer.
107
+ * Its absence means the skills are not managed — for example a development
108
+ * symlink — so there is no version to compare against.
109
+ *
110
+ * @param skillsDir - Directory that may hold the installer marker
111
+ * @returns The recorded version, or `undefined`
112
+ */
112
113
  function installedSkillsVersion(skillsDir: string): string | undefined {
113
114
  try {
114
115
  const marker = JSON.parse(
@@ -120,16 +121,15 @@ function installedSkillsVersion(skillsDir: string): string | undefined {
120
121
  }
121
122
  }
122
123
 
123
- // ---------------------------------------------------------------------------
124
- // Helper — warn (non-blocking) when the installed skills were written by a
125
- // different CodeOps version than this plugin. The plugin and the files are
126
- // installed by separate commands, so their versions can drift; a mismatch
127
- // usually means the skills need `npx opencode-codeops update` again.
128
- // ---------------------------------------------------------------------------
129
- async function warnOnVersionSkew(
130
- client: Parameters<Plugin>[0]["client"],
131
- directory: string
132
- ): Promise<void> {
124
+ /**
125
+ * Warn (non-blocking) when the installed skills were written by a different
126
+ * CodeOps version than this plugin. The plugin and the files are installed by
127
+ * separate commands, so their versions can drift; a mismatch usually means the
128
+ * skills need `npx opencode-codeops update` again.
129
+ *
130
+ * @param directory - Project directory whose local skills should be checked
131
+ */
132
+ function warnOnVersionSkew(directory: string): void {
133
133
  const skillsDirs = [
134
134
  join(homedir(), ".config", "opencode", "skills"),
135
135
  join(directory, ".opencode", "skills"),
@@ -139,38 +139,21 @@ async function warnOnVersionSkew(
139
139
  const installed = installedSkillsVersion(skillsDir)
140
140
  if (!installed || installed === packageVersion) continue
141
141
 
142
- await client.app.log({
143
- body: {
144
- service: "codeops",
145
- level: "warn",
146
- message:
147
- `CodeOps skills at ${skillsDir} are version ${installed}, ` +
148
- `but the plugin is version ${packageVersion}. ` +
149
- `Run \`npx opencode-codeops@${packageVersion} update\` to match them.`,
150
- },
151
- })
152
- }
153
- }
154
-
155
- // ---------------------------------------------------------------------------
156
- // Helper — log one content-free warning. Logging is best effort: a failed log
157
- // must never break a request.
158
- // ---------------------------------------------------------------------------
159
- async function warnContentFree(
160
- client: Parameters<Plugin>[0]["client"],
161
- message: string
162
- ): Promise<void> {
163
- try {
164
- await client.app.log({ body: { service: "codeops", level: "warn", message } })
165
- } catch {
166
- // Best effort only.
142
+ warnContentFree(
143
+ `CodeOps skills at ${skillsDir} are version ${installed}, ` +
144
+ `but the plugin is version ${packageVersion}. ` +
145
+ `Run \`npx opencode-codeops@${packageVersion} update\` to match them.`
146
+ )
167
147
  }
168
148
  }
169
149
 
170
- // ---------------------------------------------------------------------------
171
- // Helper — read the project routing config fresh on every request, so an edit
172
- // applies without restarting the session. Any failure means "no routing".
173
- // ---------------------------------------------------------------------------
150
+ /**
151
+ * Read the project routing config fresh on every request, so an edit applies
152
+ * without restarting the session. Any failure means "no routing".
153
+ *
154
+ * @param directory - Project directory holding `codeops/codeops.json`
155
+ * @returns The parsed config, or an empty object
156
+ */
174
157
  function readRoutingConfig(directory: string): unknown {
175
158
  try {
176
159
  return JSON.parse(readFileSync(join(directory, "codeops", "codeops.json"), "utf8"))
@@ -179,149 +162,139 @@ function readRoutingConfig(directory: string): unknown {
179
162
  }
180
163
  }
181
164
 
182
- // ---------------------------------------------------------------------------
183
- // Helper — append one content-free trace line when the optional
184
- // CODEOPS_EFFORT_TRACE switch is on. Tracing is diagnostic only: it never
185
- // affects a request and swallows its own failures.
186
- // ---------------------------------------------------------------------------
165
+ /**
166
+ * Append one content-free trace line when the optional CODEOPS_EFFORT_TRACE
167
+ * switch is on. Tracing is diagnostic only: it never affects a request and
168
+ * swallows its own failures.
169
+ *
170
+ * @param enabled - Whether tracing is enabled for this runtime
171
+ * @param sessionID - Session the entry belongs to
172
+ * @param entry - Content-free trace record
173
+ */
187
174
  function traceEffort(
188
175
  enabled: boolean,
189
176
  sessionID: string,
190
177
  entry: Record<string, unknown>
191
178
  ): void {
192
179
  if (!enabled) return
193
- appendEffortTrace(sessionID, { ts: new Date().toISOString(), ...entry })
180
+ appendEffortTrace(runtimeID, { ts: new Date().toISOString(), sessionID, ...entry })
181
+ }
182
+
183
+ /**
184
+ * Look up the active model's variant record from the model registry.
185
+ *
186
+ * OpenCode 2 request hooks carry only a model reference (provider and model
187
+ * id), while the provider-specific option sets live on the registered model as
188
+ * a `variants` array. Any lookup failure means "no variants", which leaves the
189
+ * request unchanged.
190
+ *
191
+ * @param ctx - Plugin context whose model registry is queried
192
+ * @param ref - Model reference from the request hook
193
+ * @returns A variant record keyed by variant id, or an empty record
194
+ */
195
+ async function readModelVariants(
196
+ ctx: Plugin.Context,
197
+ ref: { providerID: string; id: string }
198
+ ): Promise<Record<string, unknown>> {
199
+ try {
200
+ const listed = await ctx.model.list()
201
+ const info = listed.data.find(
202
+ (model) => model.providerID === ref.providerID && model.id === ref.id
203
+ )
204
+ if (info === undefined) return {}
205
+ return normalizeModelVariants(info.variants)
206
+ } catch {
207
+ return {}
208
+ }
194
209
  }
195
210
 
196
211
  // ---------------------------------------------------------------------------
197
- // CodeOps plugin for OpenCode
212
+ // CodeOps plugin for OpenCode 2
198
213
  // Replaces: hooks/hooks.json + hook_session_context.sh + hook_marker_guard.sh
199
214
  // ---------------------------------------------------------------------------
200
- export const CodeOpsPlugin: Plugin = async ({ client, directory }) => {
201
- // Reasoning-effort state lives for the lifetime of this plugin instance:
202
- // the latest dispatch-marker level per session, plus a deduplication set
203
- // for unsupported-level warnings.
204
- const effortMarkers = new Map<string, EffortLevel>()
205
- const warnedEffortLevels = new Set<string>()
206
- const effortTraceEnabled = isEffortTraceEnabled(process.env.CODEOPS_EFFORT_TRACE)
215
+ export default Plugin.define({
216
+ id: "opencode-codeops",
217
+ async setup(ctx) {
218
+ const directory = ctx.location.directory
207
219
 
208
- return {
209
- // -----------------------------------------------------------------------
210
- // Hook 1 & 2: inject standards on session.created and session.compacted.
211
- // Both are dispatched via the generic event hook.
212
- // session.created → new session (Codex: startup)
213
- // session.compacted → after compact (Codex: resume|compact)
214
- // Also the hygiene lifecycle: a new top-level session sweeps abandoned
215
- // temp directories once, and a deleted session's temp directory is removed.
216
- // -----------------------------------------------------------------------
217
- event: async ({ event }) => {
218
- if (event.type === "session.created") {
219
- const info = (event.properties as { info: { id: string; parentID?: string } }).info
220
- await injectStandards(client, info.id)
221
- await warnOnVersionSkew(client, directory)
222
- if (!info.parentID) sweepStaleTmpDirsOnce()
223
- } else if (event.type === "session.deleted") {
224
- const info = (event.properties as { info: { id: string } }).info
225
- removeSessionTmpDir(info.id)
226
- try {
227
- effortMarkers.delete(info.id)
228
- } catch {
229
- await warnContentFree(client, "Could not clear captured reasoning-effort markers.")
230
- }
231
- } else if (event.type === "session.compacted") {
232
- const sessionId: string = (event.properties as { sessionID: string }).sessionID
233
- await injectStandards(client, sessionId)
234
- }
235
- },
220
+ // Reasoning-effort state lives for the lifetime of this plugin instance:
221
+ // the latest dispatch-marker level per session, plus a deduplication set
222
+ // for unsupported-level warnings.
223
+ const effortMarkers = new Map<string, EffortLevel>()
224
+ const warnedEffortLevels = new Set<string>()
225
+ const effortTraceEnabled = isEffortTraceEnabled(process.env.CODEOPS_EFFORT_TRACE)
236
226
 
237
- // -----------------------------------------------------------------------
238
- // Hook 3: inject standards into the compaction context itself, so they
239
- // survive through the compaction summary and are not lost mid-session.
240
- // -----------------------------------------------------------------------
241
- "experimental.session.compacting": async (_input, output) => {
242
- output.context.push(
243
- "## CodeOps Standards (always active — survive this compaction)\n\n" +
244
- standardsText
245
- )
246
- },
227
+ // Sweep scratch directories abandoned by earlier interrupted runs, once.
228
+ // Cleanup is best effort: a failure must never block a session.
229
+ try {
230
+ cleanStaleTmpDirs()
231
+ } catch {
232
+ // Best effort only.
233
+ }
247
234
 
248
- // -----------------------------------------------------------------------
249
- // Hook 4: export CODEOPS_PLUGIN_ROOT into every shell so skills can call
250
- // scripts as: python3 "${CODEOPS_PLUGIN_ROOT}/scripts/codeops_plan.py"
251
- // The value is the package root (parent of this plugin/ directory), which
252
- // is where skills/, scripts/, and the other shipped assets live.
253
- // CODEOPS_TMPDIR is the session's directory under the CodeOps temp root;
254
- // skills and agents put every scratch file there and delete it when done.
255
- // -----------------------------------------------------------------------
256
- "shell.env": async (input, output) => {
257
- output.env.CODEOPS_PLUGIN_ROOT = PACKAGE_ROOT
235
+ warnOnVersionSkew(directory)
236
+
237
+ // ---------------------------------------------------------------------
238
+ // Session lifecycle: drop captured markers when a session is deleted, so
239
+ // a long-lived runtime does not remember ended sessions.
240
+ // ---------------------------------------------------------------------
241
+ const controller = new AbortController()
242
+ void (async () => {
258
243
  try {
259
- output.env.CODEOPS_TMPDIR = ensureSessionTmpDir(input.sessionID)
244
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
245
+ if (event.type === "session.deleted") {
246
+ effortMarkers.delete(event.data.sessionID)
247
+ }
248
+ }
260
249
  } catch {
261
- // Best effort: a temp-directory failure must never block a shell.
250
+ // The stream ended or the plugin unloaded; nothing to do.
262
251
  }
263
- },
264
-
265
- // -----------------------------------------------------------------------
266
- // Hook 5: advisory guard — warn (non-blocking) if any edit tool targets
267
- // codeops/.codeops.yml, which is owned exclusively by the setup-codeops
268
- // skill. Equivalent to Codex PreToolUse hook_marker_guard.sh.
269
- // args are on the output parameter per the OpenCode plugin type signature.
270
- // -----------------------------------------------------------------------
271
- "tool.execute.before": async (input, output) => {
272
- const editTools = ["write", "edit", "apply_patch"]
273
- if (!editTools.includes(input.tool)) return
252
+ })()
274
253
 
275
- const args = output.args as Record<string, unknown> | undefined
276
- const filePath: string =
277
- (args?.filePath as string | undefined) ??
278
- (args?.path as string | undefined) ??
279
- ""
254
+ // ---------------------------------------------------------------------
255
+ // Standards injection: add the CodeOps standards to every agent-loop
256
+ // request and to every compaction summary, so every agent and every turn
257
+ // sees them and they cannot be lost to compaction.
258
+ // ---------------------------------------------------------------------
259
+ await ctx.session.hook("context", (event) => {
260
+ event.system.push({ type: "text", text: standardsText })
261
+ })
280
262
 
281
- if (filePath.includes("codeops/.codeops.yml")) {
282
- process.stderr.write(
283
- "CodeOps warning: codeops/.codeops.yml is the layout marker and is " +
284
- "owned by setup-codeops. Edit it only through the setup/migration " +
285
- "workflow (run the setup-codeops skill).\n"
286
- )
287
- }
288
- },
263
+ await ctx.session.hook("compaction", (event) => {
264
+ event.system.push({ type: "text", text: `${STANDARDS_HEADING}\n\n${standardsText}` })
265
+ })
289
266
 
290
- // -----------------------------------------------------------------------
291
- // Hook 6: capture a dispatch marker from an incoming user message. The
292
- // marker travels in the dispatch packet text; it is remembered for the
293
- // whole session, because the later chat.params hook receives the latest
294
- // user message, which can differ from the message that carried it.
295
- // -----------------------------------------------------------------------
296
- "chat.message": async (input, output) => {
267
+ // ---------------------------------------------------------------------
268
+ // Effort capture: a dispatch marker travels in the incoming user prompt.
269
+ // It is remembered for the whole session, because the later request hook
270
+ // sees the same session rather than the message that carried it.
271
+ // ---------------------------------------------------------------------
272
+ await ctx.session.hook("prompt", (event) => {
297
273
  try {
298
- const texts = output.parts.map((part) =>
299
- part?.type === "text" ? part.text : undefined
300
- )
301
- const level = findEffortMarker(texts)
274
+ const level = findEffortMarker([event.prompt.text])
302
275
  if (level !== undefined) {
303
- effortMarkers.set(input.sessionID, level)
304
- traceEffort(effortTraceEnabled, input.sessionID, {
276
+ effortMarkers.set(event.sessionID, level)
277
+ traceEffort(effortTraceEnabled, event.sessionID, {
305
278
  event: "capture",
306
- messageID: output.message.id,
279
+ messageID: event.messageID,
307
280
  level,
308
281
  })
309
282
  }
310
283
  } catch {
311
- await warnContentFree(client, "Could not scan a message for a reasoning-effort marker.")
284
+ warnContentFree("Could not scan a message for a reasoning-effort marker.")
312
285
  }
313
- },
286
+ })
314
287
 
315
- // -----------------------------------------------------------------------
316
- // Hook 7: resolve the request's reasoning level (dispatch marker, then
317
- // session flag, then routing default) and merge the model's own variant
318
- // options. Any failure leaves the request unchanged.
319
- // -----------------------------------------------------------------------
320
- "chat.params": async (input, output) => {
288
+ // ---------------------------------------------------------------------
289
+ // Effort apply: resolve the request's reasoning level (dispatch marker,
290
+ // then session flag, then routing default) and merge the model's own
291
+ // variant options. Any failure leaves the request unchanged.
292
+ // ---------------------------------------------------------------------
293
+ await ctx.session.hook("context", async (event) => {
321
294
  try {
322
- const marker = effortMarkers.get(input.sessionID)
323
- const session = readSessionEffort(input.sessionID)
324
- const routing = readRoutingReasoning(readRoutingConfig(directory), input.agent)
295
+ const marker = effortMarkers.get(event.sessionID)
296
+ const session = readSessionEffort(runtimeID)
297
+ const routing = readRoutingReasoning(readRoutingConfig(directory), event.agent)
325
298
  const level = resolveEffort({ marker, session, routing })
326
299
  const source = isEffortLevel(marker)
327
300
  ? "marker"
@@ -331,10 +304,9 @@ export const CodeOpsPlugin: Plugin = async ({ client, directory }) => {
331
304
  ? "routing"
332
305
  : "none"
333
306
  if (level === undefined) {
334
- traceEffort(effortTraceEnabled, input.sessionID, {
307
+ traceEffort(effortTraceEnabled, event.sessionID, {
335
308
  event: "apply",
336
- messageID: input.message.id,
337
- agent: input.agent,
309
+ agent: event.agent,
338
310
  level: null,
339
311
  source,
340
312
  applied: false,
@@ -342,37 +314,88 @@ export const CodeOpsPlugin: Plugin = async ({ client, directory }) => {
342
314
  return
343
315
  }
344
316
 
345
- const applied = applyEffort(output.options, level, input.model)
346
- const changed = applied !== output.options
347
- traceEffort(effortTraceEnabled, input.sessionID, {
317
+ const variants = await readModelVariants(ctx, event.model)
318
+ const model = { variants }
319
+ const applied = applyEffort(event.options, level, model)
320
+ const changed = applied !== event.options
321
+ traceEffort(effortTraceEnabled, event.sessionID, {
348
322
  event: "apply",
349
- messageID: input.message.id,
350
- agent: input.agent,
323
+ agent: event.agent,
351
324
  level,
352
325
  source,
353
326
  applied: changed,
354
- variant: selectEffortVariant(level, input.model) ?? null,
355
- reasoningSupported: modelSupportsReasoning(input.model),
356
- variantLevels: Object.keys(extractModelVariants(input.model) ?? {}),
327
+ variant: selectEffortVariant(level, model) ?? null,
328
+ variantLevels: Object.keys(extractModelVariants(model) ?? {}),
357
329
  })
358
330
  if (!changed) {
359
331
  if (marker !== undefined) {
360
- const warningKey = `${input.sessionID}:${marker}`
332
+ const warningKey = `${event.sessionID}:${marker}`
361
333
  if (!warnedEffortLevels.has(warningKey)) {
362
334
  warnedEffortLevels.add(warningKey)
363
- await warnContentFree(
364
- client,
365
- `Reasoning effort ${marker} is not available for agent ${input.agent}; ` +
335
+ warnContentFree(
336
+ `Reasoning effort ${marker} is not available for agent ${event.agent}; ` +
366
337
  "request left unchanged."
367
338
  )
368
339
  }
369
340
  }
370
341
  return
371
342
  }
372
- output.options = applied
343
+ Object.assign(event.options, applied)
373
344
  } catch {
374
- await warnContentFree(client, "Could not apply a reasoning-effort level to a request.")
345
+ warnContentFree("Could not apply a reasoning-effort level to a request.")
375
346
  }
376
- },
377
- }
378
- }
347
+ })
348
+
349
+ // ---------------------------------------------------------------------
350
+ // Shell environment: export CODEOPS_PLUGIN_ROOT so skills can call
351
+ // scripts as: python3 "${CODEOPS_PLUGIN_ROOT}/scripts/codeops_plan.py".
352
+ // CODEOPS_TMPDIR is this runtime's scratch directory; skills and agents
353
+ // put every scratch file there and delete it when done.
354
+ // ---------------------------------------------------------------------
355
+ await ctx.shell.hook("create.before", (event) => {
356
+ event.env.CODEOPS_PLUGIN_ROOT = PACKAGE_ROOT
357
+ try {
358
+ event.env.CODEOPS_TMPDIR = ensureSessionTmpDir(runtimeID)
359
+ } catch {
360
+ // Best effort: a temp-directory failure must never block a shell.
361
+ }
362
+ })
363
+
364
+ // ---------------------------------------------------------------------
365
+ // Advisory guard: warn (non-blocking) if any edit tool targets
366
+ // codeops/.codeops.yml, which is owned exclusively by the setup-codeops
367
+ // skill.
368
+ // ---------------------------------------------------------------------
369
+ await ctx.tool.hook("execute.before", (event) => {
370
+ const editTools = ["write", "edit", "apply_patch", "multiedit", "patch"]
371
+ if (!editTools.includes(event.tool)) return
372
+
373
+ const input = isRecord(event.input) ? event.input : undefined
374
+ const filePath =
375
+ (typeof input?.filePath === "string" ? input.filePath : undefined) ??
376
+ (typeof input?.path === "string" ? input.path : undefined) ??
377
+ ""
378
+
379
+ if (filePath.includes("codeops/.codeops.yml")) {
380
+ process.stderr.write(
381
+ "CodeOps warning: codeops/.codeops.yml is the layout marker and is " +
382
+ "owned by setup-codeops. Edit it only through the setup/migration " +
383
+ "workflow (run the setup-codeops skill).\n"
384
+ )
385
+ }
386
+ })
387
+
388
+ // ---------------------------------------------------------------------
389
+ // Plugin cleanup: stop listening for events and remove the runtime's
390
+ // scratch directory. Both are best effort.
391
+ // ---------------------------------------------------------------------
392
+ return () => {
393
+ controller.abort()
394
+ try {
395
+ removeSessionTmpDir(runtimeID)
396
+ } catch {
397
+ // Best effort only.
398
+ }
399
+ }
400
+ },
401
+ })
@@ -54,8 +54,8 @@ These apply to all code I write unless this project's `AGENTS.md` overrides a sp
54
54
 
55
55
  - **Own your temporary artifacts.** Verification logs, diffs, patch files, commit-message files,
56
56
  scratch directories, fixture trees, and downloaded artifacts are borrowed space. Put them under
57
- `$CODEOPS_TMPDIR` when the CodeOps plugin exports it (one directory per session); otherwise use
58
- the OS temp directory. Never write temporary files into the repository.
57
+ `$CODEOPS_TMPDIR` when the CodeOps plugin exports it (one directory per OpenCode runtime);
58
+ otherwise use the OS temp directory. Never write temporary files into the repository.
59
59
  - **Delete at completion.** Before reporting any task, phase, or session complete, delete every
60
60
  temporary artifact the run created — including verification logs. If the user asked for an
61
61
  artifact to be kept, name its exact path and keep that file only.