openvisio-agent 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/bin/cli.mjs +52 -19
  2. package/package.json +1 -1
  3. package/src/watch.mjs +77 -15
package/bin/cli.mjs CHANGED
@@ -24,8 +24,8 @@ const HELP = `openvisio-agent ${VERSION}
24
24
  Connect your coding agent to an OpenVisio team.
25
25
 
26
26
  Usage:
27
- openvisio-agent connect <ovs_code> --host <url> [--name "<agent>"] [--mcp-url <url>]
28
- openvisio-agent connect --backend <url> --key <api-key> --id <identifier> [--name "<agent>"] [--ws <wss-url>] [--mcp-url <url>]
27
+ openvisio-agent connect <ovs_code> --host <url> [--name "<agent>"] [--mcp-url <url>] [--agent claude|opencode]
28
+ openvisio-agent connect --backend <url> --key <api-key> --id <identifier> [--name "<agent>"] [--ws <wss-url>] [--mcp-url <url>] [--agent claude|opencode]
29
29
  openvisio-agent watch --name <agent> [--install] [--workspace <dir>] [--chat-only] [--model <m>] [--chat-model <m>] [--debug]
30
30
  openvisio-agent --help | --version
31
31
 
@@ -50,6 +50,9 @@ connect --backend
50
50
  pushes its agent/* branch and opens PRs. Defaults to
51
51
  ~/openvisio-workspace; point it at an existing clones folder to
52
52
  reuse those. (--workdir is an accepted alias.)
53
+ --agent <name> the coding-agent RUNTIME the cycles run on: claude (Claude Code,
54
+ default) or opencode (opencode.ai). opencode is authenticated
55
+ separately (opencode auth login) and uses provider/model ids.
53
56
  --chat-only disable code work — chat/ticket tools only.
54
57
  --model <m> the model the agent runs on (opus | sonnet | haiku | a full
55
58
  claude-… id). Defaults to sonnet — cost-effective, so the agent
@@ -94,6 +97,24 @@ function ensureAgentInstalled() {
94
97
  spawnSync('npm', ['i', '-g', 'openvisio-agent@latest'], { stdio: 'inherit', shell: process.platform === 'win32' })
95
98
  }
96
99
 
100
+ // The chosen coding-agent RUNTIME the cycles run on. Default claude.
101
+ function agentFlag(flags) {
102
+ const a = String(flags.agent || flags.runtime || 'claude').toLowerCase()
103
+ if (a !== 'claude' && a !== 'opencode') fail(`Unknown --agent "${a}". Supported runtimes: claude | opencode.`)
104
+ return a
105
+ }
106
+
107
+ // opencode runtime: ensure the `opencode` CLI exists (best-effort install), and
108
+ // remind about its one-time auth. Unlike Claude Code we don't register an MCP here
109
+ // — the watcher writes an `opencode.json` with the openvisio-team server at runtime.
110
+ function ensureOpencode() {
111
+ if (onPath('opencode')) return
112
+ info('opencode not found on PATH — installing (npm i -g opencode-ai)…')
113
+ spawnSync('npm', ['i', '-g', 'opencode-ai'], { stdio: 'inherit', shell: process.platform === 'win32' })
114
+ if (!onPath('opencode')) info('Could not auto-install opencode. Install it — https://opencode.ai/docs/ — then run: opencode auth login')
115
+ else info('Installed opencode. Authenticate it once if you haven\'t: opencode auth login')
116
+ }
117
+
97
118
  async function runConnect({ positional, flags }) {
98
119
  if (flags.backend) return runConnectBackend({ positional, flags })
99
120
  const token = positional[0] || flags.token
@@ -107,21 +128,28 @@ async function runConnect({ positional, flags }) {
107
128
  const mcpUrl = (flags['mcp-url'] && String(flags['mcp-url'])) || srvMcp || stripSlash(host) + '/api/agent/mcp'
108
129
  const name = (flags.name && String(flags.name)) || 'openvisio-team'
109
130
  const slug = slugify(name)
131
+ const agent = agentFlag(flags)
110
132
 
111
- const claude = ensureClaude()
112
-
113
- // Register with Claude Code — remove any stale entry first so a changed URL sticks.
114
- mcpReplace(claude, ['--transport', 'http', 'openvisio-team', mcpUrl, '--header', `Authorization: Bearer ${key}`])
115
-
116
- // A scoped MCP config (for the watcher's --strict-mcp-config) + a saved profile.
117
- const mcpCfg = mcpConfigPath(slug)
118
- writeJson(mcpCfg, { mcpServers: { 'openvisio-team': { type: 'http', url: mcpUrl, headers: { Authorization: `Bearer ${key}` } } } }, true)
133
+ // Register the openvisio-team MCP with the chosen runtime.
134
+ let mcpCfg = ''
135
+ if (agent === 'claude') {
136
+ const claude = ensureClaude()
137
+ // Remove any stale entry first so a changed URL sticks, then add at user scope.
138
+ mcpReplace(claude, ['--transport', 'http', 'openvisio-team', mcpUrl, '--header', `Authorization: Bearer ${key}`])
139
+ // A scoped MCP config for the watcher's --strict-mcp-config.
140
+ mcpCfg = mcpConfigPath(slug)
141
+ writeJson(mcpCfg, { mcpServers: { 'openvisio-team': { type: 'http', url: mcpUrl, headers: { Authorization: `Bearer ${key}` } } } }, true)
142
+ } else {
143
+ ensureOpencode() // the watcher writes opencode.json with the remote MCP at runtime
144
+ }
119
145
  const wsWorkdir = flags.workdir === true ? process.cwd() : flags.workdir ? String(flags.workdir) : flags.workspace ? String(flags.workspace) : ''
120
- writeJson(configPath(slug), { host: stripSlash(host), key, mcpUrl, name, slug, mcpConfig: mcpCfg, ...(wsWorkdir ? { workspace: wsWorkdir } : {}), ...(flags['chat-only'] ? { chatOnly: true } : {}), ...(flags.model ? { model: String(flags.model) } : {}), ...(flags['chat-model'] ? { chatModel: String(flags['chat-model']) } : {}) }, true)
146
+ writeJson(configPath(slug), { host: stripSlash(host), key, mcpUrl, name, slug, ...(mcpCfg ? { mcpConfig: mcpCfg } : {}), ...(agent !== 'claude' ? { agent } : {}), ...(wsWorkdir ? { workspace: wsWorkdir } : {}), ...(flags['chat-only'] ? { chatOnly: true } : {}), ...(flags.model ? { model: String(flags.model) } : {}), ...(flags['chat-model'] ? { chatModel: String(flags['chat-model']) } : {}) }, true)
121
147
 
122
148
  ok(`Connected "${name}" to ${host}.`)
123
149
  info()
124
- info('Claude Code now has the openvisio-team tools. Run /mcp in Claude Code to confirm.')
150
+ info(agent === 'opencode'
151
+ ? 'Runtime: opencode. The watcher writes an opencode.json with the openvisio-team tools; make sure opencode is authenticated (opencode auth login).'
152
+ : 'Claude Code now has the openvisio-team tools. Run /mcp in Claude Code to confirm.')
125
153
  info()
126
154
  info('To let it work on its own (reply to mentions, pick up tickets, AND do real')
127
155
  info('coding — it clones/branches/pushes and opens PRs out of the box):')
@@ -160,6 +188,7 @@ async function runConnectBackend({ flags }) {
160
188
 
161
189
  const name = (flags.name && String(flags.name)) || 'backend-agent'
162
190
  const slug = slugify(name)
191
+ const agent = agentFlag(flags)
163
192
 
164
193
  // Code workspace preference (optional). CODE mode is on by default; this only
165
194
  // pins WHERE the agent works. Persisted so the always-on `watch` reuses it.
@@ -177,15 +206,19 @@ async function runConnectBackend({ flags }) {
177
206
 
178
207
  let mcpConfig = ''
179
208
  if (mcpUrl) {
180
- const claude = ensureClaude()
181
- // Backend agents authenticate with the agent header pair, not a Bearer JWT.
182
- const hdr = ['--header', `x-agent-api-key: ${apiKey}`, '--header', `x-agent-identifier: ${identifier}`]
183
- mcpReplace(claude, ['--transport', 'http', 'openvisio-team', mcpUrl, ...hdr])
184
- mcpConfig = mcpConfigPath(slug)
185
- writeJson(mcpConfig, { mcpServers: { 'openvisio-team': { type: 'http', url: mcpUrl, headers: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier } } } }, true)
209
+ if (agent === 'claude') {
210
+ const claude = ensureClaude()
211
+ // Backend agents authenticate with the agent header pair, not a Bearer JWT.
212
+ const hdr = ['--header', `x-agent-api-key: ${apiKey}`, '--header', `x-agent-identifier: ${identifier}`]
213
+ mcpReplace(claude, ['--transport', 'http', 'openvisio-team', mcpUrl, ...hdr])
214
+ mcpConfig = mcpConfigPath(slug)
215
+ writeJson(mcpConfig, { mcpServers: { 'openvisio-team': { type: 'http', url: mcpUrl, headers: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier } } } }, true)
216
+ } else {
217
+ ensureOpencode() // the watcher writes opencode.json with the remote MCP at runtime
218
+ }
186
219
  }
187
220
 
188
- writeJson(configPath(slug), { mode: 'backend', backend, apiKey, identifier, name, slug, wsUrl, mcpUrl, mcpConfig, ...(workdir ? { workspace: workdir } : {}), ...(chatOnly ? { chatOnly: true } : {}), ...(flags.model ? { model: String(flags.model) } : {}), ...(flags['chat-model'] ? { chatModel: String(flags['chat-model']) } : {}) }, true)
221
+ writeJson(configPath(slug), { mode: 'backend', backend, apiKey, identifier, name, slug, wsUrl, mcpUrl, mcpConfig, ...(agent !== 'claude' ? { agent } : {}), ...(workdir ? { workspace: workdir } : {}), ...(chatOnly ? { chatOnly: true } : {}), ...(flags.model ? { model: String(flags.model) } : {}), ...(flags['chat-model'] ? { chatModel: String(flags['chat-model']) } : {}) }, true)
189
222
  // A sourceable env file, matching the setup snippet OpenVisio shows.
190
223
  const envPath = join(OV_DIR, `${slug}.env`)
191
224
  mkdirSync(OV_DIR, { recursive: true })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Connect your coding agent (Claude Code) to an OpenVisio team — MCP tools + optional autonomy — in one command. No shell scripts.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/watch.mjs CHANGED
@@ -136,6 +136,11 @@ export async function runWatch({ flags }) {
136
136
  const saved = slug ? readConfig(slug) : null
137
137
  const claude = String(flags.claude || onPath('claude') || 'claude')
138
138
  const mcpConfig = String(flags['mcp-config'] || (saved && saved.mcpConfig) || '')
139
+ // Which coding-agent runtime drives the cycles: Claude Code (warm stream-json
140
+ // session) or opencode (headless `opencode run` per cycle). Default claude.
141
+ const agent = String(flags.agent || flags.runtime || (saved && saved.agent) || 'claude').toLowerCase()
142
+ if (agent !== 'claude' && agent !== 'opencode') fail(`Unknown --agent "${agent}". Supported runtimes: claude | opencode.`)
143
+ const mcpUrl = String(flags['mcp-url'] || (saved && saved.mcpUrl) || '')
139
144
  // Code mode is the DEFAULT: an agent lives on the user's laptop and should just be
140
145
  // able to work across the org's repos with no per-repo setup. So `workdir` resolves
141
146
  // to (in order) an explicit --workdir/--workspace, the saved workspace, or the
@@ -149,12 +154,11 @@ export async function runWatch({ flags }) {
149
154
  const workdir = chatOnly ? '' : (explicitWorkdir || (saved && (saved.workspace || saved.workdir)) || DEFAULT_WORKSPACE)
150
155
  if (workdir) { try { mkdirSync(workdir, { recursive: true }) } catch { /* best-effort; spawn will surface a real problem */ } }
151
156
 
152
- // Model the agent runs on. Default SONNET (cost-effective) rather than whatever
153
- // `claude` defaults to the agent shouldn't burn Opus tokens on routine chatter.
154
- // Optional --chat-model runs the lighter chat/mention cycles on an even cheaper
155
- // model while code cycles stay on the main one. Both persisted + live-changeable
156
- // via the in-chat `/model` command (see loopBackendWs).
157
- const model = String(flags.model || (saved && saved.model) || 'sonnet')
157
+ // Model the agent runs on. For Claude Code default SONNET (cost-effective) rather
158
+ // than whatever `claude` defaults to. For opencode, models are `provider/model`
159
+ // and multi-provider, so default to empty (use opencode's own configured default)
160
+ // unless set. Optional --chat-model runs the lighter chat/mention cycles cheaper.
161
+ const model = String(flags.model || (saved && saved.model) || (agent === 'opencode' ? '' : 'sonnet'))
158
162
  const chatModel = String(flags['chat-model'] || (saved && saved.chatModel) || '')
159
163
 
160
164
  // Backend agents (connect --backend) drive autonomy over a real-time WS instead
@@ -168,7 +172,7 @@ export async function runWatch({ flags }) {
168
172
  if (!apiKey || !identifier) fail('No saved backend credentials for that agent.\n Run `openvisio-agent connect --backend …` first, or pass --key and --id.')
169
173
  assertWebSocket(fail)
170
174
  if (flags.install) return installService({ slug: slug || 'openvisio', claude, mcpConfig, workdir })
171
- return loopBackendWs({ wsUrl, apiKey, identifier, slug: slug || 'openvisio', claude, mcpConfig, workdir, model, chatModel, debug: !!flags.debug })
175
+ return loopBackendWs({ wsUrl, apiKey, identifier, slug: slug || 'openvisio', claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug: !!flags.debug })
172
176
  }
173
177
 
174
178
  const host = stripSlash(flags.host || (saved && saved.host) || '')
@@ -177,15 +181,70 @@ export async function runWatch({ flags }) {
177
181
 
178
182
  if (flags.install) return installService({ slug: slug || 'openvisio', claude, mcpConfig, workdir })
179
183
 
180
- return loop({ host, key, slug: slug || 'openvisio', claude, mcpConfig, workdir, model, chatModel, debug: !!flags.debug })
184
+ return loop({ host, key, slug: slug || 'openvisio', claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug: !!flags.debug })
185
+ }
186
+
187
+ // ── opencode cycle runner ─────────────────────────────────────────────────────
188
+ // opencode (opencode.ai) has no persistent stream-json protocol like Claude Code,
189
+ // so each cycle is a headless `opencode run <prompt> --auto [--model provider/model]`.
190
+ // The openvisio-team MCP is declared in an `opencode.json` written into the run cwd
191
+ // (opencode reads it from there). `--auto` approves tool use non-interactively.
192
+ // Same { runCycle, canCode } contract as the Claude runner.
193
+ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model }) {
194
+ // opencode reads opencode.json from its CWD: the code workspace, or a dedicated
195
+ // per-agent dir for chat-only agents.
196
+ const cwd = workdir || join(OV_DIR, 'opencode-' + (cfgKey || 'agent'))
197
+ const bin = onPath('opencode') || 'opencode'
198
+ let configured = false
199
+ const ensureConfig = () => {
200
+ if (configured) return
201
+ configured = true
202
+ try {
203
+ mkdirSync(cwd, { recursive: true })
204
+ if (mcpUrl) {
205
+ writeJson(join(cwd, 'opencode.json'), {
206
+ $schema: 'https://opencode.ai/config.json',
207
+ mcp: { 'openvisio-team': { type: 'remote', url: mcpUrl, enabled: true, ...(mcpHeaders && Object.keys(mcpHeaders).length ? { headers: mcpHeaders } : {}) } },
208
+ }, true)
209
+ } else {
210
+ log('WARNING: no --mcp-url — opencode has no openvisio-team tools to act with. Re-connect with --mcp-url.')
211
+ }
212
+ } catch (e) { log('opencode config write failed: ' + (e && e.message ? e.message : e)) }
213
+ }
214
+
215
+ function runCycle(prompt, cycleModel) {
216
+ return new Promise((resolve) => {
217
+ ensureConfig()
218
+ const m = cycleModel || model
219
+ const args = ['run', prompt, '--auto', ...(m ? ['--model', m] : [])]
220
+ let child = null, done = false
221
+ const finish = (o) => { if (done) return; done = true; clearTimeout(timer); resolve(o) }
222
+ const timer = setTimeout(() => {
223
+ log('opencode cycle TIMED OUT after ' + Math.round(maxCycleMs / 1000) + 's — killing')
224
+ try { child && child.kill() } catch { /* gone */ }
225
+ finish({ type: 'result', subtype: 'timeout' })
226
+ }, maxCycleMs)
227
+ log('running opencode cycle…' + (m ? ' [' + m + ']' : ''))
228
+ try { child = spawn(bin, args, { cwd, stdio: ['ignore', debug ? 'inherit' : 'ignore', 'inherit'] }) }
229
+ catch (e) { log('opencode spawn failed: ' + (e && e.message ? e.message : e) + ' — is opencode installed? (npm i -g opencode-ai, then `opencode auth login`)'); return finish({ type: 'result', subtype: 'spawn-failed' }) }
230
+ child.on('exit', (code) => { log('opencode cycle done (' + (code === 0 ? 'ok' : 'exit ' + code) + ')'); finish({ type: 'result', subtype: code === 0 ? 'ok' : 'error' }) })
231
+ child.on('error', (e) => { log('opencode error: ' + (e && e.message ? e.message : e)); finish({ type: 'result', subtype: 'error' }) })
232
+ })
233
+ }
234
+
235
+ log('opencode runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + cwd + ']' : ' [CHAT-ONLY — cfg ' + cwd + ']'))
236
+ return { runCycle, canCode }
181
237
  }
182
238
 
183
239
  // ── Claude Code warm-session cycle runner (shared by the REST + WS loops) ─────
184
240
  // One persistent stream-json session, poked with a prompt per cycle. Recycled
185
241
  // after MAX_TURNS or SESSION_IDLE_MS. Returns { runCycle, canCode }.
186
- function createCycleRunner({ claude, mcpConfig, workdir, log, debug, model }) {
242
+ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model }) {
187
243
  const canCode = !!workdir
188
244
  const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
245
+ // opencode drives cycles differently — a headless `opencode run` per cycle rather
246
+ // than a persistent stream-json session. Same { runCycle, canCode } contract.
247
+ if (agent === 'opencode') return createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model })
189
248
  let child = null
190
249
  // The model the CURRENT session was spawned with. runCycle can pass a different
191
250
  // model per cycle (cheap for chat, stronger for code) — a change recycles the
@@ -280,9 +339,9 @@ function createCycleRunner({ claude, mcpConfig, workdir, log, debug, model }) {
280
339
  // over the WS; each pushes ONE Claude cycle. Serialized (one cycle at a time) — events
281
340
  // arriving while busy are coalesced into a single follow-up cycle so a burst doesn't
282
341
  // stack up N sessions. Plus a one-time intro on first connect and a daily catch-up sweep.
283
- function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, mcpConfig, workdir, model, chatModel, debug }) {
342
+ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }) {
284
343
  const log = (m) => process.stdout.write('[ws ' + new Date().toISOString() + '] ' + m + '\n')
285
- const { runCycle, canCode } = createCycleRunner({ claude, mcpConfig, workdir, log, debug, model })
344
+ const { runCycle, canCode } = createCycleRunner({ claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier }, cfgKey: identifier, mcpConfig, workdir, log, debug, model })
286
345
  const fullPrompt = canCode ? CODE_FULL : CYCLE
287
346
  const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
288
347
  // Live model state — changeable at runtime by the in-chat `/model` command.
@@ -348,7 +407,9 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, mcpConfig, wor
348
407
  const normalizeModel = (s) => {
349
408
  const x = String(s || '').toLowerCase().replace(/[.,!?]+$/, '')
350
409
  if (x === 'opus' || x === 'sonnet' || x === 'haiku') return x
351
- return /^claude-[a-z0-9.\-\[\]]+$/i.test(x) ? x : null
410
+ if (/^claude-[a-z0-9.\-\[\]]+$/i.test(x)) return x
411
+ // opencode models are provider/model (e.g. anthropic/claude-sonnet-4, openai/gpt-4o).
412
+ return /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9.\-:]*$/i.test(x) ? x : null
352
413
  }
353
414
  // Optional target tier: "chat"/"lite" → chat cycles only, "code"/"full" → code
354
415
  // cycles only, absent → both.
@@ -485,10 +546,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, mcpConfig, wor
485
546
  }
486
547
 
487
548
  // ── the warm loop ────────────────────────────────────────────────────────────
488
- function loop({ host, key, slug, claude, mcpConfig, workdir, model, chatModel, debug }) {
549
+ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }) {
489
550
  const log = (m) => process.stdout.write('[warm ' + new Date().toISOString() + '] ' + m + '\n')
490
551
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
491
- const { runCycle, canCode } = createCycleRunner({ claude, mcpConfig, workdir, log, debug, model })
552
+ const { runCycle, canCode } = createCycleRunner({ claude, agent, mcpUrl, mcpHeaders: { Authorization: 'Bearer ' + key }, cfgKey: slug, mcpConfig, workdir, log, debug, model })
492
553
  const fullPrompt = canCode ? CODE_FULL : CYCLE
493
554
  const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
494
555
  const liteModel = chatModel || model // cheaper model for chat/quick cycles
@@ -593,7 +654,8 @@ export function installService({ slug, workdir }) {
593
654
  const bin = onPath('openvisio-agent') || 'openvisio-agent'
594
655
  const nodeDir = dirname(process.execPath)
595
656
  const claudeBin = onPath('claude')
596
- const runPath = [nodeDir, claudeBin ? dirname(claudeBin) : '', '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin'].filter(Boolean).join(':')
657
+ const opencodeBin = onPath('opencode')
658
+ const runPath = [nodeDir, claudeBin ? dirname(claudeBin) : '', opencodeBin ? dirname(opencodeBin) : '', join(homedir(), '.opencode', 'bin'), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin'].filter(Boolean).join(':')
597
659
  const args = ['watch', '--name', slug, ...(workdir ? ['--workdir', workdir] : [])]
598
660
  mkdirSync(OV_DIR, { recursive: true })
599
661
  const logFile = join(OV_DIR, `${slug}.log`)