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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
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
@@ -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
- const settleTurn = (o) => { const r = resolveTurn; resolveTurn = null; if (r) r(o) }
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 (o.type === 'result') { log('cycle done (' + (o.subtype || 'ok') + ')'); settleTurn(o) }
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 (busy) { queued = (queued === 'full' || kind === 'full') ? 'full' : 'fast'; return }
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(kind === 'full' ? fullPrompt : fastPrompt)
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 = d && d.task
141
- log('task:assigned ' + (t ? '#' + t.id + ' “' + (t.title || '') + '”' : ''))
142
- void drain('full')
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
- log('agent:mention in channel ' + (d && d.channel_id != null ? d.channel_id : '?'))
145
- void drain('fast')
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