openvisio-agent 0.9.0 → 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 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
- That's it. Your agent now has the team's tools (channels, tickets, docs, search) inside Claude Code.
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. Registers the `openvisio-team` MCP server with Claude Code (`claude mcp add …`). Installs Claude Code first if it isn't on your PATH.
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
- - [Claude Code](https://www.npmjs.com/package/@anthropic-ai/claude-code) (auto-installed if missing)
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, adds the "openvisio-team" MCP server to Claude Code, and
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> the coding-agent RUNTIME the cycles run on: claude (Claude Code,
54
- default) or opencode (opencode.ai). opencode is authenticated
55
- separately (opencode auth login) and uses provider/model ids.
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> the model the agent runs on (opus | sonnet | haiku | a full
58
- claude-… id). Defaults to sonnet cost-effective, so the agent
59
- doesn't burn Opus tokens on routine chat. Engineers can also
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
- : 'Claude Code now has the openvisio-team tools. Run /mcp in Claude Code to confirm.')
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.9.0",
4
- "description": "Connect your coding agent (Claude Code) to an OpenVisio team — MCP tools + optional autonomy — in one command. No shell scripts.",
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
@@ -122,9 +122,11 @@ const DENY_TOOLS = [
122
122
  'Bash(gh pr merge:*)', 'Bash(gh repo delete:*)',
123
123
  ]
124
124
 
125
- const FAST = 2500
126
- const SLOW = 6000
127
- const IDLE_AFTER = 60000
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
- // session) or opencode (headless `opencode run` per cycle). Default claude.
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
- // Model the agent runs on. For Claude Code default SONNET (cost-effective) rather
190
- // than whatever `claude` defaults to. For opencode, models are `provider/model`
191
- // and multi-provider, so default to empty (use opencode's own configured default)
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
@@ -284,6 +302,56 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
284
302
  return { runCycle, canCode }
285
303
  }
286
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
+
287
355
  // ── Claude Code warm-session cycle runner (shared by the REST + WS loops) ─────
288
356
  // One persistent stream-json session, poked with a prompt per cycle. Recycled
289
357
  // after MAX_TURNS or SESSION_IDLE_MS. Returns { runCycle, canCode }.
@@ -293,6 +361,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
293
361
  // opencode drives cycles differently — a headless `opencode run` per cycle rather
294
362
  // than a persistent stream-json session. Same { runCycle, canCode } contract.
295
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 })
296
365
  let child = null
297
366
  // The model the CURRENT session was spawned with. runCycle can pass a different
298
367
  // model per cycle (cheap for chat, stronger for code) — a change recycles the
@@ -345,7 +414,17 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
345
414
  if (onTool && o.type === 'assistant' && o.message && Array.isArray(o.message.content)) {
346
415
  for (const b of o.message.content) if (b.type === 'tool_use' && b.name) { try { onTool(b.name) } catch { /* status is best-effort */ } }
347
416
  }
348
- if (o.type === 'result') { log('cycle done (' + (o.subtype || 'ok') + (o.is_error ? ' · ERROR' : '') + ')'); settleTurn(o) }
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
+ }
349
428
  }
350
429
  })
351
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' }) })
@@ -482,6 +561,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
482
561
  const x = String(s || '').toLowerCase().replace(/[.,!?]+$/, '')
483
562
  if (x === 'opus' || x === 'sonnet' || x === 'haiku') return x
484
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
485
565
  // opencode models are provider/model (e.g. anthropic/claude-sonnet-4, openai/gpt-4o).
486
566
  return /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9.\-:]*$/i.test(x) ? x : null
487
567
  }
@@ -590,7 +670,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
590
670
 
591
671
  const DAY_MS = 24 * 60 * 60 * 1000
592
672
  let introTimer = null, sweepStartTimer = null, sweepTimer = null
593
- if (mcpConfig) {
673
+ if (mcpConfig || mcpUrl) {
594
674
  // Workspace ethics: a one-time hello the FIRST time this agent ever connects.
595
675
  const introMarker = join(OV_DIR, 'intro-' + slugify(identifier) + '.done')
596
676
  if (!existsSync(introMarker)) {
@@ -601,7 +681,11 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
601
681
  // Catch-up sweep: shortly after startup (covers downtime) + once every day.
602
682
  // Sooner rather than later: a freshly-added agent whose WS subscription isn't
603
683
  // live yet still catches pending mentions/tasks via this REST poll_inbox sweep.
604
- sweepStartTimer = setTimeout(() => { log('startup catch-up sweep'); void drain('sweep', SWEEP) }, 12_000)
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
+ }
605
689
  sweepTimer = setInterval(() => { log('daily catch-up sweep'); void drain('sweep', SWEEP) }, DAY_MS)
606
690
  } else {
607
691
  log('no MCP config — skipping intro + daily sweep (agent has no tools to post/act)')
@@ -642,17 +726,15 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
642
726
  // A one-off prompt (intro / daily sweep) the main loop runs the next time it's
643
727
  // free — keeps everything on the single runCycle so nothing overlaps.
644
728
  let queuedSpecial = null
645
- if (mcpConfig) {
729
+ if (mcpConfig || mcpUrl) {
646
730
  const introMarker = join(OV_DIR, 'intro-' + (slug || 'openvisio') + '.done')
647
731
  if (!existsSync(introMarker)) {
648
732
  try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
649
733
  log('first run — introducing self to the workspace')
650
734
  setTimeout(() => { queuedSpecial = INTRO }, 5000)
651
735
  }
652
- // The startup poll marks pre-existing tasks as "seen" (so it won't re-handle old
653
- // ones) which would also skip tasks assigned while offline. A startup + daily
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)
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.
656
738
  setInterval(() => { log('daily catch-up sweep'); queuedSpecial = SWEEP + '\n\n' + fullPrompt }, 24 * 60 * 60 * 1000)
657
739
  }
658
740
 
@@ -687,7 +769,12 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
687
769
  const items = Array.isArray(res.items) ? res.items : []
688
770
  if (firstCheck && Array.isArray(res.items)) {
689
771
  firstCheck = false
772
+ const hasTaskBacklog = items.some((i) => i.startsWith('tk:') || i.startsWith('clm:'))
690
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
+ }
691
778
  }
692
779
  if (res.paused) {
693
780
  delay = SLOW
@@ -736,7 +823,8 @@ export function installService({ slug, workdir }) {
736
823
  const nodeDir = dirname(process.execPath)
737
824
  const claudeBin = onPath('claude')
738
825
  const opencodeBin = onPath('opencode')
739
- const runPath = [nodeDir, claudeBin ? dirname(claudeBin) : '', opencodeBin ? dirname(opencodeBin) : '', join(homedir(), '.opencode', 'bin'), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin'].filter(Boolean).join(':')
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(':')
740
828
  const args = ['watch', '--name', slug, ...(workdir ? ['--workdir', workdir] : [])]
741
829
  mkdirSync(OV_DIR, { recursive: true })
742
830
  const logFile = join(OV_DIR, `${slug}.log`)