openvisio-agent 0.1.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 ADDED
@@ -0,0 +1,62 @@
1
+ # openvisio-agent
2
+
3
+ Connect your coding agent (**Claude Code**) to an [OpenVisio](https://openvisio.app) team — in one command. No shell scripts, no `curl | bash`.
4
+
5
+ ```bash
6
+ npx -y openvisio-agent@latest connect ovs_YOURCODE --host https://your-openvisio.app --name "Ada"
7
+ ```
8
+
9
+ That's it. Your agent now has the team's tools (channels, tickets, docs, search) inside Claude Code.
10
+
11
+ ## What it does
12
+
13
+ `openvisio-agent` is a tiny, dependency-free CLI. It does exactly two things, and nothing is fetched-and-executed — the whole source is right here and on npm.
14
+
15
+ ### `connect <ovs_code> --host <url>`
16
+
17
+ 1. Redeems the **single-use** setup code for your agent key (`POST /api/agent/setup/exchange`). The code is short-lived and one-time, so it's inert if it leaks into your shell history.
18
+ 2. Registers the `openvisio-team` MCP server with Claude Code (`claude mcp add …`). Installs Claude Code first if it isn't on your PATH.
19
+ 3. Saves a scoped config under `~/.openvisio/` (chmod `600`) for the optional watcher.
20
+
21
+ Options: `--name "<agent>"` (label), `--mcp-url <url>` (override the MCP endpoint).
22
+
23
+ ### `watch --name <agent>`
24
+
25
+ Runs the **autonomy loop** — the agent replies to @mentions and picks up tickets on its own. It cheaply polls an inbox endpoint (no model spend when idle) and pokes a single warm Claude Code session only when something new arrives.
26
+
27
+ ```bash
28
+ openvisio-agent watch --name ada # run in this terminal
29
+ openvisio-agent watch --name ada --install # run in the background, start at login
30
+ openvisio-agent watch --name ada --workdir ~/repo # allow REAL work on a git branch
31
+ ```
32
+
33
+ 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.
34
+
35
+ `--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).
36
+
37
+ ## Security
38
+
39
+ - **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).
40
+ - **Single-use code.** The `ovs_` code is exchanged once for a key; a leaked code is already spent.
41
+ - **Least privilege.** Chat mode exposes only the `openvisio-team` MCP tools. Coding mode is opt-in per repo, branch-only, with a shell denylist.
42
+ - **Local secrets.** Your agent key lives in `~/.openvisio/` with `600` permissions — never printed, never committed.
43
+
44
+ ## Requirements
45
+
46
+ - Node.js ≥ 18
47
+ - [Claude Code](https://www.npmjs.com/package/@anthropic-ai/claude-code) (auto-installed if missing)
48
+
49
+ ## Getting a setup code
50
+
51
+ In OpenVisio: **Agents → your agent → Connect**. Copy the one-line command it shows (it already includes your `--host` and `--name`).
52
+
53
+ ## Uninstall
54
+
55
+ ```bash
56
+ claude mcp remove openvisio-team
57
+ # macOS: launchctl unload ~/Library/LaunchAgents/io.openvisio.<agent>.plist && rm it
58
+ # Linux: systemctl --user disable --now openvisio-<agent>.service
59
+ rm -rf ~/.openvisio
60
+ ```
61
+
62
+ MIT
package/bin/cli.mjs ADDED
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+
3
+ // openvisio-agent — connect your coding agent (Claude Code) to an OpenVisio team.
4
+ //
5
+ // npx -y openvisio-agent@latest connect <ovs_code> --host https://your-openvisio
6
+ // npx -y openvisio-agent@latest watch --name <agent> # autonomy loop
7
+ //
8
+ // Thin + server-driven: `connect` redeems the single-use setup code for the agent
9
+ // key, registers the openvisio-team MCP server with Claude Code, and saves a scoped
10
+ // config for `watch`. Nothing is fetched-and-eval'd; the whole source is on npm.
11
+
12
+ import { spawnSync } from 'node:child_process'
13
+ import { readFileSync } from 'node:fs'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { dirname, join } from 'node:path'
16
+ import { parseFlags, slugify, stripSlash, exchangeToken, ensureClaude, writeJson, mcpConfigPath, configPath, fail, ok, info } from '../src/lib.mjs'
17
+ import { runWatch } from '../src/watch.mjs'
18
+
19
+ const HERE = dirname(fileURLToPath(import.meta.url))
20
+ const VERSION = (() => { try { return JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8')).version } catch { return '0.0.0' } })()
21
+
22
+ const HELP = `openvisio-agent ${VERSION}
23
+
24
+ Connect your coding agent to an OpenVisio team.
25
+
26
+ Usage:
27
+ openvisio-agent connect <ovs_code> --host <url> [--name "<agent>"] [--mcp-url <url>]
28
+ openvisio-agent watch --name <agent> [--install] [--workdir <repo>]
29
+ openvisio-agent --help | --version
30
+
31
+ connect
32
+ Redeems the setup code, adds the "openvisio-team" MCP server to Claude Code, and
33
+ saves a config for the autonomy watcher.
34
+
35
+ watch
36
+ Runs the event-driven autonomy loop (reply to mentions, pick up tickets). Add
37
+ --install to run it in the background on login. Add --workdir <repo> to let it do
38
+ real work on a git branch (never pushes).
39
+
40
+ Docs: https://www.npmjs.com/package/openvisio-agent`
41
+
42
+ async function runConnect({ positional, flags }) {
43
+ const token = positional[0] || flags.token
44
+ const host = flags.host && String(flags.host)
45
+ if (!token) fail('Missing setup code.\n Usage: openvisio-agent connect <ovs_code> --host <your OpenVisio URL>')
46
+ if (!/^ovs_[a-z0-9]+$/i.test(String(token))) fail('That doesn\'t look like a setup code (expected ovs_…).')
47
+ if (!host) fail('Missing --host <your OpenVisio URL>.')
48
+
49
+ info(`Connecting to ${host} …`)
50
+ const { key, mcpUrl: srvMcp } = await exchangeToken(host, token)
51
+ const mcpUrl = (flags['mcp-url'] && String(flags['mcp-url'])) || srvMcp || stripSlash(host) + '/api/agent/mcp'
52
+ const name = (flags.name && String(flags.name)) || 'openvisio-team'
53
+ const slug = slugify(name)
54
+
55
+ const claude = ensureClaude()
56
+
57
+ // Register with Claude Code (idempotent — ignore "already exists").
58
+ spawnSync(claude, ['mcp', 'add', '--transport', 'http', 'openvisio-team', mcpUrl, '--header', `Authorization: Bearer ${key}`], { stdio: 'ignore' })
59
+
60
+ // A scoped MCP config (for the watcher's --strict-mcp-config) + a saved profile.
61
+ const mcpCfg = mcpConfigPath(slug)
62
+ writeJson(mcpCfg, { mcpServers: { 'openvisio-team': { type: 'http', url: mcpUrl, headers: { Authorization: `Bearer ${key}` } } } }, true)
63
+ writeJson(configPath(slug), { host: stripSlash(host), key, mcpUrl, name, slug, mcpConfig: mcpCfg }, true)
64
+
65
+ ok(`Connected "${name}" to ${host}.`)
66
+ info()
67
+ info('Claude Code now has the openvisio-team tools. Run /mcp in Claude Code to confirm.')
68
+ info()
69
+ info('To let it work on its own (reply to mentions, pick up tickets):')
70
+ info(` openvisio-agent watch --name ${slug} # run now, in this terminal`)
71
+ info(` openvisio-agent watch --name ${slug} --install # run in the background on login`)
72
+ info(` openvisio-agent watch --name ${slug} --workdir <repo> # allow real coding on a branch`)
73
+ }
74
+
75
+ async function main() {
76
+ const argv = process.argv.slice(2)
77
+ if (argv.includes('--version') || argv[0] === 'version') { info(VERSION); return }
78
+ if (argv.length === 0 || argv.includes('--help') || argv[0] === 'help') { info(HELP); return }
79
+
80
+ const cmd = argv[0]
81
+ const rest = parseFlags(argv.slice(1))
82
+ if (cmd === 'connect') return runConnect(rest)
83
+ if (cmd === 'watch') return runWatch(rest)
84
+ fail(`Unknown command "${cmd}".\n\n${HELP}`)
85
+ }
86
+
87
+ main().catch((e) => fail(e && e.stack ? e.stack : String(e)))
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "openvisio-agent",
3
+ "version": "0.1.0",
4
+ "description": "Connect your coding agent (Claude Code) to an OpenVisio team — MCP tools + optional autonomy — in one command. No shell scripts.",
5
+ "type": "module",
6
+ "bin": {
7
+ "openvisio-agent": "bin/cli.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "keywords": [
18
+ "openvisio",
19
+ "mcp",
20
+ "claude",
21
+ "claude-code",
22
+ "agent",
23
+ "autonomy"
24
+ ],
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/syntaxPriest/OpenVisio.git",
29
+ "directory": "packages/openvisio-agent"
30
+ },
31
+ "dependencies": {}
32
+ }
package/src/lib.mjs ADDED
@@ -0,0 +1,86 @@
1
+ // Shared helpers for the openvisio-agent CLI. Zero dependencies — Node built-ins
2
+ // only (global fetch needs Node >= 18).
3
+
4
+ import { spawnSync } from 'node:child_process'
5
+ import { homedir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { mkdirSync, writeFileSync, readFileSync, chmodSync } from 'node:fs'
8
+
9
+ export const OV_DIR = join(homedir(), '.openvisio')
10
+ const IS_WIN = process.platform === 'win32'
11
+
12
+ export function fail(msg) { console.error('✖ ' + msg); process.exit(1) }
13
+ export function ok(msg) { console.log('✔ ' + msg) }
14
+ export function info(msg = '') { console.log(msg) }
15
+
16
+ /** Minimal flag parser: positionals + `--key value` / boolean `--flag`. */
17
+ export function parseFlags(argv) {
18
+ const positional = []
19
+ const flags = {}
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const a = argv[i]
22
+ if (a.startsWith('--')) {
23
+ const key = a.slice(2)
24
+ const next = argv[i + 1]
25
+ if (next !== undefined && !next.startsWith('--')) { flags[key] = next; i++ } else flags[key] = true
26
+ } else positional.push(a)
27
+ }
28
+ return { positional, flags }
29
+ }
30
+
31
+ export function slugify(name) {
32
+ return (name || 'agent').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'agent'
33
+ }
34
+
35
+ export const stripSlash = (u) => String(u || '').replace(/\/+$/, '')
36
+
37
+ /** First matching path for a command, or null. */
38
+ export function onPath(cmd) {
39
+ const r = spawnSync(IS_WIN ? `where ${cmd}` : `command -v ${cmd}`, { shell: true, encoding: 'utf8' })
40
+ if (r.status !== 0) return null
41
+ return (r.stdout || '').trim().split(/\r?\n/)[0] || cmd
42
+ }
43
+
44
+ export function chmodSafe(path, mode) { try { chmodSync(path, mode) } catch { /* windows / best-effort */ } }
45
+
46
+ /** Exchange the single-use ovs_ setup code for the agent's key (+ mcpUrl if the
47
+ * server advertises one). Exits with a friendly message on the known failures. */
48
+ export async function exchangeToken(host, token) {
49
+ const url = stripSlash(host) + '/api/agent/setup/exchange'
50
+ let res
51
+ try {
52
+ res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token }) })
53
+ } catch (e) {
54
+ fail(`Couldn't reach ${host} — check the URL and your connection.\n (${e && e.message ? e.message : e})`)
55
+ }
56
+ if (res.status === 410) fail('That setup code has expired or was already used.\n Generate a fresh one in OpenVisio → Agents → your agent → Connect.')
57
+ if (res.status === 400) fail('That setup code was rejected (invalid format).')
58
+ if (!res.ok) fail(`Setup exchange failed (HTTP ${res.status}).`)
59
+ const data = await res.json().catch(() => ({}))
60
+ if (!data || !data.key) fail('Setup exchange returned no key.')
61
+ return data // { key, mcpUrl? }
62
+ }
63
+
64
+ /** Ensure Claude Code is installed; install it globally if missing. Returns the bin. */
65
+ export function ensureClaude() {
66
+ let claude = onPath('claude')
67
+ if (claude) return claude
68
+ info('Claude Code not found on PATH — installing @anthropic-ai/claude-code globally…')
69
+ spawnSync('npm', ['i', '-g', '@anthropic-ai/claude-code'], { stdio: 'inherit', shell: IS_WIN })
70
+ claude = onPath('claude')
71
+ if (!claude) fail('Claude Code still isn\'t on PATH after install.\n Install it (npm i -g @anthropic-ai/claude-code), open a new terminal, and re-run.')
72
+ return claude
73
+ }
74
+
75
+ export function writeJson(path, obj, secret = false) {
76
+ mkdirSync(OV_DIR, { recursive: true })
77
+ writeFileSync(path, JSON.stringify(obj, null, 2))
78
+ if (secret) chmodSafe(path, 0o600)
79
+ }
80
+
81
+ export function readConfig(slug) {
82
+ try { return JSON.parse(readFileSync(join(OV_DIR, `${slug}.json`), 'utf8')) } catch { return null }
83
+ }
84
+
85
+ export function configPath(slug) { return join(OV_DIR, `${slug}.json`) }
86
+ export function mcpConfigPath(slug) { return join(OV_DIR, `${slug}-team.mcp.json`) }
package/src/watch.mjs ADDED
@@ -0,0 +1,226 @@
1
+ // The OpenVisio autonomy watcher — an event-driven "warm" loop. Cheaply polls
2
+ // /api/agent/inbox (no model) and pokes ONE persistent Claude Code stream-json
3
+ // session with a cycle per new @mention / follow-up / ticket. Ported verbatim from
4
+ // the shell installer so behaviour is identical — now shipped in the package rather
5
+ // than written to disk from a pasted heredoc.
6
+
7
+ import { spawn, spawnSync } from 'node:child_process'
8
+ import { writeFileSync, mkdirSync } from 'node:fs'
9
+ import { homedir } from 'node:os'
10
+ import { join, dirname } from 'node:path'
11
+ import { OV_DIR, readConfig, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
12
+
13
+ const CYCLE = 'Run one OpenVisio autonomy cycle.'
14
+ const CYCLE_FAST = 'New chat activity in OpenVisio. Call poll_inbox once, then handle items in .mentions and .followUps ONLY (ignore .tasks/.claimable). Post AT MOST ONE reply per channel: if the same person sent several nudges (e.g. repeated @mentions or "status?"), answer them together in ONE post — never reply once per message. CRITICAL HONESTY RULE: your only tools are the openvisio-team chat/ticket tools. You CANNOT write code, read or clone a repo, or add entries to the API-testing/docs/sheets/flowchart tools — you have no tool for any of that. So NEVER say "On it" or "I will do it" for such work. If asked to do something you have no tool for, say plainly in one short message that you cannot do it yourself and what a human would need to do (or offer to file/track a ticket). Do not invent repo names or progress. Reply in 1-3 sentences, no summary; or react_message to dismiss. Then stop.'
15
+ const CODE_FULL = 'Run one OpenVisio autonomy cycle. Call get_marching_orders and poll_inbox. You have file + Bash tools and a git repo at your working directory. For assigned tickets or mentions asking for real work: FIRST run "git checkout -B agent/work" to work on a branch, make the actual changes with Read/Edit/Write, run tests if present, then "git add -A && git commit -m ...". NEVER git push, merge, or touch main. Then comment_ticket with a short summary + the branch name (and move it to Done only if auto-move is on), and post a brief channel reply. If you truly cannot (missing repo/specs), say so plainly in one message — never fabricate progress or repo names.'
16
+ const CODE_FAST = 'New chat activity in OpenVisio. Call poll_inbox, handle .mentions/.followUps ONLY, AT MOST ONE reply per channel. If a message asks for real work you can do in the git repo at your working directory, DO IT: "git checkout -B agent/work", make the changes with your file/Bash tools, commit locally (NEVER push or merge), then reply briefly with what you did + the branch name. If it needs a repo/spec you do not have, say so plainly — never fabricate progress or invent repo names. 1-3 sentences, no summary. Then stop.'
17
+
18
+ const CODE_TOOLS = ['Read', 'Grep', 'Glob', 'Edit', 'Write', 'MultiEdit', 'TodoWrite', 'Bash', 'mcp__openvisio-team__*']
19
+ const DENY_TOOLS = ['Bash(git push:*)', 'Bash(git reset --hard:*)', 'Bash(git clean:*)', 'Bash(rm:*)', 'Bash(sudo:*)', 'Bash(chmod:*)', 'Bash(curl:*)', 'Bash(wget:*)', 'Bash(npm publish:*)', 'Bash(pnpm publish:*)', 'Bash(gh pr merge:*)', 'Bash(gh repo:*)']
20
+
21
+ const FAST = 2500
22
+ const SLOW = 6000
23
+ const IDLE_AFTER = 60000
24
+ const MAX_TURNS = 15
25
+ const SESSION_IDLE_MS = 1200000
26
+
27
+ export async function runWatch({ flags }) {
28
+ const slug = flags.name ? slugify(String(flags.name)) : null
29
+ const saved = slug ? readConfig(slug) : null
30
+ const host = stripSlash(flags.host || (saved && saved.host) || '')
31
+ const key = String(flags.key || (saved && saved.key) || '')
32
+ const claude = String(flags.claude || onPath('claude') || 'claude')
33
+ const mcpConfig = String(flags['mcp-config'] || (saved && saved.mcpConfig) || '')
34
+ const workdir = flags.workdir === true ? process.cwd() : (flags.workdir ? String(flags.workdir) : '')
35
+
36
+ if (!host || !key) fail('No saved connection for that agent.\n Run `openvisio-agent connect <ovs_code> --host <url> --name <agent>` first, or pass --host and --key.')
37
+
38
+ if (flags.install) return installService({ slug: slug || 'openvisio', host, key, claude, mcpConfig, workdir })
39
+
40
+ return loop({ host, key, claude, mcpConfig, workdir })
41
+ }
42
+
43
+ // ── the warm loop ────────────────────────────────────────────────────────────
44
+ function loop({ host, key, claude, mcpConfig, workdir }) {
45
+ const canCode = !!workdir
46
+ const fullPrompt = canCode ? CODE_FULL : CYCLE
47
+ const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
48
+
49
+ const seen = new Set()
50
+ let busy = false
51
+ let firstCheck = true
52
+ let lastNewAt = Date.now()
53
+
54
+ let child = null
55
+ let turnsThisSession = 0
56
+ let sessionStartedAt = 0
57
+ let resolveTurn = null
58
+
59
+ const log = (m) => process.stdout.write('[warm ' + new Date().toISOString() + '] ' + m + '\n')
60
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
61
+ const settleTurn = (o) => { const r = resolveTurn; resolveTurn = null; if (r) r(o) }
62
+
63
+ function ensureSession() {
64
+ if (child && !child.killed) return
65
+ const base = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--strict-mcp-config', '--mcp-config', mcpConfig]
66
+ const args = canCode ? [...base, '--allowedTools', ...CODE_TOOLS, '--disallowedTools', ...DENY_TOOLS] : [...base, '--allowedTools', 'mcp__openvisio-team__*']
67
+ const c = spawn(claude, args, { cwd: workdir || undefined, stdio: ['pipe', 'pipe', 'inherit'] })
68
+ child = c
69
+ turnsThisSession = 0
70
+ sessionStartedAt = Date.now()
71
+ let localBuf = ''
72
+ c.stdout.on('data', (d) => {
73
+ if (c !== child) return
74
+ localBuf += d
75
+ let i
76
+ while ((i = localBuf.indexOf('\n')) >= 0) {
77
+ const line = localBuf.slice(0, i); localBuf = localBuf.slice(i + 1)
78
+ if (!line.trim()) continue
79
+ let o; try { o = JSON.parse(line) } catch { continue }
80
+ if (o.type === 'result') { log('cycle done (' + (o.subtype || 'ok') + ')'); settleTurn(o) }
81
+ }
82
+ })
83
+ c.on('exit', (code) => { if (c !== child) { log('old session exited ' + code); return } log('session exited ' + code); child = null; settleTurn({ type: 'result', subtype: 'exit' }) })
84
+ c.on('error', () => { if (c !== child) return; child = null; settleTurn({ type: 'result', subtype: 'error' }) })
85
+ log('warm session started')
86
+ }
87
+
88
+ function runCycle(prompt) {
89
+ return new Promise((resolve) => {
90
+ if (child && (turnsThisSession >= MAX_TURNS || Date.now() - sessionStartedAt > SESSION_IDLE_MS)) {
91
+ log('recycling session (turns=' + turnsThisSession + ')')
92
+ try { child.kill() } catch { /* already gone */ }
93
+ child = null
94
+ }
95
+ ensureSession()
96
+ turnsThisSession++
97
+ resolveTurn = resolve
98
+ try { child.stdin.write(JSON.stringify({ type: 'user', message: { role: 'user', content: prompt } }) + '\n') }
99
+ catch { settleTurn({ type: 'result', subtype: 'write-failed' }) }
100
+ })
101
+ }
102
+
103
+ async function check() {
104
+ const res = await fetch(host + '/api/agent/inbox', { headers: { authorization: 'Bearer ' + key } })
105
+ if (!res.ok) return { items: [], paused: false }
106
+ return await res.json()
107
+ }
108
+ async function quickReply() {
109
+ try {
110
+ const res = await fetch(host + '/api/agent/quick-reply', { method: 'POST', headers: { authorization: 'Bearer ' + key } })
111
+ if (!res.ok) return null
112
+ return await res.json()
113
+ } catch { return null }
114
+ }
115
+
116
+ async function run() {
117
+ log('up — warm watcher on ' + host + (canCode ? ' [code: ' + workdir + ']' : ''))
118
+ for (;;) {
119
+ let delay = FAST
120
+ try {
121
+ const res = busy ? { items: [], paused: false } : await check()
122
+ const items = Array.isArray(res.items) ? res.items : []
123
+ if (firstCheck && Array.isArray(res.items)) {
124
+ firstCheck = false
125
+ for (const i of items) if (i.startsWith('tk:') || i.startsWith('clm:')) seen.add(i)
126
+ }
127
+ if (res.paused) {
128
+ delay = SLOW
129
+ } else {
130
+ const fresh = items.filter((i) => !seen.has(i))
131
+ if (fresh.length && !busy) {
132
+ busy = true
133
+ const chatOnly = fresh.every((i) => i.startsWith('msg:') || i.startsWith('fu:'))
134
+ if (chatOnly) {
135
+ const q = await quickReply()
136
+ if (q && q.ok) {
137
+ log(fresh.length + ' new -> quick reply (' + (q.replied || 0) + ' posted)')
138
+ if (q.needsWork && canCode) { log('needs work -> full cycle'); await runCycle(fullPrompt) }
139
+ } else {
140
+ log(fresh.length + ' new -> fast reply (claude fallback)')
141
+ await runCycle(fastPrompt)
142
+ }
143
+ } else {
144
+ log(fresh.length + ' new item(s) -> full cycle' + (canCode ? ' [code]' : ''))
145
+ await runCycle(fullPrompt)
146
+ }
147
+ for (const i of items) seen.add(i)
148
+ if (seen.size > 500) { seen.clear(); for (const i of items) seen.add(i) }
149
+ busy = false
150
+ lastNewAt = Date.now()
151
+ } else {
152
+ delay = (Date.now() - lastNewAt > IDLE_AFTER) ? SLOW : FAST
153
+ }
154
+ }
155
+ } catch { delay = SLOW }
156
+ await sleep(delay)
157
+ }
158
+ }
159
+
160
+ return run()
161
+ }
162
+
163
+ // ── background service install (launchd / systemd) ───────────────────────────
164
+ function installService({ slug, workdir }) {
165
+ const binPath = onPath('openvisio-agent')
166
+ if (!binPath) {
167
+ info('Installing openvisio-agent globally so the background service has a stable path…')
168
+ spawnSync('npm', ['i', '-g', 'openvisio-agent'], { stdio: 'inherit', shell: process.platform === 'win32' })
169
+ }
170
+ const bin = onPath('openvisio-agent') || 'openvisio-agent'
171
+ const nodeDir = dirname(process.execPath)
172
+ const claudeBin = onPath('claude')
173
+ const runPath = [nodeDir, claudeBin ? dirname(claudeBin) : '', '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin'].filter(Boolean).join(':')
174
+ const args = ['watch', '--name', slug, ...(workdir ? ['--workdir', workdir] : [])]
175
+ mkdirSync(OV_DIR, { recursive: true })
176
+ const logFile = join(OV_DIR, `${slug}.log`)
177
+
178
+ if (process.platform === 'darwin') {
179
+ const label = `io.openvisio.${slug}`
180
+ const plist = join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`)
181
+ const progArgs = [bin, ...args].map((a) => ` <string>${a}</string>`).join('\n')
182
+ writeFileSync(plist, `<?xml version="1.0" encoding="UTF-8"?>
183
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
184
+ <plist version="1.0"><dict>
185
+ <key>Label</key><string>${label}</string>
186
+ <key>ProgramArguments</key><array>
187
+ ${progArgs}
188
+ </array>
189
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${runPath}</string></dict>
190
+ <key>KeepAlive</key><true/>
191
+ <key>RunAtLoad</key><true/>
192
+ <key>StandardOutPath</key><string>${logFile}</string>
193
+ <key>StandardErrorPath</key><string>${logFile}</string>
194
+ </dict></plist>
195
+ `)
196
+ spawnSync('launchctl', ['unload', plist], { stdio: 'ignore' })
197
+ const r = spawnSync('launchctl', ['load', plist], { stdio: 'inherit' })
198
+ if (r.status !== 0) fail('launchctl load failed — check the plist at ' + plist)
199
+ ok(`Live in the background, auto-starts at login. Logs: tail -f ${logFile}`)
200
+ } else if (process.platform === 'win32') {
201
+ fail('Background install isn\'t supported on Windows yet. Run it in the foreground:\n openvisio-agent watch --name ' + slug)
202
+ } else {
203
+ const dir = join(homedir(), '.config', 'systemd', 'user')
204
+ mkdirSync(dir, { recursive: true })
205
+ const unit = join(dir, `openvisio-${slug}.service`)
206
+ const execStart = [bin, ...args].map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(' ')
207
+ writeFileSync(unit, `[Unit]
208
+ Description=OpenVisio agent watcher (${slug})
209
+ After=network-online.target
210
+
211
+ [Service]
212
+ Environment=PATH=${runPath}
213
+ ExecStart=${execStart}
214
+ Restart=always
215
+ RestartSec=5
216
+
217
+ [Install]
218
+ WantedBy=default.target
219
+ `)
220
+ spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' })
221
+ const r = spawnSync('systemctl', ['--user', 'enable', '--now', `openvisio-${slug}.service`], { stdio: 'inherit' })
222
+ spawnSync('loginctl', ['enable-linger', process.env.USER || ''], { stdio: 'ignore' })
223
+ if (r.status !== 0) fail('systemctl enable failed — is this a systemd user session?')
224
+ ok(`Live in the background, auto-starts at login. Logs: journalctl --user -u openvisio-${slug} -f`)
225
+ }
226
+ }