openvisio-agent 0.12.0 → 0.13.1

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/README.md CHANGED
@@ -56,6 +56,8 @@ Then `watch --name ada` auto-detects the backend agent and runs a WebSocket loop
56
56
 
57
57
  Runs the **autonomy loop** — the agent replies to @mentions and picks up tickets on its own. It cheaply polls an inbox endpoint (no model spend when idle) and pokes a single warm Claude Code session only when something new arrives.
58
58
 
59
+ Backend/BYO watchers reconcile immediately whenever the process starts or the WebSocket connects. A direct MCP session checks both `poll_inbox` and `get_marching_orders`, covering unanswered mentions, follow-ups, and assigned tasks missed while offline. The same zero-model check runs every five minutes as a safety net; a model starts only when pending work exists.
60
+
59
61
  Routine messages, triage, introductions, catch-up checks, and ticket movement use a lightweight model by default. Claude uses Haiku and Codex uses `gpt-5.6-luna`. Requests that clearly require repository work route to the coding model: Sonnet for Claude and `gpt-5.6-sol` for Codex. Override either lane with `--chat-model` and `--model`.
60
62
 
61
63
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
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": {
package/src/watch.mjs CHANGED
@@ -550,6 +550,85 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
550
550
  // hoping poll_inbox re-surfaces the same item. Accumulated across coalesced
551
551
  // events and drained into the next cycle's prompt.
552
552
  const pending = []
553
+ let backlogProbeBusy = false
554
+ let lastTaskSignature = ''
555
+ let lastTaskTriggeredAt = 0
556
+ let mcpSessionId = ''
557
+ let mcpRpcId = 0
558
+
559
+ const mcpPayload = async (res) => {
560
+ const body = await res.text()
561
+ const data = body.split(/\r?\n/).filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trim()).pop()
562
+ return JSON.parse(data || body || '{}')
563
+ }
564
+ const mcpPost = async (message, withSession = true) => {
565
+ const res = await fetch(mcpUrl, {
566
+ method: 'POST',
567
+ headers: {
568
+ 'content-type': 'application/json',
569
+ accept: 'application/json, text/event-stream',
570
+ 'x-agent-api-key': apiKey,
571
+ 'x-agent-identifier': identifier,
572
+ ...(withSession && mcpSessionId ? { 'mcp-session-id': mcpSessionId } : {}),
573
+ },
574
+ body: JSON.stringify(message),
575
+ })
576
+ return res
577
+ }
578
+ const ensureMcpSession = async () => {
579
+ if (mcpSessionId) return
580
+ const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.13.1' } } }, false)
581
+ if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
582
+ await mcpPayload(res)
583
+ mcpSessionId = res.headers.get('mcp-session-id') || ''
584
+ if (!mcpSessionId) throw new Error('MCP initialize returned no session id')
585
+ const ready = await mcpPost({ jsonrpc: '2.0', method: 'notifications/initialized' })
586
+ if (!ready.ok) throw new Error('MCP initialized HTTP ' + ready.status)
587
+ }
588
+ const callMcpTool = async (name, args = {}, retried = false) => {
589
+ await ensureMcpSession()
590
+ const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'tools/call', params: { name, arguments: { ...args, agent_api_key: apiKey, agent_identifier: identifier } } })
591
+ if (!res.ok) {
592
+ if (!retried && (res.status === 400 || res.status === 404)) { mcpSessionId = ''; return callMcpTool(name, args, true) }
593
+ throw new Error(`MCP ${name} HTTP ${res.status}`)
594
+ }
595
+ const payload = await mcpPayload(res)
596
+ if (payload.error) throw new Error(`MCP ${name}: ${payload.error.message || 'tool error'}`)
597
+ return JSON.stringify(payload.result ?? payload)
598
+ }
599
+
600
+ // Reconcile everything that may have arrived while disconnected. This spends no
601
+ // model tokens unless the tools actually report pending work.
602
+ const reconcileBacklog = async () => {
603
+ if (!mcpUrl || backlogProbeBusy || busy) return
604
+ backlogProbeBusy = true
605
+ try {
606
+ const orders = await callMcpTool('get_marching_orders')
607
+ const inbox = await callMcpTool('poll_inbox')
608
+ const normalized = String(orders).replace(/\\"/g, '"').replace(/\s+/g, ' ').trim()
609
+ const empty = !normalized || /\b(?:no|zero)\s+(?:open\s+|assigned\s+|pending\s+)?tasks?\b/i.test(normalized) || /"(?:tasks|assigned|marching_orders)"\s*:\s*\[\s*\]/i.test(normalized)
610
+ if (empty) lastTaskSignature = ''
611
+ else if (/\b(?:task|ticket|title|status|assigned)\b/i.test(normalized)) {
612
+ const signature = normalized.slice(0, 1200)
613
+ const retryDue = Date.now() - lastTaskTriggeredAt > 30 * 60 * 1000
614
+ if (signature !== lastTaskSignature || retryDue) {
615
+ lastTaskSignature = signature
616
+ lastTaskTriggeredAt = Date.now()
617
+ log('backlog reconciliation found pending tasks -> coding cycle')
618
+ void drain('full', 'Backlog reconciliation found one or more tasks in get_marching_orders. Call get_marching_orders now, select every open task assigned to YOU, and execute the oldest/highest-priority one immediately. Do not merely acknowledge it. Move it to the active column, complete the repository work, verify it, open the PR, move the ticket to done when appropriate, and report evidence. Ignore tasks assigned to anyone else.')
619
+ }
620
+ }
621
+ const inboxText = String(inbox).replace(/\\"/g, '"').replace(/\s+/g, ' ').trim()
622
+ const hasInbox = /"(?:mentions|messages|follow_?ups|items)"\s*:\s*\[\s*\{/i.test(inboxText) || /\b(?:pending|unanswered|new)\s+(?:mention|message|follow.?up)/i.test(inboxText)
623
+ if (hasInbox) {
624
+ log('backlog reconciliation found inbox activity -> reply cycle')
625
+ void drain('fast', 'Backlog reconciliation found pending inbox activity. Call poll_inbox now and handle every unanswered mention or follow-up directed to YOU. Do not respond to other agents\' messages and do not duplicate a reply already present.')
626
+ }
627
+ } catch (e) {
628
+ mcpSessionId = ''
629
+ log('backlog reconciliation failed: ' + (e && e.message ? e.message : e))
630
+ } finally { backlogProbeBusy = false }
631
+ }
553
632
 
554
633
  // Higher rank wins when coalescing cycles requested while one is running.
555
634
  const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
@@ -577,9 +656,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
577
656
  // Codex can exit successfully after posting only "I'll do it". For coding
578
657
  // cycles, treat that as incomplete and give it one focused recovery turn.
579
658
  // Do not retry a real blocker, which would only create duplicate noise.
580
- if (agent === 'codex' && kind === 'full' && result?.subtype === 'ok' && result.didMessage && !result.didCode && !/\b(?:blocked|cannot|can't|missing|need access|permission|unclear)\b/i.test(result.outputText || '')) {
581
- log('coding cycle only acknowledged the task; running one completion recovery')
582
- await runCycle('You posted an acknowledgement but performed no repository work. Do not post another acknowledgement. Resume the same assigned task now: inspect the workspace, make the required changes, verify them, commit and push an agent/* branch, open the PR, then post exactly one result update with evidence. If a real blocker appears, report that blocker once.', codeModel)
659
+ const legitimateNoWork = /\b(?:no (?:open |assigned |pending )?tasks?|not assigned to (?:me|you)|assigned to (?:another|someone else)|blocked|cannot|can't|missing|need access|permission|unclear)\b/i.test(result?.outputText || '')
660
+ if (agent === 'codex' && kind === 'full' && result?.subtype === 'ok' && !result.didCode && !legitimateNoWork) {
661
+ log('coding cycle exited without repository action; running one completion recovery')
662
+ await runCycle('You exited the assigned coding task without performing repository work. Do not post another acknowledgement or plan. Resume the same task now: inspect the workspace, make the required changes, verify them, commit and push an agent/* branch, open the PR, then post exactly one result update with evidence. If a real blocker appears, report that blocker once.', codeModel)
583
663
  }
584
664
  } finally {
585
665
  if (heartbeat) clearInterval(heartbeat)
@@ -639,12 +719,13 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
639
719
  // fanned out org-wide. Catch both, keep only agent-assigned tasks, and let the
640
720
  // cycle confirm ownership via get_marching_orders before acting.
641
721
  if (k === 'task:created' || k === 'task:updated') {
642
- const t = raw.task && typeof raw.task === 'object' ? raw.task : {}
643
- const agentId = t.agent_id != null ? t.agent_id : (t.agentId != null ? t.agentId : null)
722
+ const envelope = raw.data && typeof raw.data === 'object' ? raw.data : raw
723
+ const t = envelope.task && typeof envelope.task === 'object' ? envelope.task : envelope
724
+ const ag = (t.agent && typeof t.agent === 'object') ? t.agent : (t.assigned_agent && typeof t.assigned_agent === 'object') ? t.assigned_agent : null
725
+ const agentId = t.agent_id ?? t.agentId ?? t.assigned_agent_id ?? t.assignedAgentId ?? t.assignee_agent_id ?? t.assigneeAgentId ?? ag?.id ?? ag?.agent_id ?? null
644
726
  if (agentId == null) return // not assigned to an agent — ignore
645
727
  // If the payload carries the agent's identifier, filter precisely to US and skip
646
728
  // other agents' tasks entirely; otherwise let get_marching_orders confirm.
647
- const ag = (t.agent && typeof t.agent === 'object') ? t.agent : null
648
729
  const agIdent = ag && (ag.identifier || ag.slug) ? String(ag.identifier || ag.slug) : null
649
730
  if (agIdent != null && agIdent !== identifier) return
650
731
  const key = `${t.id}:${agentId}`
@@ -719,10 +800,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
719
800
  // so anything assigned while the agent was offline — tasks especially — is still
720
801
  // picked up even though we don't hot-poll.
721
802
  log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
722
- handle = connectAgentWs({ wsUrl, apiKey, identifier, onEvent, log })
803
+ handle = connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect: () => { setTimeout(() => void reconcileBacklog(), 250) }, log })
723
804
 
724
805
  const DAY_MS = 24 * 60 * 60 * 1000
725
- let introTimer = null, sweepStartTimer = null, sweepTimer = null
806
+ let introTimer = null, sweepStartTimer = null, sweepTimer = null, taskProbeStartTimer = null, taskProbeTimer = null
726
807
  if (mcpConfig || mcpUrl) {
727
808
  // Workspace ethics: a one-time hello the FIRST time this agent ever connects.
728
809
  const introMarker = join(OV_DIR, 'intro-' + slugify(identifier) + '.done')
@@ -740,6 +821,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
740
821
  log('startup catch-up sweep skipped (ran within the last 6h)')
741
822
  }
742
823
  sweepTimer = setInterval(() => { log('daily catch-up sweep'); void drain('sweep', SWEEP) }, DAY_MS)
824
+ // Cheap reliability net: no model runs unless the MCP result actually contains
825
+ // assigned work. This also catches assignments created while the socket was down.
826
+ taskProbeStartTimer = setTimeout(() => void reconcileBacklog(), 3_000)
827
+ taskProbeTimer = setInterval(() => void reconcileBacklog(), 5 * 60 * 1000)
743
828
  } else {
744
829
  log('no MCP config — skipping intro + daily sweep (agent has no tools to post/act)')
745
830
  }
@@ -751,6 +836,8 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
751
836
  if (introTimer) clearTimeout(introTimer)
752
837
  if (sweepStartTimer) clearTimeout(sweepStartTimer)
753
838
  if (sweepTimer) clearInterval(sweepTimer)
839
+ if (taskProbeStartTimer) clearTimeout(taskProbeStartTimer)
840
+ if (taskProbeTimer) clearInterval(taskProbeTimer)
754
841
  try { handle && handle.close() } catch { /* noop */ }
755
842
  process.exit(0)
756
843
  }
package/src/ws.mjs CHANGED
@@ -36,7 +36,7 @@ export function assertWebSocket(fail) {
36
36
  * @param {(kind: string, payload: any) => void} o.onEvent targeted dispatch.
37
37
  * @param {(msg: string) => void} o.log
38
38
  */
39
- export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, log }) {
39
+ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect, log }) {
40
40
  const base = stripSlash(wsUrl)
41
41
  const url = `${base}?api_key=${encodeURIComponent(apiKey)}&identifier=${encodeURIComponent(identifier)}`
42
42
 
@@ -66,6 +66,7 @@ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, log }) {
66
66
  if (sock !== ws) return
67
67
  backoff = BACKOFF_MIN_MS // healthy connection → reset the backoff ramp
68
68
  log(`connected as @${identifier}`)
69
+ try { onConnect && onConnect() } catch { /* reconciliation is best-effort */ }
69
70
  clearKeepalive()
70
71
  keepalive = setInterval(() => {
71
72
  try { sock.send(JSON.stringify({ type: 'keepalive' })) } catch { /* closing */ }