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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Connect your coding agent (Claude Code) to an OpenVisio team — MCP tools + optional autonomy — in one command. No shell scripts.",
5
5
  "type": "module",
6
6
  "bin": {
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
- const { runCycle, canCode } = createCycleRunner({ claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier }, cfgKey: identifier, mcpConfig, workdir, log, debug, model })
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()