openvisio-agent 0.8.1 → 0.10.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 +11 -4
- package/bin/cli.mjs +19 -14
- package/package.json +3 -2
- package/src/lib.mjs +13 -0
- package/src/watch.mjs +148 -39
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).
|
|
@@ -100,7 +99,7 @@ function ensureAgentInstalled() {
|
|
|
100
99
|
// The chosen coding-agent RUNTIME the cycles run on. Default claude.
|
|
101
100
|
function agentFlag(flags) {
|
|
102
101
|
const a = String(flags.agent || flags.runtime || 'claude').toLowerCase()
|
|
103
|
-
if (a !== 'claude' && a !== 'opencode') fail(`Unknown --agent "${a}". Supported runtimes: claude | opencode.`)
|
|
102
|
+
if (a !== 'claude' && a !== 'codex' && a !== 'opencode') fail(`Unknown --agent "${a}". Supported runtimes: claude | codex | opencode.`)
|
|
104
103
|
return a
|
|
105
104
|
}
|
|
106
105
|
|
|
@@ -139,6 +138,8 @@ async function runConnect({ positional, flags }) {
|
|
|
139
138
|
// A scoped MCP config for the watcher's --strict-mcp-config.
|
|
140
139
|
mcpCfg = mcpConfigPath(slug)
|
|
141
140
|
writeJson(mcpCfg, { mcpServers: { 'openvisio-team': { type: 'http', url: mcpUrl, headers: { Authorization: `Bearer ${key}` } } } }, true)
|
|
141
|
+
} else if (agent === 'codex') {
|
|
142
|
+
ensureCodex()
|
|
142
143
|
} else {
|
|
143
144
|
ensureOpencode() // the watcher writes opencode.json with the remote MCP at runtime
|
|
144
145
|
}
|
|
@@ -149,7 +150,9 @@ async function runConnect({ positional, flags }) {
|
|
|
149
150
|
info()
|
|
150
151
|
info(agent === 'opencode'
|
|
151
152
|
? 'Runtime: opencode. The watcher writes an opencode.json with the openvisio-team tools; make sure opencode is authenticated (opencode auth login).'
|
|
152
|
-
:
|
|
153
|
+
: agent === 'codex'
|
|
154
|
+
? 'Runtime: Codex. The watcher injects only the openvisio-team MCP into each cycle; authenticate once with `codex login`.'
|
|
155
|
+
: 'Claude Code now has the openvisio-team tools. Run /mcp in Claude Code to confirm.')
|
|
153
156
|
info()
|
|
154
157
|
info('To let it work on its own (reply to mentions, pick up tickets, AND do real')
|
|
155
158
|
info('coding — it clones/branches/pushes and opens PRs out of the box):')
|
|
@@ -213,6 +216,8 @@ async function runConnectBackend({ flags }) {
|
|
|
213
216
|
mcpReplace(claude, ['--transport', 'http', 'openvisio-team', mcpUrl, ...hdr])
|
|
214
217
|
mcpConfig = mcpConfigPath(slug)
|
|
215
218
|
writeJson(mcpConfig, { mcpServers: { 'openvisio-team': { type: 'http', url: mcpUrl, headers: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier } } } }, true)
|
|
219
|
+
} else if (agent === 'codex') {
|
|
220
|
+
ensureCodex()
|
|
216
221
|
} else {
|
|
217
222
|
ensureOpencode() // the watcher writes opencode.json with the remote MCP at runtime
|
|
218
223
|
}
|
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.0",
|
|
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
|
@@ -40,8 +40,11 @@ const CHAT_CHARTER = [
|
|
|
40
40
|
REPLY_DISCIPLINE,
|
|
41
41
|
].join('\n')
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// NOTE: the CHARTER (who you are + reply discipline) is NOT prepended here — it is
|
|
44
|
+
// passed ONCE as the session's system prompt (cacheable, not re-billed every cycle).
|
|
45
|
+
// These bases are the small per-cycle instructions only. See createCycleRunner.
|
|
46
|
+
const CYCLE = 'Run one OpenVisio autonomy cycle: call poll_inbox and handle mentions + follow-ups. Reply in 1-3 sentences, @mention people by their EXACT full name, at most one reply per channel. Then stop.'
|
|
47
|
+
const CYCLE_FAST = [
|
|
45
48
|
'New chat activity. Do EXACTLY ONE of these:',
|
|
46
49
|
' • IF a specific mention/message FOR YOU is given above: reply to THAT ONE message exactly once with post_message, then STOP. Do NOT call poll_inbox and do NOT answer anything else this cycle — you already have the message; polling would make you re-answer it and double-post.',
|
|
47
50
|
' • IF NO specific mention is given above: call poll_inbox and reply only to items truly directed at YOU (a question to you, or a reply to your own message) — SKIP chatter aimed at someone else / another agent, ignore .tasks/.claimable, at most one reply per channel.',
|
|
@@ -67,7 +70,7 @@ const CODE_CHARTER = [
|
|
|
67
70
|
REPLY_DISCIPLINE,
|
|
68
71
|
].join('\n')
|
|
69
72
|
|
|
70
|
-
const CODE_FULL =
|
|
73
|
+
const CODE_FULL = [
|
|
71
74
|
'THIS CYCLE: call get_marching_orders and poll_inbox to see assigned tickets + mentions, then act on them.',
|
|
72
75
|
'ACKNOWLEDGE ONCE: for a task assigned to you that you have NOT already acknowledged, post a SINGLE one-line comment_ticket ("On it — picking this up now") before you start. First check the ticket/thread — if you already acknowledged it on an earlier cycle, skip this and just keep working. Then report only when you have the result.',
|
|
73
76
|
'For real code work (an assigned ticket, or a mention asking for changes), run the full flow end-to-end:',
|
|
@@ -80,7 +83,7 @@ const CODE_FULL = CODE_CHARTER + '\n\n' + [
|
|
|
80
83
|
'Bash is for git / gh / tests / clone ONLY — never to hunt for credentials (they are given to you above).',
|
|
81
84
|
].join('\n')
|
|
82
85
|
|
|
83
|
-
const CODE_FAST =
|
|
86
|
+
const CODE_FAST = [
|
|
84
87
|
'New chat activity. Do EXACTLY ONE of these:',
|
|
85
88
|
' • IF a specific mention/message FOR YOU is given above: reply to THAT ONE message exactly once with post_message, then STOP. Do NOT call poll_inbox and do NOT answer anything else this cycle — polling would re-surface the same message and make you double-post.',
|
|
86
89
|
' • IF NO specific mention is given above: call poll_inbox and reply only to items directed at YOU (asks you something, or responds to your own message) — SKIP chatter aimed at someone else / another agent; at most one reply per channel.',
|
|
@@ -119,10 +122,17 @@ const DENY_TOOLS = [
|
|
|
119
122
|
'Bash(gh pr merge:*)', 'Bash(gh repo delete:*)',
|
|
120
123
|
]
|
|
121
124
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
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
|
|
130
|
+
// Recycle the warm session after ONE cycle. Autonomy cycles are independent (a
|
|
131
|
+
// mention → a reply, a task → its work), so keeping a session across cycles just
|
|
132
|
+
// re-bills the entire prior history on every new cycle — the #1 token sink. Fresh
|
|
133
|
+
// per cycle keeps each cycle's cost to its own work (the static charter/creds ride
|
|
134
|
+
// in the cached system prompt, so a fresh spawn is cheap).
|
|
135
|
+
const MAX_TURNS = 1
|
|
126
136
|
const SESSION_IDLE_MS = 1200000
|
|
127
137
|
// A single cycle must finish within this or it's abandoned — otherwise a hung
|
|
128
138
|
// cycle (e.g. an MCP tool stalling on a down bridge) would leave `busy` stuck
|
|
@@ -131,6 +141,24 @@ const SESSION_IDLE_MS = 1200000
|
|
|
131
141
|
// minutes, and cutting it off mid-job is itself a "never circled back" failure.
|
|
132
142
|
const MAX_CYCLE_MS = 240000
|
|
133
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
|
+
}
|
|
134
162
|
|
|
135
163
|
// Refuse to run a SECOND watcher for the same agent. Two watchers connect to the
|
|
136
164
|
// WS as the same agent and BOTH reply to every mention — the #1 cause of duplicate
|
|
@@ -160,10 +188,10 @@ export async function runWatch({ flags }) {
|
|
|
160
188
|
const saved = slug ? readConfig(slug) : null
|
|
161
189
|
const claude = String(flags.claude || onPath('claude') || 'claude')
|
|
162
190
|
const mcpConfig = String(flags['mcp-config'] || (saved && saved.mcpConfig) || '')
|
|
163
|
-
// Which coding-agent runtime drives the cycles: Claude Code (warm stream-json
|
|
164
|
-
//
|
|
191
|
+
// Which coding-agent runtime drives the cycles: Claude Code (warm stream-json),
|
|
192
|
+
// Codex (`codex exec`), or opencode (`opencode run`). Default claude.
|
|
165
193
|
const agent = String(flags.agent || flags.runtime || (saved && saved.agent) || 'claude').toLowerCase()
|
|
166
|
-
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.`)
|
|
167
195
|
const mcpUrl = String(flags['mcp-url'] || (saved && saved.mcpUrl) || '')
|
|
168
196
|
// Code mode is the DEFAULT: an agent lives on the user's laptop and should just be
|
|
169
197
|
// able to work across the org's repos with no per-repo setup. So `workdir` resolves
|
|
@@ -178,11 +206,9 @@ export async function runWatch({ flags }) {
|
|
|
178
206
|
const workdir = chatOnly ? '' : (explicitWorkdir || (saved && (saved.workspace || saved.workdir)) || DEFAULT_WORKSPACE)
|
|
179
207
|
if (workdir) { try { mkdirSync(workdir, { recursive: true }) } catch { /* best-effort; spawn will surface a real problem */ } }
|
|
180
208
|
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
// unless set. Optional --chat-model runs the lighter chat/mention cycles cheaper.
|
|
185
|
-
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' : ''))
|
|
186
212
|
const chatModel = String(flags['chat-model'] || (saved && saved.chatModel) || '')
|
|
187
213
|
|
|
188
214
|
// ONE watcher per agent. A second one (e.g. a manual `watch` alongside the
|
|
@@ -227,7 +253,7 @@ export async function runWatch({ flags }) {
|
|
|
227
253
|
// The openvisio-team MCP is declared in an `opencode.json` written into the run cwd
|
|
228
254
|
// (opencode reads it from there). `--auto` approves tool use non-interactively.
|
|
229
255
|
// Same { runCycle, canCode } contract as the Claude runner.
|
|
230
|
-
function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model }) {
|
|
256
|
+
function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, systemPrompt }) {
|
|
231
257
|
// opencode reads opencode.json from its CWD: the code workspace, or a dedicated
|
|
232
258
|
// per-agent dir for chat-only agents.
|
|
233
259
|
const cwd = workdir || join(OV_DIR, 'opencode-' + (cfgKey || 'agent'))
|
|
@@ -253,7 +279,10 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
253
279
|
return new Promise((resolve) => {
|
|
254
280
|
ensureConfig()
|
|
255
281
|
const m = cycleModel || model
|
|
256
|
-
|
|
282
|
+
// opencode has no system-prompt flag; each run is a fresh process, so fold the
|
|
283
|
+
// charter/creds into the message (still not re-accumulated across cycles).
|
|
284
|
+
const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
|
|
285
|
+
const args = ['run', full, '--auto', ...(m ? ['--model', m] : [])]
|
|
257
286
|
let child = null, done = false
|
|
258
287
|
const finish = (o) => { if (done) return; done = true; clearTimeout(timer); resolve(o) }
|
|
259
288
|
const timer = setTimeout(() => {
|
|
@@ -273,15 +302,66 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
273
302
|
return { runCycle, canCode }
|
|
274
303
|
}
|
|
275
304
|
|
|
305
|
+
// Codex has a purpose-built non-interactive mode. Run a fresh, ephemeral cycle
|
|
306
|
+
// for each event and inject only this agent's OpenVisio MCP config on the command
|
|
307
|
+
// line. That avoids inheriting unrelated user MCP servers while keeping the
|
|
308
|
+
// user's normal Codex authentication. We deliberately never use Codex's dangerous
|
|
309
|
+
// approval/sandbox bypass flag.
|
|
310
|
+
function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, systemPrompt }) {
|
|
311
|
+
const bin = onPath('codex') || 'codex'
|
|
312
|
+
const cwd = workdir || OV_DIR
|
|
313
|
+
const tomlString = (v) => JSON.stringify(String(v))
|
|
314
|
+
const headerEntries = Object.entries(mcpHeaders || {}).map(([k, v]) => `${JSON.stringify(k)} = ${tomlString(v)}`).join(', ')
|
|
315
|
+
|
|
316
|
+
function runCycle(prompt, cycleModel) {
|
|
317
|
+
return new Promise((resolve) => {
|
|
318
|
+
const m = cycleModel || model
|
|
319
|
+
const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
|
|
320
|
+
const mcpOverride = mcpUrl
|
|
321
|
+
? `mcp_servers={ openvisio-team = { url = ${tomlString(mcpUrl)}${headerEntries ? `, http_headers = { ${headerEntries} }` : ''} } }`
|
|
322
|
+
: ''
|
|
323
|
+
const args = ['exec', '--ignore-user-config', '--skip-git-repo-check', '--ephemeral', '--json', '--color', 'never',
|
|
324
|
+
'--sandbox', canCode ? 'workspace-write' : 'read-only', '--approve-for-me',
|
|
325
|
+
...(m ? ['--model', m] : []),
|
|
326
|
+
...(mcpOverride ? ['-c', mcpOverride] : []),
|
|
327
|
+
full]
|
|
328
|
+
let child = null, done = false
|
|
329
|
+
const finish = (o) => { if (done) return; done = true; clearTimeout(timer); resolve(o) }
|
|
330
|
+
const timer = setTimeout(() => {
|
|
331
|
+
log('codex cycle TIMED OUT after ' + Math.round(maxCycleMs / 1000) + 's — killing')
|
|
332
|
+
try { child && child.kill() } catch { /* gone */ }
|
|
333
|
+
finish({ type: 'result', subtype: 'timeout' })
|
|
334
|
+
}, maxCycleMs)
|
|
335
|
+
log('running codex cycle…' + (m ? ' [' + m + ']' : ''))
|
|
336
|
+
try {
|
|
337
|
+
child = spawn(bin, args, { cwd, stdio: ['ignore', debug ? 'pipe' : 'ignore', 'inherit'] })
|
|
338
|
+
if (debug && child.stdout) child.stdout.on('data', (d) => {
|
|
339
|
+
for (const line of String(d).split('\n')) if (line.trim()) log(' · ' + line.trim().slice(0, 220))
|
|
340
|
+
})
|
|
341
|
+
} catch (e) {
|
|
342
|
+
log('codex spawn failed: ' + (e && e.message ? e.message : e) + ' — is Codex installed and signed in? (`npm i -g @openai/codex`, then `codex login`)')
|
|
343
|
+
return finish({ type: 'result', subtype: 'spawn-failed' })
|
|
344
|
+
}
|
|
345
|
+
child.on('exit', (code) => { log('codex cycle done (' + (code === 0 ? 'ok' : 'exit ' + code) + ')'); finish({ type: 'result', subtype: code === 0 ? 'ok' : 'error' }) })
|
|
346
|
+
child.on('error', (e) => { log('codex error: ' + (e && e.message ? e.message : e)); finish({ type: 'result', subtype: 'error' }) })
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (!mcpUrl) log('WARNING: no --mcp-url — Codex has no openvisio-team tools to act with. Re-connect with --mcp-url.')
|
|
351
|
+
log('codex runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + cwd + ']' : ' [CHAT-ONLY]'))
|
|
352
|
+
return { runCycle, canCode }
|
|
353
|
+
}
|
|
354
|
+
|
|
276
355
|
// ── Claude Code warm-session cycle runner (shared by the REST + WS loops) ─────
|
|
277
356
|
// One persistent stream-json session, poked with a prompt per cycle. Recycled
|
|
278
357
|
// after MAX_TURNS or SESSION_IDLE_MS. Returns { runCycle, canCode }.
|
|
279
|
-
function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool }) {
|
|
358
|
+
function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, systemPrompt }) {
|
|
280
359
|
const canCode = !!workdir
|
|
281
360
|
const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
|
|
282
361
|
// opencode drives cycles differently — a headless `opencode run` per cycle rather
|
|
283
362
|
// than a persistent stream-json session. Same { runCycle, canCode } contract.
|
|
284
|
-
if (agent === 'opencode') return createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model })
|
|
363
|
+
if (agent === 'opencode') return createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, systemPrompt })
|
|
364
|
+
if (agent === 'codex') return createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, systemPrompt })
|
|
285
365
|
let child = null
|
|
286
366
|
// The model the CURRENT session was spawned with. runCycle can pass a different
|
|
287
367
|
// model per cycle (cheap for chat, stronger for code) — a change recycles the
|
|
@@ -312,7 +392,9 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
312
392
|
function ensureSession() {
|
|
313
393
|
if (child && !child.killed) return
|
|
314
394
|
if (!mcpConfig) { log('WARNING: no MCP config — the agent can react to events but has no tools to act. Re-connect with --mcp-url.') }
|
|
315
|
-
|
|
395
|
+
// The charter + creds ride in the system prompt (cacheable → not re-billed each
|
|
396
|
+
// cycle), leaving only the small per-cycle instruction in the user message.
|
|
397
|
+
const base = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--strict-mcp-config', '--mcp-config', mcpConfig, ...(sessionModel ? ['--model', sessionModel] : []), ...(systemPrompt ? ['--append-system-prompt', systemPrompt] : [])]
|
|
316
398
|
const args = canCode ? [...base, '--allowedTools', ...CODE_TOOLS, '--disallowedTools', ...DENY_TOOLS] : [...base, '--allowedTools', 'mcp__openvisio-team__*']
|
|
317
399
|
const c = spawn(claude, args, { cwd: workdir || undefined, stdio: ['pipe', 'pipe', 'inherit'] })
|
|
318
400
|
child = c
|
|
@@ -332,7 +414,17 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
332
414
|
if (onTool && o.type === 'assistant' && o.message && Array.isArray(o.message.content)) {
|
|
333
415
|
for (const b of o.message.content) if (b.type === 'tool_use' && b.name) { try { onTool(b.name) } catch { /* status is best-effort */ } }
|
|
334
416
|
}
|
|
335
|
-
if (o.type === 'result') {
|
|
417
|
+
if (o.type === 'result') {
|
|
418
|
+
log('cycle done (' + (o.subtype || 'ok') + (o.is_error ? ' · ERROR' : '') + ')')
|
|
419
|
+
settleTurn(o)
|
|
420
|
+
// Autonomy cycles are independent and MAX_TURNS is one. Do not leave a
|
|
421
|
+
// full Claude runtime resident until the next event; release its CPU,
|
|
422
|
+
// memory and file watchers as soon as the result has been received.
|
|
423
|
+
if (MAX_TURNS === 1 && c === child) {
|
|
424
|
+
child = null
|
|
425
|
+
try { c.kill() } catch { /* already gone */ }
|
|
426
|
+
}
|
|
427
|
+
}
|
|
336
428
|
}
|
|
337
429
|
})
|
|
338
430
|
c.on('exit', (code) => { if (c !== child) { log('old session exited ' + code); return } log('session exited ' + code); child = null; settleTurn({ type: 'result', subtype: 'exit' }) })
|
|
@@ -387,9 +479,16 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
387
479
|
// agent:status broadcast (thinking → working → typing → done).
|
|
388
480
|
const statusTargets = new Set()
|
|
389
481
|
const emitStatus = (state) => { for (const c of statusTargets) { try { handle && handle.sendStatus(c, state) } catch { /* best-effort */ } } }
|
|
390
|
-
const
|
|
482
|
+
const canCode = !!workdir
|
|
483
|
+
// The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
|
|
484
|
+
// agent_identifier + agent_api_key as arguments. Hand them over up front.
|
|
485
|
+
const credNote = `AUTH: the openvisio-team tools REQUIRE two arguments on EVERY call — agent_identifier: "${identifier}" and agent_api_key: "${apiKey}". Include BOTH on every openvisio-team tool call (post_message, poll_inbox, react_message, comment_ticket, get_marching_orders, …). These tools may appear in your tool list namespaced (mcp__openvisio-team__post_message on Claude Code, openvisio-team_post_message on opencode) — call whichever names you actually see. The credentials are given to you right here — do NOT hunt for them (no Bash/grep/cat/find, no reading memory); just call the tools with these exact values. (Bash/git/gh ARE for your code work — this rule is only about not searching for these keys.)`
|
|
486
|
+
// The STATIC charter + creds are the session system prompt (cached, billed once),
|
|
487
|
+
// NOT re-sent in every cycle's user message — the big token saving.
|
|
488
|
+
const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
|
|
489
|
+
const { runCycle } = createCycleRunner({
|
|
391
490
|
claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
|
|
392
|
-
cfgKey: identifier, mcpConfig, workdir, log, debug, model,
|
|
491
|
+
cfgKey: identifier, mcpConfig, workdir, log, debug, model, systemPrompt,
|
|
393
492
|
// The moment the agent calls post_message it is about to speak → "typing".
|
|
394
493
|
onTool: (name) => { if (/post_message/.test(name)) emitStatus('typing') },
|
|
395
494
|
})
|
|
@@ -417,11 +516,6 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
417
516
|
// events and drained into the next cycle's prompt.
|
|
418
517
|
const pending = []
|
|
419
518
|
|
|
420
|
-
// The Mastra bridge authenticates per-CALL, not per-connection: every
|
|
421
|
-
// openvisio-team tool needs agent_identifier + agent_api_key as arguments.
|
|
422
|
-
// Hand them to the model up front so it never shells around hunting for them.
|
|
423
|
-
const credNote = `AUTH: the openvisio-team tools REQUIRE two arguments on EVERY call — agent_identifier: "${identifier}" and agent_api_key: "${apiKey}". Include BOTH on every openvisio-team tool call (post_message, poll_inbox, react_message, comment_ticket, get_marching_orders, …). These tools may appear in your tool list namespaced (mcp__openvisio-team__post_message on Claude Code, openvisio-team_post_message on opencode) — call whichever names you actually see. The credentials are given to you right here — do NOT hunt for them (no Bash/grep/cat/find, no reading memory); just call the tools with these exact values. (Bash/git/gh ARE for your code work — this rule is only about not searching for these keys.)`
|
|
424
|
-
|
|
425
519
|
// Higher rank wins when coalescing cycles requested while one is running.
|
|
426
520
|
const RANK = { fast: 0, intro: 1, sweep: 2, full: 3 }
|
|
427
521
|
const baseFor = (kind) => kind === 'intro' ? INTRO : (kind === 'full' || kind === 'sweep') ? fullPrompt : fastPrompt
|
|
@@ -431,7 +525,9 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
431
525
|
if (busy) { queued = (RANK[kind] ?? 0) >= (RANK[queued] ?? 0) ? kind : queued; log('busy — queued a ' + kind + ' follow-up cycle'); return }
|
|
432
526
|
busy = true
|
|
433
527
|
const ctx = pending.splice(0) // take everything accumulated so far
|
|
434
|
-
|
|
528
|
+
// credNote + charter live in the cached system prompt now — the per-cycle
|
|
529
|
+
// message is just the event context + the small base instruction.
|
|
530
|
+
const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + baseFor(kind)
|
|
435
531
|
// Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
|
|
436
532
|
// work (full/sweep) uses the main model.
|
|
437
533
|
const useModel = (kind === 'fast' || kind === 'intro') ? liteModel : codeModel
|
|
@@ -465,6 +561,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
465
561
|
const x = String(s || '').toLowerCase().replace(/[.,!?]+$/, '')
|
|
466
562
|
if (x === 'opus' || x === 'sonnet' || x === 'haiku') return x
|
|
467
563
|
if (/^claude-[a-z0-9.\-\[\]]+$/i.test(x)) return x
|
|
564
|
+
if (/^(?:gpt|codex|o[1-9])[a-z0-9.\-:]*$/i.test(x)) return x
|
|
468
565
|
// opencode models are provider/model (e.g. anthropic/claude-sonnet-4, openai/gpt-4o).
|
|
469
566
|
return /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9.\-:]*$/i.test(x) ? x : null
|
|
470
567
|
}
|
|
@@ -573,7 +670,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
573
670
|
|
|
574
671
|
const DAY_MS = 24 * 60 * 60 * 1000
|
|
575
672
|
let introTimer = null, sweepStartTimer = null, sweepTimer = null
|
|
576
|
-
if (mcpConfig) {
|
|
673
|
+
if (mcpConfig || mcpUrl) {
|
|
577
674
|
// Workspace ethics: a one-time hello the FIRST time this agent ever connects.
|
|
578
675
|
const introMarker = join(OV_DIR, 'intro-' + slugify(identifier) + '.done')
|
|
579
676
|
if (!existsSync(introMarker)) {
|
|
@@ -584,7 +681,11 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
584
681
|
// Catch-up sweep: shortly after startup (covers downtime) + once every day.
|
|
585
682
|
// Sooner rather than later: a freshly-added agent whose WS subscription isn't
|
|
586
683
|
// live yet still catches pending mentions/tasks via this REST poll_inbox sweep.
|
|
587
|
-
|
|
684
|
+
if (claimStartupSweep(identifier)) {
|
|
685
|
+
sweepStartTimer = setTimeout(() => { log('startup catch-up sweep'); void drain('sweep', SWEEP) }, 12_000)
|
|
686
|
+
} else {
|
|
687
|
+
log('startup catch-up sweep skipped (ran within the last 6h)')
|
|
688
|
+
}
|
|
588
689
|
sweepTimer = setInterval(() => { log('daily catch-up sweep'); void drain('sweep', SWEEP) }, DAY_MS)
|
|
589
690
|
} else {
|
|
590
691
|
log('no MCP config — skipping intro + daily sweep (agent has no tools to post/act)')
|
|
@@ -609,7 +710,11 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
609
710
|
function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }) {
|
|
610
711
|
const log = (m) => process.stdout.write('[warm ' + new Date().toISOString() + '] ' + m + '\n')
|
|
611
712
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
612
|
-
const
|
|
713
|
+
const canCode = !!workdir
|
|
714
|
+
// Static charter as the cached system prompt (this loop's MCP authenticates via a
|
|
715
|
+
// Bearer header, so no per-call creds are needed in the prompt).
|
|
716
|
+
const systemPrompt = canCode ? CODE_CHARTER : CHAT_CHARTER
|
|
717
|
+
const { runCycle } = createCycleRunner({ claude, agent, mcpUrl, mcpHeaders: { Authorization: 'Bearer ' + key }, cfgKey: slug, mcpConfig, workdir, log, debug, model, systemPrompt })
|
|
613
718
|
const fullPrompt = canCode ? CODE_FULL : CYCLE
|
|
614
719
|
const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
|
|
615
720
|
const liteModel = chatModel || model // cheaper model for chat/quick cycles
|
|
@@ -621,17 +726,15 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
|
|
|
621
726
|
// A one-off prompt (intro / daily sweep) the main loop runs the next time it's
|
|
622
727
|
// free — keeps everything on the single runCycle so nothing overlaps.
|
|
623
728
|
let queuedSpecial = null
|
|
624
|
-
if (mcpConfig) {
|
|
729
|
+
if (mcpConfig || mcpUrl) {
|
|
625
730
|
const introMarker = join(OV_DIR, 'intro-' + (slug || 'openvisio') + '.done')
|
|
626
731
|
if (!existsSync(introMarker)) {
|
|
627
732
|
try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
|
|
628
733
|
log('first run — introducing self to the workspace')
|
|
629
734
|
setTimeout(() => { queuedSpecial = INTRO }, 5000)
|
|
630
735
|
}
|
|
631
|
-
// The
|
|
632
|
-
//
|
|
633
|
-
// sweep re-checks get_marching_orders so those are still picked up.
|
|
634
|
-
setTimeout(() => { log('startup catch-up sweep'); queuedSpecial = SWEEP + '\n\n' + fullPrompt }, 12_000)
|
|
736
|
+
// The first inbox response below decides whether a startup sweep is needed.
|
|
737
|
+
// Do not spend a model turn on every service restart when the inbox is empty.
|
|
635
738
|
setInterval(() => { log('daily catch-up sweep'); queuedSpecial = SWEEP + '\n\n' + fullPrompt }, 24 * 60 * 60 * 1000)
|
|
636
739
|
}
|
|
637
740
|
|
|
@@ -666,7 +769,12 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
|
|
|
666
769
|
const items = Array.isArray(res.items) ? res.items : []
|
|
667
770
|
if (firstCheck && Array.isArray(res.items)) {
|
|
668
771
|
firstCheck = false
|
|
772
|
+
const hasTaskBacklog = items.some((i) => i.startsWith('tk:') || i.startsWith('clm:'))
|
|
669
773
|
for (const i of items) if (i.startsWith('tk:') || i.startsWith('clm:')) seen.add(i)
|
|
774
|
+
if (hasTaskBacklog) {
|
|
775
|
+
log('startup task backlog -> catch-up sweep')
|
|
776
|
+
queuedSpecial = SWEEP + '\n\n' + fullPrompt
|
|
777
|
+
}
|
|
670
778
|
}
|
|
671
779
|
if (res.paused) {
|
|
672
780
|
delay = SLOW
|
|
@@ -715,7 +823,8 @@ export function installService({ slug, workdir }) {
|
|
|
715
823
|
const nodeDir = dirname(process.execPath)
|
|
716
824
|
const claudeBin = onPath('claude')
|
|
717
825
|
const opencodeBin = onPath('opencode')
|
|
718
|
-
const
|
|
826
|
+
const codexBin = onPath('codex')
|
|
827
|
+
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(':')
|
|
719
828
|
const args = ['watch', '--name', slug, ...(workdir ? ['--workdir', workdir] : [])]
|
|
720
829
|
mkdirSync(OV_DIR, { recursive: true })
|
|
721
830
|
const logFile = join(OV_DIR, `${slug}.log`)
|