@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 +5 -5
- package/cli/commands/join.js +19 -3
- package/cli/presets/quota.json +6 -2
- package/cli/steps/copy/opencode-json.js +260 -135
- package/cli/steps/optimization/quota.js +36 -20
- package/cli/utils/copy.js +0 -1
- package/harness/.opencode/_gitignore +9 -9
- package/harness/.opencode/package.json +1 -5
- package/harness/.opencode/plugins/pc-subagent-monitor.js +144 -179
- package/harness/.opencode/plugins/pc-subagent-tiers.js +225 -248
- package/harness/.opencode/plugins/pc-system-reminders.js +386 -434
- package/harness/opencode.jsonc +21 -32
- package/package.json +1 -1
- package/skills/pc-guardrails-generic/SKILL.md +15 -10
- package/skills/pc-make-engineer/SKILL.md +59 -59
- package/skills/pc-make-engineer/template.md +36 -42
- package/skills/pc-make-guardrails/category-reference.md +2 -2
- package/skills/pc-plan-goal/branching.md +39 -30
- package/harness/.opencode/tui/pc-subagents.tsx +0 -98
- package/harness/.opencode/tui.json +0 -6
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
|
|
package/cli/commands/join.js
CHANGED
|
@@ -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,
|
|
55
|
-
|
|
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')
|
package/cli/presets/quota.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"plugin": "@slkiser/opencode-quota@
|
|
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
|
-
"
|
|
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 {
|
|
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
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
text = applyModify(text, ['plugin'], keptPlugins)
|
|
179
|
+
if ('instructions' in obj) {
|
|
180
|
+
delete obj.instructions
|
|
181
|
+
changed = true
|
|
133
182
|
}
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
139
|
-
if (
|
|
140
|
-
|
|
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 (
|
|
143
|
-
|
|
211
|
+
if (!Number.isFinite(compaction.buffer)) {
|
|
212
|
+
compaction.buffer = 10000
|
|
213
|
+
changed = true
|
|
144
214
|
}
|
|
145
|
-
if (
|
|
146
|
-
|
|
215
|
+
if (JSON.stringify(obj.compaction) !== JSON.stringify(compaction)) {
|
|
216
|
+
obj.compaction = compaction
|
|
217
|
+
changed = true
|
|
147
218
|
}
|
|
148
|
-
|
|
149
|
-
|
|
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 (
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
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 (
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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, ['
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
}
|