openvisio-agent 0.9.0 → 0.10.1
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 +11 -4
- package/bin/cli.mjs +21 -15
- package/package.json +3 -2
- package/src/lib.mjs +13 -0
- package/src/watch.mjs +123 -23
package/README.md
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
# openvisio-agent
|
|
2
2
|
|
|
3
|
-
Connect your coding agent (**Claude Code**) to an [OpenVisio](https://openvisio.app) team — in one command. No shell scripts, no `curl | bash`.
|
|
3
|
+
Connect your coding agent (**Claude Code, Codex, or OpenCode**) to an [OpenVisio](https://openvisio.app) team — in one command. No shell scripts, no `curl | bash`.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npx -y openvisio-agent@latest connect ovs_YOURCODE --host https://your-openvisio.app --name "Ada"
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Use Codex as the autonomous runtime:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
codex login
|
|
13
|
+
npx -y openvisio-agent@latest connect ovs_YOURCODE --host https://your-openvisio.app --name "Ada" --agent codex
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
That's it. Your selected runtime now has the team's tools (channels, tickets, docs, search) during autonomy cycles.
|
|
10
17
|
|
|
11
18
|
## What it does
|
|
12
19
|
|
|
@@ -15,7 +22,7 @@ That's it. Your agent now has the team's tools (channels, tickets, docs, search)
|
|
|
15
22
|
### `connect <ovs_code> --host <url>`
|
|
16
23
|
|
|
17
24
|
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.
|
|
25
|
+
2. Configures the `openvisio-team` MCP for the selected runtime. Claude Code is registered globally; Codex and OpenCode receive an isolated MCP configuration for each autonomy cycle.
|
|
19
26
|
3. Saves a scoped config under `~/.openvisio/` (chmod `600`) for the optional watcher.
|
|
20
27
|
|
|
21
28
|
Options: `--name "<agent>"` (label), `--mcp-url <url>` (override the MCP endpoint).
|
|
@@ -69,7 +76,7 @@ With `--workdir`, the agent gets file + Bash tools scoped to that repo and works
|
|
|
69
76
|
## Requirements
|
|
70
77
|
|
|
71
78
|
- Node.js ≥ 18
|
|
72
|
-
-
|
|
79
|
+
- Claude Code, Codex (`@openai/codex`), or OpenCode (the selected CLI is auto-installed if missing)
|
|
73
80
|
|
|
74
81
|
## Getting a setup code
|
|
75
82
|
|
package/bin/cli.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { spawnSync } from 'node:child_process'
|
|
|
13
13
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
14
14
|
import { fileURLToPath } from 'node:url'
|
|
15
15
|
import { dirname, join } from 'node:path'
|
|
16
|
-
import { parseFlags, slugify, stripSlash, exchangeToken, ensureClaude, writeJson, mcpConfigPath, configPath, chmodSafe, onPath, OV_DIR, fail, ok, info } from '../src/lib.mjs'
|
|
16
|
+
import { parseFlags, slugify, stripSlash, exchangeToken, ensureClaude, ensureCodex, writeJson, mcpConfigPath, configPath, chmodSafe, onPath, OV_DIR, fail, ok, info } from '../src/lib.mjs'
|
|
17
17
|
import { runWatch, installService } from '../src/watch.mjs'
|
|
18
18
|
|
|
19
19
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
@@ -24,14 +24,14 @@ const HELP = `openvisio-agent ${VERSION}
|
|
|
24
24
|
Connect your coding agent to an OpenVisio team.
|
|
25
25
|
|
|
26
26
|
Usage:
|
|
27
|
-
openvisio-agent connect <ovs_code> --host <url> [--name "<agent>"] [--mcp-url <url>] [--agent claude|opencode]
|
|
28
|
-
openvisio-agent connect --backend <url> --key <api-key> --id <identifier> [--name "<agent>"] [--ws <wss-url>] [--mcp-url <url>] [--agent claude|opencode]
|
|
27
|
+
openvisio-agent connect <ovs_code> --host <url> [--name "<agent>"] [--mcp-url <url>] [--agent claude|codex|opencode]
|
|
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
30
|
openvisio-agent --help | --version
|
|
31
31
|
|
|
32
32
|
connect
|
|
33
|
-
Redeems the setup code,
|
|
34
|
-
saves a config for the autonomy watcher.
|
|
33
|
+
Redeems the setup code, configures the "openvisio-team" MCP for the selected
|
|
34
|
+
runtime, and saves a config for the autonomy watcher.
|
|
35
35
|
|
|
36
36
|
connect --backend
|
|
37
37
|
Registers a BACKEND agent (created in OpenVisio → Agents → Connect your agent):
|
|
@@ -50,14 +50,13 @@ connect --backend
|
|
|
50
50
|
pushes its agent/* branch and opens PRs. Defaults to
|
|
51
51
|
~/openvisio-workspace; point it at an existing clones folder to
|
|
52
52
|
reuse those. (--workdir is an accepted alias.)
|
|
53
|
-
--agent <name>
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
--agent <name> coding-agent runtime: claude (default), codex, or opencode.
|
|
54
|
+
Codex authenticates separately with \`codex login\`; opencode
|
|
55
|
+
authenticates with \`opencode auth login\`.
|
|
56
56
|
--chat-only disable code work — chat/ticket tools only.
|
|
57
|
-
--model <m>
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
change it live from chat: "@agent /model sonnet".
|
|
57
|
+
--model <m> model passed to the selected runtime. Claude defaults to
|
|
58
|
+
sonnet; Codex and OpenCode use their configured default.
|
|
59
|
+
Engineers can also change it live from chat: "@agent /model …".
|
|
61
60
|
--chat-model <m> run the lighter chat/mention cycles on an even cheaper model
|
|
62
61
|
while code work stays on --model (e.g. --model sonnet
|
|
63
62
|
--chat-model haiku).
|
|
@@ -92,7 +91,8 @@ function mcpReplace(claude, addArgs) {
|
|
|
92
91
|
// background service) to work — an `npx` connect leaves nothing installed. Make
|
|
93
92
|
// the global install part of setup.
|
|
94
93
|
function ensureAgentInstalled() {
|
|
95
|
-
|
|
94
|
+
const current = onPath('openvisio-agent')
|
|
95
|
+
if (current && !current.includes('/_npx/')) return
|
|
96
96
|
info('Installing openvisio-agent globally (so `watch` and the always-on service have a stable command)…')
|
|
97
97
|
spawnSync('npm', ['i', '-g', 'openvisio-agent@latest'], { stdio: 'inherit', shell: process.platform === 'win32' })
|
|
98
98
|
}
|
|
@@ -100,7 +100,7 @@ function ensureAgentInstalled() {
|
|
|
100
100
|
// The chosen coding-agent RUNTIME the cycles run on. Default claude.
|
|
101
101
|
function agentFlag(flags) {
|
|
102
102
|
const a = String(flags.agent || flags.runtime || 'claude').toLowerCase()
|
|
103
|
-
if (a !== 'claude' && a !== 'opencode') fail(`Unknown --agent "${a}". Supported runtimes: claude | opencode.`)
|
|
103
|
+
if (a !== 'claude' && a !== 'codex' && a !== 'opencode') fail(`Unknown --agent "${a}". Supported runtimes: claude | codex | opencode.`)
|
|
104
104
|
return a
|
|
105
105
|
}
|
|
106
106
|
|
|
@@ -139,6 +139,8 @@ async function runConnect({ positional, flags }) {
|
|
|
139
139
|
// A scoped MCP config for the watcher's --strict-mcp-config.
|
|
140
140
|
mcpCfg = mcpConfigPath(slug)
|
|
141
141
|
writeJson(mcpCfg, { mcpServers: { 'openvisio-team': { type: 'http', url: mcpUrl, headers: { Authorization: `Bearer ${key}` } } } }, true)
|
|
142
|
+
} else if (agent === 'codex') {
|
|
143
|
+
ensureCodex()
|
|
142
144
|
} else {
|
|
143
145
|
ensureOpencode() // the watcher writes opencode.json with the remote MCP at runtime
|
|
144
146
|
}
|
|
@@ -149,7 +151,9 @@ async function runConnect({ positional, flags }) {
|
|
|
149
151
|
info()
|
|
150
152
|
info(agent === 'opencode'
|
|
151
153
|
? 'Runtime: opencode. The watcher writes an opencode.json with the openvisio-team tools; make sure opencode is authenticated (opencode auth login).'
|
|
152
|
-
:
|
|
154
|
+
: agent === 'codex'
|
|
155
|
+
? 'Runtime: Codex. The watcher injects only the openvisio-team MCP into each cycle; authenticate once with `codex login`.'
|
|
156
|
+
: 'Claude Code now has the openvisio-team tools. Run /mcp in Claude Code to confirm.')
|
|
153
157
|
info()
|
|
154
158
|
info('To let it work on its own (reply to mentions, pick up tickets, AND do real')
|
|
155
159
|
info('coding — it clones/branches/pushes and opens PRs out of the box):')
|
|
@@ -213,6 +217,8 @@ async function runConnectBackend({ flags }) {
|
|
|
213
217
|
mcpReplace(claude, ['--transport', 'http', 'openvisio-team', mcpUrl, ...hdr])
|
|
214
218
|
mcpConfig = mcpConfigPath(slug)
|
|
215
219
|
writeJson(mcpConfig, { mcpServers: { 'openvisio-team': { type: 'http', url: mcpUrl, headers: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier } } } }, true)
|
|
220
|
+
} else if (agent === 'codex') {
|
|
221
|
+
ensureCodex()
|
|
216
222
|
} else {
|
|
217
223
|
ensureOpencode() // the watcher writes opencode.json with the remote MCP at runtime
|
|
218
224
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openvisio-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Connect
|
|
3
|
+
"version": "0.10.1",
|
|
4
|
+
"description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team — MCP tools + optional autonomy — in one command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"openvisio-agent": "bin/cli.mjs"
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"mcp",
|
|
20
20
|
"claude",
|
|
21
21
|
"claude-code",
|
|
22
|
+
"codex",
|
|
22
23
|
"agent",
|
|
23
24
|
"autonomy"
|
|
24
25
|
],
|
package/src/lib.mjs
CHANGED
|
@@ -78,6 +78,19 @@ export function ensureClaude() {
|
|
|
78
78
|
return claude
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/** Ensure the Codex CLI is installed. Authentication remains an explicit,
|
|
82
|
+
* one-time user action (`codex login`); the watcher never handles OpenAI
|
|
83
|
+
* credentials itself. */
|
|
84
|
+
export function ensureCodex() {
|
|
85
|
+
let codex = onPath('codex')
|
|
86
|
+
if (codex) return codex
|
|
87
|
+
info('Codex CLI not found on PATH — installing @openai/codex globally…')
|
|
88
|
+
spawnSync('npm', ['i', '-g', '@openai/codex'], { stdio: 'inherit', shell: IS_WIN })
|
|
89
|
+
codex = onPath('codex')
|
|
90
|
+
if (!codex) fail('Codex still isn\'t on PATH after install.\n Install it (npm i -g @openai/codex), run `codex login`, and re-run.')
|
|
91
|
+
return codex
|
|
92
|
+
}
|
|
93
|
+
|
|
81
94
|
export function writeJson(path, obj, secret = false) {
|
|
82
95
|
mkdirSync(OV_DIR, { recursive: true })
|
|
83
96
|
writeFileSync(path, JSON.stringify(obj, null, 2))
|
package/src/watch.mjs
CHANGED
|
@@ -122,9 +122,11 @@ const DENY_TOOLS = [
|
|
|
122
122
|
'Bash(gh pr merge:*)', 'Bash(gh repo delete:*)',
|
|
123
123
|
]
|
|
124
124
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
125
|
+
// Poll promptly while activity is recent, then back off hard. A 6-second idle
|
|
126
|
+
// poll across several agents creates thousands of needless network wakeups/day.
|
|
127
|
+
const FAST = 5000
|
|
128
|
+
const SLOW = 60000
|
|
129
|
+
const IDLE_AFTER = 30000
|
|
128
130
|
// Recycle the warm session after ONE cycle. Autonomy cycles are independent (a
|
|
129
131
|
// mention → a reply, a task → its work), so keeping a session across cycles just
|
|
130
132
|
// re-bills the entire prior history on every new cycle — the #1 token sink. Fresh
|
|
@@ -139,6 +141,24 @@ const SESSION_IDLE_MS = 1200000
|
|
|
139
141
|
// minutes, and cutting it off mid-job is itself a "never circled back" failure.
|
|
140
142
|
const MAX_CYCLE_MS = 240000
|
|
141
143
|
const MAX_CODE_CYCLE_MS = 900000
|
|
144
|
+
const STARTUP_SWEEP_MIN_MS = 6 * 60 * 60 * 1000
|
|
145
|
+
|
|
146
|
+
// launchd/systemd may restart a service repeatedly during upgrades or transient
|
|
147
|
+
// failures. Cap model-powered backend catch-up sweeps to one per six hours; live
|
|
148
|
+
// WebSocket events continue to arrive immediately between sweeps.
|
|
149
|
+
function claimStartupSweep(key) {
|
|
150
|
+
const marker = join(OV_DIR, 'sweep-' + slugify(key || 'openvisio') + '.at')
|
|
151
|
+
try {
|
|
152
|
+
const last = Number(readFileSync(marker, 'utf8')) || 0
|
|
153
|
+
if (Date.now() - last < STARTUP_SWEEP_MIN_MS) return false
|
|
154
|
+
mkdirSync(OV_DIR, { recursive: true })
|
|
155
|
+
writeFileSync(marker, String(Date.now()))
|
|
156
|
+
return true
|
|
157
|
+
} catch {
|
|
158
|
+
try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(marker, String(Date.now())) } catch { /* best-effort */ }
|
|
159
|
+
return true
|
|
160
|
+
}
|
|
161
|
+
}
|
|
142
162
|
|
|
143
163
|
// Refuse to run a SECOND watcher for the same agent. Two watchers connect to the
|
|
144
164
|
// WS as the same agent and BOTH reply to every mention — the #1 cause of duplicate
|
|
@@ -168,10 +188,10 @@ export async function runWatch({ flags }) {
|
|
|
168
188
|
const saved = slug ? readConfig(slug) : null
|
|
169
189
|
const claude = String(flags.claude || onPath('claude') || 'claude')
|
|
170
190
|
const mcpConfig = String(flags['mcp-config'] || (saved && saved.mcpConfig) || '')
|
|
171
|
-
// Which coding-agent runtime drives the cycles: Claude Code (warm stream-json
|
|
172
|
-
//
|
|
191
|
+
// Which coding-agent runtime drives the cycles: Claude Code (warm stream-json),
|
|
192
|
+
// Codex (`codex exec`), or opencode (`opencode run`). Default claude.
|
|
173
193
|
const agent = String(flags.agent || flags.runtime || (saved && saved.agent) || 'claude').toLowerCase()
|
|
174
|
-
if (agent !== 'claude' && agent !== 'opencode') fail(`Unknown --agent "${agent}". Supported runtimes: claude | opencode.`)
|
|
194
|
+
if (agent !== 'claude' && agent !== 'codex' && agent !== 'opencode') fail(`Unknown --agent "${agent}". Supported runtimes: claude | codex | opencode.`)
|
|
175
195
|
const mcpUrl = String(flags['mcp-url'] || (saved && saved.mcpUrl) || '')
|
|
176
196
|
// Code mode is the DEFAULT: an agent lives on the user's laptop and should just be
|
|
177
197
|
// able to work across the org's repos with no per-repo setup. So `workdir` resolves
|
|
@@ -186,11 +206,9 @@ export async function runWatch({ flags }) {
|
|
|
186
206
|
const workdir = chatOnly ? '' : (explicitWorkdir || (saved && (saved.workspace || saved.workdir)) || DEFAULT_WORKSPACE)
|
|
187
207
|
if (workdir) { try { mkdirSync(workdir, { recursive: true }) } catch { /* best-effort; spawn will surface a real problem */ } }
|
|
188
208
|
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
// unless set. Optional --chat-model runs the lighter chat/mention cycles cheaper.
|
|
193
|
-
const model = String(flags.model || (saved && saved.model) || (agent === 'opencode' ? '' : 'sonnet'))
|
|
209
|
+
// Claude defaults to Sonnet; Codex and OpenCode use their configured defaults
|
|
210
|
+
// unless explicitly overridden. Optional --chat-model can select a lighter model.
|
|
211
|
+
const model = String(flags.model || (saved && saved.model) || (agent === 'claude' ? 'sonnet' : ''))
|
|
194
212
|
const chatModel = String(flags['chat-model'] || (saved && saved.chatModel) || '')
|
|
195
213
|
|
|
196
214
|
// ONE watcher per agent. A second one (e.g. a manual `watch` alongside the
|
|
@@ -199,9 +217,21 @@ export async function runWatch({ flags }) {
|
|
|
199
217
|
if (!flags.install) {
|
|
200
218
|
const lock = acquireSingleInstance(slug || 'openvisio')
|
|
201
219
|
if (lock.conflict) {
|
|
202
|
-
|
|
220
|
+
const watcherName = slug || 'openvisio'
|
|
221
|
+
const stop = process.platform === 'darwin'
|
|
222
|
+
? `launchctl unload ~/Library/LaunchAgents/io.openvisio.${watcherName}.plist`
|
|
223
|
+
: process.platform === 'win32'
|
|
224
|
+
? 'Stop the existing openvisio-agent process in Task Manager.'
|
|
225
|
+
: `systemctl --user stop openvisio-${watcherName}.service`
|
|
226
|
+
const logs = process.platform === 'darwin'
|
|
227
|
+
? `tail -f ~/.openvisio/${watcherName}.log`
|
|
228
|
+
: `journalctl --user -u openvisio-${watcherName} -f`
|
|
229
|
+
fail(`Another openvisio-agent watcher for "${watcherName}" is already running (pid ${lock.conflict}).\n` +
|
|
203
230
|
` Two watchers for the same agent BOTH reply to every mention — that is what causes duplicate/contradicting messages.\n` +
|
|
204
|
-
`
|
|
231
|
+
` This is usually the auto-restarting background service; killing its PID only makes it restart.\n` +
|
|
232
|
+
` To run in this terminal instead, stop the service first:\n ${stop}\n` +
|
|
233
|
+
` Or keep the service and inspect its log:\n ${logs}\n` +
|
|
234
|
+
` Refusing to start a second watcher.`)
|
|
205
235
|
}
|
|
206
236
|
process.on('exit', () => { try { lock.release && lock.release() } catch { /* noop */ } })
|
|
207
237
|
}
|
|
@@ -284,6 +314,56 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
284
314
|
return { runCycle, canCode }
|
|
285
315
|
}
|
|
286
316
|
|
|
317
|
+
// Codex has a purpose-built non-interactive mode. Run a fresh, ephemeral cycle
|
|
318
|
+
// for each event and inject only this agent's OpenVisio MCP config on the command
|
|
319
|
+
// line. That avoids inheriting unrelated user MCP servers while keeping the
|
|
320
|
+
// user's normal Codex authentication. We deliberately never use Codex's dangerous
|
|
321
|
+
// approval/sandbox bypass flag.
|
|
322
|
+
function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, systemPrompt }) {
|
|
323
|
+
const bin = onPath('codex') || 'codex'
|
|
324
|
+
const cwd = workdir || OV_DIR
|
|
325
|
+
const tomlString = (v) => JSON.stringify(String(v))
|
|
326
|
+
const headerEntries = Object.entries(mcpHeaders || {}).map(([k, v]) => `${JSON.stringify(k)} = ${tomlString(v)}`).join(', ')
|
|
327
|
+
|
|
328
|
+
function runCycle(prompt, cycleModel) {
|
|
329
|
+
return new Promise((resolve) => {
|
|
330
|
+
const m = cycleModel || model
|
|
331
|
+
const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
|
|
332
|
+
const mcpOverride = mcpUrl
|
|
333
|
+
? `mcp_servers={ openvisio-team = { url = ${tomlString(mcpUrl)}${headerEntries ? `, http_headers = { ${headerEntries} }` : ''} } }`
|
|
334
|
+
: ''
|
|
335
|
+
const args = ['exec', '--ignore-user-config', '--skip-git-repo-check', '--ephemeral', '--json', '--color', 'never',
|
|
336
|
+
...(canCode ? ['--approve-for-me'] : ['--sandbox', 'read-only']),
|
|
337
|
+
...(m ? ['--model', m] : []),
|
|
338
|
+
...(mcpOverride ? ['-c', mcpOverride] : []),
|
|
339
|
+
full]
|
|
340
|
+
let child = null, done = false
|
|
341
|
+
const finish = (o) => { if (done) return; done = true; clearTimeout(timer); resolve(o) }
|
|
342
|
+
const timer = setTimeout(() => {
|
|
343
|
+
log('codex cycle TIMED OUT after ' + Math.round(maxCycleMs / 1000) + 's — killing')
|
|
344
|
+
try { child && child.kill() } catch { /* gone */ }
|
|
345
|
+
finish({ type: 'result', subtype: 'timeout' })
|
|
346
|
+
}, maxCycleMs)
|
|
347
|
+
log('running codex cycle…' + (m ? ' [' + m + ']' : ''))
|
|
348
|
+
try {
|
|
349
|
+
child = spawn(bin, args, { cwd, stdio: ['ignore', debug ? 'pipe' : 'ignore', 'inherit'] })
|
|
350
|
+
if (debug && child.stdout) child.stdout.on('data', (d) => {
|
|
351
|
+
for (const line of String(d).split('\n')) if (line.trim()) log(' · ' + line.trim().slice(0, 220))
|
|
352
|
+
})
|
|
353
|
+
} catch (e) {
|
|
354
|
+
log('codex spawn failed: ' + (e && e.message ? e.message : e) + ' — is Codex installed and signed in? (`npm i -g @openai/codex`, then `codex login`)')
|
|
355
|
+
return finish({ type: 'result', subtype: 'spawn-failed' })
|
|
356
|
+
}
|
|
357
|
+
child.on('exit', (code) => { log('codex cycle done (' + (code === 0 ? 'ok' : 'exit ' + code) + ')'); finish({ type: 'result', subtype: code === 0 ? 'ok' : 'error' }) })
|
|
358
|
+
child.on('error', (e) => { log('codex error: ' + (e && e.message ? e.message : e)); finish({ type: 'result', subtype: 'error' }) })
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (!mcpUrl) log('WARNING: no --mcp-url — Codex has no openvisio-team tools to act with. Re-connect with --mcp-url.')
|
|
363
|
+
log('codex runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + cwd + ']' : ' [CHAT-ONLY]'))
|
|
364
|
+
return { runCycle, canCode }
|
|
365
|
+
}
|
|
366
|
+
|
|
287
367
|
// ── Claude Code warm-session cycle runner (shared by the REST + WS loops) ─────
|
|
288
368
|
// One persistent stream-json session, poked with a prompt per cycle. Recycled
|
|
289
369
|
// after MAX_TURNS or SESSION_IDLE_MS. Returns { runCycle, canCode }.
|
|
@@ -293,6 +373,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
293
373
|
// opencode drives cycles differently — a headless `opencode run` per cycle rather
|
|
294
374
|
// than a persistent stream-json session. Same { runCycle, canCode } contract.
|
|
295
375
|
if (agent === 'opencode') return createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, systemPrompt })
|
|
376
|
+
if (agent === 'codex') return createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, systemPrompt })
|
|
296
377
|
let child = null
|
|
297
378
|
// The model the CURRENT session was spawned with. runCycle can pass a different
|
|
298
379
|
// model per cycle (cheap for chat, stronger for code) — a change recycles the
|
|
@@ -345,7 +426,17 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
345
426
|
if (onTool && o.type === 'assistant' && o.message && Array.isArray(o.message.content)) {
|
|
346
427
|
for (const b of o.message.content) if (b.type === 'tool_use' && b.name) { try { onTool(b.name) } catch { /* status is best-effort */ } }
|
|
347
428
|
}
|
|
348
|
-
if (o.type === 'result') {
|
|
429
|
+
if (o.type === 'result') {
|
|
430
|
+
log('cycle done (' + (o.subtype || 'ok') + (o.is_error ? ' · ERROR' : '') + ')')
|
|
431
|
+
settleTurn(o)
|
|
432
|
+
// Autonomy cycles are independent and MAX_TURNS is one. Do not leave a
|
|
433
|
+
// full Claude runtime resident until the next event; release its CPU,
|
|
434
|
+
// memory and file watchers as soon as the result has been received.
|
|
435
|
+
if (MAX_TURNS === 1 && c === child) {
|
|
436
|
+
child = null
|
|
437
|
+
try { c.kill() } catch { /* already gone */ }
|
|
438
|
+
}
|
|
439
|
+
}
|
|
349
440
|
}
|
|
350
441
|
})
|
|
351
442
|
c.on('exit', (code) => { if (c !== child) { log('old session exited ' + code); return } log('session exited ' + code); child = null; settleTurn({ type: 'result', subtype: 'exit' }) })
|
|
@@ -482,6 +573,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
482
573
|
const x = String(s || '').toLowerCase().replace(/[.,!?]+$/, '')
|
|
483
574
|
if (x === 'opus' || x === 'sonnet' || x === 'haiku') return x
|
|
484
575
|
if (/^claude-[a-z0-9.\-\[\]]+$/i.test(x)) return x
|
|
576
|
+
if (/^(?:gpt|codex|o[1-9])[a-z0-9.\-:]*$/i.test(x)) return x
|
|
485
577
|
// opencode models are provider/model (e.g. anthropic/claude-sonnet-4, openai/gpt-4o).
|
|
486
578
|
return /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9.\-:]*$/i.test(x) ? x : null
|
|
487
579
|
}
|
|
@@ -590,7 +682,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
590
682
|
|
|
591
683
|
const DAY_MS = 24 * 60 * 60 * 1000
|
|
592
684
|
let introTimer = null, sweepStartTimer = null, sweepTimer = null
|
|
593
|
-
if (mcpConfig) {
|
|
685
|
+
if (mcpConfig || mcpUrl) {
|
|
594
686
|
// Workspace ethics: a one-time hello the FIRST time this agent ever connects.
|
|
595
687
|
const introMarker = join(OV_DIR, 'intro-' + slugify(identifier) + '.done')
|
|
596
688
|
if (!existsSync(introMarker)) {
|
|
@@ -601,7 +693,11 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
601
693
|
// Catch-up sweep: shortly after startup (covers downtime) + once every day.
|
|
602
694
|
// Sooner rather than later: a freshly-added agent whose WS subscription isn't
|
|
603
695
|
// live yet still catches pending mentions/tasks via this REST poll_inbox sweep.
|
|
604
|
-
|
|
696
|
+
if (claimStartupSweep(identifier)) {
|
|
697
|
+
sweepStartTimer = setTimeout(() => { log('startup catch-up sweep'); void drain('sweep', SWEEP) }, 12_000)
|
|
698
|
+
} else {
|
|
699
|
+
log('startup catch-up sweep skipped (ran within the last 6h)')
|
|
700
|
+
}
|
|
605
701
|
sweepTimer = setInterval(() => { log('daily catch-up sweep'); void drain('sweep', SWEEP) }, DAY_MS)
|
|
606
702
|
} else {
|
|
607
703
|
log('no MCP config — skipping intro + daily sweep (agent has no tools to post/act)')
|
|
@@ -642,17 +738,15 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
|
|
|
642
738
|
// A one-off prompt (intro / daily sweep) the main loop runs the next time it's
|
|
643
739
|
// free — keeps everything on the single runCycle so nothing overlaps.
|
|
644
740
|
let queuedSpecial = null
|
|
645
|
-
if (mcpConfig) {
|
|
741
|
+
if (mcpConfig || mcpUrl) {
|
|
646
742
|
const introMarker = join(OV_DIR, 'intro-' + (slug || 'openvisio') + '.done')
|
|
647
743
|
if (!existsSync(introMarker)) {
|
|
648
744
|
try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
|
|
649
745
|
log('first run — introducing self to the workspace')
|
|
650
746
|
setTimeout(() => { queuedSpecial = INTRO }, 5000)
|
|
651
747
|
}
|
|
652
|
-
// The
|
|
653
|
-
//
|
|
654
|
-
// sweep re-checks get_marching_orders so those are still picked up.
|
|
655
|
-
setTimeout(() => { log('startup catch-up sweep'); queuedSpecial = SWEEP + '\n\n' + fullPrompt }, 12_000)
|
|
748
|
+
// The first inbox response below decides whether a startup sweep is needed.
|
|
749
|
+
// Do not spend a model turn on every service restart when the inbox is empty.
|
|
656
750
|
setInterval(() => { log('daily catch-up sweep'); queuedSpecial = SWEEP + '\n\n' + fullPrompt }, 24 * 60 * 60 * 1000)
|
|
657
751
|
}
|
|
658
752
|
|
|
@@ -687,7 +781,12 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
|
|
|
687
781
|
const items = Array.isArray(res.items) ? res.items : []
|
|
688
782
|
if (firstCheck && Array.isArray(res.items)) {
|
|
689
783
|
firstCheck = false
|
|
784
|
+
const hasTaskBacklog = items.some((i) => i.startsWith('tk:') || i.startsWith('clm:'))
|
|
690
785
|
for (const i of items) if (i.startsWith('tk:') || i.startsWith('clm:')) seen.add(i)
|
|
786
|
+
if (hasTaskBacklog) {
|
|
787
|
+
log('startup task backlog -> catch-up sweep')
|
|
788
|
+
queuedSpecial = SWEEP + '\n\n' + fullPrompt
|
|
789
|
+
}
|
|
691
790
|
}
|
|
692
791
|
if (res.paused) {
|
|
693
792
|
delay = SLOW
|
|
@@ -728,7 +827,7 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
|
|
|
728
827
|
// ── background service install (launchd / systemd) ───────────────────────────
|
|
729
828
|
export function installService({ slug, workdir }) {
|
|
730
829
|
const binPath = onPath('openvisio-agent')
|
|
731
|
-
if (!binPath) {
|
|
830
|
+
if (!binPath || binPath.includes('/_npx/')) {
|
|
732
831
|
info('Installing openvisio-agent globally so the background service has a stable path…')
|
|
733
832
|
spawnSync('npm', ['i', '-g', 'openvisio-agent'], { stdio: 'inherit', shell: process.platform === 'win32' })
|
|
734
833
|
}
|
|
@@ -736,7 +835,8 @@ export function installService({ slug, workdir }) {
|
|
|
736
835
|
const nodeDir = dirname(process.execPath)
|
|
737
836
|
const claudeBin = onPath('claude')
|
|
738
837
|
const opencodeBin = onPath('opencode')
|
|
739
|
-
const
|
|
838
|
+
const codexBin = onPath('codex')
|
|
839
|
+
const runPath = [nodeDir, claudeBin ? dirname(claudeBin) : '', codexBin ? dirname(codexBin) : '', opencodeBin ? dirname(opencodeBin) : '', join(homedir(), '.opencode', 'bin'), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin'].filter(Boolean).join(':')
|
|
740
840
|
const args = ['watch', '--name', slug, ...(workdir ? ['--workdir', workdir] : [])]
|
|
741
841
|
mkdirSync(OV_DIR, { recursive: true })
|
|
742
842
|
const logFile = join(OV_DIR, `${slug}.log`)
|