@plainconceptsplatform/agent-harness 2.9.0 → 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -284,17 +284,15 @@ your-project/
284
284
  ├── AGENTS.md ← bootstrap mode, replaced after first "/repo-initialize"
285
285
  ├── ARCHITECTURE.md ← prompt for agents to fill in from your codebase
286
286
  ├── DESIGN.md ← prompt for agents to fill in from your codebase
287
+ ├── opencode.jsonc ← native OpenCode 2 config: agents, permissions, MCP servers
287
288
  ├── .opencode/
288
- │ ├── opencode.json ← default model + plugin config
289
289
  │ ├── harness.json ← harness config: platform, models, maxConcurrentAgents
290
290
  │ ├── harness-managed.json ← hashes of every managed file, so "update" spares your edits
291
291
  │ ├── agents/ ← build.md + plan.md (generated primaries), fullstack-engineer,
292
292
  │ │ and user-created *-engineer files (all subagents)
293
- │ ├── tui.json ← registers the Subagents sidebar panel
294
- │ ├── tui/
295
- │ │ └── pc-subagents.tsx ← TUI plugin: live Subagents panel in the sidebar
296
293
  │ └── plugins/
297
294
  │ ├── pc-subagent-monitor.js ← server plugin: subagent state → .opencode/harness-run.json, and the wave cap
295
+ │ ├── pc-subagent-tiers.js ← server plugin: derives tier agents and the two primaries
298
296
  │ └── pc-system-reminders.js ← server plugin: gates work on the agent's abilities, denies the never-rules
299
297
  └── .agents/
300
298
  └── skills/
@@ -304,6 +302,8 @@ your-project/
304
302
  └── browser-automation/
305
303
  ```
306
304
 
305
+ On OpenCode 2 the terminal client is configured globally (`~/.config/opencode/cli.json`), so the harness ships no project-local `tui.json`; the native session sidebar shows subagents without a sidecar panel.
306
+
307
307
  Platform skills ship as suffixed variants (`pc-userstory-gh`, `pc-userstory-az`, `pc-userstory-jira`, `pc-userstory-browser`) and the installer copies only the matching one, renamed to its generic name. Platform operations (ship, review, backlog) are injected directly into the `/ops-*` command files from `cli/fragments/ops-*/` during onboarding. Source-roots metadata lands in `.opencode/source-roots.json`. The always-shipped reminder plugin holds editing, shell and spawning until every ability under the agent's `## Abilities` is loaded, guardrails first, and re-arms after compaction. Token-optimization guidance is injected into `pc-guardrails-generic` marker blocks during onboarding.
308
308
 
309
309
  ---
@@ -348,7 +348,7 @@ Long unattended agent sessions can consume significant tokens. Set these control
348
348
 
349
349
  2. **Route models by task type**: use a fast and cheap model (for example `haiku`, `gpt-4o-mini`) for orchestration and status loops; reserve expensive models (for example `sonnet`, `opus`, `gpt-4o`) for implementation tasks only.
350
350
 
351
- 3. **Install the quota plugin**: the [`@slkiser/opencode-quota`](https://www.npmjs.com/package/@slkiser/opencode-quota) plugin adds `/quota` and `/quota_status` commands that surface real-time token usage inside OpenCode sessions.
351
+ 3. **Install the quota plugin**: the [`@slkiser/opencode-quota`](https://www.npmjs.com/package/@slkiser/opencode-quota) plugin adds `/quota` and `/quota_status` commands that surface real-time token usage inside OpenCode sessions. The harness installs it with popups, toasts and reset notifications switched off (`enableToast`, `resetNotifications`), slash reports kept in the chat (`tuiCommandDisplay: "inline"`), and the sidebar panel left on. Settings live in `opencode-quota/quota-toast.jsonc` at the repository root, which is where opencode-quota 5.x reads them.
352
352
 
353
353
  4. **Use `/quota` checkpoints**: run `/quota` before starting any `/plan-apply` session and after each agent wave. Pause at 75 percent consumed; stop at 90 percent.
354
354
 
@@ -50,11 +50,27 @@ export async function runJoin() {
50
50
  header('Step 2, Installing OpenCode plugins')
51
51
  const pkgPath = path.join(opencodeDir, 'package.json')
52
52
  if (await fse.pathExists(pkgPath)) {
53
+ // `reject: false` made the catch below unreachable: a failed install exited
54
+ // non-zero without throwing, so this reported success while the plugin's
55
+ // dependency was missing and every plugin silently refused to load. Let the
56
+ // call reject, and confirm the dependency is actually on disk before saying so.
57
+ const declared = Object.keys(
58
+ parseJsonc(await fse.readFile(pkgPath, 'utf8'))?.dependencies ?? {},
59
+ )
53
60
  try {
54
- await execa('npm', ['install'], { cwd: opencodeDir, reject: false, stdio: 'pipe' })
55
- success('OpenCode plugins installed')
56
- } catch {
61
+ await execa('npm', ['install'], { cwd: opencodeDir, stdio: 'pipe' })
62
+ } catch (err) {
57
63
  warn('npm install failed in .opencode/: plugins may not load correctly')
64
+ if (err?.stderr) info(String(err.stderr).trim().split('\n').slice(-3).join('\n'))
65
+ }
66
+ const missing = declared.filter(
67
+ name => !fse.pathExistsSync(path.join(opencodeDir, 'node_modules', ...name.split('/'))),
68
+ )
69
+ if (missing.length === 0) {
70
+ success('OpenCode plugins installed')
71
+ } else {
72
+ warn(`These dependencies are still missing from .opencode/node_modules: ${missing.join(', ')}`)
73
+ warn('Plugins that import them will not load. Re-run `npm install` there once the registry is reachable.')
58
74
  }
59
75
  } else {
60
76
  warn('No .opencode/package.json found: skipping plugin install')
@@ -1,5 +1,5 @@
1
1
  {
2
- "plugin": "@slkiser/opencode-quota@4.2.0",
2
+ "plugin": "@slkiser/opencode-quota@5.0.1",
3
3
  "prompt": {
4
4
  "message": "Install opencode-quota with recommended defaults?",
5
5
  "default": true,
@@ -11,6 +11,10 @@
11
11
  "percentDisplayMode": "used",
12
12
  "showSessionTokens": true,
13
13
  "enableToast": false,
14
- "tuiCommandDisplay": "inline"
14
+ "resetNotifications": { "enabled": false },
15
+ "tuiCommandDisplay": "inline",
16
+ "tuiCompactStatus": { "enabled": false },
17
+ "tuiPromptBar": { "enabled": false },
18
+ "tuiSidebarPanel": { "enabled": true }
15
19
  }
16
20
  }
@@ -1,178 +1,303 @@
1
1
  import fse from 'fs-extra'
2
- import { applyEdits, modify, parse } from 'jsonc-parser'
2
+ import { parse } from 'jsonc-parser'
3
3
  import path from 'node:path'
4
4
  import { success } from '../../utils/exec.js'
5
5
 
6
- // The reminder plugin loads every agent ability before work, including
7
- // user-installed skills outside the pc-* and openspec-* namespaces.
8
- const SHARED_PERMISSIONS = [
9
- ['question', 'allow'],
10
- ['todowrite', 'allow'],
11
- ]
6
+ // The harness targets OpenCode 2. This patcher migrates a repository's
7
+ // opencode.jsonc from the V1 shape to the native V2 shape, preserving any keys
8
+ // it does not know about. V2 reads V1 config in memory, but the harness wants a
9
+ // file that is already native so behaviour does not depend on the compatibility
10
+ // layer, and the CI merge step requires comment-free JSON.
11
+ //
12
+ // V1 -> V2 field map this handles:
13
+ // plugin[] -> plugins[]
14
+ // permissions object -> ordered permissions[] rules (bash->shell, task->subagent)
15
+ // agent{} -> agents{} (prompt->system, permission->permissions)
16
+ // skills.paths[] -> skills[]
17
+ // mcp.<name> -> mcp.servers.<name> (enabled -> !disabled)
18
+ // compaction.prune -> removed
19
+ // compaction.reserved -> compaction.buffer
20
+ // experimental.mcp_timeout -> mcp.timeout.{catalog,execution}
21
+ // instructions -> removed (V2 loads AGENTS.md, not this field)
22
+
23
+ const CONFIG_SCHEMA = 'https://opencode.ai/config.json'
12
24
 
13
- // agent-browser replaced the @different-ai/opencode-browser plugin; any entry
14
- // with that prefix in the plugin array is stale and gets stripped on update.
15
- const STALE_BROWSER_PLUGIN_PREFIX = '@different-ai/opencode-browser'
16
- const AGENT_BROWSER_MCP = {
17
- type: 'local',
18
- command: ['agent-browser', 'mcp', '--tools', 'core'],
19
- enabled: true,
25
+ // Third-party plugins the harness configures, and how they move to V2. The
26
+ // notifier is dropped: V2 has native attention notifications and sounds.
27
+ const OPENCODE_QUOTA_PACKAGE = '@slkiser/opencode-quota'
28
+ const OPENCODE_QUOTA_VERSION = '5.0.1'
29
+ const DROPPED_PLUGIN_PREFIXES = ['@opencode-ai/', '@mohak34/opencode-notifier', '@different-ai/opencode-browser']
30
+
31
+ // V1 permission keys -> V2 permission actions.
32
+ const ACTION_RENAMES = {
33
+ bash: 'shell',
34
+ task: 'subagent',
35
+ write: 'edit',
36
+ patch: 'edit',
20
37
  }
21
38
 
22
- function applyModify(text, jsonPath, value) {
23
- const edits = modify(text, jsonPath, value, {
24
- formattingOptions: { insertSpaces: true, tabSize: 2 },
25
- })
26
- return applyEdits(text, edits)
39
+ function basePackageName(entry) {
40
+ if (typeof entry !== 'string') return ''
41
+ const at = entry.lastIndexOf('@')
42
+ return at > 0 ? entry.slice(0, at) : entry
27
43
  }
28
44
 
29
- export async function patchOpencodeJson(cwd = process.cwd()) {
30
- const opencodePath = path.join(cwd, 'opencode.jsonc')
45
+ // V1 permission keys that are not V2 permission actions. todowrite is a built-in
46
+ // tool allowed by the base policy, so it needs no rule.
47
+ const NON_ACTION_PERMISSION_KEYS = new Set(['todowrite'])
31
48
 
32
- let text
33
- if (await fse.pathExists(opencodePath)) {
34
- text = await fse.readFile(opencodePath, 'utf-8')
35
- } else {
36
- text = JSON.stringify({ $schema: 'https://opencode.ai/config.json' }, null, 2)
49
+ function permissionMapToRules(map) {
50
+ const rules = []
51
+ for (const [key, value] of Object.entries(map ?? {})) {
52
+ if (value !== 'allow' && value !== 'deny' && value !== 'ask') continue
53
+ if (NON_ACTION_PERMISSION_KEYS.has(key)) continue
54
+ rules.push({ action: ACTION_RENAMES[key] ?? key, resource: '*', effect: value })
37
55
  }
56
+ return rules
57
+ }
38
58
 
39
- const errors = []
40
- const parsed = parse(text, errors)
41
- if (errors.length > 0 || typeof parsed !== 'object') {
42
- return { patched: false, reason: 'parse error' }
59
+ function upsertRules(existing, additions) {
60
+ const merged = [...existing]
61
+ for (const rule of additions) {
62
+ const index = merged.findIndex(r => r.action === rule.action && r.resource === rule.resource)
63
+ if (index === -1) merged.push(rule)
64
+ else merged[index] = rule
43
65
  }
66
+ return merged
67
+ }
44
68
 
45
- // build and plan are overridden, not disabled. Earlier versions disabled them
46
- // and pointed default_agent at fullstack-engineer; now they are the only two
47
- // primaries and pc-subagent-tiers regenerates their agent files from
48
- // fullstack-engineer.md. A repo patched by an earlier version still carries
49
- // `disable: true`, which would hide both agents, so that flag is cleared here.
50
- const hasStaleDisable = (
51
- parsed?.agent?.build?.disable !== undefined ||
52
- parsed?.agent?.plan?.disable !== undefined
53
- )
54
- const needsAgentOverride = hasStaleDisable || !(
55
- parsed?.agent?.build?.mode === 'primary' &&
56
- parsed?.agent?.plan?.mode === 'primary' &&
57
- parsed?.agent?.plan?.permission?.edit === 'deny' &&
58
- parsed?.agent?.plan?.permission?.task === 'deny'
59
- )
60
-
61
- // default_agent pointing at fullstack-engineer is now invalid: it became a
62
- // subagent when build and plan took over as the only primaries, so opencode
63
- // has no valid default to open a session with. A deliberate choice of build
64
- // or plan is left alone; anything else is repointed at plan, so a session
65
- // starts read-only.
66
- const currentDefault = parsed?.default_agent
67
- const needsDefaultAgent = currentDefault !== 'plan' && currentDefault !== 'build'
68
- const needsSkillPermission = parsed?.permission?.skill !== 'allow'
69
- const missingSharedPermissions = SHARED_PERMISSIONS.filter(
70
- ([key, value]) => parsed?.permission?.[key] !== value,
71
- )
72
- // opencode only scans .opencode/skills/ by default, but we install to .agents/skills/
73
- const needsSkillsPaths = !(
74
- Array.isArray(parsed?.skills?.paths) &&
75
- parsed.skills.paths.includes('.agents/skills')
76
- )
77
- const needsCompaction = !(
78
- parsed?.compaction?.auto === true &&
79
- parsed?.compaction?.prune === true
80
- )
81
- const staleBrowserPlugins = (Array.isArray(parsed?.plugin) ? parsed.plugin : [])
82
- .filter(entry => typeof entry === 'string' && entry.startsWith(STALE_BROWSER_PLUGIN_PREFIX))
83
- const needsBrowserMcp = JSON.stringify(parsed?.mcp?.['agent-browser']) !== JSON.stringify(AGENT_BROWSER_MCP)
84
-
85
- if (!needsAgentOverride && !needsDefaultAgent && !needsSkillPermission && missingSharedPermissions.length === 0 && !needsSkillsPaths && !needsCompaction && staleBrowserPlugins.length === 0 && !needsBrowserMcp) {
86
- return { patched: false }
69
+ function migratePluginArray(obj) {
70
+ if (!Array.isArray(obj.plugin)) return false
71
+ const plugins = []
72
+ for (const entry of obj.plugin) {
73
+ if (typeof entry !== 'string') {
74
+ plugins.push(entry)
75
+ continue
76
+ }
77
+ const base = basePackageName(entry)
78
+ if (DROPPED_PLUGIN_PREFIXES.some(prefix => base.startsWith(prefix))) continue
79
+ if (base === OPENCODE_QUOTA_PACKAGE) {
80
+ plugins.push(`${OPENCODE_QUOTA_PACKAGE}@${OPENCODE_QUOTA_VERSION}`)
81
+ continue
82
+ }
83
+ plugins.push(entry)
87
84
  }
85
+ obj.plugins = plugins
86
+ delete obj.plugin
87
+ return true
88
+ }
88
89
 
89
- // Apply edits sequentially so offsets stay correct
90
- if (needsAgentOverride) {
91
- // undefined removes the key, clearing the disable left by earlier versions.
92
- if (parsed?.agent?.build?.disable !== undefined) {
93
- text = applyModify(text, ['agent', 'build', 'disable'], undefined)
90
+ function migrateAgents(obj) {
91
+ let changed = false
92
+ const agents = { ...(obj.agents ?? {}) }
93
+
94
+ if (obj.agent && typeof obj.agent === 'object' && !Array.isArray(obj.agent)) {
95
+ for (const [id, raw] of Object.entries(obj.agent)) {
96
+ if (!raw || typeof raw !== 'object') continue
97
+ const agent = { ...raw }
98
+ if (agent.prompt !== undefined) {
99
+ agent.system = agent.prompt
100
+ delete agent.prompt
101
+ }
102
+ if (agent.disable !== undefined) {
103
+ agent.disabled = agent.disable
104
+ delete agent.disable
105
+ }
106
+ if (agent.permission && typeof agent.permission === 'object') {
107
+ agent.permissions = upsertRules(agent.permissions ?? [], permissionMapToRules(agent.permission))
108
+ delete agent.permission
109
+ }
110
+ delete agent.maxSteps
111
+ agents[id] = agent
94
112
  }
95
- if (parsed?.agent?.plan?.disable !== undefined) {
96
- text = applyModify(text, ['agent', 'plan', 'disable'], undefined)
113
+ delete obj.agent
114
+ changed = true
115
+ }
116
+
117
+ // build and plan are the only primaries; plan is read-only. Guarantee both
118
+ // even when the repo never had an explicit entry.
119
+ const build = { ...(agents.build ?? {}), mode: 'primary' }
120
+ delete build.disabled
121
+ delete build.disable
122
+ if (JSON.stringify(agents.build) !== JSON.stringify(build)) {
123
+ agents.build = build
124
+ changed = true
125
+ }
126
+ const plan = {
127
+ ...(agents.plan ?? {}),
128
+ mode: 'primary',
129
+ permissions: upsertRules(agents.plan?.permissions ?? [], [
130
+ { action: 'edit', resource: '*', effect: 'deny' },
131
+ { action: 'subagent', resource: '*', effect: 'deny' },
132
+ ]),
133
+ }
134
+ delete plan.disabled
135
+ delete plan.disable
136
+ if (JSON.stringify(agents.plan) !== JSON.stringify(plan)) {
137
+ agents.plan = plan
138
+ changed = true
139
+ }
140
+
141
+ if (changed) obj.agents = agents
142
+ return changed
143
+ }
144
+
145
+ function migrateMcp(obj) {
146
+ if (!obj.mcp || typeof obj.mcp !== 'object' || Array.isArray(obj.mcp)) return false
147
+ if (obj.mcp.servers) return false
148
+ const servers = {}
149
+ let inheritedTimeout = 300000
150
+ for (const [name, raw] of Object.entries(obj.mcp)) {
151
+ if (!raw || typeof raw !== 'object') continue
152
+ const server = { ...raw }
153
+ if ('enabled' in server) {
154
+ server.disabled = !server.enabled
155
+ delete server.enabled
97
156
  }
98
- text = applyModify(text, ['agent', 'build', 'mode'], 'primary')
99
- text = applyModify(text, ['agent', 'plan', 'mode'], 'primary')
100
- text = applyModify(text, ['agent', 'plan', 'permission', 'edit'], 'deny')
101
- // Read-only has to include spawning: a plan session that can call task()
102
- // can have a build worker make the change for it.
103
- text = applyModify(text, ['agent', 'plan', 'permission', 'task'], 'deny')
104
- }
105
- if (needsDefaultAgent) {
106
- text = applyModify(text, ['default_agent'], 'plan')
107
- }
108
- if (needsSkillPermission) text = applyModify(text, ['permission', 'skill'], 'allow')
109
- for (const [key, value] of missingSharedPermissions) {
110
- text = applyModify(text, ['permission', key], value)
111
- }
112
-
113
- // Ensure .agents/skills is in skills.paths so opencode discovers installed skills
114
- if (needsSkillsPaths) {
115
- const existingPaths = Array.isArray(parsed?.skills?.paths) ? parsed.skills.paths : []
116
- if (!existingPaths.includes('.agents/skills')) {
117
- const updatedPaths = [...existingPaths, '.agents/skills']
118
- text = applyModify(text, ['skills', 'paths'], updatedPaths)
157
+ if (typeof server.timeout === 'number') {
158
+ inheritedTimeout = server.timeout
159
+ server.timeout = { catalog: server.timeout, execution: server.timeout }
119
160
  }
161
+ servers[name] = server
162
+ }
163
+ obj.mcp = {
164
+ servers,
165
+ timeout: { catalog: inheritedTimeout, execution: inheritedTimeout },
120
166
  }
167
+ return true
168
+ }
169
+
170
+ /** Mutate `obj` in place to the native V2 shape; returns true when it changed. */
171
+ function migrateToV2(obj) {
172
+ let changed = false
121
173
 
122
- // Enable context pruning to reduce token costs in long agent sessions
123
- if (needsCompaction) {
124
- text = applyModify(text, ['compaction', 'auto'], true)
125
- text = applyModify(text, ['compaction', 'prune'], true)
126
- text = applyModify(text, ['compaction', 'reserved'], 10000)
174
+ if (obj.$schema !== CONFIG_SCHEMA) {
175
+ obj.$schema = CONFIG_SCHEMA
176
+ changed = true
127
177
  }
128
178
 
129
- // Swap the retired opencode-browser plugin for the agent-browser MCP server
130
- if (staleBrowserPlugins.length > 0) {
131
- const keptPlugins = parsed.plugin.filter(entry => !staleBrowserPlugins.includes(entry))
132
- text = applyModify(text, ['plugin'], keptPlugins)
179
+ if ('instructions' in obj) {
180
+ delete obj.instructions
181
+ changed = true
133
182
  }
134
- if (needsBrowserMcp) {
135
- text = applyModify(text, ['mcp', 'agent-browser'], AGENT_BROWSER_MCP)
183
+
184
+ if (obj.experimental && typeof obj.experimental === 'object') {
185
+ for (const key of ['openTelemetry', 'batch_tool', 'primary_tools', 'continue_loop_on_deny', 'mcp_timeout']) {
186
+ if (key in obj.experimental) {
187
+ delete obj.experimental[key]
188
+ changed = true
189
+ }
190
+ }
191
+ if (Object.keys(obj.experimental).length === 0) {
192
+ delete obj.experimental
193
+ changed = true
194
+ }
136
195
  }
137
196
 
138
- await fse.writeFile(opencodePath, text, 'utf-8')
139
- if (needsAgentOverride) {
140
- success('Set build/plan as the primary agents in opencode.jsonc (plan is read-only)')
197
+ const compaction = obj.compaction && typeof obj.compaction === 'object' ? { ...obj.compaction } : {}
198
+ if ('prune' in compaction) {
199
+ delete compaction.prune
200
+ changed = true
201
+ }
202
+ if (compaction.auto !== true) {
203
+ compaction.auto = true
204
+ changed = true
205
+ }
206
+ if ('reserved' in compaction) {
207
+ compaction.buffer = compaction.reserved
208
+ delete compaction.reserved
209
+ changed = true
141
210
  }
142
- if (needsDefaultAgent) {
143
- success(`default_agent -> plan${currentDefault ? ` (was ${currentDefault})` : ''}`)
211
+ if (!Number.isFinite(compaction.buffer)) {
212
+ compaction.buffer = 10000
213
+ changed = true
144
214
  }
145
- if (needsSkillPermission) {
146
- success('Allowed skill loading in opencode.jsonc')
215
+ if (JSON.stringify(obj.compaction) !== JSON.stringify(compaction)) {
216
+ obj.compaction = compaction
217
+ changed = true
147
218
  }
148
- if (missingSharedPermissions.length > 0) {
149
- success('Allowed question/todowrite tools in opencode.jsonc')
219
+
220
+ const additions = []
221
+ if (obj.permission && typeof obj.permission === 'object' && !Array.isArray(obj.permission)) {
222
+ additions.push(...permissionMapToRules(obj.permission))
223
+ delete obj.permission
224
+ changed = true
150
225
  }
151
- if (needsSkillsPaths) {
152
- success('Added .agents/skills to skills.paths in opencode.jsonc')
226
+ if (additions.length > 0) {
227
+ const merged = upsertRules(Array.isArray(obj.permissions) ? obj.permissions : [], additions)
228
+ if (JSON.stringify(obj.permissions) !== JSON.stringify(merged)) {
229
+ obj.permissions = merged
230
+ changed = true
231
+ }
153
232
  }
154
- if (needsCompaction) {
155
- success('Enabled context pruning (compaction.prune) in opencode.jsonc')
233
+
234
+ if (obj.skills && typeof obj.skills === 'object' && !Array.isArray(obj.skills)) {
235
+ const paths = Array.isArray(obj.skills.paths) ? obj.skills.paths : []
236
+ obj.skills = paths.includes('.agents/skills') ? paths : [...paths, '.agents/skills']
237
+ changed = true
238
+ } else if (Array.isArray(obj.skills)) {
239
+ if (!obj.skills.includes('.agents/skills')) {
240
+ obj.skills = [...obj.skills, '.agents/skills']
241
+ changed = true
242
+ }
243
+ } else {
244
+ obj.skills = ['.agents/skills']
245
+ changed = true
246
+ }
247
+
248
+ if (migrateAgents(obj)) changed = true
249
+ if (migrateMcp(obj)) changed = true
250
+ if (migratePluginArray(obj)) changed = true
251
+
252
+ // A session starts read-only: fullstack-engineer is a subagent now, so a
253
+ // default_agent left pointing at it has no valid primary to open with.
254
+ if (obj.default_agent !== 'plan' && obj.default_agent !== 'build') {
255
+ obj.default_agent = 'plan'
256
+ changed = true
257
+ }
258
+
259
+ return changed
260
+ }
261
+
262
+ export async function patchOpencodeJson(cwd = process.cwd()) {
263
+ const opencodePath = path.join(cwd, 'opencode.jsonc')
264
+
265
+ let text
266
+ if (await fse.pathExists(opencodePath)) {
267
+ text = await fse.readFile(opencodePath, 'utf-8')
268
+ } else {
269
+ text = JSON.stringify({ $schema: CONFIG_SCHEMA }, null, 2)
156
270
  }
157
- if (staleBrowserPlugins.length > 0) {
158
- success(`Removed ${staleBrowserPlugins.length} stale opencode-browser plugin entr${staleBrowserPlugins.length === 1 ? 'y' : 'ies'} in opencode.jsonc`)
271
+
272
+ const errors = []
273
+ const parsed = parse(text, errors)
274
+ if (errors.length > 0 || typeof parsed !== 'object' || parsed === null) {
275
+ return { patched: false, reason: 'parse error' }
159
276
  }
160
- if (needsBrowserMcp) {
161
- success('Configured agent-browser MCP server in opencode.jsonc')
277
+
278
+ if (!migrateToV2(parsed)) {
279
+ return { patched: false }
162
280
  }
163
281
 
282
+ await fse.writeFile(opencodePath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf-8')
283
+ success('Migrated opencode.jsonc to the native OpenCode 2 shape')
164
284
  return { patched: true }
165
285
  }
166
286
 
287
+ // OpenCode 2 plugin packages. @opencode/plugin is the V2 plugin API; the V1
288
+ // @opencode-ai/plugin and the retired TUI peers are removed on update.
167
289
  const OPENCODE_PACKAGE_DEPENDENCIES = {
168
- '@opencode-ai/plugin': '1.18.19',
169
- '@opentui/core': '0.5.6',
170
- '@opentui/solid': '0.5.6',
171
- 'solid-js': '1.9.12',
290
+ '@opencode/plugin': '2.0.22',
172
291
  }
173
292
 
174
- // agent-browser replaced this plugin; remove it from consumer installs on update
175
- const STALE_PACKAGE_DEPENDENCIES = ['@different-ai/opencode-browser']
293
+ const STALE_PACKAGE_DEPENDENCIES = [
294
+ '@opencode-ai/plugin',
295
+ '@mohak34/opencode-notifier',
296
+ '@different-ai/opencode-browser',
297
+ '@opentui/core',
298
+ '@opentui/solid',
299
+ 'solid-js',
300
+ ]
176
301
 
177
302
  export async function patchOpencodePackage(cwd = process.cwd()) {
178
303
  const packagePath = path.join(cwd, '.opencode', 'package.json')
@@ -183,7 +308,7 @@ export async function patchOpencodePackage(cwd = process.cwd()) {
183
308
 
184
309
  let patched = false
185
310
  for (const [name, version] of Object.entries(OPENCODE_PACKAGE_DEPENDENCIES)) {
186
- if (!(name in packageJson.dependencies) || packageJson.dependencies[name] === version) continue
311
+ if (packageJson.dependencies[name] === version) continue
187
312
  packageJson.dependencies[name] = version
188
313
  patched = true
189
314
  }
@@ -16,11 +16,6 @@ export function splitPackageSpec(spec) {
16
16
  return { name: spec.slice(0, separator), version: spec.slice(separator + 1) }
17
17
  }
18
18
 
19
- function ensurePlugin(config) {
20
- if (!Array.isArray(config.plugin)) config.plugin = []
21
- if (!config.plugin.includes(PLUGIN)) config.plugin.push(PLUGIN)
22
- }
23
-
24
19
  export async function installQuota(options = {}) {
25
20
  if (!options.skipHeader) header('Installing opencode-quota')
26
21
 
@@ -57,9 +52,14 @@ export async function installQuota(options = {}) {
57
52
  const opencodeDir = path.join(process.cwd(), '.opencode')
58
53
  const opencodePath = path.join(process.cwd(), 'opencode.jsonc')
59
54
  const pkgPath = path.join(opencodeDir, 'package.json')
60
- const tuiPath = path.join(opencodeDir, 'tui.json')
61
- const quotaDir = path.join(opencodeDir, 'opencode-quota')
62
- const quotaPath = path.join(quotaDir, 'quota-toast.json')
55
+
56
+ // opencode-quota 5.x reads its settings from `opencode-quota/quota-toast.jsonc`
57
+ // next to the OpenCode config (project root), not from `.opencode/`. The old
58
+ // `.opencode/opencode-quota/quota-toast.json` is the v4 location and is migrated
59
+ // below so the settings actually take effect.
60
+ const quotaDir = path.join(process.cwd(), 'opencode-quota')
61
+ const quotaPath = path.join(quotaDir, 'quota-toast.jsonc')
62
+ const legacyQuotaPath = path.join(opencodeDir, 'opencode-quota', 'quota-toast.json')
63
63
 
64
64
  let opencodeText = JSON.stringify({ $schema: 'https://opencode.ai/config.json' }, null, 2)
65
65
  let opencode
@@ -78,18 +78,20 @@ export async function installQuota(options = {}) {
78
78
  ? await fse.readJson(pkgPath)
79
79
  : {}
80
80
 
81
- const tui = await fse.pathExists(tuiPath)
82
- ? await fse.readJson(tuiPath)
83
- : { $schema: 'https://opencode.ai/tui.json' }
84
-
85
- const plugins = Array.isArray(opencode.plugin) ? opencode.plugin : []
81
+ // V2 configures server plugins under `plugins`. A V1 `plugin` array found
82
+ // here is carried over so the quota entry is not lost when the config
83
+ // patcher has not run first.
84
+ const plugins = Array.isArray(opencode.plugins)
85
+ ? [...opencode.plugins]
86
+ : Array.isArray(opencode.plugin)
87
+ ? [...opencode.plugin]
88
+ : []
86
89
  if (!plugins.includes(PLUGIN)) {
87
90
  plugins.push(PLUGIN)
88
- opencodeText = applyEdits(opencodeText, modify(opencodeText, ['plugin'], plugins, {
91
+ opencodeText = applyEdits(opencodeText, modify(opencodeText, ['plugins'], plugins, {
89
92
  formattingOptions: { insertSpaces: true, tabSize: 2 },
90
93
  }))
91
94
  }
92
- ensurePlugin(tui)
93
95
 
94
96
  if (!pkg.dependencies) pkg.dependencies = {}
95
97
  const { name: pkgName, version: pkgVersion } = splitPackageSpec(PLUGIN)
@@ -98,18 +100,32 @@ export async function installQuota(options = {}) {
98
100
  await fse.ensureDir(opencodeDir)
99
101
  await fse.writeFile(opencodePath, opencodeText, 'utf-8')
100
102
  await fse.writeJson(pkgPath, pkg, { spaces: 2 })
101
- await fse.writeJson(tuiPath, tui, { spaces: 2 })
102
103
 
103
- const quotaConfig = await fse.pathExists(quotaPath)
104
- ? await fse.readJson(quotaPath)
105
- : {}
104
+ // Carry over any existing settings (new location first, then the legacy
105
+ // v4 file) and layer the recommended defaults on top. The edge defaults
106
+ // disable toasts and reset notifications and keep slash reports inline.
107
+ let quotaConfig = {}
108
+ if (await fse.pathExists(quotaPath)) {
109
+ quotaConfig = await fse.readJson(quotaPath)
110
+ } else if (await fse.pathExists(legacyQuotaPath)) {
111
+ quotaConfig = await fse.readJson(legacyQuotaPath)
112
+ }
106
113
 
107
114
  Object.assign(quotaConfig, quotaPreset.defaults)
108
115
 
109
116
  await fse.ensureDir(quotaDir)
110
117
  await fse.writeJson(quotaPath, quotaConfig, { spaces: 2 })
111
118
 
112
- success('opencode-quota configured (manual setup)')
119
+ if (await fse.pathExists(legacyQuotaPath)) {
120
+ await fse.remove(legacyQuotaPath)
121
+ const legacyDir = path.dirname(legacyQuotaPath)
122
+ if ((await fse.readdir(legacyDir)).length === 0) {
123
+ await fse.remove(legacyDir)
124
+ }
125
+ info('Moved opencode-quota settings to opencode-quota/quota-toast.jsonc')
126
+ }
127
+
128
+ success('opencode-quota configured (popups and notifications disabled)')
113
129
  info('Restart OpenCode and run /quota to verify')
114
130
  return { optedIn: true, installed: true }
115
131
  } catch (err) {
package/cli/utils/copy.js CHANGED
@@ -40,7 +40,6 @@ function isTemplateTest(relativePath) {
40
40
  export function isManagedContentPath(relativePath) {
41
41
  const normalized = relativePath.split(path.sep).join('/')
42
42
  if (normalized.startsWith('.opencode/plugins/')) return true
43
- if (normalized === '.opencode/tui/pc-subagents.tsx') return true
44
43
  if (normalized.startsWith('.opencode/commands/') && !MARKER_COMMANDS.has(normalized)) return true
45
44
  return normalized === 'openspec/specs/.gitkeep' || normalized === 'openspec/changes/archive/.gitkeep'
46
45
  }