openvisio-agent 0.7.0 → 0.8.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 +1 -1
- package/src/watch.mjs +64 -4
- package/src/ws.mjs +4 -0
package/package.json
CHANGED
package/src/watch.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// than written to disk from a pasted heredoc.
|
|
6
6
|
|
|
7
7
|
import { spawn, spawnSync } from 'node:child_process'
|
|
8
|
-
import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
8
|
+
import { writeFileSync, mkdirSync, existsSync, readFileSync, unlinkSync } from 'node:fs'
|
|
9
9
|
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'
|
|
@@ -29,6 +29,7 @@ const REPLY_DISCIPLINE = [
|
|
|
29
29
|
' • IS IT FOR YOU? Act ONLY on messages addressed to YOU — an @mention of your exact name, a direct question to you, or a reply to something YOU said or did. If a DIFFERENT agent or person was @mentioned or asked to do something, STAY OUT: do not answer for them and do not pick up their task. When it is not yours, posting nothing is the correct move.',
|
|
30
30
|
' • NO DUPLICATES. Before you post, scan the recent thread/channel for what YOU already said. If you already replied to or acknowledged this exact request, do NOT post again. One acknowledgement per task; one answer per question. While a task is in progress, post again ONLY when you have something genuinely NEW (a result, a link, a real blocker) — never re-post "on it".',
|
|
31
31
|
' • BE SURE BEFORE YOU SPEAK. Do not claim something is possible, done, or broken until you have actually verified it — call the tool, read the code, check the real state. Never assert then contradict yourself. If you are unsure, verify FIRST, then give ONE clear, final answer instead of thinking out loud across several messages.',
|
|
32
|
+
' • NO INVENTED HISTORY. You have NO memory beyond the messages visible in THIS thread and what your tools return right now. Never fabricate past events, competitions, conversations, results, links, PR numbers, deploy URLs, or figures. If you are asked about something you have no actual record of, say plainly "I don\'t have a record of that" — do NOT make one up to play along or be helpful. Only state things you can see or verify.',
|
|
32
33
|
].join('\n')
|
|
33
34
|
|
|
34
35
|
// ── CHAT-ONLY agents (no --workdir): chat/ticket tools, no code surface. ──────
|
|
@@ -131,6 +132,29 @@ const SESSION_IDLE_MS = 1200000
|
|
|
131
132
|
const MAX_CYCLE_MS = 240000
|
|
132
133
|
const MAX_CODE_CYCLE_MS = 900000
|
|
133
134
|
|
|
135
|
+
// Refuse to run a SECOND watcher for the same agent. Two watchers connect to the
|
|
136
|
+
// WS as the same agent and BOTH reply to every mention — the #1 cause of duplicate
|
|
137
|
+
// (and contradicting, if the two are different versions) messages. A pid lock file
|
|
138
|
+
// in ~/.openvisio makes the second start fail fast instead. Stale locks (dead pid)
|
|
139
|
+
// are taken over. Returns { release } or { conflict: <pid> }.
|
|
140
|
+
function acquireSingleInstance(key) {
|
|
141
|
+
const lockPath = join(OV_DIR, 'watch-' + key + '.lock')
|
|
142
|
+
try {
|
|
143
|
+
mkdirSync(OV_DIR, { recursive: true })
|
|
144
|
+
if (existsSync(lockPath)) {
|
|
145
|
+
const pid = parseInt(String(readFileSync(lockPath, 'utf8')).trim(), 10)
|
|
146
|
+
if (pid && pid !== process.pid) {
|
|
147
|
+
let alive = false
|
|
148
|
+
try { process.kill(pid, 0); alive = true } catch (e) { alive = !!(e && e.code === 'EPERM') }
|
|
149
|
+
if (alive) return { conflict: pid }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
writeFileSync(lockPath, String(process.pid))
|
|
153
|
+
} catch { /* if the lock can't be written, don't block the agent from running */ }
|
|
154
|
+
const release = () => { try { if (parseInt(String(readFileSync(lockPath, 'utf8')).trim(), 10) === process.pid) unlinkSync(lockPath) } catch { /* already gone */ } }
|
|
155
|
+
return { release }
|
|
156
|
+
}
|
|
157
|
+
|
|
134
158
|
export async function runWatch({ flags }) {
|
|
135
159
|
const slug = flags.name ? slugify(String(flags.name)) : null
|
|
136
160
|
const saved = slug ? readConfig(slug) : null
|
|
@@ -161,6 +185,19 @@ export async function runWatch({ flags }) {
|
|
|
161
185
|
const model = String(flags.model || (saved && saved.model) || (agent === 'opencode' ? '' : 'sonnet'))
|
|
162
186
|
const chatModel = String(flags['chat-model'] || (saved && saved.chatModel) || '')
|
|
163
187
|
|
|
188
|
+
// ONE watcher per agent. A second one (e.g. a manual `watch` alongside the
|
|
189
|
+
// background service, or a stale service) is the #1 cause of duplicate replies:
|
|
190
|
+
// both connect as the same agent and both answer every mention. Refuse to start.
|
|
191
|
+
if (!flags.install) {
|
|
192
|
+
const lock = acquireSingleInstance(slug || 'openvisio')
|
|
193
|
+
if (lock.conflict) {
|
|
194
|
+
fail(`Another openvisio-agent watcher for "${slug || 'openvisio'}" is already running (pid ${lock.conflict}).\n` +
|
|
195
|
+
` Two watchers for the same agent BOTH reply to every mention — that is what causes duplicate/contradicting messages.\n` +
|
|
196
|
+
` Stop the other one (kill ${lock.conflict}), or rely on ONLY the background service. Refusing to start a second.`)
|
|
197
|
+
}
|
|
198
|
+
process.on('exit', () => { try { lock.release && lock.release() } catch { /* noop */ } })
|
|
199
|
+
}
|
|
200
|
+
|
|
164
201
|
// Backend agents (connect --backend) drive autonomy over a real-time WS instead
|
|
165
202
|
// of REST-polling the frontend relay. Detected by the saved mode / a --ws flag.
|
|
166
203
|
const backendMode = (saved && saved.mode === 'backend') || !!flags.ws
|
|
@@ -239,7 +276,7 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
239
276
|
// ── Claude Code warm-session cycle runner (shared by the REST + WS loops) ─────
|
|
240
277
|
// One persistent stream-json session, poked with a prompt per cycle. Recycled
|
|
241
278
|
// after MAX_TURNS or SESSION_IDLE_MS. Returns { runCycle, canCode }.
|
|
242
|
-
function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model }) {
|
|
279
|
+
function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool }) {
|
|
243
280
|
const canCode = !!workdir
|
|
244
281
|
const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
|
|
245
282
|
// opencode drives cycles differently — a headless `opencode run` per cycle rather
|
|
@@ -291,6 +328,10 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
291
328
|
if (!line.trim()) continue
|
|
292
329
|
let o; try { o = JSON.parse(line) } catch { continue }
|
|
293
330
|
if (debug) logEvent(o)
|
|
331
|
+
// Surface tool calls (e.g. post_message) so the loop can emit a live status.
|
|
332
|
+
if (onTool && o.type === 'assistant' && o.message && Array.isArray(o.message.content)) {
|
|
333
|
+
for (const b of o.message.content) if (b.type === 'tool_use' && b.name) { try { onTool(b.name) } catch { /* status is best-effort */ } }
|
|
334
|
+
}
|
|
294
335
|
if (o.type === 'result') { log('cycle done (' + (o.subtype || 'ok') + (o.is_error ? ' · ERROR' : '') + ')'); settleTurn(o) }
|
|
295
336
|
}
|
|
296
337
|
})
|
|
@@ -341,7 +382,17 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
341
382
|
// stack up N sessions. Plus a one-time intro on first connect and a daily catch-up sweep.
|
|
342
383
|
function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }) {
|
|
343
384
|
const log = (m) => process.stdout.write('[ws ' + new Date().toISOString() + '] ' + m + '\n')
|
|
344
|
-
|
|
385
|
+
let handle = null
|
|
386
|
+
// Channels the agent is actively working in this cycle — drives the live
|
|
387
|
+
// agent:status broadcast (thinking → working → typing → done).
|
|
388
|
+
const statusTargets = new Set()
|
|
389
|
+
const emitStatus = (state) => { for (const c of statusTargets) { try { handle && handle.sendStatus(c, state) } catch { /* best-effort */ } } }
|
|
390
|
+
const { runCycle, canCode } = createCycleRunner({
|
|
391
|
+
claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
|
|
392
|
+
cfgKey: identifier, mcpConfig, workdir, log, debug, model,
|
|
393
|
+
// The moment the agent calls post_message it is about to speak → "typing".
|
|
394
|
+
onTool: (name) => { if (/post_message/.test(name)) emitStatus('typing') },
|
|
395
|
+
})
|
|
345
396
|
const fullPrompt = canCode ? CODE_FULL : CYCLE
|
|
346
397
|
const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
|
|
347
398
|
// Live model state — changeable at runtime by the in-chat `/model` command.
|
|
@@ -352,7 +403,6 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
352
403
|
|
|
353
404
|
let busy = false
|
|
354
405
|
let queued = null // 'full' | 'fast' | 'sweep' | 'intro' — a cycle requested while one was running
|
|
355
|
-
let handle = null
|
|
356
406
|
// Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
|
|
357
407
|
// different agent re-triggers), so a noisy stream of task:updated events doesn't
|
|
358
408
|
// re-acknowledge the same assignment.
|
|
@@ -386,9 +436,16 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
386
436
|
// work (full/sweep) uses the main model.
|
|
387
437
|
const useModel = (kind === 'fast' || kind === 'intro') ? liteModel : codeModel
|
|
388
438
|
log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : '') + (useModel ? ' [' + useModel + ']' : ''))
|
|
439
|
+
// Live status: "working" now + a heartbeat so the UI (and its TTL) stays lit
|
|
440
|
+
// through a long cycle; onTool flips it to "typing" when post_message fires.
|
|
441
|
+
const targets = [...statusTargets]
|
|
442
|
+
emitStatus('working')
|
|
443
|
+
const heartbeat = targets.length ? setInterval(() => emitStatus('working'), 9000) : null
|
|
389
444
|
try {
|
|
390
445
|
await runCycle(prompt, useModel)
|
|
391
446
|
} finally {
|
|
447
|
+
if (heartbeat) clearInterval(heartbeat)
|
|
448
|
+
for (const c of targets) { try { handle && handle.sendStatus(c, 'done') } catch { /* noop */ } statusTargets.delete(c) }
|
|
392
449
|
busy = false
|
|
393
450
|
if (queued || pending.length) { const next = queued || 'fast'; queued = null; void drain(next) }
|
|
394
451
|
}
|
|
@@ -491,6 +548,9 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
491
548
|
return
|
|
492
549
|
}
|
|
493
550
|
log('agent:mention in channel ' + (cid != null ? cid : '?') + (threadRoot != null ? ' (thread ' + threadRoot + ')' : ''))
|
|
551
|
+
// Light up the live status the instant we pick this up (thinking → the cycle
|
|
552
|
+
// takes it to working → typing → done).
|
|
553
|
+
if (cid != null) { statusTargets.add(cid); try { handle && handle.sendStatus(cid, 'thinking') } catch { /* best-effort */ } }
|
|
494
554
|
const ctx = cid != null
|
|
495
555
|
? `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. Send EXACTLY ONE reply with mcp__openvisio-team__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/"better" one. 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 — "@${who.split(' ')[0]}" alone will NOT).` : ''} You ALREADY have the message here — do NOT poll_inbox, and after your single reply, STOP.`
|
|
496
556
|
: undefined
|
package/src/ws.mjs
CHANGED
|
@@ -100,6 +100,10 @@ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, log }) {
|
|
|
100
100
|
send(obj) { try { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)) } catch { /* closing */ } },
|
|
101
101
|
/** Show the agent as typing in a channel (docs/WEBSOCKET.md `{type:'typing'}`). */
|
|
102
102
|
sendTyping(channelId) { api.send({ type: 'typing', channel_id: channelId }) },
|
|
103
|
+
/** Broadcast a richer live activity state for the agent in a channel — the
|
|
104
|
+
* backend fans it out as `agent:status` (see docs/AGENT_STATUS.md). state is
|
|
105
|
+
* one of thinking | working | typing | done. */
|
|
106
|
+
sendStatus(channelId, state, detail) { api.send({ type: 'agent_status', channel_id: channelId, state, ...(detail ? { detail: String(detail).slice(0, 120) } : {}) }) },
|
|
103
107
|
close() {
|
|
104
108
|
closed = true
|
|
105
109
|
clearKeepalive()
|