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.
- package/package.json +1 -1
- package/src/watch.mjs +110 -23
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 } 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:
|
|
223
|
-
// pushes ONE Claude cycle. Serialized (one cycle at a time) — events
|
|
224
|
-
// while busy are coalesced into a single follow-up cycle so a burst
|
|
225
|
-
//
|
|
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 = (
|
|
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
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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',
|
|
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
|
-
//
|
|
301
|
-
// A thread reply that @mentions the agent fires agent:mention and
|
|
302
|
-
// in-thread above
|
|
303
|
-
//
|
|
304
|
-
//
|
|
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 = () => {
|
|
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)) {
|