openvisio-agent 0.25.17 → 0.25.19
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 +16 -0
- package/README.md +22 -1
- package/USER_GUIDE.md +11 -1
- package/bin/cli.mjs +16 -2
- package/package.json +1 -1
- package/scenarios/workspace.scenarios.mjs +4 -3
- package/src/connection-discovery.mjs +61 -0
- package/src/mastra-harness.mjs +2 -2
- package/src/opencode-failure.mjs +17 -1
- package/src/runner-pool.mjs +5 -1
- package/src/studio-connect.mjs +14 -3
- package/src/studio-connection.mjs +52 -0
- package/src/studio-server.mjs +36 -1
- package/src/update.mjs +135 -0
- package/src/watch.mjs +115 -15
- package/studio/app.mjs +110 -10
- package/studio/index.html +26 -6
- package/studio/style.css +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.25.19] — 2026-10-06
|
|
4
|
+
|
|
5
|
+
- Add Studio **Update connection** with discovery, a review of changed endpoints, and a pasted connection-object fallback. Verify saved credentials before saving, preserve agent settings, and restart the watcher after applying. Reject stale previews and updates during recorded active work; report restart failures separately from successful saves.
|
|
6
|
+
- Recover guarded channel and task-comment replies after recognized runtime transport interruptions, using the existing bounded backoff, cancellation, and single-delivery controls.
|
|
7
|
+
- Classify OpenCode provider failures carried in structured ACP error data, including quota, authentication, and unavailable-model errors. Unknown failures point to the local Studio process error without publishing raw diagnostics.
|
|
8
|
+
- Include Studio credential-object import, task-comment project resolution, and explicit backend migration fixes.
|
|
9
|
+
- Live smoke gate waived: no designated safe test organization/ticket is available. Real provider execution and Bard/Hindris behavior remain unverified. Automatic discovery requires the deployed app route; use the pasted connection-object fallback while it is unavailable.
|
|
10
|
+
|
|
11
|
+
## [0.25.18] — 2026-10-05
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
- 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.
|
|
15
|
+
- 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.
|
|
16
|
+
- 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.
|
|
17
|
+
- 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.
|
|
18
|
+
|
|
3
19
|
## [0.25.17] — 2026-09-25
|
|
4
20
|
|
|
5
21
|
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# openvisio-agent
|
|
2
2
|
|
|
3
|
-
Connect your coding agent (**Claude Code, Codex, OpenCode, Gemini CLI, or Qwen Code**) to an [OpenVisio](https://openvisio.
|
|
3
|
+
Connect your coding agent (**Claude Code, Codex, OpenCode, Gemini CLI, or Qwen Code**) to an [OpenVisio](https://openvisio.io) team — in one command. No shell scripts, no `curl | bash`.
|
|
4
4
|
|
|
5
5
|
**New here?** Start with the [user guide](USER_GUIDE.md) for setup, everyday work, Agent Studio, and troubleshooting.
|
|
6
6
|
|
|
@@ -58,6 +58,27 @@ 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.io`, 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
|
+
If the API backend itself has moved, pass `--backend <new-api-url>` along with the current `--ws <new-websocket-url>` (and `--mcp-url` if it changed). The update verifies the saved credentials against the new API before saving, preserves agent settings, and updates both API and WebSocket values in the agent's environment file. Without manual connection URLs, it discovers them from `--host` and requires that metadata to match the explicitly selected backend. Automatic discovery never migrates credentials to a different backend.
|
|
79
|
+
|
|
80
|
+
`--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.
|
|
81
|
+
|
|
61
82
|
### `watch --name <agent>`
|
|
62
83
|
|
|
63
84
|
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/USER_GUIDE.md
CHANGED
|
@@ -35,9 +35,11 @@ npm install -g openvisio-agent@latest
|
|
|
35
35
|
|
|
36
36
|
1. In OpenVisio, open **Agents** and choose the connection flow for your agent.
|
|
37
37
|
2. Select your coding runtime, register the agent, and choose **Copy credentials**. The API key is shown only once.
|
|
38
|
-
3. Open Agent Studio on your computer (`openvisio-agent studio`), choose **Connect agent**, and paste the credentials. You can also enter the fields individually.
|
|
38
|
+
3. Open Agent Studio on your computer (`openvisio-agent studio`), choose **Connect agent**, and paste the generated JSON object. Choose **Use credentials** to review the fields, or **Verify and connect** to import and connect directly. You can also enter the fields individually.
|
|
39
39
|
4. Choose **Verify and connect**. Studio checks the key and identifier and saves the connection privately on this computer. Install and sign in to your selected coding runtime, then select the agent and choose **Start watcher**.
|
|
40
40
|
|
|
41
|
+
Studio retrieves missing MCP and WebSocket URLs from the OpenVisio app URL. If discovery is unavailable, enter those URLs manually; existing entered values are preserved.
|
|
42
|
+
|
|
41
43
|
**Terminal option:** Expand **Or use a terminal command** in OpenVisio and run the supplied command. This remains available for CLI setup and advanced options. Existing agents can copy connection details from their profile and enter their previously saved API key in Studio.
|
|
42
44
|
|
|
43
45
|
Treat copied credentials and setup commands as private. Studio does not retrieve a lost API key. Keep your one-time key somewhere safe before leaving the registration screen.
|
|
@@ -46,6 +48,14 @@ Coding mode is enabled by default for backend connections. The agent can handle
|
|
|
46
48
|
|
|
47
49
|
For advanced connection options, see the [command reference](README.md#what-it-does).
|
|
48
50
|
|
|
51
|
+
## Update an existing connection
|
|
52
|
+
|
|
53
|
+
Select an agent in Studio and choose **Update connection**. Studio checks the saved OpenVisio app address for current connection URLs. Review the URLs, then choose **Apply and reconnect** to verify the saved credentials, save the settings, and restart the watcher.
|
|
54
|
+
|
|
55
|
+
If the check fails, paste the latest connection JSON object from OpenVisio and choose **Check connection**. Only connection URLs are imported; the agent keeps its saved API key, identity, models, and workspace. Finish or stop active work before applying. If Studio cannot restart the watcher, it reports that the settings were saved and asks you to restart it manually.
|
|
56
|
+
|
|
57
|
+
This control requires a package version containing the connection-update feature. Automatic discovery also requires the OpenVisio app to serve `/api/agent/setup/connection`; pasting the object works when discovery is unavailable.
|
|
58
|
+
|
|
49
59
|
## Start your watcher
|
|
50
60
|
|
|
51
61
|
A **watcher** is the local program that listens for messages and assignments and runs your agent when there is work to do. Starting it can pick up existing assignments immediately.
|
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>] [--backend <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,15 @@ 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.io).
|
|
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
|
+
Use --backend to migrate to a new API URL after verifying saved credentials.
|
|
89
|
+
Restart the watcher to apply changes. --restart stops the named watcher and
|
|
90
|
+
installs/starts its background service (macOS/Linux; interrupts active work).
|
|
91
|
+
|
|
81
92
|
stop
|
|
82
93
|
Stops the named agent's launchd/systemd service first, then terminates every
|
|
83
94
|
remaining watcher with that exact --name and clears its stale lock. Use this
|
|
@@ -209,6 +220,8 @@ async function runConnect({ positional, flags }) {
|
|
|
209
220
|
// We verify them live, then persist a config + a sourceable env file.
|
|
210
221
|
async function runConnectBackend({ flags }) {
|
|
211
222
|
const backend = stripSlash(String(flags.backend))
|
|
223
|
+
const { endpoint } = await import('../src/update.mjs')
|
|
224
|
+
const setupHost = flags.host === undefined ? 'https://openvisio.io' : endpoint(flags.host, 'host', ['http:', 'https:'])
|
|
212
225
|
const apiKey = flags.key && String(flags.key)
|
|
213
226
|
const identifier = flags.id && String(flags.id)
|
|
214
227
|
if (!/^https?:\/\//.test(backend)) fail('The --backend value must be a full URL (e.g. https://api.your-org.example/dev).')
|
|
@@ -260,7 +273,7 @@ async function runConnectBackend({ flags }) {
|
|
|
260
273
|
}
|
|
261
274
|
|
|
262
275
|
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)
|
|
276
|
+
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
277
|
// A sourceable env file, matching the setup snippet OpenVisio shows.
|
|
265
278
|
const envPath = join(OV_DIR, `${slug}.env`)
|
|
266
279
|
mkdirSync(OV_DIR, { recursive: true })
|
|
@@ -316,6 +329,7 @@ async function main() {
|
|
|
316
329
|
const cmd = argv[0]
|
|
317
330
|
const rest = parseFlags(argv.slice(1))
|
|
318
331
|
if (cmd === 'connect') return runConnect(rest)
|
|
332
|
+
if (cmd === 'update') return (await import('../src/update.mjs')).runUpdate(rest)
|
|
319
333
|
if (cmd === 'watch') return (await import('../src/watch.mjs')).runWatch(rest)
|
|
320
334
|
if (cmd === 'studio') return (await import('../src/studio-cli.mjs')).runStudio(rest)
|
|
321
335
|
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.19",
|
|
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,61 @@
|
|
|
1
|
+
export const DEFAULT_SETUP_HOST = 'https://openvisio.io'
|
|
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 function validateConnectionManifest(data) {
|
|
15
|
+
return {
|
|
16
|
+
setupHost: checkedUrl(data.setupHost || DEFAULT_SETUP_HOST, ['https:', 'http:']),
|
|
17
|
+
backend: checkedUrl(data.backend, ['https:', 'http:']),
|
|
18
|
+
wsUrl: checkedUrl(data.wsUrl, ['wss:', 'ws:']),
|
|
19
|
+
mcpUrl: checkedUrl(data.mcpUrl, ['https:', 'http:']),
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function fetchConnectionManifest(host, { fetchImpl = fetch, signal } = {}) {
|
|
24
|
+
const setupHost = checkedUrl(host || DEFAULT_SETUP_HOST, ['https:', 'http:'])
|
|
25
|
+
const response = await fetchImpl(`${setupHost}/api/agent/setup/connection`, {
|
|
26
|
+
headers: { accept: 'application/json' }, cache: 'no-store', redirect: 'error',
|
|
27
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
|
|
28
|
+
})
|
|
29
|
+
if (!response.ok) { await response.body?.cancel(); throw new Error(`Connection discovery returned HTTP ${response.status}. Saved URLs are unchanged.`) }
|
|
30
|
+
const data = await response.json()
|
|
31
|
+
if (data?.version !== 1) throw new Error('Unsupported connection configuration version.')
|
|
32
|
+
return validateConnectionManifest({ ...data, setupHost })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function fetchConnectionSettings(saved, options = {}) {
|
|
36
|
+
if (saved.mode !== 'backend') throw new Error('Automatic connection discovery requires a backend agent.')
|
|
37
|
+
const backend = checkedUrl(saved.backend, ['https:', 'http:'])
|
|
38
|
+
const next = await fetchConnectionManifest(saved.setupHost, options)
|
|
39
|
+
if (next.backend !== backend) throw new Error('Setup configuration does not match the saved backend. Use --host with this agent’s OpenVisio app URL.')
|
|
40
|
+
const { backend: _, ...settings } = next
|
|
41
|
+
return settings
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Checks run only when requested by startup, failures, or an explicit command.
|
|
45
|
+
// Serialize checks and throttle failure-triggered probes. A failed response
|
|
46
|
+
// never clears a working endpoint, and shutdown aborts an outstanding request.
|
|
47
|
+
export function createConnectionRefresh({ refresh, log = () => {}, minIntervalMs = 30_000, now = Date.now }) {
|
|
48
|
+
let pending = null, lastCheck = -Infinity, stopped = false
|
|
49
|
+
const controller = new AbortController()
|
|
50
|
+
const check = () => {
|
|
51
|
+
if (stopped) return Promise.resolve()
|
|
52
|
+
if (pending) return pending
|
|
53
|
+
if (now() - lastCheck < minIntervalMs) return Promise.resolve()
|
|
54
|
+
lastCheck = now()
|
|
55
|
+
pending = Promise.resolve().then(() => refresh(controller.signal)).catch(() => {
|
|
56
|
+
if (!stopped) log('Connection discovery unavailable; keeping the last saved endpoints.')
|
|
57
|
+
}).finally(() => { pending = null })
|
|
58
|
+
return pending
|
|
59
|
+
}
|
|
60
|
+
return { check, close() { stopped = true; controller.abort(); return pending || Promise.resolve() } }
|
|
61
|
+
}
|
package/src/mastra-harness.mjs
CHANGED
|
@@ -450,7 +450,7 @@ export function createMastraAcpRunner({
|
|
|
450
450
|
} catch (error) {
|
|
451
451
|
if (!controller.signal.aborted) {
|
|
452
452
|
inspectDiagnostics(true)
|
|
453
|
-
if (agent === 'opencode') providerFailure ||= opencodeProviderFailure(error
|
|
453
|
+
if (agent === 'opencode') providerFailure ||= opencodeProviderFailure(error)
|
|
454
454
|
}
|
|
455
455
|
const canceled = controller.signal.aborted
|
|
456
456
|
const subtype = providerFailure?.subtype || (timedOut ? 'timeout' : canceled ? 'canceled' : 'error')
|
|
@@ -463,7 +463,7 @@ export function createMastraAcpRunner({
|
|
|
463
463
|
log(`${agent} cycle done via Mastra ACP (${subtype}${!canceled && safeError ? ': ' + clean(safeError, 1200) : ''})`)
|
|
464
464
|
invalidate()
|
|
465
465
|
return {
|
|
466
|
-
type: 'result', subtype, transportInterrupted: subtype === 'error' && isTransportInterruption(error), userMessage: providerFailure?.userMessage || null, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
|
|
466
|
+
type: 'result', subtype, transportInterrupted: subtype === 'error' && isTransportInterruption(error), userMessage: providerFailure?.userMessage || (agent === 'opencode' && subtype === 'error' ? 'OpenCode could not finish the request. Open the failed cycle in Agent Studio and check its process error for the runtime or provider failure before retrying.' : null), runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
|
|
467
467
|
mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
|
|
468
468
|
didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage,
|
|
469
469
|
didMcpTaskRead, didMcpTaskUpdate,
|
package/src/opencode-failure.mjs
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
// Classify runtime diagnostics only, never assistant prose or tool output.
|
|
2
2
|
export function opencodeProviderFailure(value) {
|
|
3
|
-
const
|
|
3
|
+
const seen = new Set()
|
|
4
|
+
const diagnostic = (item, depth = 0) => {
|
|
5
|
+
if (item == null || depth > 6 || seen.has(item)) return ''
|
|
6
|
+
if (typeof item === 'string') {
|
|
7
|
+
const text = item.split('ACP agent stderr:')[0].slice(0, 16000)
|
|
8
|
+
try { return diagnostic(JSON.parse(text), depth + 1) } catch { return text }
|
|
9
|
+
}
|
|
10
|
+
if (typeof item !== 'object') return String(item)
|
|
11
|
+
seen.add(item)
|
|
12
|
+
// ACP commonly wraps a provider error in data under "Internal error".
|
|
13
|
+
// Read diagnostic fields only; never inspect prompts, tool output, or headers.
|
|
14
|
+
return [
|
|
15
|
+
...['name', 'message', 'code', 'data', 'error', 'cause'].map(key => diagnostic(item[key], depth + 1)),
|
|
16
|
+
...['status', 'statusCode', 'status_code'].filter(key => item[key] != null).map(key => `status ${item[key]}`),
|
|
17
|
+
].join(' ')
|
|
18
|
+
}
|
|
19
|
+
const text = diagnostic(value)
|
|
4
20
|
if (/rate[_ -]?limit|too many requests|\b(?:HTTP|status(?: code)?)\s*[:=]?\s*429\b/i.test(text)) {
|
|
5
21
|
return { subtype: 'rate_limited', userMessage: 'OpenCode’s model provider is rate-limiting this request. Retry after the provider limit resets, or select a model with available quota in Agent Studio.' }
|
|
6
22
|
}
|
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
|
@@ -2,6 +2,7 @@ import { mkdir, open, unlink } from 'node:fs/promises'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { slugify } from './lib.mjs'
|
|
4
4
|
import { runtimeProvider } from './runtime-providers.mjs'
|
|
5
|
+
import { fetchConnectionSettings } from './connection-discovery.mjs'
|
|
5
6
|
|
|
6
7
|
const problem = (message, status = 400) => Object.assign(new Error(message), { status })
|
|
7
8
|
|
|
@@ -23,8 +24,18 @@ export async function connectStudioAgent({ stateDir, input, fetchImpl = fetch })
|
|
|
23
24
|
const agent = field('agent')
|
|
24
25
|
if (!runtimeProvider(agent)) throw problem('Choose a supported runtime.')
|
|
25
26
|
const backend = url('backend', ['https:', 'http:'])
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
let mcpUrl = field('mcpUrl', false) ? url('mcpUrl', ['https:', 'http:']) : ''
|
|
28
|
+
let wsUrl = field('wsUrl', false) ? url('wsUrl', ['wss:', 'ws:']) : ''
|
|
29
|
+
const setupHost = field('setupHost', false) ? url('setupHost', ['https:', 'http:']) : 'https://openvisio.io'
|
|
30
|
+
if (!mcpUrl || !wsUrl) {
|
|
31
|
+
try {
|
|
32
|
+
const discovered = await fetchConnectionSettings({ mode: 'backend', backend, setupHost }, { fetchImpl })
|
|
33
|
+
mcpUrl ||= discovered.mcpUrl
|
|
34
|
+
wsUrl ||= discovered.wsUrl
|
|
35
|
+
} catch {
|
|
36
|
+
throw problem('Could not discover the missing connection URLs. Check the OpenVisio app URL, or enter the MCP and WebSocket URLs manually.', 502)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
28
39
|
let response
|
|
29
40
|
try {
|
|
30
41
|
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 +51,7 @@ export async function connectStudioAgent({ stateDir, input, fetchImpl = fetch })
|
|
|
40
51
|
// Exclusive creation also prevents overwriting existing connections or symlinks.
|
|
41
52
|
for (const [path, value] of [
|
|
42
53
|
[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 }],
|
|
54
|
+
[configPath, { mode: 'backend', backend, setupHost, apiKey, identifier, name, slug, agent, wsUrl, mcpUrl, mcpConfig: mcpPath, prPublishing: true }],
|
|
44
55
|
]) {
|
|
45
56
|
const handle = await open(path, 'wx', 0o600)
|
|
46
57
|
created.push(path)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { constants, openSync, readFileSync, fstatSync, closeSync } from 'node:fs'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { DEFAULT_SETUP_HOST, fetchConnectionManifest, validateConnectionManifest } from './connection-discovery.mjs'
|
|
5
|
+
import { saveConnectionSettings } from './update.mjs'
|
|
6
|
+
|
|
7
|
+
const problem = (message, status = 400) => Object.assign(new Error(message), { status })
|
|
8
|
+
const version = (saved, settings) => createHash('sha256').update(JSON.stringify({ saved, settings })).digest('hex')
|
|
9
|
+
function readConnection(stateDir, slug) {
|
|
10
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,79}$/i.test(slug)) throw problem('Invalid agent.')
|
|
11
|
+
const fd = openSync(join(stateDir, `${slug}.json`), constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
12
|
+
try {
|
|
13
|
+
if (!fstatSync(fd).isFile() || fstatSync(fd).size > 65536) throw problem('Agent settings are unavailable.')
|
|
14
|
+
const saved = JSON.parse(readFileSync(fd, 'utf8'))
|
|
15
|
+
if (saved.mode !== 'backend' || saved.slug !== slug || !saved.apiKey || !saved.identifier) throw problem('This agent does not support connection updates.')
|
|
16
|
+
return saved
|
|
17
|
+
} finally { closeSync(fd) }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function previewStudioConnection({ stateDir, slug, input = {}, fetchImpl = fetch }) {
|
|
21
|
+
const saved = readConnection(stateDir, slug)
|
|
22
|
+
const setupHost = input.setupHost || (saved.setupHost === 'https://openvisio.app' ? DEFAULT_SETUP_HOST : saved.setupHost) || DEFAULT_SETUP_HOST
|
|
23
|
+
let settings
|
|
24
|
+
try {
|
|
25
|
+
settings = input.settings
|
|
26
|
+
? validateConnectionManifest({ ...input.settings, setupHost: input.settings.setupHost || setupHost })
|
|
27
|
+
: await fetchConnectionManifest(setupHost, { fetchImpl })
|
|
28
|
+
} catch {
|
|
29
|
+
throw problem('Could not load current connection URLs. Check the app URL, or paste the latest connection object from OpenVisio and choose Check connection.', 502)
|
|
30
|
+
}
|
|
31
|
+
return { settings, version: version(saved, settings), backendChanged: saved.backend !== settings.backend }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function applyStudioConnection({ stateDir, slug, input, fetchImpl = fetch }) {
|
|
35
|
+
const saved = readConnection(stateDir, slug)
|
|
36
|
+
let settings
|
|
37
|
+
try { settings = validateConnectionManifest(input?.settings) } catch { throw problem('Check the connection settings before applying them.') }
|
|
38
|
+
if (!input.version || input.version !== version(saved, settings)) throw problem('Agent settings changed. Check the connection again before applying.', 409)
|
|
39
|
+
let response
|
|
40
|
+
try {
|
|
41
|
+
response = await fetchImpl(`${settings.backend}/projects`, {
|
|
42
|
+
headers: { accept: 'application/json', 'x-agent-api-key': saved.apiKey, 'x-agent-identifier': saved.identifier },
|
|
43
|
+
redirect: 'error', signal: AbortSignal.timeout(15000),
|
|
44
|
+
})
|
|
45
|
+
} catch { throw problem('Could not reach the selected backend. Saved settings are unchanged.', 502) }
|
|
46
|
+
await response.body?.cancel()
|
|
47
|
+
if (!response.ok) throw problem(`The selected backend rejected verification (HTTP ${response.status}). Saved settings are unchanged.`, 502)
|
|
48
|
+
const current = readConnection(stateDir, slug)
|
|
49
|
+
if (version(current, settings) !== input.version) throw problem('Agent settings changed during verification. Check the connection again.', 409)
|
|
50
|
+
saveConnectionSettings({ stateDir, slug, saved: current, next: { ...current, ...settings } })
|
|
51
|
+
return { saved: true }
|
|
52
|
+
}
|
package/src/studio-server.mjs
CHANGED
|
@@ -9,6 +9,8 @@ import { readModelSettings, saveModelSettings, listRuntimeModels } from './model
|
|
|
9
9
|
import { planAgentRemoval, removeLocalAgent } from './agent-removal.mjs'
|
|
10
10
|
import { controlLocalWatcher } from './watcher-control.mjs'
|
|
11
11
|
import { connectStudioAgent } from './studio-connect.mjs'
|
|
12
|
+
import { previewStudioConnection, applyStudioConnection } from './studio-connection.mjs'
|
|
13
|
+
import { deriveModel } from '../studio/app.mjs'
|
|
12
14
|
import { OV_DIR } from './lib.mjs'
|
|
13
15
|
|
|
14
16
|
const MAX_EVENTS = 500, MAX_FILES = 32, MAX_FILE_BYTES = 256 * 1024, MAX_TOTAL_BYTES = 4 * 1024 * 1024
|
|
@@ -164,7 +166,7 @@ function demoSnapshot(stamp) {
|
|
|
164
166
|
return { schemaVersion: 1, demo: true, generatedAt: new Date(stamp).toISOString(), agents, events: events.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)), limits: { maxEvents: MAX_EVENTS }, warnings: ['Demo mode contains simulated agent activity. No model or task is running.'] }
|
|
165
167
|
}
|
|
166
168
|
|
|
167
|
-
export async function startStudioServer({ stateDir, host = '127.0.0.1', port = 4317, assetsDir = DEFAULT_ASSETS, demo = false, modelCatalog = listRuntimeModels, removeAgent = removeLocalAgent, watcherControl = controlLocalWatcher, connectAgent = connectStudioAgent }) {
|
|
169
|
+
export async function startStudioServer({ stateDir, host = '127.0.0.1', port = 4317, assetsDir = DEFAULT_ASSETS, demo = false, modelCatalog = listRuntimeModels, removeAgent = removeLocalAgent, watcherControl = controlLocalWatcher, connectAgent = connectStudioAgent, previewConnection = previewStudioConnection, applyConnection = applyStudioConnection }) {
|
|
168
170
|
if (!['127.0.0.1', '::1', 'localhost'].includes(host)) throw new Error('Agent Studio can bind only to a loopback address')
|
|
169
171
|
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Agent Studio port must be an integer from 0 to 65535')
|
|
170
172
|
if (!stateDir) throw new Error('Agent Studio requires a local state directory')
|
|
@@ -227,6 +229,39 @@ export async function startStudioServer({ stateDir, host = '127.0.0.1', port = 4
|
|
|
227
229
|
}
|
|
228
230
|
return
|
|
229
231
|
}
|
|
232
|
+
const connectionRoute = /^\/api\/agents\/([a-z0-9][a-z0-9_-]{0,79})\/connection$/i.exec(path)
|
|
233
|
+
if (connectionRoute) {
|
|
234
|
+
const slug = connectionRoute[1]
|
|
235
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
236
|
+
try {
|
|
237
|
+
if (demo) throw Object.assign(new Error('Connection updates are unavailable in demo mode.'), { status: 403 })
|
|
238
|
+
if (req.method !== 'POST') throw Object.assign(new Error('Method not allowed'), { status: 405 })
|
|
239
|
+
if (req.headers.origin !== `http://${req.headers.host}` || req.headers['content-type']?.split(';')[0] !== 'application/json') throw Object.assign(new Error('Use the local Studio page to update connections.'), { status: 403 })
|
|
240
|
+
let body = ''
|
|
241
|
+
for await (const chunk of req) { body += chunk; if (Buffer.byteLength(body) > 16384) throw Object.assign(new Error('Connection request is too large.'), { status: 413 }) }
|
|
242
|
+
let input
|
|
243
|
+
try { input = JSON.parse(body) } catch { throw Object.assign(new Error('Invalid connection request.'), { status: 400 }) }
|
|
244
|
+
if (input?.action === 'check') { res.end(JSON.stringify(await previewConnection({ stateDir, slug, input }))); return }
|
|
245
|
+
if (input?.action !== 'apply') throw Object.assign(new Error('Choose check or apply.'), { status: 400 })
|
|
246
|
+
if (watcherChanges.has(slug) || removals.has(slug)) throw Object.assign(new Error('Another agent change is in progress.'), { status: 409 })
|
|
247
|
+
watcherChanges.add(slug)
|
|
248
|
+
try {
|
|
249
|
+
const model = deriveModel(await readStudioSnapshot({ stateDir, journalCache }))
|
|
250
|
+
if (model.cycles.some(cycle => cycle.agent?.slug === slug && ['active', 'queued', 'connecting', 'recovering'].includes(cycle.status))) throw Object.assign(new Error('This agent has active work. Finish or stop its work before applying and reconnecting.'), { status: 409 })
|
|
251
|
+
const result = await applyConnection({ stateDir, slug, input })
|
|
252
|
+
try {
|
|
253
|
+
await watcherControl({ stateDir, slug, action: 'restart' })
|
|
254
|
+
res.end(JSON.stringify({ ...result, restarted: true, message: 'Connection settings saved and watcher restarted. Check its activity for connection status.' }))
|
|
255
|
+
} catch {
|
|
256
|
+
res.end(JSON.stringify({ ...result, restarted: false, message: 'Connection settings saved, but the watcher could not restart. Start or restart it manually to apply the update.' }))
|
|
257
|
+
}
|
|
258
|
+
} finally { watcherChanges.delete(slug) }
|
|
259
|
+
} catch (error) {
|
|
260
|
+
res.writeHead(error.status || 503)
|
|
261
|
+
res.end(JSON.stringify({ error: error.status ? error.message : 'Could not update this agent connection. Check its saved configuration and try again.' }))
|
|
262
|
+
}
|
|
263
|
+
return
|
|
264
|
+
}
|
|
230
265
|
const removalRoute = /^\/api\/agents\/([a-z0-9][a-z0-9_-]{0,79})\/removal$/i.exec(path)
|
|
231
266
|
if (removalRoute && !demo) {
|
|
232
267
|
const slug = removalRoute[1]
|
package/src/update.mjs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
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', 'backend'])
|
|
20
|
+
if (positional.length || Object.keys(flags).some(key => !allowed.has(key))) throw new Error('Usage: openvisio-agent update --name <agent> [--host <app-url>] [--backend <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.backend !== undefined) {
|
|
30
|
+
if (saved.mode !== 'backend') throw new Error('--backend is supported only for backend agents.')
|
|
31
|
+
next.backend = stripSlash(endpoint(flags.backend, 'backend', ['https:', 'http:']))
|
|
32
|
+
const target = new URL(next.backend)
|
|
33
|
+
if (target.search || (target.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(target.hostname))) throw new Error('--backend requires HTTPS (or local HTTP), without a query.')
|
|
34
|
+
}
|
|
35
|
+
if (flags.host !== undefined) next.setupHost = endpoint(flags.host, 'host', ['http:', 'https:'])
|
|
36
|
+
if (flags.ws === undefined && flags['mcp-url'] === undefined) {
|
|
37
|
+
const discovered = await fetchConnectionSettings(next)
|
|
38
|
+
next = { ...next, ...discovered }
|
|
39
|
+
}
|
|
40
|
+
if (flags.ws !== undefined) next.wsUrl = stripSlash(endpoint(flags.ws, 'ws', ['ws:', 'wss:']))
|
|
41
|
+
if (flags['mcp-url'] !== undefined) next.mcpUrl = endpoint(flags['mcp-url'], 'mcp-url', ['http:', 'https:'])
|
|
42
|
+
|
|
43
|
+
if (next.backend !== saved.backend) {
|
|
44
|
+
// Only an explicit --backend may migrate credentials to another API host.
|
|
45
|
+
// Automatic discovery still requires an exact backend match.
|
|
46
|
+
let response
|
|
47
|
+
try {
|
|
48
|
+
response = await fetch(`${next.backend}/projects`, {
|
|
49
|
+
headers: { accept: 'application/json', 'x-agent-api-key': saved.apiKey, 'x-agent-identifier': saved.identifier },
|
|
50
|
+
redirect: 'error', signal: AbortSignal.timeout(15000),
|
|
51
|
+
})
|
|
52
|
+
} catch { throw new Error('Could not verify credentials at the new backend. Saved settings are unchanged.') }
|
|
53
|
+
await response.body?.cancel()
|
|
54
|
+
if (!response.ok) throw new Error(`New backend rejected verification (HTTP ${response.status}). Saved settings are unchanged.`)
|
|
55
|
+
}
|
|
56
|
+
const current = readConfig(slug)
|
|
57
|
+
if (!current || current.apiKey !== saved.apiKey || current.identifier !== saved.identifier || current.backend !== saved.backend) throw new Error('Saved agent identity changed during update; retry the update.')
|
|
58
|
+
next = { ...current, backend: next.backend, wsUrl: next.wsUrl, mcpUrl: next.mcpUrl, ...(next.setupHost ? { setupHost: next.setupHost } : {}) }
|
|
59
|
+
saved = current
|
|
60
|
+
|
|
61
|
+
saveConnectionSettings({ slug, saved, next, registerClaude: true })
|
|
62
|
+
ok(`Updated connection settings for "${slug}".`)
|
|
63
|
+
if (flags.restart) {
|
|
64
|
+
const { installService } = await import('./watch.mjs')
|
|
65
|
+
installService({ slug, workdir: next.workspace || '', throwOnError: true })
|
|
66
|
+
} else {
|
|
67
|
+
info('Running watchers keep their current endpoints until restarted.')
|
|
68
|
+
info(`Background watcher: openvisio-agent watch --name ${slug} --install`)
|
|
69
|
+
info(`Foreground watcher: stop it, then run openvisio-agent watch --name ${slug}`)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function saveConnectionSettings({ slug, saved, next, registerClaude = false, stateDir = OV_DIR }) {
|
|
74
|
+
const mcpChanged = next.mcpUrl !== saved.mcpUrl
|
|
75
|
+
const wsChanged = next.wsUrl !== saved.wsUrl
|
|
76
|
+
const backendChanged = next.backend !== saved.backend
|
|
77
|
+
// Prepare every local file before changing anything. Preserve other servers
|
|
78
|
+
// and settings in the agent's scoped MCP configuration.
|
|
79
|
+
let scoped
|
|
80
|
+
let scopedPath
|
|
81
|
+
let headers
|
|
82
|
+
if (mcpChanged) {
|
|
83
|
+
headers = saved.mode === 'backend'
|
|
84
|
+
? { 'x-agent-api-key': saved.apiKey, 'x-agent-identifier': saved.identifier }
|
|
85
|
+
: { Authorization: saved.key ? `Bearer ${saved.key}` : '' }
|
|
86
|
+
if (Object.values(headers).some(value => typeof value !== 'string' || !value)) throw new Error('Saved agent credentials are incomplete. Run connect first.')
|
|
87
|
+
if (saved.mcpConfig || !saved.agent || saved.agent === 'claude') {
|
|
88
|
+
scopedPath = saved.mcpConfig || join(stateDir, `${slug}-team.mcp.json`)
|
|
89
|
+
scoped = existsSync(scopedPath) ? JSON.parse(readFileSync(scopedPath, 'utf8')) : {}
|
|
90
|
+
scoped = { ...scoped, mcpServers: { ...scoped.mcpServers, 'openvisio-team': {
|
|
91
|
+
...scoped.mcpServers?.['openvisio-team'], type: 'http', url: next.mcpUrl, headers,
|
|
92
|
+
} } }
|
|
93
|
+
next.mcpConfig = scopedPath
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const envPath = join(stateDir, `${slug}.env`)
|
|
97
|
+
let envText
|
|
98
|
+
if ((wsChanged || backendChanged) && existsSync(envPath)) {
|
|
99
|
+
envText = readFileSync(envPath, 'utf8')
|
|
100
|
+
for (const [key, value] of [...(wsChanged ? [['OPENVISIO_AGENT_WS_URL', next.wsUrl]] : []), ...(backendChanged ? [['OPENVISIO_API_URL', next.backend]] : [])]) {
|
|
101
|
+
const assignment = `${key}='${value.replace(/'/g, "'\\''")}'`
|
|
102
|
+
let replaced = false
|
|
103
|
+
envText = envText.split(/\r?\n/).map(line => {
|
|
104
|
+
if (!new RegExp(`^\\s*(?:export\\s+)?${key}=`).test(line)) return line
|
|
105
|
+
replaced = true
|
|
106
|
+
return assignment
|
|
107
|
+
}).join('\n')
|
|
108
|
+
if (!replaced) envText = envText.replace(/\n?$/, '\n') + assignment + '\n'
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Claude also exposes the team MCP in interactive sessions at user scope.
|
|
113
|
+
// Report errors without printing credential-bearing arguments.
|
|
114
|
+
if (registerClaude && mcpChanged && (!saved.agent || saved.agent === 'claude')) {
|
|
115
|
+
const claude = onPath('claude')
|
|
116
|
+
if (!claude) throw new Error('Claude Code is not on PATH; install it before updating its MCP registration.')
|
|
117
|
+
spawnSync(claude, ['mcp', 'remove', 'openvisio-team', '-s', 'user'], { stdio: 'ignore' })
|
|
118
|
+
spawnSync(claude, ['mcp', 'remove', 'openvisio-team', '-s', 'local'], { stdio: 'ignore' })
|
|
119
|
+
const headerArgs = Object.entries(headers).flatMap(([key, value]) => ['--header', `${key}: ${value}`])
|
|
120
|
+
const result = spawnSync(claude, ['mcp', 'add', '--scope', 'user', '--transport', 'http', 'openvisio-team', next.mcpUrl, ...headerArgs], { stdio: 'ignore' })
|
|
121
|
+
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.')
|
|
122
|
+
}
|
|
123
|
+
if (scoped) writeJson(scopedPath, scoped, true)
|
|
124
|
+
if (envText !== undefined) {
|
|
125
|
+
const temporary = `${envPath}.${randomUUID()}.tmp`
|
|
126
|
+
try {
|
|
127
|
+
writeFileSync(temporary, envText, { flag: 'wx', mode: 0o600 })
|
|
128
|
+
renameSync(temporary, envPath)
|
|
129
|
+
} finally {
|
|
130
|
+
try { unlinkSync(temporary) } catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
writeJson(join(stateDir, `${slug}.json`), next, true)
|
|
134
|
+
return next
|
|
135
|
+
}
|
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()
|
|
@@ -767,7 +781,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
767
781
|
const runner = replyRunners.acquire(key, { key, canCoordinate: item.kind === 'coord' })
|
|
768
782
|
activeReplyRunners.add(runner)
|
|
769
783
|
item.control.runner = runner
|
|
770
|
-
try { return await cycleContext.run({ cycleId: item.control.cycleId }, () =>
|
|
784
|
+
try { return await cycleContext.run({ cycleId: item.control.cycleId }, () => item.delivery?.watcherOwned
|
|
785
|
+
? executeRecoverableCycle(item, runner)
|
|
786
|
+
: executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, '')) }
|
|
771
787
|
finally { replyRunners.release(key); activeReplyRunners.delete(runner); item.control.runner = null }
|
|
772
788
|
},
|
|
773
789
|
onError: (error, item) => {
|
|
@@ -952,7 +968,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
952
968
|
handle?.subscribeOrganization?.(selfOrganizationId)
|
|
953
969
|
}
|
|
954
970
|
}
|
|
955
|
-
const
|
|
971
|
+
const makeMcpClient = dependencies.createMcpClient || createMcpHttpClient
|
|
972
|
+
let mcpClient = dependencies.mcpClient || makeMcpClient({ url: mcpUrl, apiKey, identifier, clientVersion: '0.18.5', log })
|
|
956
973
|
const callMcpTool = (name, args = {}) => {
|
|
957
974
|
if (stopping) return Promise.reject(new Error('Watcher is stopping'))
|
|
958
975
|
const toolCallId = `${journal.runId}:mcp:${++mcpSequence}`
|
|
@@ -964,6 +981,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
964
981
|
return result
|
|
965
982
|
}, (error) => {
|
|
966
983
|
emit('tool.finished', { ...metadata, status: 'failed' })
|
|
984
|
+
void connectionRefresh?.check()
|
|
967
985
|
throw error
|
|
968
986
|
}))
|
|
969
987
|
}
|
|
@@ -1008,15 +1026,18 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1008
1026
|
// The transport owns cache invalidation after a session reset. Do not keep
|
|
1009
1027
|
// an independent name cache that can outlive its advertised capabilities.
|
|
1010
1028
|
if (mcpToolDiscoveryPromise) return mcpToolDiscoveryPromise
|
|
1011
|
-
|
|
1029
|
+
const client = mcpClient
|
|
1030
|
+
const discovery = client.listTools(refresh).then((tools) => {
|
|
1012
1031
|
const names = new Set(tools.map((tool) => String(tool?.name || '')).filter(Boolean))
|
|
1032
|
+
if (client !== mcpClient) return names
|
|
1013
1033
|
const changed = !mcpToolNames || names.size !== mcpToolNames.size || [...names].some((name) => !mcpToolNames.has(name))
|
|
1014
1034
|
mcpToolNames = names
|
|
1015
1035
|
const required = ['list_agents', 'list_projects', 'list_tasks', 'get_ticket', 'update_ticket', 'post_message']
|
|
1016
1036
|
const missing = required.filter((name) => !mcpToolNames.has(name))
|
|
1017
1037
|
if (changed) log(`MCP tools ready (${mcpToolNames.size})${missing.length ? '; missing core tools: ' + missing.join(', ') : ''}`)
|
|
1018
1038
|
return mcpToolNames
|
|
1019
|
-
}).finally(() => { mcpToolDiscoveryPromise = null })
|
|
1039
|
+
}).finally(() => { if (mcpToolDiscoveryPromise === discovery) mcpToolDiscoveryPromise = null })
|
|
1040
|
+
mcpToolDiscoveryPromise = discovery
|
|
1020
1041
|
return mcpToolDiscoveryPromise
|
|
1021
1042
|
}
|
|
1022
1043
|
// Nearby channel roots are optional. Their deadline must not discard an
|
|
@@ -1691,7 +1712,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1691
1712
|
activity.clear(control.cycleId)
|
|
1692
1713
|
const delayMs = recoveryDelays[attempt - 1]
|
|
1693
1714
|
emit('cycle.recovering', { cycleId: control.cycleId, status: 'recovering', attempt, retryInMs: delayMs, reason: 'Runtime connection interrupted; preserving accepted work' })
|
|
1694
|
-
log(
|
|
1715
|
+
log(`${item.kind === 'full' ? 'coding' : 'reply'} runtime connection interrupted; resuming attempt ${attempt + 1} in ${delayMs / 1000}s or on reconnect`)
|
|
1695
1716
|
await new Promise((resolve) => {
|
|
1696
1717
|
const wake = () => { clearTimeout(timer); recoveryWaiters.delete(wake); control.wakeRecovery = null; resolve() }
|
|
1697
1718
|
// A known event-channel outage can last longer than the retry budget.
|
|
@@ -1857,11 +1878,11 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1857
1878
|
return
|
|
1858
1879
|
}
|
|
1859
1880
|
if (!cycleSucceeded(result)) {
|
|
1860
|
-
if (kind === 'full' && interruptedRuntimeResult(result) && cycleControl.attempt <= recoveryDelays.length) return { ...result, transportInterrupted: true }
|
|
1881
|
+
if ((kind === 'full' || delivery?.watcherOwned) && interruptedRuntimeResult(result) && cycleControl.attempt <= recoveryDelays.length) return { ...result, transportInterrupted: true }
|
|
1861
1882
|
const outcome = result?.subtype || 'an unknown runtime error'
|
|
1862
1883
|
const providerPause = agent === 'opencode' && ['rate_limited', 'authentication_error', 'model_unavailable'].includes(outcome)
|
|
1863
1884
|
? { kind, model: result.model || useModel, retryAt: outcome === 'rate_limited' ? Date.now() + 30 * 60_000 : null } : null
|
|
1864
|
-
const providerMessage =
|
|
1885
|
+
const providerMessage = result?.userMessage || (outcome === 'rate_limited' ? 'The model provider is rate-limiting this request. Choose a model with available quota in Agent Studio, or retry once the limit resets.' : '')
|
|
1865
1886
|
const resumeMessage = providerPause ? (outcome === 'rate_limited'
|
|
1866
1887
|
? " I'll retry this ticket after a 30-minute cooldown, or when its model or ticket changes."
|
|
1867
1888
|
: " I'll resume this ticket when its model or ticket changes; after fixing credentials, update the ticket to retry.") : " I've paused this revision until the ticket changes."
|
|
@@ -2048,17 +2069,51 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2048
2069
|
}
|
|
2049
2070
|
}
|
|
2050
2071
|
|
|
2072
|
+
const commentProjectReads = new Map()
|
|
2073
|
+
const resolveCommentProject = (ticketId) => {
|
|
2074
|
+
const key = String(ticketId)
|
|
2075
|
+
if (commentProjectReads.has(key)) return commentProjectReads.get(key)
|
|
2076
|
+
const pending = (async () => {
|
|
2077
|
+
const data = toolData(await callMcpReadWithRetry('list_projects'), 'list_projects')
|
|
2078
|
+
const projects = Array.isArray(data.projects) ? data.projects : []
|
|
2079
|
+
const matches = await mapConcurrent(projects.filter(project => validBackendId(project?.id)), 3, async project => {
|
|
2080
|
+
const tasks = await loadProjectTickets({
|
|
2081
|
+
projectId: Number(project.id),
|
|
2082
|
+
read: async (name, args) => toolData(await callMcpReadWithRetry(name, args), name),
|
|
2083
|
+
listTools: () => mcpClient.listTools(),
|
|
2084
|
+
matches: task => String(task.id) === key,
|
|
2085
|
+
})
|
|
2086
|
+
return tasks.some(task => String(task.id) === key) ? Number(project.id) : null
|
|
2087
|
+
})
|
|
2088
|
+
const ids = [...new Set(matches.filter(id => id != null))]
|
|
2089
|
+
if (ids.length !== 1) throw new Error('Could not resolve one authoritative project for this task comment.')
|
|
2090
|
+
return ids[0]
|
|
2091
|
+
})().finally(() => commentProjectReads.delete(key))
|
|
2092
|
+
commentProjectReads.set(key, pending)
|
|
2093
|
+
return pending
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2051
2096
|
const handleTaskComment = async (raw) => {
|
|
2052
2097
|
const event = taskCommentFromEvent(raw)
|
|
2053
|
-
|
|
2098
|
+
let projectId = event?.projectId
|
|
2054
2099
|
const ticketId = event?.taskId
|
|
2055
2100
|
const comment = event?.comment
|
|
2056
2101
|
const commentId = comment && (comment.id ?? comment.comment_id ?? comment.commentId)
|
|
2057
2102
|
const text = String(comment?.content ?? comment?.body ?? comment?.text ?? comment?.message ?? '').replace(/\s+/g, ' ').trim()
|
|
2058
|
-
if (!validBackendId(projectId) || !validBackendId(ticketId) || !validBackendId(commentId) || !text) {
|
|
2103
|
+
if ((projectId != null && !validBackendId(projectId)) || !validBackendId(ticketId) || !validBackendId(commentId) || !text) {
|
|
2059
2104
|
log('task:comment missing project/task/comment identity or content — ignored')
|
|
2060
2105
|
return { status: 'ignored-malformed' }
|
|
2061
2106
|
}
|
|
2107
|
+
// Backend comment rows carry task_id but not project_id. Some broadcasts
|
|
2108
|
+
// likewise omit the project. Resolve it from authenticated task listings;
|
|
2109
|
+
// never infer it from another event or assume the first project is correct.
|
|
2110
|
+
if (projectId == null) {
|
|
2111
|
+
try { projectId = await resolveCommentProject(ticketId) }
|
|
2112
|
+
catch (error) {
|
|
2113
|
+
log(`task:comment project lookup failed for ticket #${ticketId}: ` + (error?.message || error))
|
|
2114
|
+
return { status: 'retry' }
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2062
2117
|
const sourceKey = `task-comment:${Number(projectId)}:${Number(ticketId)}:${Number(commentId)}`
|
|
2063
2118
|
if (memory.has(sourceKey, 'received')) {
|
|
2064
2119
|
log(`task:comment duplicate project=${projectId} ticket=${ticketId} comment=${commentId} — skipped`)
|
|
@@ -2095,6 +2150,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2095
2150
|
log(`task:comment ticket #${ticketId} is not assigned to this agent — ignored`)
|
|
2096
2151
|
return { status: 'ignored-unassigned' }
|
|
2097
2152
|
}
|
|
2153
|
+
// Duplicate deliveries can share the asynchronous project/owner reads.
|
|
2154
|
+
// Recheck immediately before reserving this source and starting work.
|
|
2155
|
+
if (memory.has(sourceKey, 'received')) return { status: 'duplicate' }
|
|
2098
2156
|
if (peer) {
|
|
2099
2157
|
const meta = admitPeerTurn(memory.get(loopKey), text, target)
|
|
2100
2158
|
if (!meta) return { status: 'ignored-peer-loop' }
|
|
@@ -2402,10 +2460,11 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2402
2460
|
// recover channel mentions even when the backend omits targeted dispatch.
|
|
2403
2461
|
// All conversation event types share recipient, ownership and deduplication guards.
|
|
2404
2462
|
log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
|
|
2405
|
-
|
|
2463
|
+
const connectEvents = () => (dependencies.connect || connectAgentWs)({ wsUrl, apiKey, identifier, onEvent,
|
|
2406
2464
|
onDisconnect: () => {
|
|
2407
2465
|
eventConnection = 'disconnected'
|
|
2408
2466
|
emit('connection.disconnected', { status: 'disconnected', reason: 'Event connection lost; accepted work remains running' })
|
|
2467
|
+
void connectionRefresh?.check()
|
|
2409
2468
|
},
|
|
2410
2469
|
onConnect: () => {
|
|
2411
2470
|
if (stopping) return
|
|
@@ -2416,6 +2475,45 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2416
2475
|
queueMicrotask(() => { if (!stopping) void ensureSelfAgent() })
|
|
2417
2476
|
if (autoStart) setTimeout(() => { if (!stopping) void reconcileBacklog() }, 250)
|
|
2418
2477
|
}, log })
|
|
2478
|
+
handle = connectEvents()
|
|
2479
|
+
|
|
2480
|
+
const refreshConnection = async (signal) => {
|
|
2481
|
+
if (stopping || !setupHost) return
|
|
2482
|
+
const latest = await (dependencies.fetchConnectionSettings || fetchConnectionSettings)({ mode: 'backend', backend, setupHost }, { signal })
|
|
2483
|
+
if (stopping) return
|
|
2484
|
+
const mcpChanged = latest.mcpUrl !== mcpUrl
|
|
2485
|
+
const wsChanged = latest.wsUrl !== wsUrl
|
|
2486
|
+
if (!mcpChanged && !wsChanged) return
|
|
2487
|
+
// Re-read immediately before saving to preserve live Studio/model updates.
|
|
2488
|
+
const saved = JSON.parse(readFileSync(join(stateDir, `${slug}.json`), 'utf8'))
|
|
2489
|
+
if (saved.identifier !== identifier || saved.apiKey !== apiKey || stripSlash(saved.backend) !== stripSlash(backend)) throw new Error('Saved agent identity changed; reconnect explicitly.')
|
|
2490
|
+
saveConnectionSettings({ slug, saved, next: { ...saved, ...latest }, stateDir })
|
|
2491
|
+
if (mcpChanged) {
|
|
2492
|
+
mcpUrl = latest.mcpUrl
|
|
2493
|
+
runnerOptions.mcpUrl = mcpUrl
|
|
2494
|
+
mcpClient = makeMcpClient({ url: mcpUrl, apiKey, identifier, clientVersion: '0.18.5', log })
|
|
2495
|
+
mcpToolNames = null
|
|
2496
|
+
mcpToolDiscoveryPromise = null
|
|
2497
|
+
mcpToolDiscoveryWarned = false
|
|
2498
|
+
// Retire idle sessions now and active sessions when they finish. Never
|
|
2499
|
+
// cancel accepted work just because deployment configuration changed.
|
|
2500
|
+
workRunners.invalidate()
|
|
2501
|
+
replyRunners.invalidate()
|
|
2502
|
+
void discoverMcpTools().catch(() => {})
|
|
2503
|
+
}
|
|
2504
|
+
if (wsChanged) {
|
|
2505
|
+
wsUrl = latest.wsUrl
|
|
2506
|
+
handle?.close()
|
|
2507
|
+
eventConnection = 'disconnected'
|
|
2508
|
+
handle = connectEvents()
|
|
2509
|
+
if (selfOrganizationId != null) handle?.subscribeOrganization?.(selfOrganizationId)
|
|
2510
|
+
} else if (autoStart && mcpChanged) void reconcileBacklog()
|
|
2511
|
+
log('Connection settings refreshed from agent setup; new sessions use the current endpoints.')
|
|
2512
|
+
}
|
|
2513
|
+
if (setupHost) {
|
|
2514
|
+
connectionRefresh = createConnectionRefresh({ refresh: refreshConnection, log, ...dependencies.connectionRefreshOptions })
|
|
2515
|
+
if (autoStart) void connectionRefresh.check()
|
|
2516
|
+
}
|
|
2419
2517
|
|
|
2420
2518
|
let authorizationProbeBusy = false
|
|
2421
2519
|
const legacyAuthorizationChecks = new Set()
|
|
@@ -2503,6 +2601,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2503
2601
|
closePromise = (async () => {
|
|
2504
2602
|
clearInterval(authorizationTimer)
|
|
2505
2603
|
clearInterval(deliveryTimer)
|
|
2604
|
+
await connectionRefresh?.close()
|
|
2506
2605
|
modelSettingsWatcher?.close()
|
|
2507
2606
|
if (introTimer) clearTimeout(introTimer)
|
|
2508
2607
|
try { handle?.close() } catch { /* already disconnected */ }
|
|
@@ -2528,6 +2627,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2528
2627
|
return {
|
|
2529
2628
|
done, close, receive: onEvent, reconcile: () => stopping ? Promise.resolve() : trackOperation(reconcileBacklog()),
|
|
2530
2629
|
resumeAuthorizations: resumeReadyAuthorizations, retryDeliveries: flushPendingReplies,
|
|
2630
|
+
refreshConnection: () => connectionRefresh?.check() || Promise.resolve(),
|
|
2531
2631
|
get pending() { return queues.work.size + queues.reply.size + mentionIntake.size },
|
|
2532
2632
|
}
|
|
2533
2633
|
}
|
package/studio/app.mjs
CHANGED
|
@@ -10,6 +10,29 @@ const timeValue = value => Number.isFinite(Date.parse(value)) ? Date.parse(value
|
|
|
10
10
|
const agentId = item => text(item?.identifier || item?.id || item?.slug || 'unknown');
|
|
11
11
|
const agentName = item => text(item?.name || item?.slug || item?.identifier || 'Unknown agent');
|
|
12
12
|
|
|
13
|
+
export const credentialFields = ['name', 'identifier', 'apiKey', 'backend', 'mcpUrl', 'wsUrl', 'setupHost', 'agent'];
|
|
14
|
+
|
|
15
|
+
export function parseAgentCredentials(value) {
|
|
16
|
+
let credentials;
|
|
17
|
+
const source = value.trim().replace(/^```(?:json)?\s*\n([\s\S]*?)\n```$/i, '$1');
|
|
18
|
+
try { credentials = JSON.parse(source); }
|
|
19
|
+
catch { throw new Error('Paste a complete JSON credentials object from OpenVisio → Copy credentials.'); }
|
|
20
|
+
if (!credentials || typeof credentials !== 'object' || Array.isArray(credentials)) throw new Error('Credentials must be a JSON object.');
|
|
21
|
+
const result = {};
|
|
22
|
+
for (const key of credentialFields) {
|
|
23
|
+
const raw = credentials[key];
|
|
24
|
+
if (raw !== undefined && typeof raw !== 'string') throw new Error(`The ${key} field must be text.`);
|
|
25
|
+
const field = (raw || '').trim();
|
|
26
|
+
if (field.length > 4096 || /[\r\n\0]/.test(field)) throw new Error(`Enter a valid ${key}.`);
|
|
27
|
+
result[key] = field;
|
|
28
|
+
}
|
|
29
|
+
const missing = ['name', 'identifier', 'apiKey', 'backend'].filter(key => !result[key]);
|
|
30
|
+
if (missing.length) throw new Error(`Credentials are missing: ${missing.join(', ')}. Copy the complete object from OpenVisio.`);
|
|
31
|
+
result.agent = result.agent.toLowerCase() || 'claude';
|
|
32
|
+
if (!['claude', 'codex', 'opencode', 'gemini', 'qwen'].includes(result.agent)) throw new Error('Choose a supported runtime: Claude Code, Codex, OpenCode, Gemini CLI, or Qwen Code.');
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
|
|
13
36
|
export function normalizeStatus(value, fallback = 'observed') {
|
|
14
37
|
const status = text(value).toLowerCase().replaceAll('-', '_');
|
|
15
38
|
if (['active', 'running', 'started', 'ready', 'in_progress', 'working', 'online', 'preflight'].includes(status)) return 'active';
|
|
@@ -400,6 +423,7 @@ function initializeStudio() {
|
|
|
400
423
|
if (!agent || state.snapshot.demo) return;
|
|
401
424
|
$('model-settings-agent').textContent = `${agentName(agent)} · Models`;
|
|
402
425
|
$('open-model-settings').disabled = !agent.settingsEditable;
|
|
426
|
+
$('open-connection-update').disabled = !agent.settingsEditable || watcherChanging;
|
|
403
427
|
$('open-agent-removal').disabled = !agent.removable;
|
|
404
428
|
const watcherRunning = agent.status === 'online' || agent.watcherAlive === true;
|
|
405
429
|
$('toggle-watcher').querySelector('span').textContent = watcherRunning ? 'Stop watcher' : 'Start watcher';
|
|
@@ -824,30 +848,37 @@ function initializeStudio() {
|
|
|
824
848
|
|
|
825
849
|
$('server-address').textContent = location.host;
|
|
826
850
|
let connectingAgent = false;
|
|
827
|
-
const credentialFields = ['name', 'identifier', 'apiKey', 'backend', 'mcpUrl', 'wsUrl', 'agent'];
|
|
828
851
|
$('open-agent-connect').addEventListener('click', () => {
|
|
829
852
|
$('agent-connect-error').hidden = true;
|
|
830
853
|
$('agent-connect-dialog').showModal();
|
|
831
854
|
});
|
|
832
855
|
$('close-agent-connect').addEventListener('click', () => { if (!connectingAgent) $('agent-connect-dialog').close(); });
|
|
833
856
|
$('agent-connect-dialog').addEventListener('cancel', event => { if (connectingAgent) event.preventDefault(); });
|
|
834
|
-
$('agent-connect-dialog').addEventListener('close', () =>
|
|
835
|
-
|
|
836
|
-
|
|
857
|
+
$('agent-connect-dialog').addEventListener('close', () => {
|
|
858
|
+
$('agent-connect-form').reset();
|
|
859
|
+
$('connect-import-status').textContent = 'Paste the complete object, then choose Use credentials to review the fields.';
|
|
860
|
+
});
|
|
861
|
+
const importCredentials = () => {
|
|
862
|
+
if (connectingAgent) return false;
|
|
837
863
|
try {
|
|
838
|
-
const credentials =
|
|
839
|
-
|
|
840
|
-
for (const key of credentialFields) $('connect-' + key).value = typeof credentials[key] === 'string' ? credentials[key] : key === 'agent' ? 'claude' : '';
|
|
864
|
+
const credentials = parseAgentCredentials($('connect-credentials').value);
|
|
865
|
+
for (const key of credentialFields) $('connect-' + key).value = credentials[key];
|
|
841
866
|
$('connect-credentials').value = '';
|
|
842
867
|
$('agent-connect-error').hidden = true;
|
|
843
|
-
|
|
844
|
-
|
|
868
|
+
$('connect-import-status').textContent = 'Credentials imported. Review the fields, then choose Verify and connect.';
|
|
869
|
+
return true;
|
|
870
|
+
} catch (error) {
|
|
871
|
+
$('agent-connect-error').textContent = error.message;
|
|
845
872
|
$('agent-connect-error').hidden = false;
|
|
873
|
+
return false;
|
|
846
874
|
}
|
|
847
|
-
}
|
|
875
|
+
};
|
|
876
|
+
$('import-agent-credentials').addEventListener('click', importCredentials);
|
|
848
877
|
$('agent-connect-form').addEventListener('submit', async event => {
|
|
849
878
|
event.preventDefault();
|
|
850
879
|
if (connectingAgent) return;
|
|
880
|
+
if ($('connect-credentials').value.trim() && !importCredentials()) return;
|
|
881
|
+
if (!$('agent-connect-form').reportValidity()) return;
|
|
851
882
|
connectingAgent = true;
|
|
852
883
|
$('save-agent-connect').disabled = true;
|
|
853
884
|
$('close-agent-connect').disabled = true;
|
|
@@ -966,6 +997,75 @@ function initializeStudio() {
|
|
|
966
997
|
scheduleRender();
|
|
967
998
|
}
|
|
968
999
|
}
|
|
1000
|
+
let connectionAgent = null, connectionPlan = null, connectionBusy = false;
|
|
1001
|
+
const connectionBusyState = busy => {
|
|
1002
|
+
connectionBusy = busy;
|
|
1003
|
+
for (const id of ['connection-update-host', 'connection-update-object', 'check-connection-update', 'close-connection-update']) $(id).disabled = busy;
|
|
1004
|
+
$('apply-connection-update').disabled = busy || !connectionPlan;
|
|
1005
|
+
};
|
|
1006
|
+
const invalidateConnectionPlan = () => {
|
|
1007
|
+
connectionPlan = null;
|
|
1008
|
+
$('connection-update-preview').hidden = true;
|
|
1009
|
+
$('apply-connection-update').disabled = true;
|
|
1010
|
+
$('connection-update-status').textContent = '';
|
|
1011
|
+
$('connection-update-error').hidden = true;
|
|
1012
|
+
};
|
|
1013
|
+
$('open-connection-update').addEventListener('click', () => {
|
|
1014
|
+
connectionAgent = state.model.agents.find(agent => agentId(agent) === state.selectedAgent);
|
|
1015
|
+
if (!connectionAgent?.settingsEditable) return;
|
|
1016
|
+
$('connection-update-form').reset();
|
|
1017
|
+
invalidateConnectionPlan();
|
|
1018
|
+
$('connection-update-title').textContent = `Update ${agentName(connectionAgent)} connection`;
|
|
1019
|
+
$('connection-update-dialog').showModal();
|
|
1020
|
+
$('connection-update-form').requestSubmit();
|
|
1021
|
+
});
|
|
1022
|
+
$('close-connection-update').addEventListener('click', () => { if (!connectionBusy) $('connection-update-dialog').close(); });
|
|
1023
|
+
$('connection-update-dialog').addEventListener('cancel', event => { if (connectionBusy) event.preventDefault(); });
|
|
1024
|
+
$('connection-update-dialog').addEventListener('close', () => { $('connection-update-form').reset(); invalidateConnectionPlan(); });
|
|
1025
|
+
for (const id of ['connection-update-host', 'connection-update-object']) $(id).addEventListener('input', invalidateConnectionPlan);
|
|
1026
|
+
$('connection-update-form').addEventListener('submit', async event => {
|
|
1027
|
+
event.preventDefault();
|
|
1028
|
+
if (!connectionAgent || connectionBusy) return;
|
|
1029
|
+
invalidateConnectionPlan();
|
|
1030
|
+
connectionBusyState(true);
|
|
1031
|
+
$('connection-update-status').textContent = 'Checking current connection URLs…';
|
|
1032
|
+
try {
|
|
1033
|
+
const input = { action: 'check', setupHost: $('connection-update-host').value.trim() || undefined };
|
|
1034
|
+
const pasted = $('connection-update-object').value.trim();
|
|
1035
|
+
if (pasted) {
|
|
1036
|
+
let parsed;
|
|
1037
|
+
try { parsed = JSON.parse(pasted.replace(/^```(?:json)?\s*\n([\s\S]*?)\n```$/i, '$1')); } catch { throw new Error('Paste a complete JSON connection object.'); }
|
|
1038
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('Paste a JSON connection object.');
|
|
1039
|
+
input.settings = Object.fromEntries(['backend', 'mcpUrl', 'wsUrl', 'setupHost'].map(key => [key, parsed[key]]));
|
|
1040
|
+
if (input.setupHost) input.settings.setupHost = input.setupHost;
|
|
1041
|
+
}
|
|
1042
|
+
connectionPlan = await settingsFetch(`/api/agents/${encodeURIComponent(connectionAgent.slug)}/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input) });
|
|
1043
|
+
$('connection-update-object').value = '';
|
|
1044
|
+
$('connection-update-preview').textContent = JSON.stringify(connectionPlan.settings, null, 2);
|
|
1045
|
+
$('connection-update-preview').hidden = false;
|
|
1046
|
+
$('connection-update-status').textContent = connectionPlan.backendChanged ? 'The API backend has moved. Review the new URLs before applying. Your saved key will be verified against this backend.' : 'Review the current URLs, then apply and reconnect.';
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
$('connection-update-status').textContent = '';
|
|
1049
|
+
$('connection-update-error').textContent = error.message;
|
|
1050
|
+
$('connection-update-error').hidden = false;
|
|
1051
|
+
} finally { connectionBusyState(false); }
|
|
1052
|
+
});
|
|
1053
|
+
$('apply-connection-update').addEventListener('click', async () => {
|
|
1054
|
+
if (!connectionPlan || connectionBusy) return;
|
|
1055
|
+
connectionBusyState(true);
|
|
1056
|
+
$('connection-update-error').hidden = true;
|
|
1057
|
+
$('connection-update-status').textContent = 'Verifying and applying connection settings…';
|
|
1058
|
+
try {
|
|
1059
|
+
const result = await settingsFetch(`/api/agents/${encodeURIComponent(connectionAgent.slug)}/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'apply', ...connectionPlan }) });
|
|
1060
|
+
connectionPlan = null;
|
|
1061
|
+
$('connection-update-status').textContent = result.message;
|
|
1062
|
+
receive(await settingsFetch('/api/snapshot'), true);
|
|
1063
|
+
} catch (error) {
|
|
1064
|
+
$('connection-update-status').textContent = '';
|
|
1065
|
+
$('connection-update-error').textContent = error.message;
|
|
1066
|
+
$('connection-update-error').hidden = false;
|
|
1067
|
+
} finally { connectionBusyState(false); }
|
|
1068
|
+
});
|
|
969
1069
|
$('toggle-watcher').addEventListener('click', () => void changeWatcher($('toggle-watcher').dataset.action || 'start'));
|
|
970
1070
|
$('restart-watcher').addEventListener('click', () => void changeWatcher('restart'));
|
|
971
1071
|
$('pause-button').addEventListener('click', () => {
|
package/studio/index.html
CHANGED
|
@@ -65,26 +65,46 @@
|
|
|
65
65
|
<div id="notice" class="notice" role="status" hidden></div>
|
|
66
66
|
<section id="model-settings-summary" class="model-settings-summary" aria-label="Agent model settings" hidden>
|
|
67
67
|
<div><h2 id="model-settings-agent">Model settings</h2><p id="model-settings-state" role="status"></p></div>
|
|
68
|
-
<div class="header-actions model-settings-actions-row"><button id="toggle-watcher" class="button studio-action" type="button"><svg class="icon" aria-hidden="true"><use href="#i-power"/></svg><span>Start watcher</span></button><button id="restart-watcher" class="button studio-action restart-action" type="button"><svg class="icon" aria-hidden="true"><use href="#i-refresh"/></svg><span>Restart watcher</span></button><button id="open-model-settings" class="button studio-action settings-action" type="button"><svg class="icon" aria-hidden="true"><use href="#i-sliders"/></svg><span>Model settings</span></button><button id="open-agent-removal" class="button studio-action remove-action" type="button"><svg class="icon" aria-hidden="true"><use href="#i-user-minus"/></svg><span>Remove agent</span></button></div>
|
|
68
|
+
<div class="header-actions model-settings-actions-row"><button id="toggle-watcher" class="button studio-action" type="button"><svg class="icon" aria-hidden="true"><use href="#i-power"/></svg><span>Start watcher</span></button><button id="restart-watcher" class="button studio-action restart-action" type="button"><svg class="icon" aria-hidden="true"><use href="#i-refresh"/></svg><span>Restart watcher</span></button><button id="open-connection-update" class="button studio-action" type="button"><svg class="icon" aria-hidden="true"><use href="#i-refresh"/></svg><span>Update connection</span></button><button id="open-model-settings" class="button studio-action settings-action" type="button"><svg class="icon" aria-hidden="true"><use href="#i-sliders"/></svg><span>Model settings</span></button><button id="open-agent-removal" class="button studio-action remove-action" type="button"><svg class="icon" aria-hidden="true"><use href="#i-user-minus"/></svg><span>Remove agent</span></button></div>
|
|
69
69
|
</section>
|
|
70
70
|
<dialog id="agent-connect-dialog" aria-labelledby="agent-connect-title">
|
|
71
|
-
<form id="agent-connect-form" class="model-settings-form" autocomplete="off">
|
|
71
|
+
<form id="agent-connect-form" class="model-settings-form" autocomplete="off" novalidate>
|
|
72
72
|
<div class="section-heading"><h2 id="agent-connect-title">Connect agent</h2><button id="close-agent-connect" class="button" type="button">Cancel</button></div>
|
|
73
73
|
<p>Copy credentials from OpenVisio after registering your agent, then paste them below. Or enter the fields yourself.</p>
|
|
74
|
-
<label for="connect-credentials">Paste credentials</label>
|
|
75
|
-
<textarea id="connect-credentials" rows="
|
|
74
|
+
<label for="connect-credentials">Paste generated credentials object</label>
|
|
75
|
+
<textarea id="connect-credentials" rows="5" spellcheck="false" aria-describedby="connect-import-status" placeholder='Paste the JSON object from OpenVisio → Copy credentials'></textarea>
|
|
76
|
+
<button id="import-agent-credentials" class="button" type="button">Use credentials</button>
|
|
77
|
+
<p id="connect-import-status" role="status">Paste the complete object, then choose Use credentials to review the fields.</p>
|
|
76
78
|
<label for="connect-name">Local agent name</label><input id="connect-name" type="text" required maxlength="4096" spellcheck="false">
|
|
77
79
|
<label for="connect-identifier">Agent identifier</label><input id="connect-identifier" type="text" required maxlength="4096" spellcheck="false">
|
|
78
80
|
<label for="connect-apiKey">API key</label><input id="connect-apiKey" type="password" required maxlength="4096" spellcheck="false">
|
|
79
81
|
<label for="connect-backend">Backend URL</label><input id="connect-backend" type="url" required maxlength="4096" spellcheck="false">
|
|
80
|
-
<label for="connect-mcpUrl">MCP URL</label><input id="connect-mcpUrl" type="url"
|
|
81
|
-
<label for="connect-wsUrl">WebSocket URL</label><input id="connect-wsUrl" type="url"
|
|
82
|
+
<label for="connect-mcpUrl">MCP URL (optional)</label><input id="connect-mcpUrl" type="url" maxlength="4096" spellcheck="false">
|
|
83
|
+
<label for="connect-wsUrl">WebSocket URL (optional)</label><input id="connect-wsUrl" type="url" maxlength="4096" spellcheck="false">
|
|
84
|
+
<p>Missing connection URLs are retrieved from your OpenVisio app when you connect.</p>
|
|
85
|
+
<label for="connect-setupHost">OpenVisio app URL (optional)</label><input id="connect-setupHost" type="url" maxlength="4096" placeholder="https://openvisio.io" spellcheck="false">
|
|
82
86
|
<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
87
|
<p>Install and sign in to your chosen runtime on this computer. After connecting, select the agent and choose Start watcher.</p>
|
|
84
88
|
<p id="agent-connect-error" role="alert" hidden></p>
|
|
85
89
|
<div class="model-settings-actions"><button id="save-agent-connect" class="button primary" type="submit">Verify and connect</button></div>
|
|
86
90
|
</form>
|
|
87
91
|
</dialog>
|
|
92
|
+
<dialog id="connection-update-dialog" aria-labelledby="connection-update-title">
|
|
93
|
+
<form id="connection-update-form" class="model-settings-form" autocomplete="off">
|
|
94
|
+
<div class="section-heading"><h2 id="connection-update-title">Update connection</h2><button id="close-connection-update" class="button" type="button">Close</button></div>
|
|
95
|
+
<p>Get the latest connection URLs using this agent’s saved credentials. Applying the update restarts the watcher; active work must finish first.</p>
|
|
96
|
+
<label for="connection-update-host">OpenVisio app URL (optional)</label>
|
|
97
|
+
<input id="connection-update-host" type="url" placeholder="Use the saved app URL" maxlength="4096">
|
|
98
|
+
<label for="connection-update-object">Latest connection object (optional)</label>
|
|
99
|
+
<textarea id="connection-update-object" rows="4" spellcheck="false" placeholder="If automatic discovery is unavailable, paste the connection JSON copied from OpenVisio."></textarea>
|
|
100
|
+
<p>Your saved API key and agent identity are kept. Only connection URLs are imported.</p>
|
|
101
|
+
<button id="check-connection-update" class="button" type="submit">Check connection</button>
|
|
102
|
+
<pre id="connection-update-preview" hidden></pre>
|
|
103
|
+
<p id="connection-update-status" role="status"></p>
|
|
104
|
+
<p id="connection-update-error" role="alert" hidden></p>
|
|
105
|
+
<div class="model-settings-actions"><button id="apply-connection-update" class="button primary" type="button" disabled>Apply and reconnect</button></div>
|
|
106
|
+
</form>
|
|
107
|
+
</dialog>
|
|
88
108
|
<dialog id="model-settings-dialog" aria-labelledby="model-settings-title">
|
|
89
109
|
<form id="model-settings-form" class="model-settings-form">
|
|
90
110
|
<div class="section-heading"><h2 id="model-settings-title">Model settings</h2><button id="close-model-settings" class="button" type="button" aria-label="Close model settings">Close</button></div>
|
package/studio/style.css
CHANGED
|
@@ -368,8 +368,8 @@
|
|
|
368
368
|
#toggle-watcher[data-action="stop"] .icon, .remove-action .icon { color: var(--destructive); }
|
|
369
369
|
.restart-action .icon { color: var(--orange); }
|
|
370
370
|
.settings-action .icon { color: var(--purple); }
|
|
371
|
-
#agent-connect-dialog, #model-settings-dialog, #agent-removal-dialog { width: min(500px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); margin: auto; padding: 24px; overflow-y: auto; border: 1px solid var(--border); border-radius: 24px; color: var(--ink); background: var(--surface, white); box-shadow: 0 24px 80px #0002; }
|
|
372
|
-
#agent-connect-dialog::backdrop, #model-settings-dialog::backdrop, #agent-removal-dialog::backdrop { background: #0005; }
|
|
371
|
+
#agent-connect-dialog, #model-settings-dialog, #agent-removal-dialog, #connection-update-dialog { width: min(500px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); margin: auto; padding: 24px; overflow-y: auto; border: 1px solid var(--border); border-radius: 24px; color: var(--ink); background: var(--surface, white); box-shadow: 0 24px 80px #0002; }
|
|
372
|
+
#agent-connect-dialog::backdrop, #model-settings-dialog::backdrop, #agent-removal-dialog::backdrop, #connection-update-dialog::backdrop { background: #0005; }
|
|
373
373
|
.model-settings-form { display: grid; gap: 12px; }
|
|
374
374
|
.model-settings-form h2 { font-size: 17px; font-weight: 500; }
|
|
375
375
|
.model-settings-form p { margin: 0; font-size: 12px; line-height: 1.6; color: var(--muted); }
|
|
@@ -377,7 +377,7 @@
|
|
|
377
377
|
.model-settings-form label span { font-size: 12px; color: var(--muted); }
|
|
378
378
|
.model-settings-form input, .model-settings-form textarea, .model-settings-form select { width: 100%; min-width: 0; padding: 11px 14px; border: 1px solid var(--border); border-radius: 18px; background: transparent; color: var(--ink); font-size: 13px; }
|
|
379
379
|
.model-settings-actions { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; margin-top: 8px; }
|
|
380
|
-
#agent-connect-error, #model-settings-error, #agent-removal-error { color: #9f3030; }
|
|
380
|
+
#agent-connect-error, #model-settings-error, #agent-removal-error, #connection-update-error { color: #9f3030; }
|
|
381
381
|
@media (max-width: 640px) {
|
|
382
382
|
.page-header { flex-direction: column; }
|
|
383
383
|
.header-actions { flex-direction: row; flex-wrap: wrap; align-items: center; padding: 0; gap: 8px; }
|
|
@@ -406,3 +406,5 @@
|
|
|
406
406
|
}
|
|
407
407
|
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 0s !important; animation-duration: 0s !important; scroll-behavior: auto !important; } }
|
|
408
408
|
}
|
|
409
|
+
|
|
410
|
+
#connection-update-preview { white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; font-size: 12px; line-height: 1.6; }
|