openvisio-agent 0.6.2 → 0.7.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/bin/cli.mjs +52 -19
- package/package.json +1 -1
- package/src/watch.mjs +115 -16
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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(
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
package/src/watch.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// than written to disk from a pasted heredoc.
|
|
6
6
|
|
|
7
7
|
import { spawn, spawnSync } from 'node:child_process'
|
|
8
|
-
import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
8
|
+
import { writeFileSync, mkdirSync, existsSync, readFileSync, unlinkSync } from 'node:fs'
|
|
9
9
|
import { homedir } from 'node:os'
|
|
10
10
|
import { join, dirname } from 'node:path'
|
|
11
11
|
import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
|
|
@@ -29,6 +29,7 @@ const REPLY_DISCIPLINE = [
|
|
|
29
29
|
' • IS IT FOR YOU? Act ONLY on messages addressed to YOU — an @mention of your exact name, a direct question to you, or a reply to something YOU said or did. If a DIFFERENT agent or person was @mentioned or asked to do something, STAY OUT: do not answer for them and do not pick up their task. When it is not yours, posting nothing is the correct move.',
|
|
30
30
|
' • NO DUPLICATES. Before you post, scan the recent thread/channel for what YOU already said. If you already replied to or acknowledged this exact request, do NOT post again. One acknowledgement per task; one answer per question. While a task is in progress, post again ONLY when you have something genuinely NEW (a result, a link, a real blocker) — never re-post "on it".',
|
|
31
31
|
' • BE SURE BEFORE YOU SPEAK. Do not claim something is possible, done, or broken until you have actually verified it — call the tool, read the code, check the real state. Never assert then contradict yourself. If you are unsure, verify FIRST, then give ONE clear, final answer instead of thinking out loud across several messages.',
|
|
32
|
+
' • NO INVENTED HISTORY. You have NO memory beyond the messages visible in THIS thread and what your tools return right now. Never fabricate past events, competitions, conversations, results, links, PR numbers, deploy URLs, or figures. If you are asked about something you have no actual record of, say plainly "I don\'t have a record of that" — do NOT make one up to play along or be helpful. Only state things you can see or verify.',
|
|
32
33
|
].join('\n')
|
|
33
34
|
|
|
34
35
|
// ── CHAT-ONLY agents (no --workdir): chat/ticket tools, no code surface. ──────
|
|
@@ -131,11 +132,39 @@ const SESSION_IDLE_MS = 1200000
|
|
|
131
132
|
const MAX_CYCLE_MS = 240000
|
|
132
133
|
const MAX_CODE_CYCLE_MS = 900000
|
|
133
134
|
|
|
135
|
+
// Refuse to run a SECOND watcher for the same agent. Two watchers connect to the
|
|
136
|
+
// WS as the same agent and BOTH reply to every mention — the #1 cause of duplicate
|
|
137
|
+
// (and contradicting, if the two are different versions) messages. A pid lock file
|
|
138
|
+
// in ~/.openvisio makes the second start fail fast instead. Stale locks (dead pid)
|
|
139
|
+
// are taken over. Returns { release } or { conflict: <pid> }.
|
|
140
|
+
function acquireSingleInstance(key) {
|
|
141
|
+
const lockPath = join(OV_DIR, 'watch-' + key + '.lock')
|
|
142
|
+
try {
|
|
143
|
+
mkdirSync(OV_DIR, { recursive: true })
|
|
144
|
+
if (existsSync(lockPath)) {
|
|
145
|
+
const pid = parseInt(String(readFileSync(lockPath, 'utf8')).trim(), 10)
|
|
146
|
+
if (pid && pid !== process.pid) {
|
|
147
|
+
let alive = false
|
|
148
|
+
try { process.kill(pid, 0); alive = true } catch (e) { alive = !!(e && e.code === 'EPERM') }
|
|
149
|
+
if (alive) return { conflict: pid }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
writeFileSync(lockPath, String(process.pid))
|
|
153
|
+
} catch { /* if the lock can't be written, don't block the agent from running */ }
|
|
154
|
+
const release = () => { try { if (parseInt(String(readFileSync(lockPath, 'utf8')).trim(), 10) === process.pid) unlinkSync(lockPath) } catch { /* already gone */ } }
|
|
155
|
+
return { release }
|
|
156
|
+
}
|
|
157
|
+
|
|
134
158
|
export async function runWatch({ flags }) {
|
|
135
159
|
const slug = flags.name ? slugify(String(flags.name)) : null
|
|
136
160
|
const saved = slug ? readConfig(slug) : null
|
|
137
161
|
const claude = String(flags.claude || onPath('claude') || 'claude')
|
|
138
162
|
const mcpConfig = String(flags['mcp-config'] || (saved && saved.mcpConfig) || '')
|
|
163
|
+
// Which coding-agent runtime drives the cycles: Claude Code (warm stream-json
|
|
164
|
+
// session) or opencode (headless `opencode run` per cycle). Default claude.
|
|
165
|
+
const agent = String(flags.agent || flags.runtime || (saved && saved.agent) || 'claude').toLowerCase()
|
|
166
|
+
if (agent !== 'claude' && agent !== 'opencode') fail(`Unknown --agent "${agent}". Supported runtimes: claude | opencode.`)
|
|
167
|
+
const mcpUrl = String(flags['mcp-url'] || (saved && saved.mcpUrl) || '')
|
|
139
168
|
// Code mode is the DEFAULT: an agent lives on the user's laptop and should just be
|
|
140
169
|
// able to work across the org's repos with no per-repo setup. So `workdir` resolves
|
|
141
170
|
// to (in order) an explicit --workdir/--workspace, the saved workspace, or the
|
|
@@ -149,14 +178,26 @@ export async function runWatch({ flags }) {
|
|
|
149
178
|
const workdir = chatOnly ? '' : (explicitWorkdir || (saved && (saved.workspace || saved.workdir)) || DEFAULT_WORKSPACE)
|
|
150
179
|
if (workdir) { try { mkdirSync(workdir, { recursive: true }) } catch { /* best-effort; spawn will surface a real problem */ } }
|
|
151
180
|
|
|
152
|
-
// Model the agent runs on.
|
|
153
|
-
// `claude` defaults to
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
const model = String(flags.model || (saved && saved.model) || 'sonnet')
|
|
181
|
+
// Model the agent runs on. For Claude Code default SONNET (cost-effective) rather
|
|
182
|
+
// than whatever `claude` defaults to. For opencode, models are `provider/model`
|
|
183
|
+
// and multi-provider, so default to empty (use opencode's own configured default)
|
|
184
|
+
// unless set. Optional --chat-model runs the lighter chat/mention cycles cheaper.
|
|
185
|
+
const model = String(flags.model || (saved && saved.model) || (agent === 'opencode' ? '' : 'sonnet'))
|
|
158
186
|
const chatModel = String(flags['chat-model'] || (saved && saved.chatModel) || '')
|
|
159
187
|
|
|
188
|
+
// ONE watcher per agent. A second one (e.g. a manual `watch` alongside the
|
|
189
|
+
// background service, or a stale service) is the #1 cause of duplicate replies:
|
|
190
|
+
// both connect as the same agent and both answer every mention. Refuse to start.
|
|
191
|
+
if (!flags.install) {
|
|
192
|
+
const lock = acquireSingleInstance(slug || 'openvisio')
|
|
193
|
+
if (lock.conflict) {
|
|
194
|
+
fail(`Another openvisio-agent watcher for "${slug || 'openvisio'}" is already running (pid ${lock.conflict}).\n` +
|
|
195
|
+
` Two watchers for the same agent BOTH reply to every mention — that is what causes duplicate/contradicting messages.\n` +
|
|
196
|
+
` Stop the other one (kill ${lock.conflict}), or rely on ONLY the background service. Refusing to start a second.`)
|
|
197
|
+
}
|
|
198
|
+
process.on('exit', () => { try { lock.release && lock.release() } catch { /* noop */ } })
|
|
199
|
+
}
|
|
200
|
+
|
|
160
201
|
// Backend agents (connect --backend) drive autonomy over a real-time WS instead
|
|
161
202
|
// of REST-polling the frontend relay. Detected by the saved mode / a --ws flag.
|
|
162
203
|
const backendMode = (saved && saved.mode === 'backend') || !!flags.ws
|
|
@@ -168,7 +209,7 @@ export async function runWatch({ flags }) {
|
|
|
168
209
|
if (!apiKey || !identifier) fail('No saved backend credentials for that agent.\n Run `openvisio-agent connect --backend …` first, or pass --key and --id.')
|
|
169
210
|
assertWebSocket(fail)
|
|
170
211
|
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 })
|
|
212
|
+
return loopBackendWs({ wsUrl, apiKey, identifier, slug: slug || 'openvisio', claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug: !!flags.debug })
|
|
172
213
|
}
|
|
173
214
|
|
|
174
215
|
const host = stripSlash(flags.host || (saved && saved.host) || '')
|
|
@@ -177,15 +218,70 @@ export async function runWatch({ flags }) {
|
|
|
177
218
|
|
|
178
219
|
if (flags.install) return installService({ slug: slug || 'openvisio', claude, mcpConfig, workdir })
|
|
179
220
|
|
|
180
|
-
return loop({ host, key, slug: slug || 'openvisio', claude, mcpConfig, workdir, model, chatModel, debug: !!flags.debug })
|
|
221
|
+
return loop({ host, key, slug: slug || 'openvisio', claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug: !!flags.debug })
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── opencode cycle runner ─────────────────────────────────────────────────────
|
|
225
|
+
// opencode (opencode.ai) has no persistent stream-json protocol like Claude Code,
|
|
226
|
+
// so each cycle is a headless `opencode run <prompt> --auto [--model provider/model]`.
|
|
227
|
+
// The openvisio-team MCP is declared in an `opencode.json` written into the run cwd
|
|
228
|
+
// (opencode reads it from there). `--auto` approves tool use non-interactively.
|
|
229
|
+
// Same { runCycle, canCode } contract as the Claude runner.
|
|
230
|
+
function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model }) {
|
|
231
|
+
// opencode reads opencode.json from its CWD: the code workspace, or a dedicated
|
|
232
|
+
// per-agent dir for chat-only agents.
|
|
233
|
+
const cwd = workdir || join(OV_DIR, 'opencode-' + (cfgKey || 'agent'))
|
|
234
|
+
const bin = onPath('opencode') || 'opencode'
|
|
235
|
+
let configured = false
|
|
236
|
+
const ensureConfig = () => {
|
|
237
|
+
if (configured) return
|
|
238
|
+
configured = true
|
|
239
|
+
try {
|
|
240
|
+
mkdirSync(cwd, { recursive: true })
|
|
241
|
+
if (mcpUrl) {
|
|
242
|
+
writeJson(join(cwd, 'opencode.json'), {
|
|
243
|
+
$schema: 'https://opencode.ai/config.json',
|
|
244
|
+
mcp: { 'openvisio-team': { type: 'remote', url: mcpUrl, enabled: true, ...(mcpHeaders && Object.keys(mcpHeaders).length ? { headers: mcpHeaders } : {}) } },
|
|
245
|
+
}, true)
|
|
246
|
+
} else {
|
|
247
|
+
log('WARNING: no --mcp-url — opencode has no openvisio-team tools to act with. Re-connect with --mcp-url.')
|
|
248
|
+
}
|
|
249
|
+
} catch (e) { log('opencode config write failed: ' + (e && e.message ? e.message : e)) }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function runCycle(prompt, cycleModel) {
|
|
253
|
+
return new Promise((resolve) => {
|
|
254
|
+
ensureConfig()
|
|
255
|
+
const m = cycleModel || model
|
|
256
|
+
const args = ['run', prompt, '--auto', ...(m ? ['--model', m] : [])]
|
|
257
|
+
let child = null, done = false
|
|
258
|
+
const finish = (o) => { if (done) return; done = true; clearTimeout(timer); resolve(o) }
|
|
259
|
+
const timer = setTimeout(() => {
|
|
260
|
+
log('opencode cycle TIMED OUT after ' + Math.round(maxCycleMs / 1000) + 's — killing')
|
|
261
|
+
try { child && child.kill() } catch { /* gone */ }
|
|
262
|
+
finish({ type: 'result', subtype: 'timeout' })
|
|
263
|
+
}, maxCycleMs)
|
|
264
|
+
log('running opencode cycle…' + (m ? ' [' + m + ']' : ''))
|
|
265
|
+
try { child = spawn(bin, args, { cwd, stdio: ['ignore', debug ? 'inherit' : 'ignore', 'inherit'] }) }
|
|
266
|
+
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' }) }
|
|
267
|
+
child.on('exit', (code) => { log('opencode cycle done (' + (code === 0 ? 'ok' : 'exit ' + code) + ')'); finish({ type: 'result', subtype: code === 0 ? 'ok' : 'error' }) })
|
|
268
|
+
child.on('error', (e) => { log('opencode error: ' + (e && e.message ? e.message : e)); finish({ type: 'result', subtype: 'error' }) })
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
log('opencode runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + cwd + ']' : ' [CHAT-ONLY — cfg ' + cwd + ']'))
|
|
273
|
+
return { runCycle, canCode }
|
|
181
274
|
}
|
|
182
275
|
|
|
183
276
|
// ── Claude Code warm-session cycle runner (shared by the REST + WS loops) ─────
|
|
184
277
|
// One persistent stream-json session, poked with a prompt per cycle. Recycled
|
|
185
278
|
// after MAX_TURNS or SESSION_IDLE_MS. Returns { runCycle, canCode }.
|
|
186
|
-
function createCycleRunner({ claude, mcpConfig, workdir, log, debug, model }) {
|
|
279
|
+
function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model }) {
|
|
187
280
|
const canCode = !!workdir
|
|
188
281
|
const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
|
|
282
|
+
// opencode drives cycles differently — a headless `opencode run` per cycle rather
|
|
283
|
+
// than a persistent stream-json session. Same { runCycle, canCode } contract.
|
|
284
|
+
if (agent === 'opencode') return createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model })
|
|
189
285
|
let child = null
|
|
190
286
|
// The model the CURRENT session was spawned with. runCycle can pass a different
|
|
191
287
|
// model per cycle (cheap for chat, stronger for code) — a change recycles the
|
|
@@ -280,9 +376,9 @@ function createCycleRunner({ claude, mcpConfig, workdir, log, debug, model }) {
|
|
|
280
376
|
// over the WS; each pushes ONE Claude cycle. Serialized (one cycle at a time) — events
|
|
281
377
|
// arriving while busy are coalesced into a single follow-up cycle so a burst doesn't
|
|
282
378
|
// 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 }) {
|
|
379
|
+
function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }) {
|
|
284
380
|
const log = (m) => process.stdout.write('[ws ' + new Date().toISOString() + '] ' + m + '\n')
|
|
285
|
-
const { runCycle, canCode } = createCycleRunner({ claude, mcpConfig, workdir, log, debug, model })
|
|
381
|
+
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
382
|
const fullPrompt = canCode ? CODE_FULL : CYCLE
|
|
287
383
|
const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
|
|
288
384
|
// Live model state — changeable at runtime by the in-chat `/model` command.
|
|
@@ -348,7 +444,9 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, mcpConfig, wor
|
|
|
348
444
|
const normalizeModel = (s) => {
|
|
349
445
|
const x = String(s || '').toLowerCase().replace(/[.,!?]+$/, '')
|
|
350
446
|
if (x === 'opus' || x === 'sonnet' || x === 'haiku') return x
|
|
351
|
-
|
|
447
|
+
if (/^claude-[a-z0-9.\-\[\]]+$/i.test(x)) return x
|
|
448
|
+
// opencode models are provider/model (e.g. anthropic/claude-sonnet-4, openai/gpt-4o).
|
|
449
|
+
return /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9.\-:]*$/i.test(x) ? x : null
|
|
352
450
|
}
|
|
353
451
|
// Optional target tier: "chat"/"lite" → chat cycles only, "code"/"full" → code
|
|
354
452
|
// cycles only, absent → both.
|
|
@@ -485,10 +583,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, mcpConfig, wor
|
|
|
485
583
|
}
|
|
486
584
|
|
|
487
585
|
// ── the warm loop ────────────────────────────────────────────────────────────
|
|
488
|
-
function loop({ host, key, slug, claude, mcpConfig, workdir, model, chatModel, debug }) {
|
|
586
|
+
function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }) {
|
|
489
587
|
const log = (m) => process.stdout.write('[warm ' + new Date().toISOString() + '] ' + m + '\n')
|
|
490
588
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
491
|
-
const { runCycle, canCode } = createCycleRunner({ claude, mcpConfig, workdir, log, debug, model })
|
|
589
|
+
const { runCycle, canCode } = createCycleRunner({ claude, agent, mcpUrl, mcpHeaders: { Authorization: 'Bearer ' + key }, cfgKey: slug, mcpConfig, workdir, log, debug, model })
|
|
492
590
|
const fullPrompt = canCode ? CODE_FULL : CYCLE
|
|
493
591
|
const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
|
|
494
592
|
const liteModel = chatModel || model // cheaper model for chat/quick cycles
|
|
@@ -593,7 +691,8 @@ export function installService({ slug, workdir }) {
|
|
|
593
691
|
const bin = onPath('openvisio-agent') || 'openvisio-agent'
|
|
594
692
|
const nodeDir = dirname(process.execPath)
|
|
595
693
|
const claudeBin = onPath('claude')
|
|
596
|
-
const
|
|
694
|
+
const opencodeBin = onPath('opencode')
|
|
695
|
+
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
696
|
const args = ['watch', '--name', slug, ...(workdir ? ['--workdir', workdir] : [])]
|
|
598
697
|
mkdirSync(OV_DIR, { recursive: true })
|
|
599
698
|
const logFile = join(OV_DIR, `${slug}.log`)
|