openvisio-agent 0.25.17 → 0.25.18
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/CHANGELOG.md +8 -0
- package/README.md +19 -0
- package/bin/cli.mjs +15 -2
- package/package.json +1 -1
- package/scenarios/workspace.scenarios.mjs +4 -3
- package/src/connection-discovery.mjs +45 -0
- package/src/runner-pool.mjs +5 -1
- package/src/studio-connect.mjs +2 -1
- package/src/update.mjs +111 -0
- package/src/watch.mjs +70 -9
- package/studio/app.mjs +1 -1
- package/studio/index.html +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.25.18] — 2026-10-05
|
|
4
|
+
|
|
5
|
+
- Add `update --name <agent>` to fetch current connection URLs from agent setup without re-entering credentials. Support `--host` for the stable app address, manual URL overrides, and an optional background watcher restart.
|
|
6
|
+
- Refresh backend connections at startup and after connection failures, with no periodic discovery polling. Preserve saved credentials and settings, retain the last known endpoints on discovery failure, and verify that discovered configuration matches the saved backend.
|
|
7
|
+
- Reconnect changed WebSocket endpoints and use updated MCP endpoints for new runtime sessions while allowing active work to finish. Carry the discovery address through CLI and Studio setup.
|
|
8
|
+
- Deployment requirement: the OpenVisio app must serve `/api/agent/setup/connection`. Immediate propagation to healthy connections still requires a backend configuration-change event publisher, which is not included in this release.
|
|
9
|
+
- Live smoke gate waived: no designated safe test organization/ticket is configured for this release. Real provider execution and end-to-end behavior against deployed discovery and backend services remain unverified; local certification covers simulated service boundaries.
|
|
10
|
+
|
|
3
11
|
## [0.25.17] — 2026-09-25
|
|
4
12
|
|
|
5
13
|
- Preserve the original goal across reply-to-work handoffs and require a structured assessment of requested outcomes and observed evidence before a successful cycle. Continue unfinished work in the same session up to twice, preserve specific blockers, and fail closed on invalid assessments.
|
package/README.md
CHANGED
|
@@ -58,6 +58,25 @@ npx -y openvisio-agent@latest connect --backend https://api.your-org.example/dev
|
|
|
58
58
|
|
|
59
59
|
Then `watch --name ada` auto-detects the backend agent and runs a WebSocket loop instead of polling: it connects with `?api_key=&identifier=`, keeps the connection warm with keepalives, reconnects with backoff, and queues each accepted assignment or mention independently. A work cycle can contain many assistant, tool, and message actions; emitting a progress message does not end it. The Mastra runtime requires **Node ≥ 22.13**.
|
|
60
60
|
|
|
61
|
+
### Automatic connection updates
|
|
62
|
+
|
|
63
|
+
Backend/BYO watchers fetch the current WebSocket and MCP URLs from the stable OpenVisio app's `/api/agent/setup/connection` endpoint at startup and after connection failures (at most once per 30 seconds). There is no periodic discovery timer: a healthy, idle watcher makes no discovery requests. The request sends no credentials and accepts only a complete configuration matching the saved backend. Failed discovery keeps the last saved endpoints. It does not poll team tasks or invoke a model.
|
|
64
|
+
|
|
65
|
+
If the old connections remain healthy, an endpoint change is picked up on the next startup, connection failure, or explicit `update` command. Immediate propagation to healthy watchers requires a backend-published configuration-change event; that publisher is not implemented here.
|
|
66
|
+
|
|
67
|
+
When URLs change, the watcher saves them, reconnects its WebSocket, and uses the new MCP endpoint for new runtime sessions. Active sessions finish before being retired; they can still encounter errors if their old MCP endpoint is removed during a turn. There is no automatic process restart or cancellation of accepted work. Credentials, runtime, models, workspace, and other settings are preserved.
|
|
68
|
+
|
|
69
|
+
New setup commands and copied Studio credentials include the app address. For self-hosted or staging setups, pass `--host <app-url>` to `connect`. Legacy configurations try `https://openvisio.app`, but reject a different backend. Existing agents need this package version installed and their watcher restarted once. To set a different discovery host and refresh immediately:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npx -y openvisio-agent@latest update --name ada \
|
|
73
|
+
--host https://your-openvisio.app --restart
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
After the host is saved, `openvisio-agent update --name ada` fetches the current URLs without requiring either URL or another API key. Automatic refresh updates the watcher's scoped MCP configuration; the explicit `update` command also refreshes Claude's global registration when the MCP URL changes. Manual `--ws` and/or `--mcp-url` overrides remain available. Relay agents support manual MCP updates only.
|
|
77
|
+
|
|
78
|
+
`--restart` explicitly stops the named watcher (interrupting active work) and installs/starts its background service on macOS or Linux. It does not upgrade an existing global package; install the new release first. Omit it to save changes without interrupting work. Foreground watchers can be stopped and started with `openvisio-agent watch --name ada`. Explicit URL overrides on `watch` disable discovery for that invocation. The discovery app host must remain reachable; changing that host or the backend identity requires an explicit update/reconnect.
|
|
79
|
+
|
|
61
80
|
### `watch --name <agent>`
|
|
62
81
|
|
|
63
82
|
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 starts isolated runtime sessions only when something new arrives.
|
package/bin/cli.mjs
CHANGED
|
@@ -26,9 +26,10 @@ Connect your coding agent to an OpenVisio team.
|
|
|
26
26
|
|
|
27
27
|
Usage:
|
|
28
28
|
openvisio-agent connect <ovs_code> --host <url> [--name "<agent>"] [--mcp-url <url>] [--agent ${supportedRuntimeList('|')}]
|
|
29
|
-
openvisio-agent connect --backend <url> --key <api-key> --id <identifier> [--name "<agent>"] [--ws <wss-url>] [--mcp-url <url>] [--agent ${supportedRuntimeList('|')}]
|
|
29
|
+
openvisio-agent connect --backend <url> --key <api-key> --id <identifier> [--name "<agent>"] [--host <app-url>] [--ws <wss-url>] [--mcp-url <url>] [--agent ${supportedRuntimeList('|')}]
|
|
30
30
|
openvisio-agent watch --name <agent> [--install] [--workspace <dir>] [--chat-only] [--model <m>] [--chat-model <m>] [--debug]
|
|
31
31
|
openvisio-agent stop --name <agent>
|
|
32
|
+
openvisio-agent update --name <agent> [--host <app-url>] [--ws <wss-url>] [--mcp-url <url>] [--restart]
|
|
32
33
|
openvisio-agent studio [--port <number>] [--no-open] [--demo]
|
|
33
34
|
openvisio-agent authorize-pr-push [--repo <dir>]
|
|
34
35
|
openvisio-agent push-pr-branch
|
|
@@ -47,6 +48,7 @@ connect --backend
|
|
|
47
48
|
(launchd/systemd) that auto-starts on login, so @mentions/assignments are caught
|
|
48
49
|
even with no terminal open. Requests authenticate with the x-agent-api-key +
|
|
49
50
|
x-agent-identifier headers.
|
|
51
|
+
--host <url> stable OpenVisio app URL for automatic endpoint discovery.
|
|
50
52
|
--ws <wss-url> API-Gateway WebSocket base (same as NEXT_PUBLIC_BACKEND_WS_URL)
|
|
51
53
|
— enables real-time autonomy (task:assigned / agent:mention).
|
|
52
54
|
--mcp-url <url> registers the openvisio-team MCP so the agent has tools to ACT
|
|
@@ -78,6 +80,14 @@ watch
|
|
|
78
80
|
setup needed. Use --workspace <dir> to relocate it (e.g. an existing clones folder),
|
|
79
81
|
--chat-only to disable code work, and --install to run in the background on login.
|
|
80
82
|
|
|
83
|
+
update
|
|
84
|
+
Fetches current connection URLs from agent setup, preserving credentials and
|
|
85
|
+
settings. --host selects the OpenVisio app (default: https://openvisio.app).
|
|
86
|
+
Watchers refresh at startup and after connection failures, with no periodic poll.
|
|
87
|
+
Explicit --ws/--mcp-url values remain available for manual updates.
|
|
88
|
+
Restart the watcher to apply changes. --restart stops the named watcher and
|
|
89
|
+
installs/starts its background service (macOS/Linux; interrupts active work).
|
|
90
|
+
|
|
81
91
|
stop
|
|
82
92
|
Stops the named agent's launchd/systemd service first, then terminates every
|
|
83
93
|
remaining watcher with that exact --name and clears its stale lock. Use this
|
|
@@ -209,6 +219,8 @@ async function runConnect({ positional, flags }) {
|
|
|
209
219
|
// We verify them live, then persist a config + a sourceable env file.
|
|
210
220
|
async function runConnectBackend({ flags }) {
|
|
211
221
|
const backend = stripSlash(String(flags.backend))
|
|
222
|
+
const { endpoint } = await import('../src/update.mjs')
|
|
223
|
+
const setupHost = flags.host === undefined ? 'https://openvisio.app' : endpoint(flags.host, 'host', ['http:', 'https:'])
|
|
212
224
|
const apiKey = flags.key && String(flags.key)
|
|
213
225
|
const identifier = flags.id && String(flags.id)
|
|
214
226
|
if (!/^https?:\/\//.test(backend)) fail('The --backend value must be a full URL (e.g. https://api.your-org.example/dev).')
|
|
@@ -260,7 +272,7 @@ async function runConnectBackend({ flags }) {
|
|
|
260
272
|
}
|
|
261
273
|
|
|
262
274
|
if (!chatOnly) configurePrPublishing({ workspace: workdir || DEFAULT_WORKSPACE, enabled: !flags['no-pr-push'] })
|
|
263
|
-
writeJson(configPath(slug), { ...(flags.channel ? { dedicatedChannelId: Number(flags.channel) } : {}), prPublishing: !flags['no-pr-push'], mode: 'backend', backend, apiKey, identifier, name, slug, wsUrl, mcpUrl, mcpConfig, ...(agent !== 'claude' ? { agent } : {}), ...(workdir ? { workspace: workdir } : {}), ...(chatOnly ? { chatOnly: true } : {}), ...(flags.model ? { model: String(flags.model) } : {}), ...(flags['chat-model'] ? { chatModel: String(flags['chat-model']) } : {}) }, true)
|
|
275
|
+
writeJson(configPath(slug), { ...(flags.channel ? { dedicatedChannelId: Number(flags.channel) } : {}), prPublishing: !flags['no-pr-push'], mode: 'backend', backend, setupHost, apiKey, identifier, name, slug, wsUrl, mcpUrl, mcpConfig, ...(agent !== 'claude' ? { agent } : {}), ...(workdir ? { workspace: workdir } : {}), ...(chatOnly ? { chatOnly: true } : {}), ...(flags.model ? { model: String(flags.model) } : {}), ...(flags['chat-model'] ? { chatModel: String(flags['chat-model']) } : {}) }, true)
|
|
264
276
|
// A sourceable env file, matching the setup snippet OpenVisio shows.
|
|
265
277
|
const envPath = join(OV_DIR, `${slug}.env`)
|
|
266
278
|
mkdirSync(OV_DIR, { recursive: true })
|
|
@@ -316,6 +328,7 @@ async function main() {
|
|
|
316
328
|
const cmd = argv[0]
|
|
317
329
|
const rest = parseFlags(argv.slice(1))
|
|
318
330
|
if (cmd === 'connect') return runConnect(rest)
|
|
331
|
+
if (cmd === 'update') return (await import('../src/update.mjs')).runUpdate(rest)
|
|
319
332
|
if (cmd === 'watch') return (await import('../src/watch.mjs')).runWatch(rest)
|
|
320
333
|
if (cmd === 'studio') return (await import('../src/studio-cli.mjs')).runStudio(rest)
|
|
321
334
|
if (cmd === 'stop') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openvisio-agent",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.18",
|
|
4
4
|
"description": "Connect Claude Code, Codex, OpenCode, Gemini CLI, or Qwen Code to an OpenVisio team \u2014 MCP tools + optional autonomy \u2014 in one command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,7 @@ async function eventually(predicate, description) {
|
|
|
16
16
|
assert.fail(`Watcher did not reach: ${description}`)
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export function fixture({ agent = 'codex', name, task = true, run, evaluateGoal, routeReport, threadError = false, commentTools = [], onComment, historyTools = [], readHistory, selfProfile = self, listAgents, getAgent, getTicket, listChannels, listActivity, transportRecoveryDelays, activityReporter } = {}) {
|
|
19
|
+
export function fixture({ agent = 'codex', name, task = true, run, evaluateGoal, routeReport, threadError = false, commentTools = [], onComment, historyTools = [], readHistory, selfProfile = self, listAgents, getAgent, getTicket, listChannels, listActivity, transportRecoveryDelays, activityReporter, watcherOptions = {}, watcherDependencies = {} } = {}) {
|
|
20
20
|
const dir = mkdtempSync(join(tmpdir(), 'byo-workspace-'))
|
|
21
21
|
const posts = [], runs = [], calls = [], logs = [], messages = [], subscriptions = []
|
|
22
22
|
const routingRuns = [], evaluations = []
|
|
@@ -82,14 +82,15 @@ export function fixture({ agent = 'codex', name, task = true, run, evaluateGoal,
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
const start = () => {
|
|
85
|
-
watcher = createBackendWatcher({ wsUrl: 'wss://unused.example.test', mcpUrl: 'https://unused.example.test/mcp', apiKey: 'test-key', identifier: 'alex', slug: 'test-alex', name, agent, workdir: join(dir, 'workspace'), model: 'test-code', chatModel: 'test-chat' }, {
|
|
85
|
+
watcher = createBackendWatcher({ wsUrl: 'wss://unused.example.test', mcpUrl: 'https://unused.example.test/mcp', apiKey: 'test-key', identifier: 'alex', slug: 'test-alex', name, agent, workdir: join(dir, 'workspace'), model: 'test-code', chatModel: 'test-chat', ...watcherOptions }, {
|
|
86
86
|
stateDir: dir, memory, mcpClient: mcp, createRunner, transportRecoveryDelays, activityReporter, connect: (callbacks) => { connection = callbacks; return { close() {}, subscribeOrganization(id) { subscriptions.push(id) } } }, autoStart: false, log: (value) => logs.push(value),
|
|
87
|
+
...watcherDependencies,
|
|
87
88
|
})
|
|
88
89
|
return watcher
|
|
89
90
|
}
|
|
90
91
|
start()
|
|
91
92
|
return {
|
|
92
|
-
dir, ticket, posts, runs, evaluations, routingRuns, calls, logs, messages, memory, subscriptions,
|
|
93
|
+
dir, ticket, posts, runs, evaluations, routingRuns, calls, logs, messages, memory, subscriptions, mcp,
|
|
93
94
|
get watcher() { return watcher },
|
|
94
95
|
get connection() { return connection },
|
|
95
96
|
get closed() { return closed },
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const DEFAULT_SETUP_HOST = 'https://openvisio.app'
|
|
2
|
+
|
|
3
|
+
// Public deployment metadata. Discovery never sends agent credentials and must
|
|
4
|
+
// match the saved backend before its endpoints can receive those credentials.
|
|
5
|
+
function checkedUrl(value, protocols) {
|
|
6
|
+
if (typeof value !== 'string' || /\s/.test(value)) throw new Error('Invalid connection URL in setup configuration.')
|
|
7
|
+
let url
|
|
8
|
+
try { url = new URL(value) } catch { throw new Error('Invalid connection URL in setup configuration.') }
|
|
9
|
+
if (!protocols.includes(url.protocol) || !url.hostname || url.username || url.password || url.search || url.hash) throw new Error('Invalid connection URL in setup configuration.')
|
|
10
|
+
if (['http:', 'ws:'].includes(url.protocol) && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) throw new Error('Setup configuration requires secure connection URLs.')
|
|
11
|
+
return url.href.replace(/\/+$/, '')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function fetchConnectionSettings(saved, { fetchImpl = fetch, signal } = {}) {
|
|
15
|
+
if (saved.mode !== 'backend') throw new Error('Automatic connection discovery requires a backend agent.')
|
|
16
|
+
const setupHost = checkedUrl(saved.setupHost || DEFAULT_SETUP_HOST, ['https:', 'http:'])
|
|
17
|
+
const backend = checkedUrl(saved.backend, ['https:', 'http:'])
|
|
18
|
+
const response = await fetchImpl(`${setupHost}/api/agent/setup/connection`, {
|
|
19
|
+
headers: { accept: 'application/json' }, cache: 'no-store', redirect: 'error',
|
|
20
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
|
|
21
|
+
})
|
|
22
|
+
if (!response.ok) { await response.body?.cancel(); throw new Error(`Connection discovery returned HTTP ${response.status}. Saved URLs are unchanged.`) }
|
|
23
|
+
const data = await response.json()
|
|
24
|
+
if (data?.version !== 1 || checkedUrl(data.backend, ['https:', 'http:']) !== backend) throw new Error('Setup configuration does not match the saved backend. Use --host with this agent’s OpenVisio app URL.')
|
|
25
|
+
return { setupHost, wsUrl: checkedUrl(data.wsUrl, ['wss:', 'ws:']), mcpUrl: checkedUrl(data.mcpUrl, ['https:', 'http:']) }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Checks run only when requested by startup, failures, or an explicit command.
|
|
29
|
+
// Serialize checks and throttle failure-triggered probes. A failed response
|
|
30
|
+
// never clears a working endpoint, and shutdown aborts an outstanding request.
|
|
31
|
+
export function createConnectionRefresh({ refresh, log = () => {}, minIntervalMs = 30_000, now = Date.now }) {
|
|
32
|
+
let pending = null, lastCheck = -Infinity, stopped = false
|
|
33
|
+
const controller = new AbortController()
|
|
34
|
+
const check = () => {
|
|
35
|
+
if (stopped) return Promise.resolve()
|
|
36
|
+
if (pending) return pending
|
|
37
|
+
if (now() - lastCheck < minIntervalMs) return Promise.resolve()
|
|
38
|
+
lastCheck = now()
|
|
39
|
+
pending = Promise.resolve().then(() => refresh(controller.signal)).catch(() => {
|
|
40
|
+
if (!stopped) log('Connection discovery unavailable; keeping the last saved endpoints.')
|
|
41
|
+
}).finally(() => { pending = null })
|
|
42
|
+
return pending
|
|
43
|
+
}
|
|
44
|
+
return { check, close() { stopped = true; controller.abort(); return pending || Promise.resolve() } }
|
|
45
|
+
}
|
package/src/runner-pool.mjs
CHANGED
|
@@ -11,7 +11,7 @@ export function createRunnerPool({ create, capacity = 8, idleMs = 300_000, now =
|
|
|
11
11
|
}
|
|
12
12
|
function prune() {
|
|
13
13
|
for (const [key, entry] of entries) {
|
|
14
|
-
if (!entry.busy && (idleMs === 0 || now() - entry.usedAt >= idleMs || entries.size > capacity)) {
|
|
14
|
+
if (!entry.busy && (entry.stale || idleMs === 0 || now() - entry.usedAt >= idleMs || entries.size > capacity)) {
|
|
15
15
|
entries.delete(key)
|
|
16
16
|
dispose(entry)
|
|
17
17
|
}
|
|
@@ -20,6 +20,10 @@ export function createRunnerPool({ create, capacity = 8, idleMs = 300_000, now =
|
|
|
20
20
|
const timer = idleMs === 0 ? null : setInterval(prune, Math.min(idleMs, 30_000))
|
|
21
21
|
timer?.unref?.()
|
|
22
22
|
return {
|
|
23
|
+
invalidate() {
|
|
24
|
+
for (const entry of entries.values()) entry.stale = true
|
|
25
|
+
prune()
|
|
26
|
+
},
|
|
23
27
|
acquire(key, options) {
|
|
24
28
|
if (closed) throw new Error('Session pool is closed')
|
|
25
29
|
prune()
|
package/src/studio-connect.mjs
CHANGED
|
@@ -25,6 +25,7 @@ export async function connectStudioAgent({ stateDir, input, fetchImpl = fetch })
|
|
|
25
25
|
const backend = url('backend', ['https:', 'http:'])
|
|
26
26
|
const mcpUrl = url('mcpUrl', ['https:', 'http:'])
|
|
27
27
|
const wsUrl = url('wsUrl', ['wss:', 'ws:'])
|
|
28
|
+
const setupHost = field('setupHost', false) ? url('setupHost', ['https:', 'http:']) : 'https://openvisio.app'
|
|
28
29
|
let response
|
|
29
30
|
try {
|
|
30
31
|
response = await fetchImpl(`${backend}/projects`, { headers: { accept: 'application/json', 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier }, redirect: 'error', signal: AbortSignal.timeout(15000) })
|
|
@@ -40,7 +41,7 @@ export async function connectStudioAgent({ stateDir, input, fetchImpl = fetch })
|
|
|
40
41
|
// Exclusive creation also prevents overwriting existing connections or symlinks.
|
|
41
42
|
for (const [path, value] of [
|
|
42
43
|
[mcpPath, { mcpServers: { 'openvisio-team': { type: 'http', url: mcpUrl, headers: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier } } } }],
|
|
43
|
-
[configPath, { mode: 'backend', backend, apiKey, identifier, name, slug, agent, wsUrl, mcpUrl, mcpConfig: mcpPath, prPublishing: true }],
|
|
44
|
+
[configPath, { mode: 'backend', backend, setupHost, apiKey, identifier, name, slug, agent, wsUrl, mcpUrl, mcpConfig: mcpPath, prPublishing: true }],
|
|
44
45
|
]) {
|
|
45
46
|
const handle = await open(path, 'wx', 0o600)
|
|
46
47
|
created.push(path)
|
package/src/update.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
5
|
+
import { fetchConnectionSettings } from './connection-discovery.mjs'
|
|
6
|
+
import { OV_DIR, readConfig, writeJson, slugify, stripSlash, onPath, ok, info } from './lib.mjs'
|
|
7
|
+
|
|
8
|
+
export function endpoint(value, flag, protocols) {
|
|
9
|
+
if (typeof value !== 'string' || !value.trim() || /\s/.test(value)) throw new Error(`--${flag} requires a full URL.`)
|
|
10
|
+
let url
|
|
11
|
+
try { url = new URL(value) } catch { throw new Error(`--${flag} requires a full URL.`) }
|
|
12
|
+
if (!protocols.includes(url.protocol) || !url.hostname || url.username || url.password || url.hash) {
|
|
13
|
+
throw new Error(`--${flag} requires a ${protocols.join(' or ')} URL without credentials or a fragment.`)
|
|
14
|
+
}
|
|
15
|
+
return value
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function runUpdate({ flags, positional = [] }) {
|
|
19
|
+
const allowed = new Set(['name', 'ws', 'mcp-url', 'restart', 'host'])
|
|
20
|
+
if (positional.length || Object.keys(flags).some(key => !allowed.has(key))) throw new Error('Usage: openvisio-agent update --name <agent> [--host <app-url>] [--ws <url>] [--mcp-url <url>] [--restart]')
|
|
21
|
+
if (typeof flags.name !== 'string' || !flags.name.trim()) throw new Error('Missing --name <agent>.')
|
|
22
|
+
if (flags.restart !== undefined && flags.restart !== true) throw new Error('--restart does not take a value.')
|
|
23
|
+
if (flags.restart && process.platform === 'win32') throw new Error('--restart requires macOS or Linux. Omit it and restart the foreground watcher yourself.')
|
|
24
|
+
const slug = slugify(flags.name)
|
|
25
|
+
let saved = readConfig(slug)
|
|
26
|
+
if (!saved || typeof saved !== 'object' || Array.isArray(saved)) throw new Error(`No readable configuration for "${slug}". Run connect first.`)
|
|
27
|
+
if (flags.ws !== undefined && saved.mode !== 'backend') throw new Error('--ws is supported only for backend agents.')
|
|
28
|
+
let next = { ...saved }
|
|
29
|
+
if (flags.host !== undefined) next.setupHost = endpoint(flags.host, 'host', ['http:', 'https:'])
|
|
30
|
+
if (flags.ws === undefined && flags['mcp-url'] === undefined) {
|
|
31
|
+
const discovered = await fetchConnectionSettings(next)
|
|
32
|
+
const current = readConfig(slug)
|
|
33
|
+
if (!current || current.apiKey !== saved.apiKey || current.identifier !== saved.identifier || current.backend !== saved.backend) throw new Error('Saved agent identity changed during discovery; retry the update.')
|
|
34
|
+
saved = current
|
|
35
|
+
next = { ...current, ...discovered }
|
|
36
|
+
}
|
|
37
|
+
if (flags.ws !== undefined) next.wsUrl = stripSlash(endpoint(flags.ws, 'ws', ['ws:', 'wss:']))
|
|
38
|
+
if (flags['mcp-url'] !== undefined) next.mcpUrl = endpoint(flags['mcp-url'], 'mcp-url', ['http:', 'https:'])
|
|
39
|
+
|
|
40
|
+
saveConnectionSettings({ slug, saved, next, registerClaude: true })
|
|
41
|
+
ok(`Updated connection settings for "${slug}".`)
|
|
42
|
+
if (flags.restart) {
|
|
43
|
+
const { installService } = await import('./watch.mjs')
|
|
44
|
+
installService({ slug, workdir: next.workspace || '', throwOnError: true })
|
|
45
|
+
} else {
|
|
46
|
+
info('Running watchers keep their current endpoints until restarted.')
|
|
47
|
+
info(`Background watcher: openvisio-agent watch --name ${slug} --install`)
|
|
48
|
+
info(`Foreground watcher: stop it, then run openvisio-agent watch --name ${slug}`)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function saveConnectionSettings({ slug, saved, next, registerClaude = false, stateDir = OV_DIR }) {
|
|
53
|
+
const mcpChanged = next.mcpUrl !== saved.mcpUrl
|
|
54
|
+
const wsChanged = next.wsUrl !== saved.wsUrl
|
|
55
|
+
// Prepare every local file before changing anything. Preserve other servers
|
|
56
|
+
// and settings in the agent's scoped MCP configuration.
|
|
57
|
+
let scoped
|
|
58
|
+
let scopedPath
|
|
59
|
+
let headers
|
|
60
|
+
if (mcpChanged) {
|
|
61
|
+
headers = saved.mode === 'backend'
|
|
62
|
+
? { 'x-agent-api-key': saved.apiKey, 'x-agent-identifier': saved.identifier }
|
|
63
|
+
: { Authorization: saved.key ? `Bearer ${saved.key}` : '' }
|
|
64
|
+
if (Object.values(headers).some(value => typeof value !== 'string' || !value)) throw new Error('Saved agent credentials are incomplete. Run connect first.')
|
|
65
|
+
if (saved.mcpConfig || !saved.agent || saved.agent === 'claude') {
|
|
66
|
+
scopedPath = saved.mcpConfig || join(stateDir, `${slug}-team.mcp.json`)
|
|
67
|
+
scoped = existsSync(scopedPath) ? JSON.parse(readFileSync(scopedPath, 'utf8')) : {}
|
|
68
|
+
scoped = { ...scoped, mcpServers: { ...scoped.mcpServers, 'openvisio-team': {
|
|
69
|
+
...scoped.mcpServers?.['openvisio-team'], type: 'http', url: next.mcpUrl, headers,
|
|
70
|
+
} } }
|
|
71
|
+
next.mcpConfig = scopedPath
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const envPath = join(stateDir, `${slug}.env`)
|
|
75
|
+
let envText
|
|
76
|
+
if (wsChanged && existsSync(envPath)) {
|
|
77
|
+
const lines = readFileSync(envPath, 'utf8').split(/\r?\n/)
|
|
78
|
+
const assignment = `OPENVISIO_AGENT_WS_URL='${next.wsUrl.replace(/'/g, "'\\''")}'`
|
|
79
|
+
let replaced = false
|
|
80
|
+
envText = lines.map(line => {
|
|
81
|
+
if (!/^\s*(?:export\s+)?OPENVISIO_AGENT_WS_URL=/.test(line)) return line
|
|
82
|
+
replaced = true
|
|
83
|
+
return assignment
|
|
84
|
+
}).join('\n')
|
|
85
|
+
if (!replaced) envText = envText.replace(/\n?$/, '\n') + assignment + '\n'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Claude also exposes the team MCP in interactive sessions at user scope.
|
|
89
|
+
// Report errors without printing credential-bearing arguments.
|
|
90
|
+
if (registerClaude && mcpChanged && (!saved.agent || saved.agent === 'claude')) {
|
|
91
|
+
const claude = onPath('claude')
|
|
92
|
+
if (!claude) throw new Error('Claude Code is not on PATH; install it before updating its MCP registration.')
|
|
93
|
+
spawnSync(claude, ['mcp', 'remove', 'openvisio-team', '-s', 'user'], { stdio: 'ignore' })
|
|
94
|
+
spawnSync(claude, ['mcp', 'remove', 'openvisio-team', '-s', 'local'], { stdio: 'ignore' })
|
|
95
|
+
const headerArgs = Object.entries(headers).flatMap(([key, value]) => ['--header', `${key}: ${value}`])
|
|
96
|
+
const result = spawnSync(claude, ['mcp', 'add', '--scope', 'user', '--transport', 'http', 'openvisio-team', next.mcpUrl, ...headerArgs], { stdio: 'ignore' })
|
|
97
|
+
if (result.status !== 0) throw new Error('Could not update the Claude MCP registration. Saved agent configuration is unchanged; retry the update after fixing Claude Code.')
|
|
98
|
+
}
|
|
99
|
+
if (scoped) writeJson(scopedPath, scoped, true)
|
|
100
|
+
if (envText !== undefined) {
|
|
101
|
+
const temporary = `${envPath}.${randomUUID()}.tmp`
|
|
102
|
+
try {
|
|
103
|
+
writeFileSync(temporary, envText, { flag: 'wx', mode: 0o600 })
|
|
104
|
+
renameSync(temporary, envPath)
|
|
105
|
+
} finally {
|
|
106
|
+
try { unlinkSync(temporary) } catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
writeJson(join(stateDir, `${slug}.json`), next, true)
|
|
110
|
+
return next
|
|
111
|
+
}
|
package/src/watch.mjs
CHANGED
|
@@ -44,6 +44,8 @@ import { startStudioInBackground } from './studio-cli.mjs'
|
|
|
44
44
|
import { toolData } from './team-data.mjs'
|
|
45
45
|
import { REPORT_ROUTING_PROMPT, reportChannelSelection } from './report-routing.mjs'
|
|
46
46
|
import { runtimeProvider, supportedRuntimeList } from './runtime-providers.mjs'
|
|
47
|
+
import { DEFAULT_SETUP_HOST, fetchConnectionSettings, createConnectionRefresh } from './connection-discovery.mjs'
|
|
48
|
+
import { saveConnectionSettings } from './update.mjs'
|
|
47
49
|
|
|
48
50
|
function runtimeJournal(options, supplied) {
|
|
49
51
|
let journal
|
|
@@ -279,15 +281,15 @@ function acquireSingleInstance(key) {
|
|
|
279
281
|
|
|
280
282
|
export async function runWatch({ flags }) {
|
|
281
283
|
const slug = flags.name ? slugify(String(flags.name)) : null
|
|
282
|
-
|
|
284
|
+
let saved = slug ? readConfig(slug) : null
|
|
283
285
|
const claude = String(flags.claude || onPath('claude') || 'claude')
|
|
284
|
-
|
|
286
|
+
let mcpConfig = String(flags['mcp-config'] || (saved && saved.mcpConfig) || '')
|
|
285
287
|
// Which coding-agent runtime drives the cycles. Claude keeps its native stream;
|
|
286
288
|
// ACP-capable runtimes share the Mastra harness. Default claude.
|
|
287
289
|
const agent = String(flags.agent || flags.runtime || (saved && saved.agent) || 'claude').toLowerCase()
|
|
288
290
|
const provider = runtimeProvider(agent)
|
|
289
291
|
if (!provider) fail(`Unknown --agent "${agent}". Supported runtimes: ${supportedRuntimeList()}.`)
|
|
290
|
-
|
|
292
|
+
let mcpUrl = String(flags['mcp-url'] || (saved && saved.mcpUrl) || '')
|
|
291
293
|
// Code mode is the DEFAULT: an agent lives on the user's laptop and should just be
|
|
292
294
|
// able to work across the org's repos with no per-repo setup. So `workdir` resolves
|
|
293
295
|
// to (in order) an explicit --workdir/--workspace, the saved workspace, or the
|
|
@@ -329,6 +331,16 @@ export async function runWatch({ flags }) {
|
|
|
329
331
|
process.on('exit', () => { try { lock.release && lock.release() } catch { /* noop */ } })
|
|
330
332
|
}
|
|
331
333
|
|
|
334
|
+
if (saved?.mode === 'backend' && !flags.install && !flags.ws && !flags['mcp-url'] && !flags.backend && !flags.key && !flags.id) {
|
|
335
|
+
try {
|
|
336
|
+
const next = { ...saved, ...await fetchConnectionSettings(saved) }
|
|
337
|
+
if (next.wsUrl !== saved.wsUrl || next.mcpUrl !== saved.mcpUrl || next.setupHost !== saved.setupHost) saveConnectionSettings({ slug, saved, next })
|
|
338
|
+
saved = next
|
|
339
|
+
mcpUrl = String(saved.mcpUrl || '')
|
|
340
|
+
mcpConfig = String(flags['mcp-config'] || saved.mcpConfig || '')
|
|
341
|
+
} catch { info('Connection discovery unavailable; using the last saved endpoints.') }
|
|
342
|
+
}
|
|
343
|
+
|
|
332
344
|
// Backend agents (connect --backend) drive autonomy over a real-time WS instead
|
|
333
345
|
// of REST-polling the frontend relay. Detected by the saved mode / a --ws flag.
|
|
334
346
|
const backendMode = (saved && saved.mode === 'backend') || !!flags.ws
|
|
@@ -342,7 +354,8 @@ export async function runWatch({ flags }) {
|
|
|
342
354
|
assertWebSocket(fail)
|
|
343
355
|
if (flags.install) return installService({ slug: slug || 'openvisio', claude, mcpConfig, workdir })
|
|
344
356
|
startStudioInBackground()
|
|
345
|
-
|
|
357
|
+
const setupHost = flags.ws || flags['mcp-url'] || flags.backend || flags.key || flags.id ? '' : (saved?.setupHost || DEFAULT_SETUP_HOST)
|
|
358
|
+
return loopBackendWs({ backend, wsUrl, apiKey, identifier, setupHost, slug: slug || 'openvisio', name: String(saved?.name || slug || identifier), claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug: !!flags.debug })
|
|
346
359
|
}
|
|
347
360
|
|
|
348
361
|
const host = stripSlash(flags.host || (saved && saved.host) || '')
|
|
@@ -611,7 +624,7 @@ function loopBackendWs(options) {
|
|
|
611
624
|
|
|
612
625
|
// Production orchestration with replaceable I/O boundaries for deterministic
|
|
613
626
|
// workspace simulations. Tests run this same routing, queue and delivery code.
|
|
614
|
-
export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug, name, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }, dependencies = {}) {
|
|
627
|
+
export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, setupHost, slug, name, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }, dependencies = {}) {
|
|
615
628
|
const validBackendId = (value) => value != null && /^\d+$/.test(String(value)) && Number.isSafeInteger(Number(value)) && Number(value) > 0
|
|
616
629
|
const stateDir = dependencies.stateDir || OV_DIR
|
|
617
630
|
const makeRunner = dependencies.createRunner || createCycleRunner
|
|
@@ -632,6 +645,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
632
645
|
const cycleContext = new AsyncLocalStorage()
|
|
633
646
|
emit('watcher.started', { status: 'running', model, chatModel, workdir: workdir || '' })
|
|
634
647
|
let handle = null
|
|
648
|
+
let connectionRefresh = null
|
|
635
649
|
// Activity belongs to an individual cycle. Independent ticket workers must
|
|
636
650
|
// not clear or overwrite each other's status targets.
|
|
637
651
|
const replyStatusTargets = new Set()
|
|
@@ -952,7 +966,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
952
966
|
handle?.subscribeOrganization?.(selfOrganizationId)
|
|
953
967
|
}
|
|
954
968
|
}
|
|
955
|
-
const
|
|
969
|
+
const makeMcpClient = dependencies.createMcpClient || createMcpHttpClient
|
|
970
|
+
let mcpClient = dependencies.mcpClient || makeMcpClient({ url: mcpUrl, apiKey, identifier, clientVersion: '0.18.5', log })
|
|
956
971
|
const callMcpTool = (name, args = {}) => {
|
|
957
972
|
if (stopping) return Promise.reject(new Error('Watcher is stopping'))
|
|
958
973
|
const toolCallId = `${journal.runId}:mcp:${++mcpSequence}`
|
|
@@ -964,6 +979,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
964
979
|
return result
|
|
965
980
|
}, (error) => {
|
|
966
981
|
emit('tool.finished', { ...metadata, status: 'failed' })
|
|
982
|
+
void connectionRefresh?.check()
|
|
967
983
|
throw error
|
|
968
984
|
}))
|
|
969
985
|
}
|
|
@@ -1008,15 +1024,18 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1008
1024
|
// The transport owns cache invalidation after a session reset. Do not keep
|
|
1009
1025
|
// an independent name cache that can outlive its advertised capabilities.
|
|
1010
1026
|
if (mcpToolDiscoveryPromise) return mcpToolDiscoveryPromise
|
|
1011
|
-
|
|
1027
|
+
const client = mcpClient
|
|
1028
|
+
const discovery = client.listTools(refresh).then((tools) => {
|
|
1012
1029
|
const names = new Set(tools.map((tool) => String(tool?.name || '')).filter(Boolean))
|
|
1030
|
+
if (client !== mcpClient) return names
|
|
1013
1031
|
const changed = !mcpToolNames || names.size !== mcpToolNames.size || [...names].some((name) => !mcpToolNames.has(name))
|
|
1014
1032
|
mcpToolNames = names
|
|
1015
1033
|
const required = ['list_agents', 'list_projects', 'list_tasks', 'get_ticket', 'update_ticket', 'post_message']
|
|
1016
1034
|
const missing = required.filter((name) => !mcpToolNames.has(name))
|
|
1017
1035
|
if (changed) log(`MCP tools ready (${mcpToolNames.size})${missing.length ? '; missing core tools: ' + missing.join(', ') : ''}`)
|
|
1018
1036
|
return mcpToolNames
|
|
1019
|
-
}).finally(() => { mcpToolDiscoveryPromise = null })
|
|
1037
|
+
}).finally(() => { if (mcpToolDiscoveryPromise === discovery) mcpToolDiscoveryPromise = null })
|
|
1038
|
+
mcpToolDiscoveryPromise = discovery
|
|
1020
1039
|
return mcpToolDiscoveryPromise
|
|
1021
1040
|
}
|
|
1022
1041
|
// Nearby channel roots are optional. Their deadline must not discard an
|
|
@@ -2402,10 +2421,11 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2402
2421
|
// recover channel mentions even when the backend omits targeted dispatch.
|
|
2403
2422
|
// All conversation event types share recipient, ownership and deduplication guards.
|
|
2404
2423
|
log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
|
|
2405
|
-
|
|
2424
|
+
const connectEvents = () => (dependencies.connect || connectAgentWs)({ wsUrl, apiKey, identifier, onEvent,
|
|
2406
2425
|
onDisconnect: () => {
|
|
2407
2426
|
eventConnection = 'disconnected'
|
|
2408
2427
|
emit('connection.disconnected', { status: 'disconnected', reason: 'Event connection lost; accepted work remains running' })
|
|
2428
|
+
void connectionRefresh?.check()
|
|
2409
2429
|
},
|
|
2410
2430
|
onConnect: () => {
|
|
2411
2431
|
if (stopping) return
|
|
@@ -2416,6 +2436,45 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2416
2436
|
queueMicrotask(() => { if (!stopping) void ensureSelfAgent() })
|
|
2417
2437
|
if (autoStart) setTimeout(() => { if (!stopping) void reconcileBacklog() }, 250)
|
|
2418
2438
|
}, log })
|
|
2439
|
+
handle = connectEvents()
|
|
2440
|
+
|
|
2441
|
+
const refreshConnection = async (signal) => {
|
|
2442
|
+
if (stopping || !setupHost) return
|
|
2443
|
+
const latest = await (dependencies.fetchConnectionSettings || fetchConnectionSettings)({ mode: 'backend', backend, setupHost }, { signal })
|
|
2444
|
+
if (stopping) return
|
|
2445
|
+
const mcpChanged = latest.mcpUrl !== mcpUrl
|
|
2446
|
+
const wsChanged = latest.wsUrl !== wsUrl
|
|
2447
|
+
if (!mcpChanged && !wsChanged) return
|
|
2448
|
+
// Re-read immediately before saving to preserve live Studio/model updates.
|
|
2449
|
+
const saved = JSON.parse(readFileSync(join(stateDir, `${slug}.json`), 'utf8'))
|
|
2450
|
+
if (saved.identifier !== identifier || saved.apiKey !== apiKey || stripSlash(saved.backend) !== stripSlash(backend)) throw new Error('Saved agent identity changed; reconnect explicitly.')
|
|
2451
|
+
saveConnectionSettings({ slug, saved, next: { ...saved, ...latest }, stateDir })
|
|
2452
|
+
if (mcpChanged) {
|
|
2453
|
+
mcpUrl = latest.mcpUrl
|
|
2454
|
+
runnerOptions.mcpUrl = mcpUrl
|
|
2455
|
+
mcpClient = makeMcpClient({ url: mcpUrl, apiKey, identifier, clientVersion: '0.18.5', log })
|
|
2456
|
+
mcpToolNames = null
|
|
2457
|
+
mcpToolDiscoveryPromise = null
|
|
2458
|
+
mcpToolDiscoveryWarned = false
|
|
2459
|
+
// Retire idle sessions now and active sessions when they finish. Never
|
|
2460
|
+
// cancel accepted work just because deployment configuration changed.
|
|
2461
|
+
workRunners.invalidate()
|
|
2462
|
+
replyRunners.invalidate()
|
|
2463
|
+
void discoverMcpTools().catch(() => {})
|
|
2464
|
+
}
|
|
2465
|
+
if (wsChanged) {
|
|
2466
|
+
wsUrl = latest.wsUrl
|
|
2467
|
+
handle?.close()
|
|
2468
|
+
eventConnection = 'disconnected'
|
|
2469
|
+
handle = connectEvents()
|
|
2470
|
+
if (selfOrganizationId != null) handle?.subscribeOrganization?.(selfOrganizationId)
|
|
2471
|
+
} else if (autoStart && mcpChanged) void reconcileBacklog()
|
|
2472
|
+
log('Connection settings refreshed from agent setup; new sessions use the current endpoints.')
|
|
2473
|
+
}
|
|
2474
|
+
if (setupHost) {
|
|
2475
|
+
connectionRefresh = createConnectionRefresh({ refresh: refreshConnection, log, ...dependencies.connectionRefreshOptions })
|
|
2476
|
+
if (autoStart) void connectionRefresh.check()
|
|
2477
|
+
}
|
|
2419
2478
|
|
|
2420
2479
|
let authorizationProbeBusy = false
|
|
2421
2480
|
const legacyAuthorizationChecks = new Set()
|
|
@@ -2503,6 +2562,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2503
2562
|
closePromise = (async () => {
|
|
2504
2563
|
clearInterval(authorizationTimer)
|
|
2505
2564
|
clearInterval(deliveryTimer)
|
|
2565
|
+
await connectionRefresh?.close()
|
|
2506
2566
|
modelSettingsWatcher?.close()
|
|
2507
2567
|
if (introTimer) clearTimeout(introTimer)
|
|
2508
2568
|
try { handle?.close() } catch { /* already disconnected */ }
|
|
@@ -2528,6 +2588,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2528
2588
|
return {
|
|
2529
2589
|
done, close, receive: onEvent, reconcile: () => stopping ? Promise.resolve() : trackOperation(reconcileBacklog()),
|
|
2530
2590
|
resumeAuthorizations: resumeReadyAuthorizations, retryDeliveries: flushPendingReplies,
|
|
2591
|
+
refreshConnection: () => connectionRefresh?.check() || Promise.resolve(),
|
|
2531
2592
|
get pending() { return queues.work.size + queues.reply.size + mentionIntake.size },
|
|
2532
2593
|
}
|
|
2533
2594
|
}
|
package/studio/app.mjs
CHANGED
|
@@ -824,7 +824,7 @@ function initializeStudio() {
|
|
|
824
824
|
|
|
825
825
|
$('server-address').textContent = location.host;
|
|
826
826
|
let connectingAgent = false;
|
|
827
|
-
const credentialFields = ['name', 'identifier', 'apiKey', 'backend', 'mcpUrl', 'wsUrl', 'agent'];
|
|
827
|
+
const credentialFields = ['name', 'identifier', 'apiKey', 'backend', 'mcpUrl', 'wsUrl', 'setupHost', 'agent'];
|
|
828
828
|
$('open-agent-connect').addEventListener('click', () => {
|
|
829
829
|
$('agent-connect-error').hidden = true;
|
|
830
830
|
$('agent-connect-dialog').showModal();
|
package/studio/index.html
CHANGED
|
@@ -79,6 +79,7 @@
|
|
|
79
79
|
<label for="connect-backend">Backend URL</label><input id="connect-backend" type="url" required maxlength="4096" spellcheck="false">
|
|
80
80
|
<label for="connect-mcpUrl">MCP URL</label><input id="connect-mcpUrl" type="url" required maxlength="4096" spellcheck="false">
|
|
81
81
|
<label for="connect-wsUrl">WebSocket URL</label><input id="connect-wsUrl" type="url" required maxlength="4096" spellcheck="false">
|
|
82
|
+
<label for="connect-setupHost">OpenVisio app URL (optional)</label><input id="connect-setupHost" type="url" maxlength="4096" placeholder="https://openvisio.app" spellcheck="false">
|
|
82
83
|
<label for="connect-agent">Runtime</label><select id="connect-agent"><option value="claude">Claude Code</option><option value="codex">Codex</option><option value="opencode">OpenCode</option><option value="gemini">Gemini CLI</option><option value="qwen">Qwen Code</option></select>
|
|
83
84
|
<p>Install and sign in to your chosen runtime on this computer. After connecting, select the agent and choose Start watcher.</p>
|
|
84
85
|
<p id="agent-connect-error" role="alert" hidden></p>
|