openvisio-agent 0.15.1 → 0.16.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/package.json +4 -1
- package/src/events.mjs +27 -0
- package/src/watch.mjs +50 -21
- package/src/ws.mjs +3 -1
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openvisio-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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": {
|
|
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,27 @@
|
|
|
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
|
+
}
|
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 { 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
|
|
@@ -528,10 +529,16 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
528
529
|
function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }) {
|
|
529
530
|
const log = (m) => process.stdout.write('[ws ' + new Date().toISOString() + '] ' + m + '\n')
|
|
530
531
|
let handle = null
|
|
532
|
+
let statusEnabled = true
|
|
533
|
+
let lastStatusSentAt = 0
|
|
531
534
|
// Channels the agent is actively working in this cycle — drives the live
|
|
532
535
|
// agent:status broadcast (thinking → working → typing → done).
|
|
533
536
|
const statusTargets = new Set()
|
|
534
|
-
const
|
|
537
|
+
const sendStatus = (channelId, state) => {
|
|
538
|
+
if (!statusEnabled) return
|
|
539
|
+
try { if (handle) { lastStatusSentAt = Date.now(); handle.sendStatus(channelId, state) } } catch { /* best-effort */ }
|
|
540
|
+
}
|
|
541
|
+
const emitStatus = (state) => { for (const c of statusTargets) sendStatus(c, state) }
|
|
535
542
|
const canCode = !!workdir
|
|
536
543
|
// The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
|
|
537
544
|
// agent_identifier + agent_api_key as arguments. Hand them over up front.
|
|
@@ -539,12 +546,18 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
539
546
|
// The STATIC charter + creds are the session system prompt (cached, billed once),
|
|
540
547
|
// NOT re-sent in every cycle's user message — the big token saving.
|
|
541
548
|
const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
|
|
542
|
-
const
|
|
549
|
+
const runnerOptions = {
|
|
543
550
|
claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
|
|
544
551
|
cfgKey: identifier, mcpConfig, workdir, log, debug, model, systemPrompt,
|
|
545
552
|
// The moment the agent calls post_message it is about to speak → "typing".
|
|
546
553
|
onTool: (name) => { if (/post_message/.test(name)) emitStatus('typing') },
|
|
547
|
-
}
|
|
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
|
+
}
|
|
548
561
|
const fullPrompt = canCode ? CODE_FULL : CYCLE
|
|
549
562
|
const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
|
|
550
563
|
// Live model state — changeable at runtime by the in-chat `/model` command.
|
|
@@ -553,8 +566,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
553
566
|
let codeModel = model
|
|
554
567
|
let liteModel = chatModel || model
|
|
555
568
|
|
|
556
|
-
|
|
557
|
-
|
|
569
|
+
const lanes = {
|
|
570
|
+
work: { busy: false, queued: null, pending: [] },
|
|
571
|
+
reply: { busy: false, queued: null, pending: [] },
|
|
572
|
+
}
|
|
558
573
|
// Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
|
|
559
574
|
// different agent re-triggers), so a noisy stream of task:updated events doesn't
|
|
560
575
|
// re-acknowledge the same assignment.
|
|
@@ -567,7 +582,6 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
567
582
|
// channel + message / task), so the agent acts on THEM directly instead of
|
|
568
583
|
// hoping poll_inbox re-surfaces the same item. Accumulated across coalesced
|
|
569
584
|
// events and drained into the next cycle's prompt.
|
|
570
|
-
const pending = []
|
|
571
585
|
let backlogProbeBusy = false
|
|
572
586
|
let lastTaskSignature = ''
|
|
573
587
|
let lastTaskTriggeredAt = 0
|
|
@@ -596,7 +610,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
596
610
|
}
|
|
597
611
|
const ensureMcpSession = async () => {
|
|
598
612
|
if (mcpSessionId) return
|
|
599
|
-
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.0' } } }, false)
|
|
600
614
|
if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
|
|
601
615
|
await mcpPayload(res)
|
|
602
616
|
mcpSessionId = res.headers.get('mcp-session-id') || ''
|
|
@@ -629,7 +643,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
629
643
|
// Reconcile everything that may have arrived while disconnected. This spends no
|
|
630
644
|
// model tokens unless the tools actually report pending work.
|
|
631
645
|
const reconcileBacklog = async () => {
|
|
632
|
-
if (!mcpUrl || backlogProbeBusy
|
|
646
|
+
if (!mcpUrl || backlogProbeBusy) return
|
|
633
647
|
backlogProbeBusy = true
|
|
634
648
|
try {
|
|
635
649
|
const agentsData = toolData(await callMcpTool('list_agents'))
|
|
@@ -690,10 +704,12 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
690
704
|
const baseFor = (kind) => kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? COORDINATE : fastPrompt
|
|
691
705
|
|
|
692
706
|
async function drain(kind, context) {
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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)
|
|
697
713
|
// credNote + charter live in the cached system prompt now — the per-cycle
|
|
698
714
|
// message is just the event context + the small base instruction.
|
|
699
715
|
const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + baseFor(kind)
|
|
@@ -707,7 +723,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
707
723
|
emitStatus('working')
|
|
708
724
|
const heartbeat = targets.length ? setInterval(() => emitStatus('working'), 9000) : null
|
|
709
725
|
try {
|
|
710
|
-
const result = await runCycle(prompt, useModel)
|
|
726
|
+
const result = await runners[laneName].runCycle(prompt, useModel)
|
|
711
727
|
// A zero exit is not proof of work. Assigned-ticket cycles must both use
|
|
712
728
|
// the task MCP and leave repository evidence. A simple `ls` no longer
|
|
713
729
|
// counts as completion. Do not retry a verified real blocker.
|
|
@@ -716,13 +732,13 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
716
732
|
if (agent === 'codex' && kind === 'full' && result?.subtype === 'ok' && incomplete && !legitimateNoWork) {
|
|
717
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)
|
|
718
734
|
log('coding cycle incomplete; recovery requires: ' + missing.join(', '))
|
|
719
|
-
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)
|
|
720
736
|
}
|
|
721
737
|
} finally {
|
|
722
738
|
if (heartbeat) clearInterval(heartbeat)
|
|
723
|
-
for (const c of targets) {
|
|
724
|
-
busy = false
|
|
725
|
-
if (queued || pending.length) { const next = queued || 'fast'; queued = null; void drain(next) }
|
|
739
|
+
for (const c of targets) { sendStatus(c, 'done'); statusTargets.delete(c) }
|
|
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) }
|
|
726
742
|
}
|
|
727
743
|
}
|
|
728
744
|
|
|
@@ -837,15 +853,28 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
837
853
|
log('agent:mention in channel ' + (cid != null ? cid : '?') + (threadRoot != null ? ' (thread ' + threadRoot + ')' : ''))
|
|
838
854
|
// Light up the live status the instant we pick this up (thinking → the cycle
|
|
839
855
|
// takes it to working → typing → done).
|
|
840
|
-
if (cid != null) { statusTargets.add(cid);
|
|
856
|
+
if (cid != null) { statusTargets.add(cid); sendStatus(cid, 'thinking') }
|
|
841
857
|
const codingMention = canCode && needsCode(text)
|
|
842
858
|
const ctx = cid != null
|
|
843
859
|
? `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.`
|
|
844
860
|
: undefined
|
|
845
|
-
|
|
861
|
+
if (codingMention) {
|
|
862
|
+
const ack = cid != null
|
|
863
|
+
? `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.`
|
|
864
|
+
: undefined
|
|
865
|
+
void drain('coord', ack)
|
|
866
|
+
void drain('full', ctx)
|
|
867
|
+
} else void drain('fast', ctx)
|
|
846
868
|
} else if (k === 'error') {
|
|
847
|
-
|
|
848
|
-
|
|
869
|
+
const detail = raw && (raw.message || raw.error || raw.reason || raw.code || raw.d?.message || raw.d?.error)
|
|
870
|
+
// Older backend stages reject agent_status. Stop its heartbeat after the
|
|
871
|
+
// first immediate rejection instead of producing an error every nine seconds.
|
|
872
|
+
if (statusEnabled && lastStatusSentAt && Date.now() - lastStatusSentAt < 3000) {
|
|
873
|
+
statusEnabled = false
|
|
874
|
+
log('live agent status unsupported by this backend; disabling status heartbeat' + (detail ? ': ' + String(detail).slice(0, 160) : ''))
|
|
875
|
+
} else {
|
|
876
|
+
log('error event: ' + (detail ? String(detail) : JSON.stringify(raw)).slice(0, 220))
|
|
877
|
+
}
|
|
849
878
|
} else {
|
|
850
879
|
log('event ' + k)
|
|
851
880
|
}
|
package/src/ws.mjs
CHANGED
|
@@ -79,7 +79,9 @@ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect,
|
|
|
79
79
|
try { msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data)) } catch { return }
|
|
80
80
|
const kind = msg && msg.k
|
|
81
81
|
if (!kind || kind === 'keepalive:ack') return // keepalive round-trip, nothing to do
|
|
82
|
-
|
|
82
|
+
// Error metadata often lives on the JSON envelope rather than `d`. Preserve
|
|
83
|
+
// it so callers do not receive an unhelpful empty object.
|
|
84
|
+
try { onEvent(kind, kind === 'error' ? msg : msg.d) } catch (e) { log('event handler error: ' + (e && e.message ? e.message : e)) }
|
|
83
85
|
})
|
|
84
86
|
|
|
85
87
|
const down = (why) => {
|