openvisio-agent 0.11.0 → 0.12.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/README.md +3 -0
- package/bin/cli.mjs +12 -1
- package/package.json +1 -1
- package/src/watch.mjs +89 -16
package/README.md
CHANGED
|
@@ -62,12 +62,15 @@ Routine messages, triage, introductions, catch-up checks, and ticket movement us
|
|
|
62
62
|
openvisio-agent watch --name ada # run in this terminal
|
|
63
63
|
openvisio-agent watch --name ada --install # run in the background, start at login
|
|
64
64
|
openvisio-agent watch --name ada --workdir ~/repo # allow REAL work on a git branch
|
|
65
|
+
openvisio-agent stop --name ada # stop service + every ada watcher
|
|
65
66
|
```
|
|
66
67
|
|
|
67
68
|
With `--workdir`, the agent gets file + Bash tools scoped to that repo and works on a branch. Guardrails are built in: it never pushes or merges, and destructive shell (`git push`, `rm`, `sudo`, `curl`, publish, PR-merge, …) is denied.
|
|
68
69
|
|
|
69
70
|
`--install` sets up a background service (launchd on macOS, systemd `--user` on Linux) that runs `watch` and restarts on login. Logs go to `~/.openvisio/<agent>.log` (macOS) or `journalctl --user -u openvisio-<agent>` (Linux).
|
|
70
71
|
|
|
72
|
+
Do not chase auto-changing watcher PIDs. `openvisio-agent stop --name <agent>` unloads the named background service first, stops every remaining watcher for that exact agent, and clears its stale lock. Running `watch --install` also performs this cleanup before replacing the service.
|
|
73
|
+
|
|
71
74
|
## Security
|
|
72
75
|
|
|
73
76
|
- **No opaque script.** You run a named, versioned npm package you can read here and on [npmjs.com](https://www.npmjs.com/package/openvisio-agent).
|
package/bin/cli.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
|
14
14
|
import { fileURLToPath } from 'node:url'
|
|
15
15
|
import { dirname, join } from 'node:path'
|
|
16
16
|
import { parseFlags, slugify, stripSlash, exchangeToken, ensureClaude, ensureCodex, writeJson, mcpConfigPath, configPath, chmodSafe, onPath, OV_DIR, fail, ok, info } from '../src/lib.mjs'
|
|
17
|
-
import { runWatch, installService } from '../src/watch.mjs'
|
|
17
|
+
import { runWatch, installService, stopWatchers } from '../src/watch.mjs'
|
|
18
18
|
|
|
19
19
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
20
20
|
const VERSION = (() => { try { return JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8')).version } catch { return '0.0.0' } })()
|
|
@@ -27,6 +27,7 @@ Usage:
|
|
|
27
27
|
openvisio-agent connect <ovs_code> --host <url> [--name "<agent>"] [--mcp-url <url>] [--agent claude|codex|opencode]
|
|
28
28
|
openvisio-agent connect --backend <url> --key <api-key> --id <identifier> [--name "<agent>"] [--ws <wss-url>] [--mcp-url <url>] [--agent claude|codex|opencode]
|
|
29
29
|
openvisio-agent watch --name <agent> [--install] [--workspace <dir>] [--chat-only] [--model <m>] [--chat-model <m>] [--debug]
|
|
30
|
+
openvisio-agent stop --name <agent>
|
|
30
31
|
openvisio-agent --help | --version
|
|
31
32
|
|
|
32
33
|
connect
|
|
@@ -72,6 +73,11 @@ watch
|
|
|
72
73
|
setup needed. Use --workspace <dir> to relocate it (e.g. an existing clones folder),
|
|
73
74
|
--chat-only to disable code work, and --install to run in the background on login.
|
|
74
75
|
|
|
76
|
+
stop
|
|
77
|
+
Stops the named agent's launchd/systemd service first, then terminates every
|
|
78
|
+
remaining watcher with that exact --name and clears its stale lock. Use this
|
|
79
|
+
instead of killing changing PIDs: openvisio-agent stop --name Alex
|
|
80
|
+
|
|
75
81
|
Docs: https://www.npmjs.com/package/openvisio-agent`
|
|
76
82
|
|
|
77
83
|
// Register the `openvisio-team` MCP at USER (global) scope so it's available in
|
|
@@ -280,6 +286,11 @@ async function main() {
|
|
|
280
286
|
const rest = parseFlags(argv.slice(1))
|
|
281
287
|
if (cmd === 'connect') return runConnect(rest)
|
|
282
288
|
if (cmd === 'watch') return runWatch(rest)
|
|
289
|
+
if (cmd === 'stop') {
|
|
290
|
+
const name = String(rest.flags.name || rest.positional[0] || '')
|
|
291
|
+
if (!name) fail('Missing agent name.\n Usage: openvisio-agent stop --name <agent>')
|
|
292
|
+
return stopWatchers({ slug: slugify(name) })
|
|
293
|
+
}
|
|
283
294
|
fail(`Unknown command "${cmd}".\n\n${HELP}`)
|
|
284
295
|
}
|
|
285
296
|
|
package/package.json
CHANGED
package/src/watch.mjs
CHANGED
|
@@ -81,7 +81,7 @@ const CODE_CHARTER = [
|
|
|
81
81
|
|
|
82
82
|
const CODE_FULL = [
|
|
83
83
|
'THIS CYCLE: call get_marching_orders and poll_inbox to see assigned tickets + mentions, then act on them.',
|
|
84
|
-
'
|
|
84
|
+
'DO NOT post a promise or pre-work acknowledgement. Start the repository work immediately. Your first task/channel update must contain either a verified result (branch, commit, PR, tests) or a concrete blocker you actually encountered.',
|
|
85
85
|
'For real code work (an assigned ticket, or a mention asking for changes), run the full flow end-to-end:',
|
|
86
86
|
' 1. GET THE CODE: use verified thread/history/recall context first, locate the target repo under your workspace root, and read its AGENTS.md. Reuse an existing clone; clone only if absent. Check `git status` before changing anything and preserve unrelated user work. Update from the remote only when it is safe. Do this yourself; never ask the user for a path you can discover.',
|
|
87
87
|
' 2. BRANCH: git checkout -B agent/<short-task-slug>. NEVER work on, commit to, or push main/master.',
|
|
@@ -229,18 +229,13 @@ export async function runWatch({ flags }) {
|
|
|
229
229
|
const lock = acquireSingleInstance(slug || 'openvisio')
|
|
230
230
|
if (lock.conflict) {
|
|
231
231
|
const watcherName = slug || 'openvisio'
|
|
232
|
-
const stop = process.platform === 'darwin'
|
|
233
|
-
? `launchctl unload ~/Library/LaunchAgents/io.openvisio.${watcherName}.plist`
|
|
234
|
-
: process.platform === 'win32'
|
|
235
|
-
? 'Stop the existing openvisio-agent process in Task Manager.'
|
|
236
|
-
: `systemctl --user stop openvisio-${watcherName}.service`
|
|
237
232
|
const logs = process.platform === 'darwin'
|
|
238
233
|
? `tail -f ~/.openvisio/${watcherName}.log`
|
|
239
234
|
: `journalctl --user -u openvisio-${watcherName} -f`
|
|
240
235
|
fail(`Another openvisio-agent watcher for "${watcherName}" is already running (pid ${lock.conflict}).\n` +
|
|
241
236
|
` Two watchers for the same agent BOTH reply to every mention — that is what causes duplicate/contradicting messages.\n` +
|
|
242
237
|
` This is usually the auto-restarting background service; killing its PID only makes it restart.\n` +
|
|
243
|
-
`
|
|
238
|
+
` Stop the service and every watcher for this agent with:\n openvisio-agent stop --name ${watcherName}\n` +
|
|
244
239
|
` Or keep the service and inspect its log:\n ${logs}\n` +
|
|
245
240
|
` Refusing to start a second watcher.`)
|
|
246
241
|
}
|
|
@@ -348,8 +343,20 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
348
343
|
...(m ? ['--model', m] : []),
|
|
349
344
|
...(mcpOverride ? ['-c', mcpOverride] : []),
|
|
350
345
|
full]
|
|
351
|
-
let child = null, done = false
|
|
352
|
-
const finish = (o) => { if (done) return; done = true; clearTimeout(timer); resolve(o) }
|
|
346
|
+
let child = null, done = false, didCode = false, didMessage = false, outputText = '', jsonlBuffer = ''
|
|
347
|
+
const finish = (o) => { if (done) return; done = true; clearTimeout(timer); resolve({ ...o, didCode, didMessage, outputText }) }
|
|
348
|
+
const inspectLine = (line) => {
|
|
349
|
+
const s = line.trim()
|
|
350
|
+
if (!s) return
|
|
351
|
+
if (/command_execution|file_change|apply_patch|shell_command|exec_command/i.test(s)) didCode = true
|
|
352
|
+
if (/post_message|comment_ticket/i.test(s)) didMessage = true
|
|
353
|
+
try {
|
|
354
|
+
const event = JSON.parse(s)
|
|
355
|
+
const item = event.item ?? event
|
|
356
|
+
if (item?.type === 'agent_message' && typeof item.text === 'string') outputText += ' ' + item.text
|
|
357
|
+
} catch { /* non-JSON diagnostic */ }
|
|
358
|
+
if (debug) log(' · ' + s.slice(0, 220))
|
|
359
|
+
}
|
|
353
360
|
const timer = setTimeout(() => {
|
|
354
361
|
log('codex cycle TIMED OUT after ' + Math.round(maxCycleMs / 1000) + 's — killing')
|
|
355
362
|
try { child && child.kill() } catch { /* gone */ }
|
|
@@ -357,15 +364,20 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
357
364
|
}, maxCycleMs)
|
|
358
365
|
log('running codex cycle…' + (m ? ' [' + m + ']' : ''))
|
|
359
366
|
try {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
367
|
+
// Always inspect Codex JSONL so a successful process exit cannot be
|
|
368
|
+
// mistaken for completed work. Keep it out of normal logs unless debug.
|
|
369
|
+
child = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'inherit'] })
|
|
370
|
+
if (child.stdout) child.stdout.on('data', (d) => {
|
|
371
|
+
jsonlBuffer += String(d)
|
|
372
|
+
const lines = jsonlBuffer.split('\n')
|
|
373
|
+
jsonlBuffer = lines.pop() ?? ''
|
|
374
|
+
for (const line of lines) inspectLine(line)
|
|
363
375
|
})
|
|
364
376
|
} catch (e) {
|
|
365
377
|
log('codex spawn failed: ' + (e && e.message ? e.message : e) + ' — is Codex installed and signed in? (`npm i -g @openai/codex`, then `codex login`)')
|
|
366
378
|
return finish({ type: 'result', subtype: 'spawn-failed' })
|
|
367
379
|
}
|
|
368
|
-
child.on('
|
|
380
|
+
child.on('close', (code) => { inspectLine(jsonlBuffer); jsonlBuffer = ''; log('codex cycle done (' + (code === 0 ? 'ok' : 'exit ' + code) + ')'); finish({ type: 'result', subtype: code === 0 ? 'ok' : 'error' }) })
|
|
369
381
|
child.on('error', (e) => { log('codex error: ' + (e && e.message ? e.message : e)); finish({ type: 'result', subtype: 'error' }) })
|
|
370
382
|
})
|
|
371
383
|
}
|
|
@@ -561,7 +573,14 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
561
573
|
emitStatus('working')
|
|
562
574
|
const heartbeat = targets.length ? setInterval(() => emitStatus('working'), 9000) : null
|
|
563
575
|
try {
|
|
564
|
-
await runCycle(prompt, useModel)
|
|
576
|
+
const result = await runCycle(prompt, useModel)
|
|
577
|
+
// Codex can exit successfully after posting only "I'll do it". For coding
|
|
578
|
+
// cycles, treat that as incomplete and give it one focused recovery turn.
|
|
579
|
+
// Do not retry a real blocker, which would only create duplicate noise.
|
|
580
|
+
if (agent === 'codex' && kind === 'full' && result?.subtype === 'ok' && result.didMessage && !result.didCode && !/\b(?:blocked|cannot|can't|missing|need access|permission|unclear)\b/i.test(result.outputText || '')) {
|
|
581
|
+
log('coding cycle only acknowledged the task; running one completion recovery')
|
|
582
|
+
await runCycle('You posted an acknowledgement but performed no repository work. Do not post another acknowledgement. Resume the same assigned task now: inspect the workspace, make the required changes, verify them, commit and push an agent/* branch, open the PR, then post exactly one result update with evidence. If a real blocker appears, report that blocker once.', codeModel)
|
|
583
|
+
}
|
|
565
584
|
} finally {
|
|
566
585
|
if (heartbeat) clearInterval(heartbeat)
|
|
567
586
|
for (const c of targets) { try { handle && handle.sendStatus(c, 'done') } catch { /* noop */ } statusTargets.delete(c) }
|
|
@@ -580,7 +599,8 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
580
599
|
|
|
581
600
|
// Route obvious repository work to the coding lane. Everything else, including
|
|
582
601
|
// chat and board movement, stays on the lightweight coordination model.
|
|
583
|
-
const needsCode = (value) => /\b(?:code|coding|implement|implementation|fix|bug|debug|refactor|test|tests|build|compile|repository|repo|github|git|branch|commit|pull request|pr|endpoint|api|component|function|class|database|migration|schema|deploy|release|package|npm|typescript|javascript|python|swift|rust|golang|css|html|file|files)\b/i.test(String(value || ''))
|
|
602
|
+
const needsCode = (value) => /\b(?:code|coding|implement|implementation|create|make|design|fix|bug|debug|refactor|test|tests|build|compile|repository|repo|github|git|branch|commit|pull request|pr|endpoint|api|component|feature|page|screen|ui|function|class|database|migration|schema|deploy|release|package|npm|typescript|javascript|python|swift|rust|golang|css|html|file|files|documentation)\b/i.test(String(value || ''))
|
|
603
|
+
const coordinationOnly = (value) => /\b(?:move|moving|status|column|assign|reassign|unassign|comment|reply|message|triage|prioriti[sz]e|label|rename|close|reopen)\b/i.test(String(value || '')) && !needsCode(value)
|
|
584
604
|
|
|
585
605
|
// Engineers change the model under the hood from chat: "/model", "/model sonnet",
|
|
586
606
|
// "use model haiku", "switch model to opus". Returns {report} | {set} | {invalid}.
|
|
@@ -633,7 +653,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
633
653
|
log('task ' + k.slice(5) + ' #' + (t.id != null ? t.id : '?') + ' (agent ' + agentId + ') “' + (t.title || '') + '”')
|
|
634
654
|
const desc = t.description ? ' — ' + String(t.description).replace(/\s+/g, ' ').slice(0, 400) : ''
|
|
635
655
|
const taskText = [t.title, t.description, t.type, t.kind, Array.isArray(t.labels) ? t.labels.join(' ') : t.labels].filter(Boolean).join(' ')
|
|
636
|
-
|
|
656
|
+
// An assigned task is presumed to require execution. Only explicit board or
|
|
657
|
+
// messaging work stays on the cheap lane; vague verbs such as "create",
|
|
658
|
+
// "make", or "improve" must not strand a coding task in coordination.
|
|
659
|
+
const kind = canCode && !coordinationOnly(taskText) ? 'full' : 'coord'
|
|
637
660
|
void drain(kind, `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, acknowledge it once, complete it with the tools appropriate to this ${kind === 'full' ? 'coding' : 'coordination'} lane, move the ticket when appropriate, and report the verified result. If it is not yours, do nothing and stop.`)
|
|
638
661
|
} else if (k === 'agent:mention') {
|
|
639
662
|
const cid = raw.channel_id != null ? raw.channel_id : (raw.channelId != null ? raw.channelId : null)
|
|
@@ -843,7 +866,57 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
|
|
|
843
866
|
}
|
|
844
867
|
|
|
845
868
|
// ── background service install (launchd / systemd) ───────────────────────────
|
|
869
|
+
/** Stop the auto-restarting service first, then every orphaned watcher whose
|
|
870
|
+
* --name resolves to this exact slug. Never use a broad pkill pattern. */
|
|
871
|
+
export function stopWatchers({ slug, quiet = false }) {
|
|
872
|
+
const key = slugify(slug || 'openvisio')
|
|
873
|
+
let serviceStopped = false
|
|
874
|
+
|
|
875
|
+
if (process.platform === 'darwin') {
|
|
876
|
+
const plist = join(homedir(), 'Library', 'LaunchAgents', `io.openvisio.${key}.plist`)
|
|
877
|
+
if (existsSync(plist)) {
|
|
878
|
+
const r = spawnSync('launchctl', ['unload', plist], { stdio: 'ignore' })
|
|
879
|
+
serviceStopped = r.status === 0
|
|
880
|
+
}
|
|
881
|
+
} else if (process.platform !== 'win32') {
|
|
882
|
+
const r = spawnSync('systemctl', ['--user', 'stop', `openvisio-${key}.service`], { stdio: 'ignore' })
|
|
883
|
+
serviceStopped = r.status === 0
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const watcherPids = () => {
|
|
887
|
+
if (process.platform === 'win32') return []
|
|
888
|
+
const r = spawnSync('ps', ['-axo', 'pid=,command='], { encoding: 'utf8' })
|
|
889
|
+
if (r.status !== 0) return []
|
|
890
|
+
const out = []
|
|
891
|
+
for (const line of String(r.stdout || '').split(/\r?\n/)) {
|
|
892
|
+
const m = /^\s*(\d+)\s+(.+)$/.exec(line)
|
|
893
|
+
if (!m) continue
|
|
894
|
+
const pid = Number(m[1]); const command = m[2]
|
|
895
|
+
if (pid === process.pid || !/\bopenvisio-agent\b/.test(command) || !/\bwatch\b/.test(command)) continue
|
|
896
|
+
const name = /(?:^|\s)--name(?:=|\s+)["']?([^"'\s]+)/.exec(command)?.[1]
|
|
897
|
+
if (name && slugify(name) === key) out.push(pid)
|
|
898
|
+
}
|
|
899
|
+
return out
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const found = watcherPids()
|
|
903
|
+
for (const pid of found) { try { process.kill(pid, 'SIGTERM') } catch { /* already stopped */ } }
|
|
904
|
+
if (found.length) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 600)
|
|
905
|
+
const stubborn = watcherPids()
|
|
906
|
+
for (const pid of stubborn) { try { process.kill(pid, 'SIGKILL') } catch { /* already stopped */ } }
|
|
907
|
+
if (stubborn.length) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 150)
|
|
908
|
+
|
|
909
|
+
const remaining = watcherPids()
|
|
910
|
+
if (remaining.length) fail(`Could not stop watcher${remaining.length === 1 ? '' : 's'} for "${key}": ${remaining.join(', ')}`)
|
|
911
|
+
try { unlinkSync(join(OV_DIR, `watch-${key}.lock`)) } catch { /* absent */ }
|
|
912
|
+
if (!quiet) ok(`Stopped ${found.length} watcher process${found.length === 1 ? '' : 'es'} for "${key}"${serviceStopped ? ' and disabled its background service' : ''}.`)
|
|
913
|
+
return { stopped: found.length, serviceStopped }
|
|
914
|
+
}
|
|
915
|
+
|
|
846
916
|
export function installService({ slug, workdir }) {
|
|
917
|
+
// Replacing a service must also remove manually-started watchers. Otherwise the
|
|
918
|
+
// new KeepAlive process repeatedly spawns, sees their lock, exits, and respawns.
|
|
919
|
+
stopWatchers({ slug, quiet: true })
|
|
847
920
|
const binPath = onPath('openvisio-agent')
|
|
848
921
|
if (!binPath || binPath.includes('/_npx/')) {
|
|
849
922
|
info('Installing openvisio-agent globally so the background service has a stable path…')
|