openvisio-agent 0.11.1 → 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,18 +56,23 @@ 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
62
64
  openvisio-agent watch --name ada # run in this terminal
63
65
  openvisio-agent watch --name ada --install # run in the background, start at login
64
66
  openvisio-agent watch --name ada --workdir ~/repo # allow REAL work on a git branch
67
+ openvisio-agent stop --name ada # stop service + every ada watcher
65
68
  ```
66
69
 
67
70
  With `--workdir`, the agent gets file + Bash tools scoped to that repo and works on a branch. Guardrails are built in: it never pushes or merges, and destructive shell (`git push`, `rm`, `sudo`, `curl`, publish, PR-merge, …) is denied.
68
71
 
69
72
  `--install` sets up a background service (launchd on macOS, systemd `--user` on Linux) that runs `watch` and restarts on login. Logs go to `~/.openvisio/<agent>.log` (macOS) or `journalctl --user -u openvisio-<agent>` (Linux).
70
73
 
74
+ Do not chase auto-changing watcher PIDs. `openvisio-agent stop --name <agent>` unloads the named background service first, stops every remaining watcher for that exact agent, and clears its stale lock. Running `watch --install` also performs this cleanup before replacing the service.
75
+
71
76
  ## Security
72
77
 
73
78
  - **No opaque script.** You run a named, versioned npm package you can read here and on [npmjs.com](https://www.npmjs.com/package/openvisio-agent).
package/bin/cli.mjs CHANGED
@@ -14,7 +14,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
14
14
  import { fileURLToPath } from 'node:url'
15
15
  import { dirname, join } from 'node:path'
16
16
  import { parseFlags, slugify, stripSlash, exchangeToken, ensureClaude, ensureCodex, writeJson, mcpConfigPath, configPath, chmodSafe, onPath, OV_DIR, fail, ok, info } from '../src/lib.mjs'
17
- import { runWatch, installService } from '../src/watch.mjs'
17
+ import { runWatch, installService, stopWatchers } from '../src/watch.mjs'
18
18
 
19
19
  const HERE = dirname(fileURLToPath(import.meta.url))
20
20
  const VERSION = (() => { try { return JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8')).version } catch { return '0.0.0' } })()
@@ -27,6 +27,7 @@ Usage:
27
27
  openvisio-agent connect <ovs_code> --host <url> [--name "<agent>"] [--mcp-url <url>] [--agent claude|codex|opencode]
28
28
  openvisio-agent connect --backend <url> --key <api-key> --id <identifier> [--name "<agent>"] [--ws <wss-url>] [--mcp-url <url>] [--agent claude|codex|opencode]
29
29
  openvisio-agent watch --name <agent> [--install] [--workspace <dir>] [--chat-only] [--model <m>] [--chat-model <m>] [--debug]
30
+ openvisio-agent stop --name <agent>
30
31
  openvisio-agent --help | --version
31
32
 
32
33
  connect
@@ -72,6 +73,11 @@ watch
72
73
  setup needed. Use --workspace <dir> to relocate it (e.g. an existing clones folder),
73
74
  --chat-only to disable code work, and --install to run in the background on login.
74
75
 
76
+ stop
77
+ Stops the named agent's launchd/systemd service first, then terminates every
78
+ remaining watcher with that exact --name and clears its stale lock. Use this
79
+ instead of killing changing PIDs: openvisio-agent stop --name Alex
80
+
75
81
  Docs: https://www.npmjs.com/package/openvisio-agent`
76
82
 
77
83
  // Register the `openvisio-team` MCP at USER (global) scope so it's available in
@@ -280,6 +286,11 @@ async function main() {
280
286
  const rest = parseFlags(argv.slice(1))
281
287
  if (cmd === 'connect') return runConnect(rest)
282
288
  if (cmd === 'watch') return runWatch(rest)
289
+ if (cmd === 'stop') {
290
+ const name = String(rest.flags.name || rest.positional[0] || '')
291
+ if (!name) fail('Missing agent name.\n Usage: openvisio-agent stop --name <agent>')
292
+ return stopWatchers({ slug: slugify(name) })
293
+ }
283
294
  fail(`Unknown command "${cmd}".\n\n${HELP}`)
284
295
  }
285
296
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.11.1",
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
@@ -229,18 +229,13 @@ export async function runWatch({ flags }) {
229
229
  const lock = acquireSingleInstance(slug || 'openvisio')
230
230
  if (lock.conflict) {
231
231
  const watcherName = slug || 'openvisio'
232
- const stop = process.platform === 'darwin'
233
- ? `launchctl unload ~/Library/LaunchAgents/io.openvisio.${watcherName}.plist`
234
- : process.platform === 'win32'
235
- ? 'Stop the existing openvisio-agent process in Task Manager.'
236
- : `systemctl --user stop openvisio-${watcherName}.service`
237
232
  const logs = process.platform === 'darwin'
238
233
  ? `tail -f ~/.openvisio/${watcherName}.log`
239
234
  : `journalctl --user -u openvisio-${watcherName} -f`
240
235
  fail(`Another openvisio-agent watcher for "${watcherName}" is already running (pid ${lock.conflict}).\n` +
241
236
  ` Two watchers for the same agent BOTH reply to every mention — that is what causes duplicate/contradicting messages.\n` +
242
237
  ` This is usually the auto-restarting background service; killing its PID only makes it restart.\n` +
243
- ` To run in this terminal instead, stop the service first:\n ${stop}\n` +
238
+ ` Stop the service and every watcher for this agent with:\n openvisio-agent stop --name ${watcherName}\n` +
244
239
  ` Or keep the service and inspect its log:\n ${logs}\n` +
245
240
  ` Refusing to start a second watcher.`)
246
241
  }
@@ -555,6 +550,85 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
555
550
  // hoping poll_inbox re-surfaces the same item. Accumulated across coalesced
556
551
  // events and drained into the next cycle's prompt.
557
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
+ }
558
632
 
559
633
  // Higher rank wins when coalescing cycles requested while one is running.
560
634
  const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
@@ -644,12 +718,13 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
644
718
  // fanned out org-wide. Catch both, keep only agent-assigned tasks, and let the
645
719
  // cycle confirm ownership via get_marching_orders before acting.
646
720
  if (k === 'task:created' || k === 'task:updated') {
647
- const t = raw.task && typeof raw.task === 'object' ? raw.task : {}
648
- 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
649
725
  if (agentId == null) return // not assigned to an agent — ignore
650
726
  // If the payload carries the agent's identifier, filter precisely to US and skip
651
727
  // other agents' tasks entirely; otherwise let get_marching_orders confirm.
652
- const ag = (t.agent && typeof t.agent === 'object') ? t.agent : null
653
728
  const agIdent = ag && (ag.identifier || ag.slug) ? String(ag.identifier || ag.slug) : null
654
729
  if (agIdent != null && agIdent !== identifier) return
655
730
  const key = `${t.id}:${agentId}`
@@ -724,10 +799,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
724
799
  // so anything assigned while the agent was offline — tasks especially — is still
725
800
  // picked up even though we don't hot-poll.
726
801
  log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
727
- handle = connectAgentWs({ wsUrl, apiKey, identifier, onEvent, log })
802
+ handle = connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect: () => { setTimeout(() => void reconcileBacklog(), 250) }, log })
728
803
 
729
804
  const DAY_MS = 24 * 60 * 60 * 1000
730
- let introTimer = null, sweepStartTimer = null, sweepTimer = null
805
+ let introTimer = null, sweepStartTimer = null, sweepTimer = null, taskProbeStartTimer = null, taskProbeTimer = null
731
806
  if (mcpConfig || mcpUrl) {
732
807
  // Workspace ethics: a one-time hello the FIRST time this agent ever connects.
733
808
  const introMarker = join(OV_DIR, 'intro-' + slugify(identifier) + '.done')
@@ -745,6 +820,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
745
820
  log('startup catch-up sweep skipped (ran within the last 6h)')
746
821
  }
747
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)
748
827
  } else {
749
828
  log('no MCP config — skipping intro + daily sweep (agent has no tools to post/act)')
750
829
  }
@@ -756,6 +835,8 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
756
835
  if (introTimer) clearTimeout(introTimer)
757
836
  if (sweepStartTimer) clearTimeout(sweepStartTimer)
758
837
  if (sweepTimer) clearInterval(sweepTimer)
838
+ if (taskProbeStartTimer) clearTimeout(taskProbeStartTimer)
839
+ if (taskProbeTimer) clearInterval(taskProbeTimer)
759
840
  try { handle && handle.close() } catch { /* noop */ }
760
841
  process.exit(0)
761
842
  }
@@ -871,7 +952,57 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
871
952
  }
872
953
 
873
954
  // ── background service install (launchd / systemd) ───────────────────────────
955
+ /** Stop the auto-restarting service first, then every orphaned watcher whose
956
+ * --name resolves to this exact slug. Never use a broad pkill pattern. */
957
+ export function stopWatchers({ slug, quiet = false }) {
958
+ const key = slugify(slug || 'openvisio')
959
+ let serviceStopped = false
960
+
961
+ if (process.platform === 'darwin') {
962
+ const plist = join(homedir(), 'Library', 'LaunchAgents', `io.openvisio.${key}.plist`)
963
+ if (existsSync(plist)) {
964
+ const r = spawnSync('launchctl', ['unload', plist], { stdio: 'ignore' })
965
+ serviceStopped = r.status === 0
966
+ }
967
+ } else if (process.platform !== 'win32') {
968
+ const r = spawnSync('systemctl', ['--user', 'stop', `openvisio-${key}.service`], { stdio: 'ignore' })
969
+ serviceStopped = r.status === 0
970
+ }
971
+
972
+ const watcherPids = () => {
973
+ if (process.platform === 'win32') return []
974
+ const r = spawnSync('ps', ['-axo', 'pid=,command='], { encoding: 'utf8' })
975
+ if (r.status !== 0) return []
976
+ const out = []
977
+ for (const line of String(r.stdout || '').split(/\r?\n/)) {
978
+ const m = /^\s*(\d+)\s+(.+)$/.exec(line)
979
+ if (!m) continue
980
+ const pid = Number(m[1]); const command = m[2]
981
+ if (pid === process.pid || !/\bopenvisio-agent\b/.test(command) || !/\bwatch\b/.test(command)) continue
982
+ const name = /(?:^|\s)--name(?:=|\s+)["']?([^"'\s]+)/.exec(command)?.[1]
983
+ if (name && slugify(name) === key) out.push(pid)
984
+ }
985
+ return out
986
+ }
987
+
988
+ const found = watcherPids()
989
+ for (const pid of found) { try { process.kill(pid, 'SIGTERM') } catch { /* already stopped */ } }
990
+ if (found.length) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 600)
991
+ const stubborn = watcherPids()
992
+ for (const pid of stubborn) { try { process.kill(pid, 'SIGKILL') } catch { /* already stopped */ } }
993
+ if (stubborn.length) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 150)
994
+
995
+ const remaining = watcherPids()
996
+ if (remaining.length) fail(`Could not stop watcher${remaining.length === 1 ? '' : 's'} for "${key}": ${remaining.join(', ')}`)
997
+ try { unlinkSync(join(OV_DIR, `watch-${key}.lock`)) } catch { /* absent */ }
998
+ if (!quiet) ok(`Stopped ${found.length} watcher process${found.length === 1 ? '' : 'es'} for "${key}"${serviceStopped ? ' and disabled its background service' : ''}.`)
999
+ return { stopped: found.length, serviceStopped }
1000
+ }
1001
+
874
1002
  export function installService({ slug, workdir }) {
1003
+ // Replacing a service must also remove manually-started watchers. Otherwise the
1004
+ // new KeepAlive process repeatedly spawns, sees their lock, exits, and respawns.
1005
+ stopWatchers({ slug, quiet: true })
875
1006
  const binPath = onPath('openvisio-agent')
876
1007
  if (!binPath || binPath.includes('/_npx/')) {
877
1008
  info('Installing openvisio-agent globally so the background service has a stable path…')
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 */ }