openvisio-agent 0.15.2 → 0.16.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/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.15.2",
3
+ "version": "0.16.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": {
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,48 @@
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
+ }
28
+
29
+ // A mention event means this agent's name appeared somewhere, not necessarily
30
+ // that the request was addressed to it. Reject a later-agent hand-off before a
31
+ // model starts, while keeping explicitly shared requests addressed to both.
32
+ export function requestTargetsLaterAgent(text, selfAliases) {
33
+ const value = String(text || '')
34
+ const aliases = new Set((selfAliases || []).map((v) => String(v || '').toLowerCase().replace(/^@/, '')).filter(Boolean))
35
+ if (!value || !aliases.size) return false
36
+
37
+ const mentions = [...value.matchAll(/@([a-z0-9](?:[a-z0-9_.-]*[a-z0-9_-])?)/gi)].map((match) => ({ name: match[1].toLowerCase(), index: match.index ?? 0, end: (match.index ?? 0) + match[0].length }))
38
+ const self = mentions.find((mention) => aliases.has(mention.name))
39
+ if (!self) return false
40
+ const later = mentions.find((mention) => mention.index > self.index && !aliases.has(mention.name))
41
+ if (!later) return false
42
+
43
+ const bridge = value.slice(self.end, later.index).toLowerCase()
44
+ const suffix = value.slice(later.end).trim().toLowerCase()
45
+ const shared = /\b(?:and|with|alongside)\s*$/.test(bridge.trim()) && /\b(?:both|together|you (?:two|all)|everyone)\b/.test(suffix)
46
+ if (shared) return false
47
+ return /\?|^(?:[,.:;-]\s*)?(?:please\s+)?(?:can|could|would|will|do|write|create|make|send|fix|check|review|prepare|give|tell|help|handle|take|move|update|implement)\b/i.test(suffix)
48
+ }
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 { requestTargetsLaterAgent, 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
@@ -545,12 +546,18 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
545
546
  // The STATIC charter + creds are the session system prompt (cached, billed once),
546
547
  // NOT re-sent in every cycle's user message — the big token saving.
547
548
  const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
548
- const { runCycle } = createCycleRunner({
549
+ const runnerOptions = {
549
550
  claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
550
551
  cfgKey: identifier, mcpConfig, workdir, log, debug, model, systemPrompt,
551
552
  // The moment the agent calls post_message it is about to speak → "typing".
552
553
  onTool: (name) => { if (/post_message/.test(name)) emitStatus('typing') },
553
- })
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
+ }
554
561
  const fullPrompt = canCode ? CODE_FULL : CYCLE
555
562
  const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
556
563
  // Live model state — changeable at runtime by the in-chat `/model` command.
@@ -559,8 +566,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
559
566
  let codeModel = model
560
567
  let liteModel = chatModel || model
561
568
 
562
- let busy = false
563
- 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
+ }
564
573
  // Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
565
574
  // different agent re-triggers), so a noisy stream of task:updated events doesn't
566
575
  // re-acknowledge the same assignment.
@@ -573,7 +582,6 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
573
582
  // channel + message / task), so the agent acts on THEM directly instead of
574
583
  // hoping poll_inbox re-surfaces the same item. Accumulated across coalesced
575
584
  // events and drained into the next cycle's prompt.
576
- const pending = []
577
585
  let backlogProbeBusy = false
578
586
  let lastTaskSignature = ''
579
587
  let lastTaskTriggeredAt = 0
@@ -602,7 +610,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
602
610
  }
603
611
  const ensureMcpSession = async () => {
604
612
  if (mcpSessionId) return
605
- const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.15.2' } } }, 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.1' } } }, false)
606
614
  if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
607
615
  await mcpPayload(res)
608
616
  mcpSessionId = res.headers.get('mcp-session-id') || ''
@@ -635,7 +643,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
635
643
  // Reconcile everything that may have arrived while disconnected. This spends no
636
644
  // model tokens unless the tools actually report pending work.
637
645
  const reconcileBacklog = async () => {
638
- if (!mcpUrl || backlogProbeBusy || busy) return
646
+ if (!mcpUrl || backlogProbeBusy) return
639
647
  backlogProbeBusy = true
640
648
  try {
641
649
  const agentsData = toolData(await callMcpTool('list_agents'))
@@ -696,10 +704,12 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
696
704
  const baseFor = (kind) => kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? COORDINATE : fastPrompt
697
705
 
698
706
  async function drain(kind, context) {
699
- if (context) pending.push(context)
700
- if (busy) { queued = (RANK[kind] ?? 0) >= (RANK[queued] ?? 0) ? kind : queued; log('busy — queued a ' + kind + ' follow-up cycle'); return }
701
- busy = true
702
- 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)
703
713
  // credNote + charter live in the cached system prompt now — the per-cycle
704
714
  // message is just the event context + the small base instruction.
705
715
  const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + baseFor(kind)
@@ -713,7 +723,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
713
723
  emitStatus('working')
714
724
  const heartbeat = targets.length ? setInterval(() => emitStatus('working'), 9000) : null
715
725
  try {
716
- const result = await runCycle(prompt, useModel)
726
+ const result = await runners[laneName].runCycle(prompt, useModel)
717
727
  // A zero exit is not proof of work. Assigned-ticket cycles must both use
718
728
  // the task MCP and leave repository evidence. A simple `ls` no longer
719
729
  // counts as completion. Do not retry a verified real blocker.
@@ -722,13 +732,13 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
722
732
  if (agent === 'codex' && kind === 'full' && result?.subtype === 'ok' && incomplete && !legitimateNoWork) {
723
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)
724
734
  log('coding cycle incomplete; recovery requires: ' + missing.join(', '))
725
- 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)
726
736
  }
727
737
  } finally {
728
738
  if (heartbeat) clearInterval(heartbeat)
729
739
  for (const c of targets) { sendStatus(c, 'done'); statusTargets.delete(c) }
730
- busy = false
731
- if (queued || pending.length) { const next = queued || 'fast'; queued = null; void drain(next) }
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) }
732
742
  }
733
743
  }
734
744
 
@@ -818,6 +828,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
818
828
  const dedupeKey = mid != null ? 'id:' + mid : 'sig:' + (cid != null ? cid : '?') + '|' + text.slice(0, 100)
819
829
  if (seenMentions.has(dedupeKey)) { log('agent:mention (dup) — skipped'); return }
820
830
  seenMentions.add(dedupeKey); if (seenMentions.size > 500) seenMentions.clear()
831
+ if (requestTargetsLaterAgent(text, [slug, identifier])) {
832
+ log('agent:mention addressed to a later-mentioned agent — skipped')
833
+ return
834
+ }
821
835
  // Under-the-hood model control from chat (view / switch the model the agent runs).
822
836
  const mcmd = cid != null ? parseModelCmd(text) : null
823
837
  if (mcmd) {
@@ -848,7 +862,13 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
848
862
  const ctx = cid != null
849
863
  ? `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.`
850
864
  : undefined
851
- void drain(codingMention ? 'full' : 'fast', ctx)
865
+ if (codingMention) {
866
+ const ack = cid != null
867
+ ? `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.`
868
+ : undefined
869
+ void drain('coord', ack)
870
+ void drain('full', ctx)
871
+ } else void drain('fast', ctx)
852
872
  } else if (k === 'error') {
853
873
  const detail = raw && (raw.message || raw.error || raw.reason || raw.code || raw.d?.message || raw.d?.error)
854
874
  // Older backend stages reject agent_status. Stop its heartbeat after the