openvisio-agent 0.6.1 → 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.
- package/bin/cli.mjs +52 -19
- package/package.json +1 -1
- package/src/watch.mjs +94 -26
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
|
@@ -40,7 +40,12 @@ const CHAT_CHARTER = [
|
|
|
40
40
|
].join('\n')
|
|
41
41
|
|
|
42
42
|
const CYCLE = CHAT_CHARTER + '\n\nRun one OpenVisio autonomy cycle: call poll_inbox and handle mentions + follow-ups. Reply in 1-3 sentences, @mention people by their EXACT full name, at most one reply per channel. Then stop.'
|
|
43
|
-
const CYCLE_FAST = CHAT_CHARTER + '\n\
|
|
43
|
+
const CYCLE_FAST = CHAT_CHARTER + '\n\n' + [
|
|
44
|
+
'New chat activity. Do EXACTLY ONE of these:',
|
|
45
|
+
' • IF a specific mention/message FOR YOU is given above: reply to THAT ONE message exactly once with mcp__openvisio-team__post_message, then STOP. Do NOT call poll_inbox and do NOT answer anything else this cycle — you already have the message; polling would make you re-answer it and double-post.',
|
|
46
|
+
' • IF NO specific mention is given above: call poll_inbox and reply only to items truly directed at YOU (a question to you, or a reply to your own message) — SKIP chatter aimed at someone else / another agent, ignore .tasks/.claimable, at most one reply per channel.',
|
|
47
|
+
'Post ONE message total for the thing you are answering — compose it fully, then send once. Never send a reply and then a "better" version; never repeat a reply you already sent. Be sure of your answer before sending. If asked for work you have no tool for, say so plainly and offer to file a ticket. 1-3 sentences, no summary. Then stop.',
|
|
48
|
+
].join('\n')
|
|
44
49
|
|
|
45
50
|
// ── CODE agents (--workdir given): full file + Bash + git/gh surface. ─────────
|
|
46
51
|
// A stable "who you are / how you work" charter prepended to every code cycle.
|
|
@@ -75,10 +80,11 @@ const CODE_FULL = CODE_CHARTER + '\n\n' + [
|
|
|
75
80
|
].join('\n')
|
|
76
81
|
|
|
77
82
|
const CODE_FAST = CODE_CHARTER + '\n\n' + [
|
|
78
|
-
'New chat activity.
|
|
79
|
-
'
|
|
80
|
-
'
|
|
81
|
-
'
|
|
83
|
+
'New chat activity. Do EXACTLY ONE of these:',
|
|
84
|
+
' • IF a specific mention/message FOR YOU is given above: reply to THAT ONE message exactly once with mcp__openvisio-team__post_message, then STOP. Do NOT call poll_inbox and do NOT answer anything else this cycle — polling would re-surface the same message and make you double-post.',
|
|
85
|
+
' • IF NO specific mention is given above: call poll_inbox and reply only to items directed at YOU (asks you something, or responds to your own message) — SKIP chatter aimed at someone else / another agent; at most one reply per channel.',
|
|
86
|
+
'If the message asks YOU for real CODE work, do the WHOLE job now — branch (checkout -B agent/<slug>), edit, run tests, commit, push your branch, open a PR (gh pr create), then reply once with the PR link and @mention the requester by exact full name. Never push to main, never --force, never merge.',
|
|
87
|
+
'Post ONE message for the thing you are answering — send it once; never a reply then a "better" version; never repeat a reply you already sent; be sure before you send. Do NOT promise and stop — finish and report in THIS cycle. 1-3 sentences, no summary. Then stop.',
|
|
82
88
|
].join('\n')
|
|
83
89
|
|
|
84
90
|
// ── Workspace-ethics cycles (both chat-only + code agents) ───────────────────
|
|
@@ -130,6 +136,11 @@ export async function runWatch({ flags }) {
|
|
|
130
136
|
const saved = slug ? readConfig(slug) : null
|
|
131
137
|
const claude = String(flags.claude || onPath('claude') || 'claude')
|
|
132
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) || '')
|
|
133
144
|
// Code mode is the DEFAULT: an agent lives on the user's laptop and should just be
|
|
134
145
|
// able to work across the org's repos with no per-repo setup. So `workdir` resolves
|
|
135
146
|
// to (in order) an explicit --workdir/--workspace, the saved workspace, or the
|
|
@@ -143,12 +154,11 @@ export async function runWatch({ flags }) {
|
|
|
143
154
|
const workdir = chatOnly ? '' : (explicitWorkdir || (saved && (saved.workspace || saved.workdir)) || DEFAULT_WORKSPACE)
|
|
144
155
|
if (workdir) { try { mkdirSync(workdir, { recursive: true }) } catch { /* best-effort; spawn will surface a real problem */ } }
|
|
145
156
|
|
|
146
|
-
// Model the agent runs on.
|
|
147
|
-
// `claude` defaults to
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
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'))
|
|
152
162
|
const chatModel = String(flags['chat-model'] || (saved && saved.chatModel) || '')
|
|
153
163
|
|
|
154
164
|
// Backend agents (connect --backend) drive autonomy over a real-time WS instead
|
|
@@ -162,7 +172,7 @@ export async function runWatch({ flags }) {
|
|
|
162
172
|
if (!apiKey || !identifier) fail('No saved backend credentials for that agent.\n Run `openvisio-agent connect --backend …` first, or pass --key and --id.')
|
|
163
173
|
assertWebSocket(fail)
|
|
164
174
|
if (flags.install) return installService({ slug: slug || 'openvisio', claude, mcpConfig, workdir })
|
|
165
|
-
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 })
|
|
166
176
|
}
|
|
167
177
|
|
|
168
178
|
const host = stripSlash(flags.host || (saved && saved.host) || '')
|
|
@@ -171,15 +181,70 @@ export async function runWatch({ flags }) {
|
|
|
171
181
|
|
|
172
182
|
if (flags.install) return installService({ slug: slug || 'openvisio', claude, mcpConfig, workdir })
|
|
173
183
|
|
|
174
|
-
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 }
|
|
175
237
|
}
|
|
176
238
|
|
|
177
239
|
// ── Claude Code warm-session cycle runner (shared by the REST + WS loops) ─────
|
|
178
240
|
// One persistent stream-json session, poked with a prompt per cycle. Recycled
|
|
179
241
|
// after MAX_TURNS or SESSION_IDLE_MS. Returns { runCycle, canCode }.
|
|
180
|
-
function createCycleRunner({ claude, mcpConfig, workdir, log, debug, model }) {
|
|
242
|
+
function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model }) {
|
|
181
243
|
const canCode = !!workdir
|
|
182
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 })
|
|
183
248
|
let child = null
|
|
184
249
|
// The model the CURRENT session was spawned with. runCycle can pass a different
|
|
185
250
|
// model per cycle (cheap for chat, stronger for code) — a change recycles the
|
|
@@ -274,9 +339,9 @@ function createCycleRunner({ claude, mcpConfig, workdir, log, debug, model }) {
|
|
|
274
339
|
// over the WS; each pushes ONE Claude cycle. Serialized (one cycle at a time) — events
|
|
275
340
|
// arriving while busy are coalesced into a single follow-up cycle so a burst doesn't
|
|
276
341
|
// stack up N sessions. Plus a one-time intro on first connect and a daily catch-up sweep.
|
|
277
|
-
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 }) {
|
|
278
343
|
const log = (m) => process.stdout.write('[ws ' + new Date().toISOString() + '] ' + m + '\n')
|
|
279
|
-
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 })
|
|
280
345
|
const fullPrompt = canCode ? CODE_FULL : CYCLE
|
|
281
346
|
const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
|
|
282
347
|
// Live model state — changeable at runtime by the in-chat `/model` command.
|
|
@@ -342,7 +407,9 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, mcpConfig, wor
|
|
|
342
407
|
const normalizeModel = (s) => {
|
|
343
408
|
const x = String(s || '').toLowerCase().replace(/[.,!?]+$/, '')
|
|
344
409
|
if (x === 'opus' || x === 'sonnet' || x === 'haiku') return x
|
|
345
|
-
|
|
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
|
|
346
413
|
}
|
|
347
414
|
// Optional target tier: "chat"/"lite" → chat cycles only, "code"/"full" → code
|
|
348
415
|
// cycles only, absent → both.
|
|
@@ -396,11 +463,11 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, mcpConfig, wor
|
|
|
396
463
|
const parent = msg.parent_id != null ? msg.parent_id : (msg.parentId != null ? msg.parentId : null)
|
|
397
464
|
const threadRoot = parent != null ? parent : mid
|
|
398
465
|
// De-dupe: the same mention re-delivered (reconnect replay / dup fan-out) must
|
|
399
|
-
// NOT trigger a second reply
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
466
|
+
// NOT trigger a second reply. Key by message id, or a channel+text signature
|
|
467
|
+
// when the payload carries no id.
|
|
468
|
+
const dedupeKey = mid != null ? 'id:' + mid : 'sig:' + (cid != null ? cid : '?') + '|' + text.slice(0, 100)
|
|
469
|
+
if (seenMentions.has(dedupeKey)) { log('agent:mention (dup) — skipped'); return }
|
|
470
|
+
seenMentions.add(dedupeKey); if (seenMentions.size > 500) seenMentions.clear()
|
|
404
471
|
// Under-the-hood model control from chat (view / switch the model the agent runs).
|
|
405
472
|
const mcmd = cid != null ? parseModelCmd(text) : null
|
|
406
473
|
if (mcmd) {
|
|
@@ -425,7 +492,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, mcpConfig, wor
|
|
|
425
492
|
}
|
|
426
493
|
log('agent:mention in channel ' + (cid != null ? cid : '?') + (threadRoot != null ? ' (thread ' + threadRoot + ')' : ''))
|
|
427
494
|
const ctx = cid != null
|
|
428
|
-
? `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU
|
|
495
|
+
? `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. Send EXACTLY ONE reply with mcp__openvisio-team__post_message — arguments: channel_id ${cid}${threadRoot != null ? `, parent_id ${threadRoot} (reply IN THAT THREAD, do not start a new top-level message)` : ''}, plus agent_identifier + agent_api_key from the AUTH line above, and a 1-3 sentence reply. Compose the whole answer, then post it ONCE — do NOT post a first reply and then a revised/"better" one. FIRST read the recent messages in this thread: if you already answered this, or another agent was the one addressed, do NOT post at all. Be sure of your answer before sending.${who ? ` To @mention them back, write their EXACT full name "@${who}" (a mention only links when the name matches exactly — "@${who.split(' ')[0]}" alone will NOT).` : ''} You ALREADY have the message here — do NOT poll_inbox, and after your single reply, STOP.`
|
|
429
496
|
: undefined
|
|
430
497
|
void drain('fast', ctx)
|
|
431
498
|
} else if (k === 'error') {
|
|
@@ -479,10 +546,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, mcpConfig, wor
|
|
|
479
546
|
}
|
|
480
547
|
|
|
481
548
|
// ── the warm loop ────────────────────────────────────────────────────────────
|
|
482
|
-
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 }) {
|
|
483
550
|
const log = (m) => process.stdout.write('[warm ' + new Date().toISOString() + '] ' + m + '\n')
|
|
484
551
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
485
|
-
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 })
|
|
486
553
|
const fullPrompt = canCode ? CODE_FULL : CYCLE
|
|
487
554
|
const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
|
|
488
555
|
const liteModel = chatModel || model // cheaper model for chat/quick cycles
|
|
@@ -587,7 +654,8 @@ export function installService({ slug, workdir }) {
|
|
|
587
654
|
const bin = onPath('openvisio-agent') || 'openvisio-agent'
|
|
588
655
|
const nodeDir = dirname(process.execPath)
|
|
589
656
|
const claudeBin = onPath('claude')
|
|
590
|
-
const
|
|
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(':')
|
|
591
659
|
const args = ['watch', '--name', slug, ...(workdir ? ['--workdir', workdir] : [])]
|
|
592
660
|
mkdirSync(OV_DIR, { recursive: true })
|
|
593
661
|
const logFile = join(OV_DIR, `${slug}.log`)
|