openvisio-agent 0.12.0 → 0.13.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/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.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": {
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.0' } } }, 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 }
@@ -639,12 +718,13 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
639
718
  // fanned out org-wide. Catch both, keep only agent-assigned tasks, and let the
640
719
  // cycle confirm ownership via get_marching_orders before acting.
641
720
  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)
721
+ const envelope = raw.data && typeof raw.data === 'object' ? raw.data : raw
722
+ const t = envelope.task && typeof envelope.task === 'object' ? envelope.task : envelope
723
+ const ag = (t.agent && typeof t.agent === 'object') ? t.agent : (t.assigned_agent && typeof t.assigned_agent === 'object') ? t.assigned_agent : null
724
+ 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
725
  if (agentId == null) return // not assigned to an agent — ignore
645
726
  // If the payload carries the agent's identifier, filter precisely to US and skip
646
727
  // other agents' tasks entirely; otherwise let get_marching_orders confirm.
647
- const ag = (t.agent && typeof t.agent === 'object') ? t.agent : null
648
728
  const agIdent = ag && (ag.identifier || ag.slug) ? String(ag.identifier || ag.slug) : null
649
729
  if (agIdent != null && agIdent !== identifier) return
650
730
  const key = `${t.id}:${agentId}`
@@ -719,10 +799,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
719
799
  // so anything assigned while the agent was offline — tasks especially — is still
720
800
  // picked up even though we don't hot-poll.
721
801
  log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
722
- handle = connectAgentWs({ wsUrl, apiKey, identifier, onEvent, log })
802
+ handle = connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect: () => { setTimeout(() => void reconcileBacklog(), 250) }, log })
723
803
 
724
804
  const DAY_MS = 24 * 60 * 60 * 1000
725
- let introTimer = null, sweepStartTimer = null, sweepTimer = null
805
+ let introTimer = null, sweepStartTimer = null, sweepTimer = null, taskProbeStartTimer = null, taskProbeTimer = null
726
806
  if (mcpConfig || mcpUrl) {
727
807
  // Workspace ethics: a one-time hello the FIRST time this agent ever connects.
728
808
  const introMarker = join(OV_DIR, 'intro-' + slugify(identifier) + '.done')
@@ -740,6 +820,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
740
820
  log('startup catch-up sweep skipped (ran within the last 6h)')
741
821
  }
742
822
  sweepTimer = setInterval(() => { log('daily catch-up sweep'); void drain('sweep', SWEEP) }, DAY_MS)
823
+ // Cheap reliability net: no model runs unless the MCP result actually contains
824
+ // assigned work. This also catches assignments created while the socket was down.
825
+ taskProbeStartTimer = setTimeout(() => void reconcileBacklog(), 3_000)
826
+ taskProbeTimer = setInterval(() => void reconcileBacklog(), 5 * 60 * 1000)
743
827
  } else {
744
828
  log('no MCP config — skipping intro + daily sweep (agent has no tools to post/act)')
745
829
  }
@@ -751,6 +835,8 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
751
835
  if (introTimer) clearTimeout(introTimer)
752
836
  if (sweepStartTimer) clearTimeout(sweepStartTimer)
753
837
  if (sweepTimer) clearInterval(sweepTimer)
838
+ if (taskProbeStartTimer) clearTimeout(taskProbeStartTimer)
839
+ if (taskProbeTimer) clearInterval(taskProbeTimer)
754
840
  try { handle && handle.close() } catch { /* noop */ }
755
841
  process.exit(0)
756
842
  }
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 */ }