openvisio-agent 0.3.1 → 0.3.3
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 +42 -15
- package/package.json +1 -1
- package/src/watch.mjs +43 -11
package/bin/cli.mjs
CHANGED
|
@@ -13,8 +13,8 @@ import { spawnSync } from 'node:child_process'
|
|
|
13
13
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
14
14
|
import { fileURLToPath } from 'node:url'
|
|
15
15
|
import { dirname, join } from 'node:path'
|
|
16
|
-
import { parseFlags, slugify, stripSlash, exchangeToken, ensureClaude, writeJson, mcpConfigPath, configPath, chmodSafe, OV_DIR, fail, ok, info } from '../src/lib.mjs'
|
|
17
|
-
import { runWatch } from '../src/watch.mjs'
|
|
16
|
+
import { parseFlags, slugify, stripSlash, exchangeToken, ensureClaude, writeJson, mcpConfigPath, configPath, chmodSafe, onPath, OV_DIR, fail, ok, info } from '../src/lib.mjs'
|
|
17
|
+
import { runWatch, installService } from '../src/watch.mjs'
|
|
18
18
|
|
|
19
19
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
20
20
|
const VERSION = (() => { try { return JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8')).version } catch { return '0.0.0' } })()
|
|
@@ -26,7 +26,7 @@ Connect your coding agent to an OpenVisio team.
|
|
|
26
26
|
Usage:
|
|
27
27
|
openvisio-agent connect <ovs_code> --host <url> [--name "<agent>"] [--mcp-url <url>]
|
|
28
28
|
openvisio-agent connect --backend <url> --key <api-key> --id <identifier> [--name "<agent>"] [--ws <wss-url>] [--mcp-url <url>]
|
|
29
|
-
openvisio-agent watch --name <agent> [--install] [--workdir <repo>]
|
|
29
|
+
openvisio-agent watch --name <agent> [--install] [--workdir <repo>] [--debug]
|
|
30
30
|
openvisio-agent --help | --version
|
|
31
31
|
|
|
32
32
|
connect
|
|
@@ -35,14 +35,19 @@ connect
|
|
|
35
35
|
|
|
36
36
|
connect --backend
|
|
37
37
|
Registers a BACKEND agent (created in OpenVisio → Agents → Connect your agent):
|
|
38
|
-
verifies the api-key + identifier against the org backend,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
verifies the api-key + identifier against the org backend, saves the credentials
|
|
39
|
+
(~/.openvisio/<agent>.json + a sourceable <agent>.env), and — when --ws is given —
|
|
40
|
+
installs openvisio-agent globally and sets up an ALWAYS-ON background listener
|
|
41
|
+
(launchd/systemd) that auto-starts on login, so @mentions/assignments are caught
|
|
42
|
+
even with no terminal open. Requests authenticate with the x-agent-api-key +
|
|
43
|
+
x-agent-identifier headers.
|
|
42
44
|
--ws <wss-url> API-Gateway WebSocket base (same as NEXT_PUBLIC_BACKEND_WS_URL)
|
|
43
45
|
— enables real-time autonomy (task:assigned / agent:mention).
|
|
44
46
|
--mcp-url <url> registers the openvisio-team MCP so the agent has tools to ACT
|
|
45
47
|
on those events. Needs Node >= 21 for the WebSocket.
|
|
48
|
+
--workdir <repo> the always-on listener may do real coding on a branch there.
|
|
49
|
+
--no-service skip the background service — just save config + print the
|
|
50
|
+
watch commands to run yourself.
|
|
46
51
|
|
|
47
52
|
watch
|
|
48
53
|
Runs the event-driven autonomy loop (reply to mentions, pick up tickets). Add
|
|
@@ -64,6 +69,15 @@ function mcpReplace(claude, addArgs) {
|
|
|
64
69
|
spawnSync(claude, ['mcp', 'add', '--scope', 'user', ...addArgs], { stdio: 'ignore' })
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
// The persistent `openvisio-agent` command must exist for `watch` (and the
|
|
73
|
+
// background service) to work — an `npx` connect leaves nothing installed. Make
|
|
74
|
+
// the global install part of setup.
|
|
75
|
+
function ensureAgentInstalled() {
|
|
76
|
+
if (onPath('openvisio-agent')) return
|
|
77
|
+
info('Installing openvisio-agent globally (so `watch` and the always-on service have a stable command)…')
|
|
78
|
+
spawnSync('npm', ['i', '-g', 'openvisio-agent@latest'], { stdio: 'inherit', shell: process.platform === 'win32' })
|
|
79
|
+
}
|
|
80
|
+
|
|
67
81
|
async function runConnect({ positional, flags }) {
|
|
68
82
|
if (flags.backend) return runConnectBackend({ positional, flags })
|
|
69
83
|
const token = positional[0] || flags.token
|
|
@@ -164,18 +178,31 @@ async function runConnectBackend({ flags }) {
|
|
|
164
178
|
info()
|
|
165
179
|
info('The backend dispatches board tasks assigned to this agent. Every request it')
|
|
166
180
|
info('makes authenticates with the x-agent-api-key + x-agent-identifier headers.')
|
|
167
|
-
if (wsUrl) {
|
|
181
|
+
if (!wsUrl) {
|
|
182
|
+
info()
|
|
183
|
+
info('Tip: pass --ws <wss-url> and --mcp-url <url> to enable real-time autonomy —')
|
|
184
|
+
info(' the agent then reacts to task:assigned / agent:mention, always-on.')
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Real-time autonomy IS the point of a backend agent: install the command and a
|
|
189
|
+
// background service that auto-starts on login, so a mention is always caught —
|
|
190
|
+
// no terminal left open, survives reboot. Opt out with --no-service.
|
|
191
|
+
const workdir = flags.workdir === true ? process.cwd() : (flags.workdir ? String(flags.workdir) : '')
|
|
192
|
+
if (flags['no-service'] || process.platform === 'win32') {
|
|
193
|
+
ensureAgentInstalled()
|
|
168
194
|
info()
|
|
169
|
-
info('
|
|
170
|
-
info(
|
|
171
|
-
info(` openvisio-agent watch --name ${slug}
|
|
172
|
-
info(` openvisio-agent watch --name ${slug} --
|
|
173
|
-
if (!mcpUrl) info(' (add --mcp-url on connect to give the agent tools to ACT on those events.)')
|
|
195
|
+
if (process.platform === 'win32') info('Background auto-start isn\'t supported on Windows yet — run the listener yourself:')
|
|
196
|
+
else info('Real-time autonomy is ready. Start the always-on listener:')
|
|
197
|
+
info(` openvisio-agent watch --name ${slug} # run now, in this terminal`)
|
|
198
|
+
info(` openvisio-agent watch --name ${slug} --install # background, auto-start on login`)
|
|
174
199
|
} else {
|
|
175
200
|
info()
|
|
176
|
-
info('
|
|
177
|
-
|
|
201
|
+
info('Setting up the always-on autonomy listener (auto-starts on login, survives reboot)…')
|
|
202
|
+
installService({ slug, workdir })
|
|
203
|
+
ok('Your agent is now live — it will catch @mentions and assignments even with no terminal open.')
|
|
178
204
|
}
|
|
205
|
+
if (!mcpUrl) info('Note: no --mcp-url was given, so the agent hears events but has no tools to reply. Reconnect with --mcp-url to fix.')
|
|
179
206
|
}
|
|
180
207
|
|
|
181
208
|
async function main() {
|
package/package.json
CHANGED
package/src/watch.mjs
CHANGED
|
@@ -24,6 +24,10 @@ const SLOW = 6000
|
|
|
24
24
|
const IDLE_AFTER = 60000
|
|
25
25
|
const MAX_TURNS = 15
|
|
26
26
|
const SESSION_IDLE_MS = 1200000
|
|
27
|
+
// A single cycle must finish within this or it's abandoned — otherwise a hung
|
|
28
|
+
// cycle (e.g. an MCP tool stalling on a down bridge) would leave `busy` stuck
|
|
29
|
+
// true forever and silently queue every later mention.
|
|
30
|
+
const MAX_CYCLE_MS = 240000
|
|
27
31
|
|
|
28
32
|
export async function runWatch({ flags }) {
|
|
29
33
|
const slug = flags.name ? slugify(String(flags.name)) : null
|
|
@@ -43,7 +47,7 @@ export async function runWatch({ flags }) {
|
|
|
43
47
|
if (!apiKey || !identifier) fail('No saved backend credentials for that agent.\n Run `openvisio-agent connect --backend …` first, or pass --key and --id.')
|
|
44
48
|
assertWebSocket(fail)
|
|
45
49
|
if (flags.install) return installService({ slug: slug || 'openvisio', claude, mcpConfig, workdir })
|
|
46
|
-
return loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir })
|
|
50
|
+
return loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir, debug: !!flags.debug })
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
const host = stripSlash(flags.host || (saved && saved.host) || '')
|
|
@@ -52,19 +56,36 @@ export async function runWatch({ flags }) {
|
|
|
52
56
|
|
|
53
57
|
if (flags.install) return installService({ slug: slug || 'openvisio', claude, mcpConfig, workdir })
|
|
54
58
|
|
|
55
|
-
return loop({ host, key, claude, mcpConfig, workdir })
|
|
59
|
+
return loop({ host, key, claude, mcpConfig, workdir, debug: !!flags.debug })
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
// ── Claude Code warm-session cycle runner (shared by the REST + WS loops) ─────
|
|
59
63
|
// One persistent stream-json session, poked with a prompt per cycle. Recycled
|
|
60
64
|
// after MAX_TURNS or SESSION_IDLE_MS. Returns { runCycle, canCode }.
|
|
61
|
-
function createCycleRunner({ claude, mcpConfig, workdir, log }) {
|
|
65
|
+
function createCycleRunner({ claude, mcpConfig, workdir, log, debug }) {
|
|
62
66
|
const canCode = !!workdir
|
|
63
67
|
let child = null
|
|
64
68
|
let turnsThisSession = 0
|
|
65
69
|
let sessionStartedAt = 0
|
|
66
70
|
let resolveTurn = null
|
|
67
|
-
|
|
71
|
+
let cycleTimer = null
|
|
72
|
+
const clearCycleTimer = () => { if (cycleTimer) { clearTimeout(cycleTimer); cycleTimer = null } }
|
|
73
|
+
const settleTurn = (o) => { clearCycleTimer(); const r = resolveTurn; resolveTurn = null; if (r) r(o) }
|
|
74
|
+
|
|
75
|
+
// --debug: surface what the cycle actually does (tool calls, tool errors, text)
|
|
76
|
+
// so a silent/hung cycle is diagnosable.
|
|
77
|
+
function logEvent(o) {
|
|
78
|
+
if (o.type === 'assistant' && o.message && Array.isArray(o.message.content)) {
|
|
79
|
+
for (const b of o.message.content) {
|
|
80
|
+
if (b.type === 'tool_use') log(' → tool ' + b.name)
|
|
81
|
+
else if (b.type === 'text' && b.text && b.text.trim()) log(' · ' + b.text.trim().replace(/\s+/g, ' ').slice(0, 140))
|
|
82
|
+
}
|
|
83
|
+
} else if (o.type === 'user' && o.message && Array.isArray(o.message.content)) {
|
|
84
|
+
for (const b of o.message.content) if (b.type === 'tool_result' && b.is_error) log(' ✗ tool error: ' + JSON.stringify(b.content).slice(0, 180))
|
|
85
|
+
} else if (o.type === 'system' && o.subtype === 'init') {
|
|
86
|
+
log(' session init — mcp servers: ' + JSON.stringify(o.mcp_servers || []).slice(0, 200))
|
|
87
|
+
}
|
|
88
|
+
}
|
|
68
89
|
|
|
69
90
|
function ensureSession() {
|
|
70
91
|
if (child && !child.killed) return
|
|
@@ -84,7 +105,8 @@ function createCycleRunner({ claude, mcpConfig, workdir, log }) {
|
|
|
84
105
|
const line = localBuf.slice(0, i); localBuf = localBuf.slice(i + 1)
|
|
85
106
|
if (!line.trim()) continue
|
|
86
107
|
let o; try { o = JSON.parse(line) } catch { continue }
|
|
87
|
-
if (
|
|
108
|
+
if (debug) logEvent(o)
|
|
109
|
+
if (o.type === 'result') { log('cycle done (' + (o.subtype || 'ok') + (o.is_error ? ' · ERROR' : '') + ')'); settleTurn(o) }
|
|
88
110
|
}
|
|
89
111
|
})
|
|
90
112
|
c.on('exit', (code) => { if (c !== child) { log('old session exited ' + code); return } log('session exited ' + code); child = null; settleTurn({ type: 'result', subtype: 'exit' }) })
|
|
@@ -102,6 +124,15 @@ function createCycleRunner({ claude, mcpConfig, workdir, log }) {
|
|
|
102
124
|
ensureSession()
|
|
103
125
|
turnsThisSession++
|
|
104
126
|
resolveTurn = resolve
|
|
127
|
+
// Backstop: abandon a cycle that never returns a result so `busy` is released
|
|
128
|
+
// and queued mentions can proceed.
|
|
129
|
+
clearCycleTimer()
|
|
130
|
+
cycleTimer = setTimeout(() => {
|
|
131
|
+
log('cycle TIMED OUT after ' + Math.round(MAX_CYCLE_MS / 1000) + 's — killing the session so the queue can proceed')
|
|
132
|
+
try { child && child.kill() } catch { /* already gone */ }
|
|
133
|
+
child = null
|
|
134
|
+
settleTurn({ type: 'result', subtype: 'timeout' })
|
|
135
|
+
}, MAX_CYCLE_MS)
|
|
105
136
|
try { child.stdin.write(JSON.stringify({ type: 'user', message: { role: 'user', content: prompt } }) + '\n') }
|
|
106
137
|
catch { settleTurn({ type: 'result', subtype: 'write-failed' }) }
|
|
107
138
|
})
|
|
@@ -115,9 +146,9 @@ function createCycleRunner({ claude, mcpConfig, workdir, log }) {
|
|
|
115
146
|
// pushes ONE Claude cycle. Serialized (one cycle at a time) — events arriving
|
|
116
147
|
// while busy are coalesced into a single follow-up cycle so a burst of mentions
|
|
117
148
|
// doesn't stack up N sessions.
|
|
118
|
-
function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir }) {
|
|
149
|
+
function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir, debug }) {
|
|
119
150
|
const log = (m) => process.stdout.write('[ws ' + new Date().toISOString() + '] ' + m + '\n')
|
|
120
|
-
const { runCycle, canCode } = createCycleRunner({ claude, mcpConfig, workdir, log })
|
|
151
|
+
const { runCycle, canCode } = createCycleRunner({ claude, mcpConfig, workdir, log, debug })
|
|
121
152
|
const fullPrompt = canCode ? CODE_FULL : CYCLE
|
|
122
153
|
const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
|
|
123
154
|
|
|
@@ -125,8 +156,9 @@ function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir }
|
|
|
125
156
|
let queued = null // 'full' | 'fast' — a cycle requested while one was running
|
|
126
157
|
|
|
127
158
|
async function drain(kind) {
|
|
128
|
-
if (busy) { queued = (queued === 'full' || kind === 'full') ? 'full' : 'fast'; return }
|
|
159
|
+
if (busy) { queued = (queued === 'full' || kind === 'full') ? 'full' : 'fast'; log('busy — queued a ' + kind + ' follow-up cycle'); return }
|
|
129
160
|
busy = true
|
|
161
|
+
log('running ' + kind + ' cycle…')
|
|
130
162
|
try {
|
|
131
163
|
await runCycle(kind === 'full' ? fullPrompt : fastPrompt)
|
|
132
164
|
} finally {
|
|
@@ -161,10 +193,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir }
|
|
|
161
193
|
}
|
|
162
194
|
|
|
163
195
|
// ── the warm loop ────────────────────────────────────────────────────────────
|
|
164
|
-
function loop({ host, key, claude, mcpConfig, workdir }) {
|
|
196
|
+
function loop({ host, key, claude, mcpConfig, workdir, debug }) {
|
|
165
197
|
const log = (m) => process.stdout.write('[warm ' + new Date().toISOString() + '] ' + m + '\n')
|
|
166
198
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
167
|
-
const { runCycle, canCode } = createCycleRunner({ claude, mcpConfig, workdir, log })
|
|
199
|
+
const { runCycle, canCode } = createCycleRunner({ claude, mcpConfig, workdir, log, debug })
|
|
168
200
|
const fullPrompt = canCode ? CODE_FULL : CYCLE
|
|
169
201
|
const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
|
|
170
202
|
|
|
@@ -234,7 +266,7 @@ function loop({ host, key, claude, mcpConfig, workdir }) {
|
|
|
234
266
|
}
|
|
235
267
|
|
|
236
268
|
// ── background service install (launchd / systemd) ───────────────────────────
|
|
237
|
-
function installService({ slug, workdir }) {
|
|
269
|
+
export function installService({ slug, workdir }) {
|
|
238
270
|
const binPath = onPath('openvisio-agent')
|
|
239
271
|
if (!binPath) {
|
|
240
272
|
info('Installing openvisio-agent globally so the background service has a stable path…')
|