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 +62 -0
- package/bin/cli.mjs +87 -0
- package/package.json +32 -0
- package/src/lib.mjs +86 -0
- package/src/watch.mjs +226 -0
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
|
+
}
|