openvisio-agent 0.4.0 → 0.5.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/watch.mjs +110 -23
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.4.0",
3
+ "version": "0.5.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 } from 'node:fs'
8
+ import { writeFileSync, mkdirSync, existsSync } 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, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
@@ -48,6 +48,7 @@ const CODE_CHARTER = [
48
48
 
49
49
  const CODE_FULL = CODE_CHARTER + '\n\n' + [
50
50
  'THIS CYCLE: call get_marching_orders and poll_inbox to see assigned tickets + mentions, then act on them.',
51
+ 'ACKNOWLEDGE FIRST: for a task assigned to you, post a one-line comment_ticket ("On it — picking this up now") BEFORE you start, so the team sees you have it. Then do the work and report when done.',
51
52
  'For real code work (an assigned ticket, or a mention asking for changes), run the full flow end-to-end:',
52
53
  ' 1. GET THE CODE: locate the target repo under your workspace root. If it\'s already a subfolder, cd in and `git pull`; if it isn\'t cloned yet, clone it into the workspace (gh repo clone <org>/<repo>, or git clone <url>) and cd in. Do this yourself — never ask the user for a path.',
53
54
  ' 2. BRANCH: git checkout -B agent/<short-task-slug>. NEVER work on, commit to, or push main/master.',
@@ -65,6 +66,23 @@ const CODE_FAST = CODE_CHARTER + '\n\n' + [
65
66
  'Do NOT promise and stop — finish and report in THIS cycle. Reply 1-3 sentences, no summary. Then stop.',
66
67
  ].join('\n')
67
68
 
69
+ // ── Workspace-ethics cycles (both chat-only + code agents) ───────────────────
70
+ // INTRO: a one-time hello when the agent first joins a workspace. SWEEP: a daily
71
+ // (and on-startup) catch-up so nothing assigned while the agent was offline is
72
+ // missed — TASKS especially.
73
+ const INTRO = [
74
+ 'You have just JOINED this OpenVisio workspace (your first connection). Workspace etiquette: introduce yourself so the team knows you are here and reachable.',
75
+ 'Find the most general channel — call poll_inbox (or list channels) and pick the "general"/main one — then post_message there ONCE: give your name, say you are an AI teammate who picks up tasks assigned to you and answers @mentions, and invite people to mention you. 1-2 sentences, warm and professional.',
76
+ 'Post it EXACTLY ONCE, then stop. Do NOT do any other work this cycle.',
77
+ ].join('\n')
78
+ const SWEEP = [
79
+ 'DAILY CATCH-UP — you may have missed items while offline. Prioritize TASKS.',
80
+ 'Call get_marching_orders AND poll_inbox, then:',
81
+ ' 1. For every task assigned to you that you have NOT started: post a brief comment_ticket acknowledgement first ("Catching up — picking this up now"), then do the work end-to-end and report (branch/PR + a short channel note).',
82
+ ' 2. Answer any @mentions or thread follow-ups you missed — at most one reply per channel.',
83
+ 'If there is genuinely nothing outstanding, STOP silently — do NOT post a "nothing to do" message.',
84
+ ].join('\n')
85
+
68
86
  // Bash covers git/gh/clone/tests; the deny list is where the guardrails live.
69
87
  const CODE_TOOLS = ['Read', 'Grep', 'Glob', 'Edit', 'Write', 'MultiEdit', 'TodoWrite', 'Bash', 'mcp__openvisio-team__*']
70
88
  // Push + PR creation ARE allowed (agents raise PRs), but main/master, force-pushes,
@@ -130,7 +148,7 @@ export async function runWatch({ flags }) {
130
148
 
131
149
  if (flags.install) return installService({ slug: slug || 'openvisio', claude, mcpConfig, workdir })
132
150
 
133
- return loop({ host, key, claude, mcpConfig, workdir, debug: !!flags.debug })
151
+ return loop({ host, key, slug: slug || 'openvisio', claude, mcpConfig, workdir, debug: !!flags.debug })
134
152
  }
135
153
 
136
154
  // ── Claude Code warm-session cycle runner (shared by the REST + WS loops) ─────
@@ -219,10 +237,10 @@ function createCycleRunner({ claude, mcpConfig, workdir, log, debug }) {
219
237
  }
220
238
 
221
239
  // ── the backend WS loop ──────────────────────────────────────────────────────
222
- // Real-time: the backend pushes task:assigned / agent:mention over the WS; each
223
- // pushes ONE Claude cycle. Serialized (one cycle at a time) — events arriving
224
- // while busy are coalesced into a single follow-up cycle so a burst of mentions
225
- // doesn't stack up N sessions.
240
+ // Real-time: the backend pushes task:created/task:updated (assignments) + agent:mention
241
+ // over the WS; each pushes ONE Claude cycle. Serialized (one cycle at a time) — events
242
+ // arriving while busy are coalesced into a single follow-up cycle so a burst doesn't
243
+ // stack up N sessions. Plus a one-time intro on first connect and a daily catch-up sweep.
226
244
  function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir, debug }) {
227
245
  const log = (m) => process.stdout.write('[ws ' + new Date().toISOString() + '] ' + m + '\n')
228
246
  const { runCycle, canCode } = createCycleRunner({ claude, mcpConfig, workdir, log, debug })
@@ -230,8 +248,12 @@ function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir,
230
248
  const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
231
249
 
232
250
  let busy = false
233
- let queued = null // 'full' | 'fast' — a cycle requested while one was running
251
+ let queued = null // 'full' | 'fast' | 'sweep' | 'intro' — a cycle requested while one was running
234
252
  let handle = null
253
+ // Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
254
+ // different agent re-triggers), so a noisy stream of task:updated events doesn't
255
+ // re-acknowledge the same assignment.
256
+ const seenTasks = new Set()
235
257
  // Context lines from the events themselves (the WS payload already carries the
236
258
  // channel + message / task), so the agent acts on THEM directly instead of
237
259
  // hoping poll_inbox re-surfaces the same item. Accumulated across coalesced
@@ -243,13 +265,16 @@ function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir,
243
265
  // Hand them to the model up front so it never shells around hunting for them.
244
266
  const credNote = `AUTH: the openvisio-team (mcp__openvisio-team__*) tools REQUIRE two arguments on EVERY call — agent_identifier: "${identifier}" and agent_api_key: "${apiKey}". Include BOTH on every openvisio-team tool call (post_message, poll_inbox, react_message, comment_ticket, …). They are given to you right here — do NOT hunt for them (no Bash/grep/cat/find to locate credentials, no reading memory); just call the tools with these exact values. (Bash/git/gh ARE for your code work — this rule is only about not searching for these keys.)`
245
267
 
268
+ // Higher rank wins when coalescing cycles requested while one is running.
269
+ const RANK = { fast: 0, intro: 1, sweep: 2, full: 3 }
270
+ const baseFor = (kind) => kind === 'intro' ? INTRO : (kind === 'full' || kind === 'sweep') ? fullPrompt : fastPrompt
271
+
246
272
  async function drain(kind, context) {
247
273
  if (context) pending.push(context)
248
- if (busy) { queued = (queued === 'full' || kind === 'full') ? 'full' : 'fast'; log('busy — queued a ' + kind + ' follow-up cycle'); return }
274
+ if (busy) { queued = (RANK[kind] ?? 0) >= (RANK[queued] ?? 0) ? kind : queued; log('busy — queued a ' + kind + ' follow-up cycle'); return }
249
275
  busy = true
250
276
  const ctx = pending.splice(0) // take everything accumulated so far
251
- const base = kind === 'full' ? fullPrompt : fastPrompt
252
- const prompt = credNote + '\n\n' + (ctx.length ? ctx.join('\n') + '\n\n' : '') + base
277
+ const prompt = credNote + '\n\n' + (ctx.length ? ctx.join('\n') + '\n\n' : '') + baseFor(kind)
253
278
  log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : ''))
254
279
  try {
255
280
  await runCycle(prompt)
@@ -269,11 +294,25 @@ function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir,
269
294
 
270
295
  function onEvent(k, d) {
271
296
  const raw = d && typeof d === 'object' ? d : {}
272
- if (k === 'task:assigned') {
273
- const t = raw.task || {}
274
- log('task:assigned ' + (t.id != null ? '#' + t.id + ' “' + (t.title || '') + '”' : ''))
297
+ // The backend has NO `task:assigned` event — a task assigned to an agent arrives
298
+ // as `task:created` (assigned on create) or `task:updated` (assignee changed),
299
+ // fanned out org-wide. Catch both, keep only agent-assigned tasks, and let the
300
+ // cycle confirm ownership via get_marching_orders before acting.
301
+ if (k === 'task:created' || k === 'task:updated') {
302
+ const t = raw.task && typeof raw.task === 'object' ? raw.task : {}
303
+ const agentId = t.agent_id != null ? t.agent_id : (t.agentId != null ? t.agentId : null)
304
+ if (agentId == null) return // not assigned to an agent — ignore
305
+ // If the payload carries the agent's identifier, filter precisely to US and skip
306
+ // other agents' tasks entirely; otherwise let get_marching_orders confirm.
307
+ const ag = (t.agent && typeof t.agent === 'object') ? t.agent : null
308
+ const agIdent = ag && (ag.identifier || ag.slug) ? String(ag.identifier || ag.slug) : null
309
+ if (agIdent != null && agIdent !== identifier) return
310
+ const key = `${t.id}:${agentId}`
311
+ if (t.id != null && seenTasks.has(key)) return
312
+ if (t.id != null) { seenTasks.add(key); if (seenTasks.size > 500) seenTasks.clear() }
313
+ log('task ' + k.slice(5) + ' #' + (t.id != null ? t.id : '?') + ' (agent ' + agentId + ') “' + (t.title || '') + '”')
275
314
  const desc = t.description ? ' — ' + String(t.description).replace(/\s+/g, ' ').slice(0, 400) : ''
276
- void drain('full', t.id != null ? `You were ASSIGNED task #${t.id}: "${t.title || ''}"${desc}. Handle it, then comment_ticket with a short summary.` : undefined)
315
+ void drain('full', `A task was just ${k === 'task:created' ? 'created and assigned' : 'assigned'} to an agent in this workspace — task #${t.id != null ? t.id : '?'}: "${t.title || ''}"${desc} (agent_id ${agentId}). Call get_marching_orders to confirm it is assigned to YOU. If it IS yours: FIRST post a brief comment_ticket acknowledgement ("On it — picking this up now, will update shortly"), THEN do the work end-to-end and report back (branch/PR + a channel note). If it is NOT yours, do nothing and stop.`)
277
316
  } else if (k === 'agent:mention') {
278
317
  const cid = raw.channel_id != null ? raw.channel_id : (raw.channelId != null ? raw.channelId : null)
279
318
  const msg = raw.message && typeof raw.message === 'object' ? raw.message : {}
@@ -297,25 +336,48 @@ function loopBackendWs({ wsUrl, apiKey, identifier, claude, mcpConfig, workdir,
297
336
  }
298
337
  }
299
338
 
300
- // NO polling — the WebSocket is the only trigger (task:assigned / agent:mention).
301
- // A thread reply that @mentions the agent fires agent:mention and is handled
302
- // in-thread above; genuinely un-mentioned thread activity has no WS event, so
303
- // it's a backend concern (dispatch a thread event to participant agents), not a
304
- // reason to poll.
339
+ // The WebSocket drives real-time reactions (task:created/updated assigned to us,
340
+ // agent:mention). A thread reply that @mentions the agent fires agent:mention and
341
+ // is handled in-thread above. In addition we run a DAILY (and on-startup) sweep
342
+ // so anything assigned while the agent was offline tasks especially is still
343
+ // picked up even though we don't hot-poll.
305
344
  log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
306
345
  handle = connectAgentWs({ wsUrl, apiKey, identifier, onEvent, log })
307
346
 
347
+ const DAY_MS = 24 * 60 * 60 * 1000
348
+ let introTimer = null, sweepStartTimer = null, sweepTimer = null
349
+ if (mcpConfig) {
350
+ // Workspace ethics: a one-time hello the FIRST time this agent ever connects.
351
+ const introMarker = join(OV_DIR, 'intro-' + slugify(identifier) + '.done')
352
+ if (!existsSync(introMarker)) {
353
+ try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
354
+ log('first connection — introducing self to the workspace')
355
+ introTimer = setTimeout(() => void drain('intro'), 5000) // let the socket subscribe first
356
+ }
357
+ // Catch-up sweep: shortly after startup (covers downtime) + once every day.
358
+ sweepStartTimer = setTimeout(() => { log('startup catch-up sweep'); void drain('sweep', SWEEP) }, 30_000)
359
+ sweepTimer = setInterval(() => { log('daily catch-up sweep'); void drain('sweep', SWEEP) }, DAY_MS)
360
+ } else {
361
+ log('no MCP config — skipping intro + daily sweep (agent has no tools to post/act)')
362
+ }
363
+
308
364
  return new Promise(() => {
309
- // Run until killed. Tidy up the socket on termination so a restarting
310
- // service doesn't leak a half-open connection.
311
- const bye = () => { try { handle && handle.close() } catch { /* noop */ } process.exit(0) }
365
+ // Run until killed. Tidy up the socket + timers on termination so a restarting
366
+ // service doesn't leak a half-open connection or a dangling interval.
367
+ const bye = () => {
368
+ if (introTimer) clearTimeout(introTimer)
369
+ if (sweepStartTimer) clearTimeout(sweepStartTimer)
370
+ if (sweepTimer) clearInterval(sweepTimer)
371
+ try { handle && handle.close() } catch { /* noop */ }
372
+ process.exit(0)
373
+ }
312
374
  process.on('SIGTERM', bye)
313
375
  process.on('SIGINT', bye)
314
376
  })
315
377
  }
316
378
 
317
379
  // ── the warm loop ────────────────────────────────────────────────────────────
318
- function loop({ host, key, claude, mcpConfig, workdir, debug }) {
380
+ function loop({ host, key, slug, claude, mcpConfig, workdir, debug }) {
319
381
  const log = (m) => process.stdout.write('[warm ' + new Date().toISOString() + '] ' + m + '\n')
320
382
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
321
383
  const { runCycle, canCode } = createCycleRunner({ claude, mcpConfig, workdir, log, debug })
@@ -326,6 +388,22 @@ function loop({ host, key, claude, mcpConfig, workdir, debug }) {
326
388
  let busy = false
327
389
  let firstCheck = true
328
390
  let lastNewAt = Date.now()
391
+ // A one-off prompt (intro / daily sweep) the main loop runs the next time it's
392
+ // free — keeps everything on the single runCycle so nothing overlaps.
393
+ let queuedSpecial = null
394
+ if (mcpConfig) {
395
+ const introMarker = join(OV_DIR, 'intro-' + (slug || 'openvisio') + '.done')
396
+ if (!existsSync(introMarker)) {
397
+ try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
398
+ log('first run — introducing self to the workspace')
399
+ setTimeout(() => { queuedSpecial = INTRO }, 5000)
400
+ }
401
+ // The startup poll marks pre-existing tasks as "seen" (so it won't re-handle old
402
+ // ones) — which would also skip tasks assigned while offline. A startup + daily
403
+ // sweep re-checks get_marching_orders so those are still picked up.
404
+ setTimeout(() => { log('startup catch-up sweep'); queuedSpecial = SWEEP + '\n\n' + fullPrompt }, 30_000)
405
+ setInterval(() => { log('daily catch-up sweep'); queuedSpecial = SWEEP + '\n\n' + fullPrompt }, 24 * 60 * 60 * 1000)
406
+ }
329
407
 
330
408
  async function check() {
331
409
  const res = await fetch(host + '/api/agent/inbox', { headers: { authorization: 'Bearer ' + key } })
@@ -345,6 +423,15 @@ function loop({ host, key, claude, mcpConfig, workdir, debug }) {
345
423
  for (;;) {
346
424
  let delay = FAST
347
425
  try {
426
+ // Run a queued one-off (intro / daily sweep) first when free, on the same
427
+ // runCycle so it never overlaps a normal cycle.
428
+ if (!busy && queuedSpecial) {
429
+ const p = queuedSpecial; queuedSpecial = null
430
+ busy = true
431
+ try { await runCycle(p) } finally { busy = false; lastNewAt = Date.now() }
432
+ await sleep(FAST)
433
+ continue
434
+ }
348
435
  const res = busy ? { items: [], paused: false } : await check()
349
436
  const items = Array.isArray(res.items) ? res.items : []
350
437
  if (firstCheck && Array.isArray(res.items)) {