openvisio-agent 0.15.1 → 0.16.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/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team — MCP tools + optional autonomy — in one command.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "openvisio-agent": "bin/cli.mjs"
8
8
  },
9
+ "scripts": {
10
+ "test": "node --test"
11
+ },
9
12
  "files": [
10
13
  "bin",
11
14
  "src",
package/src/events.mjs ADDED
@@ -0,0 +1,27 @@
1
+ // Backend deployments have emitted task bodies in a few compatible envelope
2
+ // shapes over time. Keep that transport detail out of the watcher so an
3
+ // assignment cannot be silently dropped during a rolling backend/client update.
4
+ export function taskFromEvent(payload) {
5
+ if (!payload || typeof payload !== 'object') return null
6
+
7
+ const candidates = [
8
+ payload.task,
9
+ payload.data && payload.data.task,
10
+ payload.payload && payload.payload.task,
11
+ payload.data,
12
+ payload.payload,
13
+ payload,
14
+ ]
15
+
16
+ for (const candidate of candidates) {
17
+ if (!candidate || typeof candidate !== 'object') continue
18
+ const hasTaskIdentity = candidate.id != null || candidate.task_id != null || candidate.taskId != null
19
+ if (hasTaskIdentity) return candidate
20
+ }
21
+ return null
22
+ }
23
+
24
+ export function taskAgentId(task) {
25
+ if (!task || typeof task !== 'object') return null
26
+ return task.agent_id != null ? task.agent_id : (task.agentId != null ? task.agentId : null)
27
+ }
package/src/watch.mjs CHANGED
@@ -10,6 +10,7 @@ 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'
12
12
  import { connectAgentWs, assertWebSocket } from './ws.mjs'
13
+ import { taskAgentId, taskFromEvent } from './events.mjs'
13
14
 
14
15
  // Behaviour prompts. The openvisio-team MCP bridge requires the agent's
15
16
  // credentials as ARGUMENTS on every tool call — those are injected at runtime by
@@ -528,10 +529,16 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
528
529
  function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }) {
529
530
  const log = (m) => process.stdout.write('[ws ' + new Date().toISOString() + '] ' + m + '\n')
530
531
  let handle = null
532
+ let statusEnabled = true
533
+ let lastStatusSentAt = 0
531
534
  // Channels the agent is actively working in this cycle — drives the live
532
535
  // agent:status broadcast (thinking → working → typing → done).
533
536
  const statusTargets = new Set()
534
- const emitStatus = (state) => { for (const c of statusTargets) { try { handle && handle.sendStatus(c, state) } catch { /* best-effort */ } } }
537
+ const sendStatus = (channelId, state) => {
538
+ if (!statusEnabled) return
539
+ try { if (handle) { lastStatusSentAt = Date.now(); handle.sendStatus(channelId, state) } } catch { /* best-effort */ }
540
+ }
541
+ const emitStatus = (state) => { for (const c of statusTargets) sendStatus(c, state) }
535
542
  const canCode = !!workdir
536
543
  // The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
537
544
  // agent_identifier + agent_api_key as arguments. Hand them over up front.
@@ -539,12 +546,18 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
539
546
  // The STATIC charter + creds are the session system prompt (cached, billed once),
540
547
  // NOT re-sent in every cycle's user message — the big token saving.
541
548
  const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
542
- const { runCycle } = createCycleRunner({
549
+ const runnerOptions = {
543
550
  claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
544
551
  cfgKey: identifier, mcpConfig, workdir, log, debug, model, systemPrompt,
545
552
  // The moment the agent calls post_message it is about to speak → "typing".
546
553
  onTool: (name) => { if (/post_message/.test(name)) emitStatus('typing') },
547
- })
554
+ }
555
+ // One watcher and one WS subscription, but two independent model lanes. This
556
+ // avoids duplicate event delivery while mentions can be answered during code.
557
+ const runners = {
558
+ work: createCycleRunner(runnerOptions),
559
+ reply: createCycleRunner({ ...runnerOptions, cfgKey: identifier + '-reply' }),
560
+ }
548
561
  const fullPrompt = canCode ? CODE_FULL : CYCLE
549
562
  const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
550
563
  // Live model state — changeable at runtime by the in-chat `/model` command.
@@ -553,8 +566,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
553
566
  let codeModel = model
554
567
  let liteModel = chatModel || model
555
568
 
556
- let busy = false
557
- let queued = null // 'full' | 'coord' | 'fast' | 'sweep' | 'intro' — a cycle requested while one was running
569
+ const lanes = {
570
+ work: { busy: false, queued: null, pending: [] },
571
+ reply: { busy: false, queued: null, pending: [] },
572
+ }
558
573
  // Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
559
574
  // different agent re-triggers), so a noisy stream of task:updated events doesn't
560
575
  // re-acknowledge the same assignment.
@@ -567,7 +582,6 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
567
582
  // channel + message / task), so the agent acts on THEM directly instead of
568
583
  // hoping poll_inbox re-surfaces the same item. Accumulated across coalesced
569
584
  // events and drained into the next cycle's prompt.
570
- const pending = []
571
585
  let backlogProbeBusy = false
572
586
  let lastTaskSignature = ''
573
587
  let lastTaskTriggeredAt = 0
@@ -596,7 +610,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
596
610
  }
597
611
  const ensureMcpSession = async () => {
598
612
  if (mcpSessionId) return
599
- const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.15.1' } } }, false)
613
+ const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.16.0' } } }, false)
600
614
  if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
601
615
  await mcpPayload(res)
602
616
  mcpSessionId = res.headers.get('mcp-session-id') || ''
@@ -629,7 +643,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
629
643
  // Reconcile everything that may have arrived while disconnected. This spends no
630
644
  // model tokens unless the tools actually report pending work.
631
645
  const reconcileBacklog = async () => {
632
- if (!mcpUrl || backlogProbeBusy || busy) return
646
+ if (!mcpUrl || backlogProbeBusy) return
633
647
  backlogProbeBusy = true
634
648
  try {
635
649
  const agentsData = toolData(await callMcpTool('list_agents'))
@@ -690,10 +704,12 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
690
704
  const baseFor = (kind) => kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? COORDINATE : fastPrompt
691
705
 
692
706
  async function drain(kind, context) {
693
- if (context) pending.push(context)
694
- if (busy) { queued = (RANK[kind] ?? 0) >= (RANK[queued] ?? 0) ? kind : queued; log('busy — queued a ' + kind + ' follow-up cycle'); return }
695
- busy = true
696
- const ctx = pending.splice(0) // take everything accumulated so far
707
+ const laneName = kind === 'full' ? 'work' : 'reply'
708
+ const lane = lanes[laneName]
709
+ if (context) lane.pending.push(context)
710
+ if (lane.busy) { lane.queued = (RANK[kind] ?? 0) >= (RANK[lane.queued] ?? 0) ? kind : lane.queued; log(laneName + ' lane busy — queued a ' + kind + ' follow-up cycle'); return }
711
+ lane.busy = true
712
+ const ctx = lane.pending.splice(0)
697
713
  // credNote + charter live in the cached system prompt now — the per-cycle
698
714
  // message is just the event context + the small base instruction.
699
715
  const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + baseFor(kind)
@@ -707,7 +723,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
707
723
  emitStatus('working')
708
724
  const heartbeat = targets.length ? setInterval(() => emitStatus('working'), 9000) : null
709
725
  try {
710
- const result = await runCycle(prompt, useModel)
726
+ const result = await runners[laneName].runCycle(prompt, useModel)
711
727
  // A zero exit is not proof of work. Assigned-ticket cycles must both use
712
728
  // the task MCP and leave repository evidence. A simple `ls` no longer
713
729
  // counts as completion. Do not retry a verified real blocker.
@@ -716,13 +732,13 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
716
732
  if (agent === 'codex' && kind === 'full' && result?.subtype === 'ok' && incomplete && !legitimateNoWork) {
717
733
  const missing = [!result.didMcpTaskRead && 'read the ticket through get_ticket/list_tasks', !result.didRepoMutation && 'perform and verify the repository change', !result.didMcpTaskUpdate && 'update the ticket through update_ticket'].filter(Boolean)
718
734
  log('coding cycle incomplete; recovery requires: ' + missing.join(', '))
719
- await runCycle(`The assigned task is NOT complete. Missing evidence: ${missing.join('; ')}. Do not post another acknowledgement or plan. Resume now. First use get_ticket/list_tasks and list_task_types, then do the repository work, verify it, commit and push an agent/* branch, open the PR, call update_ticket with the correct board column, and post exactly one result update with evidence. If a real blocker appears, report it once.`, codeModel)
735
+ await runners.work.runCycle(`The assigned task is NOT complete. Missing evidence: ${missing.join('; ')}. Do not post another acknowledgement or plan. Resume now. First use get_ticket/list_tasks and list_task_types, then do the repository work, verify it, commit and push an agent/* branch, open the PR, call update_ticket with the correct board column, and post exactly one result update with evidence. If a real blocker appears, report it once.`, codeModel)
720
736
  }
721
737
  } finally {
722
738
  if (heartbeat) clearInterval(heartbeat)
723
- for (const c of targets) { try { handle && handle.sendStatus(c, 'done') } catch { /* noop */ } statusTargets.delete(c) }
724
- busy = false
725
- if (queued || pending.length) { const next = queued || 'fast'; queued = null; void drain(next) }
739
+ for (const c of targets) { sendStatus(c, 'done'); statusTargets.delete(c) }
740
+ lane.busy = false
741
+ if (lane.queued || lane.pending.length) { const next = lane.queued || (laneName === 'work' ? 'full' : 'fast'); lane.queued = null; void drain(next) }
726
742
  }
727
743
  }
728
744
 
@@ -837,15 +853,28 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
837
853
  log('agent:mention in channel ' + (cid != null ? cid : '?') + (threadRoot != null ? ' (thread ' + threadRoot + ')' : ''))
838
854
  // Light up the live status the instant we pick this up (thinking → the cycle
839
855
  // takes it to working → typing → done).
840
- if (cid != null) { statusTargets.add(cid); try { handle && handle.sendStatus(cid, 'thinking') } catch { /* best-effort */ } }
856
+ if (cid != null) { statusTargets.add(cid); sendStatus(cid, 'thinking') }
841
857
  const codingMention = canCode && needsCode(text)
842
858
  const ctx = cid != null
843
859
  ? `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. ${codingMention ? 'This is repository work: complete the coding flow first, then send' : 'Send'} EXACTLY ONE reply with 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 version. 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.` : ''} You ALREADY have the message here. Do not poll_inbox, and after your single reply, STOP.`
844
860
  : undefined
845
- void drain(codingMention ? 'full' : 'fast', ctx)
861
+ if (codingMention) {
862
+ const ack = cid != null
863
+ ? `You were asked for repository work in channel ${cid}${threadRoot != null ? `, thread ${threadRoot}` : ''}. The dedicated work lane has accepted it. Post exactly one short reply with post_message in that same thread saying you have picked it up and will return there with the verified result. Include agent_identifier + agent_api_key. Do not inspect or edit code in this reply lane.`
864
+ : undefined
865
+ void drain('coord', ack)
866
+ void drain('full', ctx)
867
+ } else void drain('fast', ctx)
846
868
  } else if (k === 'error') {
847
- // Surface the backend's rejection detail instead of a bare "event error".
848
- log('error event: ' + JSON.stringify(raw).slice(0, 220))
869
+ const detail = raw && (raw.message || raw.error || raw.reason || raw.code || raw.d?.message || raw.d?.error)
870
+ // Older backend stages reject agent_status. Stop its heartbeat after the
871
+ // first immediate rejection instead of producing an error every nine seconds.
872
+ if (statusEnabled && lastStatusSentAt && Date.now() - lastStatusSentAt < 3000) {
873
+ statusEnabled = false
874
+ log('live agent status unsupported by this backend; disabling status heartbeat' + (detail ? ': ' + String(detail).slice(0, 160) : ''))
875
+ } else {
876
+ log('error event: ' + (detail ? String(detail) : JSON.stringify(raw)).slice(0, 220))
877
+ }
849
878
  } else {
850
879
  log('event ' + k)
851
880
  }
package/src/ws.mjs CHANGED
@@ -79,7 +79,9 @@ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect,
79
79
  try { msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data)) } catch { return }
80
80
  const kind = msg && msg.k
81
81
  if (!kind || kind === 'keepalive:ack') return // keepalive round-trip, nothing to do
82
- try { onEvent(kind, msg.d) } catch (e) { log('event handler error: ' + (e && e.message ? e.message : e)) }
82
+ // Error metadata often lives on the JSON envelope rather than `d`. Preserve
83
+ // it so callers do not receive an unhelpful empty object.
84
+ try { onEvent(kind, kind === 'error' ? msg : msg.d) } catch (e) { log('event handler error: ' + (e && e.message ? e.message : e)) }
83
85
  })
84
86
 
85
87
  const down = (why) => {