openvisio-agent 0.3.2 → 0.3.4
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 +1 -1
- package/package.json +1 -1
- package/src/watch.mjs +76 -18
package/bin/cli.mjs
CHANGED
|
@@ -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
|
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,34 +146,61 @@ 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
|
|
|
124
155
|
let busy = false
|
|
125
156
|
let queued = null // 'full' | 'fast' — a cycle requested while one was running
|
|
157
|
+
// Context lines from the events themselves (the WS payload already carries the
|
|
158
|
+
// channel + message / task), so the agent acts on THEM directly instead of
|
|
159
|
+
// hoping poll_inbox re-surfaces the same item. Accumulated across coalesced
|
|
160
|
+
// events and drained into the next cycle's prompt.
|
|
161
|
+
const pending = []
|
|
126
162
|
|
|
127
|
-
async function drain(kind) {
|
|
128
|
-
if (
|
|
163
|
+
async function drain(kind, context) {
|
|
164
|
+
if (context) pending.push(context)
|
|
165
|
+
if (busy) { queued = (queued === 'full' || kind === 'full') ? 'full' : 'fast'; log('busy — queued a ' + kind + ' follow-up cycle'); return }
|
|
129
166
|
busy = true
|
|
167
|
+
const ctx = pending.splice(0) // take everything accumulated so far
|
|
168
|
+
const base = kind === 'full' ? fullPrompt : fastPrompt
|
|
169
|
+
const prompt = ctx.length ? ctx.join('\n') + '\n\n' + base : base
|
|
170
|
+
log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : ''))
|
|
130
171
|
try {
|
|
131
|
-
await runCycle(
|
|
172
|
+
await runCycle(prompt)
|
|
132
173
|
} finally {
|
|
133
174
|
busy = false
|
|
134
|
-
if (queued) { const next = queued; queued = null; void drain(next) }
|
|
175
|
+
if (queued || pending.length) { const next = queued || 'fast'; queued = null; void drain(next) }
|
|
135
176
|
}
|
|
136
177
|
}
|
|
137
178
|
|
|
179
|
+
// Distill an event's WS payload into a directive the model can act on without
|
|
180
|
+
// any extra lookup.
|
|
181
|
+
const senderName = (m) => {
|
|
182
|
+
const s = m && (m.sender || m.user || m.author)
|
|
183
|
+
if (!s) return ''
|
|
184
|
+
return (`${s.first_name || ''} ${s.last_name || ''}`.trim() || s.name || s.email || '')
|
|
185
|
+
}
|
|
186
|
+
|
|
138
187
|
function onEvent(k, d) {
|
|
188
|
+
const raw = d && typeof d === 'object' ? d : {}
|
|
139
189
|
if (k === 'task:assigned') {
|
|
140
|
-
const t =
|
|
141
|
-
log('task:assigned ' + (t ? '#' + t.id + ' “' + (t.title || '') + '”' : ''))
|
|
142
|
-
|
|
190
|
+
const t = raw.task || {}
|
|
191
|
+
log('task:assigned ' + (t.id != null ? '#' + t.id + ' “' + (t.title || '') + '”' : ''))
|
|
192
|
+
const desc = t.description ? ' — ' + String(t.description).replace(/\s+/g, ' ').slice(0, 400) : ''
|
|
193
|
+
void drain('full', t.id != null ? `You were ASSIGNED task #${t.id}: "${t.title || ''}"${desc}. Handle it, then comment_ticket with a short summary.` : undefined)
|
|
143
194
|
} else if (k === 'agent:mention') {
|
|
144
|
-
|
|
145
|
-
|
|
195
|
+
const cid = raw.channel_id != null ? raw.channel_id : (raw.channelId != null ? raw.channelId : null)
|
|
196
|
+
const msg = raw.message && typeof raw.message === 'object' ? raw.message : {}
|
|
197
|
+
const text = String(msg.content || msg.body || msg.text || '').replace(/\s+/g, ' ').slice(0, 600)
|
|
198
|
+
const who = senderName(msg)
|
|
199
|
+
log('agent:mention in channel ' + (cid != null ? cid : '?'))
|
|
200
|
+
const ctx = cid != null
|
|
201
|
+
? `You were @mentioned in OpenVisio channel ${cid}${who ? ` by ${who}` : ''}: "${text}". Reply DIRECTLY in that channel now with post_message (channel_id ${cid}), 1-3 sentences addressing it. Do NOT depend on poll_inbox to find it — you already have it here. You MAY still call poll_inbox to catch OTHER pending items, but you MUST answer this one.`
|
|
202
|
+
: undefined
|
|
203
|
+
void drain('fast', ctx)
|
|
146
204
|
} else {
|
|
147
205
|
log('event ' + k)
|
|
148
206
|
}
|
|
@@ -161,10 +219,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir }
|
|
|
161
219
|
}
|
|
162
220
|
|
|
163
221
|
// ── the warm loop ────────────────────────────────────────────────────────────
|
|
164
|
-
function loop({ host, key, claude, mcpConfig, workdir }) {
|
|
222
|
+
function loop({ host, key, claude, mcpConfig, workdir, debug }) {
|
|
165
223
|
const log = (m) => process.stdout.write('[warm ' + new Date().toISOString() + '] ' + m + '\n')
|
|
166
224
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
167
|
-
const { runCycle, canCode } = createCycleRunner({ claude, mcpConfig, workdir, log })
|
|
225
|
+
const { runCycle, canCode } = createCycleRunner({ claude, mcpConfig, workdir, log, debug })
|
|
168
226
|
const fullPrompt = canCode ? CODE_FULL : CYCLE
|
|
169
227
|
const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
|
|
170
228
|
|