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 +4 -1
- package/src/events.mjs +48 -0
- package/src/watch.mjs +36 -16
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openvisio-agent",
|
|
3
|
-
"version": "0.
|
|
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
|
|
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
|
-
|
|
563
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
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
|