openvisio-agent 0.25.4 → 0.25.6
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 +15 -0
- package/README.md +9 -7
- package/USER_GUIDE.md +1 -1
- package/bin/cli.mjs +27 -28
- package/package.json +4 -2
- package/scenarios/runtime.scenarios.mjs +4 -2
- package/scripts/certify.mjs +6 -1
- package/src/agent-journal.mjs +1 -4
- package/src/agent-removal.mjs +2 -1
- package/src/mastra-harness.mjs +3 -12
- package/src/model-settings.mjs +18 -5
- package/src/runner-pool.mjs +5 -5
- package/src/runtime-providers.mjs +46 -0
- package/src/studio-server.mjs +6 -1
- package/src/watch.mjs +34 -42
- package/studio/app.mjs +1 -1
- package/studio/guide.html +4 -4
- package/studio/index.html +5 -1
- package/studio/style.css +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## [0.25.6] — 2026-09-21
|
|
6
|
+
|
|
7
|
+
- Keep healthy backend watchers event-driven after their connection recovery pass instead of polling every project for tasks and activity every 30 seconds.
|
|
8
|
+
- Stop idle journal heartbeats, derive Studio liveness from watcher PID locks, and apply model-setting changes through filesystem notifications instead of two-second polling.
|
|
9
|
+
- Add semantic icons and status colors to Agent Studio's watcher, model-settings, and agent-removal actions.
|
|
10
|
+
|
|
11
|
+
## [0.25.5] — 2026-09-21
|
|
12
|
+
|
|
13
|
+
- Add Gemini CLI and Qwen Code as coding-agent providers through their native ACP modes.
|
|
14
|
+
- Centralize provider commands, installation, authentication guidance, model defaults, Studio model discovery, and cleanup in one runtime registry.
|
|
15
|
+
- Give every ACP provider the same authenticated OpenVisio MCP bridge, watcher lifecycle, workspace isolation, session reuse, and live Studio model controls.
|
|
16
|
+
- Stop OpenCode ACP children as soon as their cycle finishes, leaving only the lightweight event watcher running while idle.
|
|
17
|
+
|
|
3
18
|
## [0.25.4] — 2026-09-21
|
|
4
19
|
|
|
5
20
|
- Route the backend's `agent:message` events through the same verified recipient, deduplication, queue, model, and delivery path as targeted mentions.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# openvisio-agent
|
|
2
2
|
|
|
3
|
-
Connect your coding agent (**Claude Code, Codex, or
|
|
3
|
+
Connect your coding agent (**Claude Code, Codex, OpenCode, Gemini CLI, or Qwen Code**) to an [OpenVisio](https://openvisio.app) 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
|
|
|
@@ -17,6 +17,8 @@ codex login
|
|
|
17
17
|
npx -y openvisio-agent@latest connect ovs_YOURCODE --host https://your-openvisio.app --name "Ada" --agent codex
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
+
Gemini CLI and Qwen Code use the same setup and watcher. Choose `--agent gemini` or `--agent qwen`; setup installs the selected CLI when it is missing. Launch that CLI once to complete its normal authentication flow.
|
|
21
|
+
|
|
20
22
|
That's it. Your selected runtime now has the team's tools (channels, tickets, docs, search) during autonomy cycles.
|
|
21
23
|
|
|
22
24
|
## What it does
|
|
@@ -26,7 +28,7 @@ That's it. Your selected runtime now has the team's tools (channels, tickets, do
|
|
|
26
28
|
### `connect <ovs_code> --host <url>`
|
|
27
29
|
|
|
28
30
|
1. Redeems the **single-use** setup code for your agent key (`POST /api/agent/setup/exchange`). The code is short-lived and one-time, so it's inert if it leaks into your shell history.
|
|
29
|
-
2. Configures the `openvisio-team` MCP for the selected runtime. Claude Code is registered globally;
|
|
31
|
+
2. Configures the `openvisio-team` MCP for the selected runtime. Claude Code is registered globally; ACP runtimes receive an isolated authenticated MCP configuration for each autonomy cycle.
|
|
30
32
|
3. Saves a scoped config under `~/.openvisio/` (chmod `600`) for the optional watcher.
|
|
31
33
|
|
|
32
34
|
Options: `--name "<agent>"` (label), `--mcp-url <url>` (override the MCP endpoint).
|
|
@@ -60,7 +62,7 @@ Then `watch --name ada` auto-detects the backend agent and runs a WebSocket loop
|
|
|
60
62
|
|
|
61
63
|
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.
|
|
62
64
|
|
|
63
|
-
Codex and
|
|
65
|
+
Codex, OpenCode, Gemini CLI, and Qwen Code cycles run through one Mastra ACP harness. Codex uses the packaged `codex-acp` adapter; the others use their native ACP servers. Every provider receives the same authenticated team tools, workspace isolation, cancellation, and Studio model-selection path. Each accepted ticket gets its own ACP session and worktree. Mastra Memory stores ticket/thread context in local libSQL under `~/.openvisio/`, while a compact JSON ledger retains only exact replay, cancellation, and delivery keys.
|
|
64
66
|
|
|
65
67
|
Backend watchers also provide four local context tools through the authenticated MCP bridge:
|
|
66
68
|
|
|
@@ -79,7 +81,7 @@ The integration follows Mastra's [semantic memory](https://mastra.ai/docs/memory
|
|
|
79
81
|
|
|
80
82
|
Model selections are stable. Exact IDs (including reasoning effort) are preserved. An unsuffixed ACP model may resolve once to the same model with `medium` effort; the watcher saves that exact selection. Unavailable selections produce an explicit error rather than switching generations or families. Claude versioned IDs are preserved, with automatic family fallbacks disabled. Codex uses the configured chat model for replies.
|
|
81
83
|
|
|
82
|
-
Backend/BYO watchers reconcile
|
|
84
|
+
Backend/BYO watchers reconcile once whenever the WebSocket starts or reconnects. A direct MCP session discovers and caches the backend's actual `tools/list` response, then uses available tools such as `list_agents`, `list_projects`, `list_tasks`, `list_task_types`, and `list_activity` to recover assigned tasks and recent mention activity missed while offline. Team discovery is shared by every runtime provider: structured MCP results and compact Markdown list envelopes are normalized once before the watcher resolves its project, identity, boards, tickets, or dedicated channel. Adding another model provider therefore reuses the same saved team connection and setup path instead of adding provider-specific project discovery. Optional actions such as ticket comments are used only when advertised; their absence cannot strand a completed ticket in a retry loop. After the recovery pass, the healthy event connection waits without polling MCP tools and starts a model only when work arrives.
|
|
83
85
|
|
|
84
86
|
The backend MCP may be stateful or stateless. A successful initialize response without `Mcp-Session-Id` is accepted as stateless, so OpenCode agents do not stop with “MCP initialize returned no session id.” Each OpenCode lane keeps its MCP identity in a private per-agent config directory while the repository is supplied separately with `--dir`; stale workspace configuration therefore cannot swap one agent's credentials for another's. The generated remote configuration sends the agent headers directly, disables OAuth probing, and backend cycles never request relay-only inbox calls or MCP resource-discovery tools in place of team actions.
|
|
85
87
|
|
|
@@ -100,7 +102,7 @@ openvisio-agent stop --name ada # stop service + every ada watc
|
|
|
100
102
|
|
|
101
103
|
Agents choose their tools, investigation steps, context management, and when to end a turn. Coding agents can delegate independent subtasks through the runtime’s native sub-agent tools: Codex multi-agent is enabled, OpenCode permits task delegation, and Claude permits Agent/Task. The parent supplies scoped context and file ownership, works alongside children, reviews their results, and reports once. Delegation depends on the tools advertised by the installed runtime; it does not grant additional task or publishing authority. Each turn receives the agent’s configured identity, role, and voice when supplied by its profile; that context is cached across restarts. Reply sessions can request their configured coding workspace through `openvisio_request_work_session`, carrying their findings, source thread, and permissions into the continuation. They do not ask you to reassign work because of an internal scheduling choice. A successful native final turn ends the cycle; it does not mark a ticket done. The agent decides when to update the board. Research, audits, and already-satisfied requests can finish without code changes, ticket mutations, or a PR. Tool failures stay visible as diagnostics without forcing another model turn or replacing the agent's explanation.
|
|
102
104
|
|
|
103
|
-
The context graph records source-to-continuation relationships and recalls direct neighbors without walking into unrelated conversations. The watcher saves the agent's final response for delivery retry and avoids rerunning an unchanged assignment after restart.
|
|
105
|
+
The context graph records source-to-continuation relationships and recalls direct neighbors without walking into unrelated conversations. The watcher saves the agent's final response for delivery retry and avoids rerunning an unchanged assignment after restart. Every supported runtime keeps context within a ticket or conversation; native runtimes handle compaction. A stalled-session watchdog resets when activity arrives, so ongoing work is not stopped by a fixed task duration. Ownership, cancellation, delivery deduplication, credential injection, and repository permissions remain enforced.
|
|
104
106
|
|
|
105
107
|
The watcher can run up to three assigned coding tasks concurrently. Each ticket gets an isolated model runtime and ticket-specific worktree; tasks sharing the same mutable workspace remain serialized to prevent branch and file conflicts. Replies to one thread stay serialized while up to three independent threads run concurrently, and cancellation targets only the matching ticket or source thread. The work pool reuses local clones and is instructed to preserve dirty/staged work, create unique branches, and stage only task-owned changes. The independent reply lane receives a chat charter and no coding workspace. Codex publishes local branches through the constrained helper, then opens a PR with `gh pr create`; linked-codebase MCP operations are a fallback when the repository cannot be obtained locally.
|
|
106
108
|
|
|
@@ -112,7 +114,7 @@ Authorization recovery checks paused repositories locally every two seconds and
|
|
|
112
114
|
|
|
113
115
|
Fresh top-level messages belong in the agent’s dedicated channel; responses to existing requests stay in their source thread. The watcher resolves a unique exact identity/name match (including `agent-alex` and `alex-agent`) and never falls back to general. Use `connect ... --channel <id>` for a differently named dedicated channel. If it is missing or ambiguous, ticket results remain recorded and channel delivery stays pending; the watcher does not invent a channel-creation tool.
|
|
114
116
|
|
|
115
|
-
Codex and
|
|
117
|
+
Codex, OpenCode, Gemini CLI, and Qwen Code use Mastra ACP directly. OpenCode starts only for an accepted cycle and exits as soon as that cycle releases it, so the idle watcher does not retain an OpenCode child process. Other runtimes retain work sessions per ticket in a six-entry pool and reply sessions in an eight-entry pool; their idle sessions expire after five minutes, and active sessions cannot be evicted. Git snapshots are asynchronous, lightweight commands lazily load the watcher, and logs separate queue wait, preflight, first-event, and total runtime time. Studio reads watcher liveness from the local PID lock instead of requiring journal heartbeats, and model changes arrive through filesystem notifications. Projects reconcile with concurrency three and enqueue tickets before reading optional activity history. Coordination sessions can update the board without gaining repository access.
|
|
116
118
|
|
|
117
119
|
Assignments use independent FIFO entries with stable-key duplicate suppression and bounded, worktree-aware concurrency. Backlog recovery uses the same ticket verification path as WebSocket events, and each queued ticket is checked again before a model starts. MCP calls have a 20-second deadline including response bodies; tool discovery is cached and paginated. Codex MCP failures retain a bounded, credential-redacted reason in the watcher log. Cancellation targets only its ACP session. Credentials and replay state use atomic file replacement; Mastra persists contextual memory in libSQL.
|
|
118
120
|
|
|
@@ -134,7 +136,7 @@ Do not chase auto-changing watcher PIDs. `openvisio-agent stop --name <agent>` u
|
|
|
134
136
|
## Requirements
|
|
135
137
|
|
|
136
138
|
- Node.js ≥ 22.13
|
|
137
|
-
- Claude Code, Codex (`@openai/codex`), or
|
|
139
|
+
- Claude Code, Codex (`@openai/codex`), OpenCode, Gemini CLI (`@google/gemini-cli`), or Qwen Code (`@qwen-code/qwen-code`). The selected CLI is installed when missing.
|
|
138
140
|
|
|
139
141
|
## Getting a setup code
|
|
140
142
|
|
package/USER_GUIDE.md
CHANGED
|
@@ -14,7 +14,7 @@ The first semantic search downloads a local embedding model. Keyword search rema
|
|
|
14
14
|
|
|
15
15
|
## Before you begin
|
|
16
16
|
|
|
17
|
-
You need Node.js **22.13 or newer**, access to your OpenVisio team, and a supported coding agent: Claude Code, Codex, or
|
|
17
|
+
You need Node.js **22.13 or newer**, access to your OpenVisio team, and a supported coding agent: Claude Code, Codex, OpenCode, Gemini CLI, or Qwen Code. Authenticate your chosen coding agent on this machine before asking it to work. Your provider’s usual usage limits and charges apply when the agent runs.
|
|
18
18
|
|
|
19
19
|
Check your installed versions in a terminal:
|
|
20
20
|
|
package/bin/cli.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
15
15
|
import { dirname, join } from 'node:path'
|
|
16
16
|
import { parseFlags, slugify, stripSlash, exchangeToken, ensureClaude, ensureCodex, writeJson, mcpConfigPath, configPath, chmodSafe, onPath, OV_DIR, DEFAULT_WORKSPACE, fail, ok, info } from '../src/lib.mjs'
|
|
17
17
|
import { authorizePrPush, pushPrBranch, revokePrPush, configurePrPublishing } from '../src/pr-push.mjs'
|
|
18
|
+
import { runtimeProvider, supportedRuntimeList } from '../src/runtime-providers.mjs'
|
|
18
19
|
|
|
19
20
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
20
21
|
const VERSION = (() => { try { return JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8')).version } catch { return '0.0.0' } })()
|
|
@@ -24,8 +25,8 @@ const HELP = `openvisio-agent ${VERSION}
|
|
|
24
25
|
Connect your coding agent to an OpenVisio team.
|
|
25
26
|
|
|
26
27
|
Usage:
|
|
27
|
-
openvisio-agent connect <ovs_code> --host <url> [--name "<agent>"] [--mcp-url <url>] [--agent
|
|
28
|
-
openvisio-agent connect --backend <url> --key <api-key> --id <identifier> [--name "<agent>"] [--ws <wss-url>] [--mcp-url <url>] [--agent
|
|
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
30
|
openvisio-agent watch --name <agent> [--install] [--workspace <dir>] [--chat-only] [--model <m>] [--chat-model <m>] [--debug]
|
|
30
31
|
openvisio-agent stop --name <agent>
|
|
31
32
|
openvisio-agent studio [--port <number>] [--no-open] [--demo]
|
|
@@ -55,12 +56,12 @@ connect --backend
|
|
|
55
56
|
pushes its agent/* branch and opens PRs. Defaults to
|
|
56
57
|
~/openvisio-workspace; point it at an existing clones folder to
|
|
57
58
|
reuse those. (--workdir is an accepted alias.)
|
|
58
|
-
--agent <name> coding-agent runtime:
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
--agent <name> coding-agent runtime: ${supportedRuntimeList(', ')}.
|
|
60
|
+
The selected CLI is installed when missing. Authenticate it
|
|
61
|
+
once with its normal login flow before the watcher runs.
|
|
61
62
|
--chat-only disable code work — chat/ticket tools only.
|
|
62
63
|
--model <m> coding model. Claude defaults to sonnet; Codex defaults to
|
|
63
|
-
gpt-5.6-sol;
|
|
64
|
+
gpt-5.6-sol; other providers use their configured default.
|
|
64
65
|
Engineers can also change it live from chat: "@agent /model …".
|
|
65
66
|
--chat-model <m> coordination model for chat, triage, and board movement.
|
|
66
67
|
Claude defaults to haiku. Codex uses gpt-5.6-sol for every
|
|
@@ -135,19 +136,26 @@ function ensureAgentInstalled() {
|
|
|
135
136
|
// The chosen coding-agent RUNTIME the cycles run on. Default claude.
|
|
136
137
|
function agentFlag(flags) {
|
|
137
138
|
const a = String(flags.agent || flags.runtime || 'claude').toLowerCase()
|
|
138
|
-
if (a
|
|
139
|
+
if (!runtimeProvider(a)) fail(`Unknown --agent "${a}". Supported runtimes: ${supportedRuntimeList()}.`)
|
|
139
140
|
return a
|
|
140
141
|
}
|
|
141
142
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (onPath(
|
|
147
|
-
info(
|
|
148
|
-
spawnSync('npm', ['i', '-g',
|
|
149
|
-
if (!onPath(
|
|
150
|
-
|
|
143
|
+
function ensureRuntime(agent) {
|
|
144
|
+
if (agent === 'claude') return ensureClaude()
|
|
145
|
+
if (agent === 'codex') return ensureCodex()
|
|
146
|
+
const provider = runtimeProvider(agent)
|
|
147
|
+
if (onPath(provider.command)) return provider.command
|
|
148
|
+
info(`${provider.name} not found on PATH — installing ${provider.npmPackage} globally…`)
|
|
149
|
+
spawnSync('npm', ['i', '-g', provider.npmPackage], { stdio: 'inherit', shell: process.platform === 'win32' })
|
|
150
|
+
if (!onPath(provider.command)) fail(`${provider.name} still isn't on PATH after install. Install ${provider.npmPackage}, run \`${provider.authCommand}\` once, and re-run.`)
|
|
151
|
+
info(`Installed ${provider.name}. Authenticate it once if you haven't: ${provider.authCommand}`)
|
|
152
|
+
return onPath(provider.command)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function runtimeReadyMessage(agent) {
|
|
156
|
+
const provider = runtimeProvider(agent)
|
|
157
|
+
if (agent === 'claude') return 'Claude Code now has the openvisio-team tools. Run /mcp in Claude Code to confirm.'
|
|
158
|
+
return `Runtime: ${provider.name}. The watcher injects the authenticated openvisio-team MCP into every ACP session. Authenticate once with \`${provider.authCommand}\`.`
|
|
151
159
|
}
|
|
152
160
|
|
|
153
161
|
async function runConnect({ positional, flags }) {
|
|
@@ -174,10 +182,8 @@ async function runConnect({ positional, flags }) {
|
|
|
174
182
|
// A scoped MCP config for the watcher's --strict-mcp-config.
|
|
175
183
|
mcpCfg = mcpConfigPath(slug)
|
|
176
184
|
writeJson(mcpCfg, { mcpServers: { 'openvisio-team': { type: 'http', url: mcpUrl, headers: { Authorization: `Bearer ${key}` } } } }, true)
|
|
177
|
-
} else if (agent === 'codex') {
|
|
178
|
-
ensureCodex()
|
|
179
185
|
} else {
|
|
180
|
-
|
|
186
|
+
ensureRuntime(agent)
|
|
181
187
|
}
|
|
182
188
|
const wsWorkdir = flags.workdir === true ? process.cwd() : flags.workdir ? String(flags.workdir) : flags.workspace ? String(flags.workspace) : ''
|
|
183
189
|
if (!flags['chat-only']) configurePrPublishing({ workspace: wsWorkdir || DEFAULT_WORKSPACE, enabled: !flags['no-pr-push'] })
|
|
@@ -185,11 +191,7 @@ async function runConnect({ positional, flags }) {
|
|
|
185
191
|
|
|
186
192
|
ok(`Connected "${name}" to ${host}.`)
|
|
187
193
|
info()
|
|
188
|
-
info(agent
|
|
189
|
-
? 'Runtime: opencode. The watcher writes an opencode.json with the openvisio-team tools; make sure opencode is authenticated (opencode auth login).'
|
|
190
|
-
: agent === 'codex'
|
|
191
|
-
? 'Runtime: Codex. The watcher injects only the openvisio-team MCP into each cycle; authenticate once with `codex login`.'
|
|
192
|
-
: 'Claude Code now has the openvisio-team tools. Run /mcp in Claude Code to confirm.')
|
|
194
|
+
info(runtimeReadyMessage(agent))
|
|
193
195
|
info()
|
|
194
196
|
info('To let it work on its own (reply to mentions, pick up tickets, AND do real')
|
|
195
197
|
info('coding — it clones/branches/pushes and opens PRs out of the box):')
|
|
@@ -229,6 +231,7 @@ async function runConnectBackend({ flags }) {
|
|
|
229
231
|
const name = (flags.name && String(flags.name)) || 'backend-agent'
|
|
230
232
|
const slug = slugify(name)
|
|
231
233
|
const agent = agentFlag(flags)
|
|
234
|
+
ensureRuntime(agent)
|
|
232
235
|
|
|
233
236
|
// Code workspace preference (optional). CODE mode is on by default; this only
|
|
234
237
|
// pins WHERE the agent works. Persisted so the always-on `watch` reuses it.
|
|
@@ -253,10 +256,6 @@ async function runConnectBackend({ flags }) {
|
|
|
253
256
|
mcpReplace(claude, ['--transport', 'http', 'openvisio-team', mcpUrl, ...hdr])
|
|
254
257
|
mcpConfig = mcpConfigPath(slug)
|
|
255
258
|
writeJson(mcpConfig, { mcpServers: { 'openvisio-team': { type: 'http', url: mcpUrl, headers: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier } } } }, true)
|
|
256
|
-
} else if (agent === 'codex') {
|
|
257
|
-
ensureCodex()
|
|
258
|
-
} else {
|
|
259
|
-
ensureOpencode() // the watcher writes opencode.json with the remote MCP at runtime
|
|
260
259
|
}
|
|
261
260
|
}
|
|
262
261
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openvisio-agent",
|
|
3
|
-
"version": "0.25.
|
|
4
|
-
"description": "Connect Claude Code, Codex, or
|
|
3
|
+
"version": "0.25.6",
|
|
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": {
|
|
7
7
|
"openvisio-agent": "bin/cli.mjs"
|
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
"claude",
|
|
31
31
|
"claude-code",
|
|
32
32
|
"codex",
|
|
33
|
+
"gemini-cli",
|
|
34
|
+
"qwen-code",
|
|
33
35
|
"agent",
|
|
34
36
|
"autonomy"
|
|
35
37
|
],
|
|
@@ -215,9 +215,11 @@ for (const phase of ['eviction', 'close']) for (const failureKind of ['sync', 'a
|
|
|
215
215
|
}
|
|
216
216
|
for (const [name, options] of [
|
|
217
217
|
['zero-capacity', { capacity: 0 }], ['negative-capacity', { capacity: -1 }], ['fractional-capacity', { capacity: 1.5 }],
|
|
218
|
-
['infinite-capacity', { capacity: Infinity }],
|
|
219
|
-
['infinite-expiry', { idleMs: Infinity }], ['nan-expiry', { idleMs: NaN }],
|
|
218
|
+
['infinite-capacity', { capacity: Infinity }],
|
|
220
219
|
]) add(`pool-invalid-${name}`, 'runtime-pool', `Invalid ${name} configuration fails before starting any timer`, async () => { assert.throws(() => createRunnerPool({ ...options, create: () => ({}) }), /positive/) })
|
|
220
|
+
for (const [name, idleMs] of [['negative-expiry', -1], ['infinite-expiry', Infinity], ['nan-expiry', NaN]]) {
|
|
221
|
+
add(`pool-invalid-${name}`, 'runtime-pool', `Invalid ${name} configuration fails before starting any timer`, async () => { assert.throws(() => createRunnerPool({ idleMs, create: () => ({}) }), /non-negative/) })
|
|
222
|
+
}
|
|
221
223
|
|
|
222
224
|
function fakeAcp({ events = [], negotiate, select, stream, cancel } = {}) {
|
|
223
225
|
const state = { instances: [], prompts: [], modelReads: 0, modelSets: [], disconnects: 0, cancels: 0 }
|
package/scripts/certify.mjs
CHANGED
|
@@ -24,6 +24,7 @@ run('diff whitespace check', 'git', ['diff', '--check'], repo)
|
|
|
24
24
|
const watcher = readFileSync(join(root, 'src', 'watch.mjs'), 'utf8')
|
|
25
25
|
const events = readFileSync(join(root, 'src', 'events.mjs'), 'utf8')
|
|
26
26
|
const memory = readFileSync(join(root, 'src', 'memory.mjs'), 'utf8')
|
|
27
|
+
const journal = readFileSync(join(root, 'src', 'agent-journal.mjs'), 'utf8')
|
|
27
28
|
const mcpHttp = readFileSync(join(root, 'src', 'mcp-http.mjs'), 'utf8')
|
|
28
29
|
const opencodeConfig = readFileSync(join(root, 'src', 'opencode-config.mjs'), 'utf8')
|
|
29
30
|
const codexConfig = readFileSync(join(root, 'src', 'codex-config.mjs'), 'utf8')
|
|
@@ -33,6 +34,7 @@ const cli = readFileSync(join(root, 'bin', 'cli.mjs'), 'utf8')
|
|
|
33
34
|
const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
|
|
34
35
|
const cycleQueue = readFileSync(join(root, 'src', 'cycle-queue.mjs'), 'utf8')
|
|
35
36
|
const mastraHarness = readFileSync(join(root, 'src', 'mastra-harness.mjs'), 'utf8')
|
|
37
|
+
const runtimeProviders = readFileSync(join(root, 'src', 'runtime-providers.mjs'), 'utf8')
|
|
36
38
|
const modelSelection = readFileSync(join(root, 'src', 'model-selection.mjs'), 'utf8')
|
|
37
39
|
const activityHook = readFileSync(join(repo, 'frontend', 'hooks', 'useAgentActivity.ts'), 'utf8')
|
|
38
40
|
const taskHook = readFileSync(join(repo, 'frontend', 'hooks', 'useBackendTasks.ts'), 'utf8')
|
|
@@ -49,6 +51,9 @@ const assertions = [
|
|
|
49
51
|
['authorization recovery is local and independent of backlog cadence', watcher.includes('resumeReadyAuthorizations(), 2_000') && watcher.includes('releaseAuthorizedPause(key')],
|
|
50
52
|
['new follow-ups only deduplicate later replies', watcher.includes('repliesAfterSource(renderedAgentMessages') && watcher.includes('sourceMessageId: mid')],
|
|
51
53
|
['Mastra snapshots do not block the event loop', mastraHarness.includes('await execFileAsync') && !mastraHarness.includes('spawnSync')],
|
|
54
|
+
['OpenCode exits after work while the event watcher stays idle', runtimeProviders.includes("id: 'opencode'") && runtimeProviders.includes('idleSessionMs: 0') && watcher.includes('idleMs: runtimeIdleMs')],
|
|
55
|
+
['healthy backend watchers do not poll projects while idle', watcher.includes('onConnect: () =>') && watcher.includes('void reconcileBacklog()') && !watcher.includes('taskProbeTimer') && !watcher.includes('taskProbeStartTimer')],
|
|
56
|
+
['idle watchers use event notifications without journal or settings polling', watcher.includes('watchFs(stateDir') && !watcher.includes('modelSettingsTimer') && !journal.includes("emit('runtime.heartbeat'")],
|
|
52
57
|
['ticket pickup precedes optional activity reads', watcher.indexOf("await handleTaskSignal('task:assigned'", watcher.indexOf('const reconcileBacklog')) < watcher.indexOf("const activityData = await callMcpTool", watcher.indexOf('const reconcileBacklog'))],
|
|
53
58
|
['task:assigned is handled', watcher.includes("k === 'task:assigned'")],
|
|
54
59
|
['task signals are verified with get_ticket', watcher.includes("callMcpTool('get_ticket'")],
|
|
@@ -85,7 +90,7 @@ const assertions = [
|
|
|
85
90
|
['reply runner has no coding workspace or coding charter', watcher.includes("workdir: ''") && watcher.includes("systemPrompt: CHAT_CHARTER + '\\n\\n' + credNote") && opencodeConfig.includes("'*': 'deny'")],
|
|
86
91
|
['MCP requests have a deadline including response bodies', mcpHttp.includes('controller.abort()') && mcpHttp.includes('res.text()')],
|
|
87
92
|
['Mastra memory uses real ticket and thread identities', watcher.includes('createMastraMemory') && watcher.includes('await memory.context(memoryRefs)') && memory.includes('new Memory({ storage') && memory.includes('new LibSQLStore') && memory.includes('ticket:${refs.projectId}:${refs.ticketId}') && memory.includes('channel:${refs.channelId}:thread:')],
|
|
88
|
-
['
|
|
93
|
+
['ACP providers use scoped Mastra sessions', watcher.includes('createMastraAcpRunner') && watcher.includes('workRunners.acquire(sessionKey') && watcher.includes('replyRunners.acquire(key') && mastraHarness.includes('persistSession: true')],
|
|
89
94
|
['runtime models are selected explicitly before work starts', mastraHarness.includes('getAvailableModels()') && mastraHarness.includes('resolveAvailableModel(requestedModel') && mastraHarness.includes('acp.setModel(resolution.selected)') && modelSelection.includes('automatic model substitution is disabled') && watcher.includes('if (changed) persistModel()')],
|
|
90
95
|
['one watcher owns bounded work and per-thread reply sessions', watcher.includes('MAX_CONCURRENT_WORKERS = 3') && watcher.includes('workRunners.release(sessionKey)') && watcher.includes('replyRunners.release(key)') && watcher.includes('groupKey: (item) => item.taskRef')],
|
|
91
96
|
['workers serialize shared worktrees while independent worktrees run concurrently', watcher.includes('groupKey: (item) => item.workdir || workdir') && cycleQueue.includes('active.size < concurrency') && cycleQueue.includes('activeGroups.has(candidate[1].group)')],
|
package/src/agent-journal.mjs
CHANGED
|
@@ -59,7 +59,7 @@ export function createAgentJournal({ stateDir, agent, secrets = [] }) {
|
|
|
59
59
|
const stem = `${slug}-${digest}`
|
|
60
60
|
const path = join(directory, stem + '.jsonl')
|
|
61
61
|
let sequence = 0, pendingBytes = 0, dropped = 0, written = 0, lastError = null
|
|
62
|
-
let timer,
|
|
62
|
+
let timer, closing = false, closePromise, writing = false
|
|
63
63
|
const pending = []
|
|
64
64
|
const ready = (async () => {
|
|
65
65
|
await mkdir(directory, { recursive: true, mode: 0o700 })
|
|
@@ -120,11 +120,8 @@ export function createAgentJournal({ stateDir, agent, secrets = [] }) {
|
|
|
120
120
|
catch (error) { lastError = error; return false }
|
|
121
121
|
}
|
|
122
122
|
emit('runtime.started', { status: 'running' })
|
|
123
|
-
heartbeat = setInterval(() => emit('runtime.heartbeat', { status: 'running', droppedEvents: dropped }), 15_000)
|
|
124
|
-
heartbeat.unref?.()
|
|
125
123
|
const close = () => {
|
|
126
124
|
if (closePromise) return closePromise
|
|
127
|
-
clearInterval(heartbeat)
|
|
128
125
|
emit('runtime.stopped', { status: 'stopped', droppedEvents: dropped })
|
|
129
126
|
closing = true
|
|
130
127
|
closePromise = (async () => {
|
package/src/agent-removal.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { join, resolve } from 'node:path'
|
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
9
9
|
import { OV_DIR, slugify } from './lib.mjs'
|
|
10
10
|
import { agentProcessSnapshot, agentProcessTree, stopAgentProcessTree } from './agent-processes.mjs'
|
|
11
|
+
import { RUNTIME_IDS } from './runtime-providers.mjs'
|
|
11
12
|
|
|
12
13
|
const exec = promisify(execFile)
|
|
13
14
|
const hash = value => createHash('sha256').update(value).digest('hex')
|
|
@@ -63,7 +64,7 @@ export async function planAgentRemoval({ stateDir, slug }) {
|
|
|
63
64
|
for (const name of names) if (await lstat(join(stateDir, name)).catch(() => null)) files.push(name)
|
|
64
65
|
if (await realDirectory(join(stateDir, 'observability'))) {
|
|
65
66
|
// The stem contains this setup's slug and the exact provider/identity hash.
|
|
66
|
-
const stems = [...new Set([
|
|
67
|
+
const stems = [...new Set([...RUNTIME_IDS, config.agent || 'claude'])].map(provider => `${slug.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 60)}-${hash(`${identifier}:${provider}`).slice(0, 12)}`)
|
|
67
68
|
for (const name of await readdir(join(stateDir, 'observability'))) {
|
|
68
69
|
if (stems.some(stem => [`${stem}.jsonl`, `${stem}.1.jsonl`, `${stem}.2.jsonl`].includes(name))) files.push(`observability/${name}`)
|
|
69
70
|
}
|
package/src/mastra-harness.mjs
CHANGED
|
@@ -2,7 +2,6 @@ import { execFile } from 'node:child_process'
|
|
|
2
2
|
import { promisify } from 'node:util'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
import { AcpAgent } from '@mastra/acp'
|
|
5
|
-
import { onPath } from './lib.mjs'
|
|
6
5
|
import { resolveAvailableModel } from './model-selection.mjs'
|
|
7
6
|
import { codexPolicyBlock } from './events.mjs'
|
|
8
7
|
import { WORK_SESSION_TOOL, workSessionRequest } from './runtime-control.mjs'
|
|
@@ -10,6 +9,7 @@ import { contextToolNames } from './context-tools.mjs'
|
|
|
10
9
|
import { buildOpencodeConfig } from './opencode-config.mjs'
|
|
11
10
|
import { opencodeProviderFailure } from './opencode-failure.mjs'
|
|
12
11
|
import { isTransportInterruption } from './transport-recovery.mjs'
|
|
12
|
+
import { acpRuntimeCommand } from './runtime-providers.mjs'
|
|
13
13
|
|
|
14
14
|
const MCP_TOOL_NAMES = [
|
|
15
15
|
WORK_SESSION_TOOL, ...contextToolNames, 'list_agents', 'list_projects', 'list_boards', 'list_tasks', 'list_task_types', 'get_ticket',
|
|
@@ -34,17 +34,8 @@ async function abortable(operation, signal) {
|
|
|
34
34
|
finally { signal.removeEventListener('abort', onAbort) }
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function commandFor(agent) {
|
|
38
|
-
if (agent === 'opencode') return { command: onPath('opencode') || 'opencode', args: ['acp', '--print-logs', '--log-level', 'ERROR'] }
|
|
39
|
-
if (agent === 'codex') {
|
|
40
|
-
const filename = process.platform === 'win32' ? 'codex-acp.cmd' : 'codex-acp'
|
|
41
|
-
return { command: fileURLToPath(new URL(`../node_modules/.bin/${filename}`, import.meta.url)), args: [] }
|
|
42
|
-
}
|
|
43
|
-
return null
|
|
44
|
-
}
|
|
45
|
-
|
|
46
37
|
export async function listAcpRuntimeModels(agent, { AcpAgentClass = AcpAgent, cwd = process.cwd(), timeoutMs = 15_000 } = {}) {
|
|
47
|
-
const runtime =
|
|
38
|
+
const runtime = acpRuntimeCommand(agent)
|
|
48
39
|
if (!runtime) return []
|
|
49
40
|
const acp = new AcpAgentClass({
|
|
50
41
|
id: `openvisio-${agent}-model-catalog-${Date.now()}`,
|
|
@@ -127,7 +118,7 @@ export function createMastraAcpRunner({
|
|
|
127
118
|
agent, mcpUrl, mcpHeaders = {}, workdir, canCode = !!workdir, canCoordinate = false, workspaceAvailable = canCode, maxCycleMs = 20 * 60_000,
|
|
128
119
|
log = () => {}, debug = false, model, onTool, onEvent, systemPrompt = '', getContextEnvironment, AcpAgentClass = AcpAgent,
|
|
129
120
|
}) {
|
|
130
|
-
const runtime =
|
|
121
|
+
const runtime = acpRuntimeCommand(agent)
|
|
131
122
|
if (!runtime) return null
|
|
132
123
|
if (!Number.isFinite(maxCycleMs) || maxCycleMs <= 0 || maxCycleMs > 2_147_483_647) throw new Error('ACP maxCycleMs must be a positive supported timeout')
|
|
133
124
|
let active = null
|
package/src/model-settings.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { join } from 'node:path'
|
|
|
4
4
|
import { execFile } from 'node:child_process'
|
|
5
5
|
import { promisify } from 'node:util'
|
|
6
6
|
import { onPath } from './lib.mjs'
|
|
7
|
+
import { runtimeProvider } from './runtime-providers.mjs'
|
|
7
8
|
|
|
8
9
|
const modelId = value => typeof value === 'string' && value.length <= 200 && /^[a-zA-Z0-9][a-zA-Z0-9_.:/+\[\]-]*$/.test(value)
|
|
9
10
|
const problem = (message, status = 400) => Object.assign(new Error(message), { status })
|
|
@@ -47,6 +48,8 @@ export function saveModelSettings(stateDir, slug, input) {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
export async function listRuntimeModels(provider) {
|
|
51
|
+
const runtime = runtimeProvider(provider)
|
|
52
|
+
if (!runtime) return []
|
|
50
53
|
if (provider === 'claude') return ['sonnet', 'opus', 'haiku']
|
|
51
54
|
if (provider === 'codex') {
|
|
52
55
|
try {
|
|
@@ -56,9 +59,19 @@ export async function listRuntimeModels(provider) {
|
|
|
56
59
|
} catch { /* keep the configured watcher default available when ACP discovery is unavailable */ }
|
|
57
60
|
return ['gpt-5.6-sol[medium]']
|
|
58
61
|
}
|
|
59
|
-
if (provider
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
if (provider === 'opencode') {
|
|
63
|
+
const command = onPath('opencode')
|
|
64
|
+
if (!command) throw problem('OpenCode is not installed on this computer.', 503)
|
|
65
|
+
const { stdout } = await promisify(execFile)(command, ['models'], { timeout: 15_000, maxBuffer: 1024 * 1024, encoding: 'utf8' })
|
|
66
|
+
return [...new Set(stdout.split(/\r?\n/).map(s => s.trim()).filter(modelId))].slice(0, 1000)
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const { listAcpRuntimeModels } = await import('./mastra-harness.mjs')
|
|
70
|
+
const models = (await listAcpRuntimeModels(provider)).filter(modelId)
|
|
71
|
+
if (models.length) return models.slice(0, 1000)
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (!onPath(runtime.command)) throw problem(`${runtime.name} is not installed on this computer.`, 503)
|
|
74
|
+
throw error
|
|
75
|
+
}
|
|
76
|
+
return []
|
|
64
77
|
}
|
package/src/runner-pool.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// session, and never share history between tickets or conversation threads.
|
|
3
3
|
export function createRunnerPool({ create, capacity = 8, idleMs = 300_000, now = Date.now, onError = () => {} }) {
|
|
4
4
|
if (!Number.isInteger(capacity) || capacity < 1) throw new Error('runner pool capacity must be a positive integer')
|
|
5
|
-
if (!Number.isFinite(idleMs) || idleMs
|
|
5
|
+
if (!Number.isFinite(idleMs) || idleMs < 0) throw new Error('runner pool idleMs must be non-negative and finite')
|
|
6
6
|
const entries = new Map()
|
|
7
7
|
let closed = false
|
|
8
8
|
const report = (error) => { try { Promise.resolve(onError(error)).catch(() => {}) } catch { /* reporting cannot strand cleanup */ } }
|
|
@@ -11,14 +11,14 @@ 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 && (now() - entry.usedAt >= idleMs || entries.size > capacity)) {
|
|
14
|
+
if (!entry.busy && (idleMs === 0 || now() - entry.usedAt >= idleMs || entries.size > capacity)) {
|
|
15
15
|
entries.delete(key)
|
|
16
16
|
dispose(entry)
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
-
const timer = setInterval(prune, Math.min(idleMs, 30_000))
|
|
21
|
-
timer
|
|
20
|
+
const timer = idleMs === 0 ? null : setInterval(prune, Math.min(idleMs, 30_000))
|
|
21
|
+
timer?.unref?.()
|
|
22
22
|
return {
|
|
23
23
|
acquire(key, options) {
|
|
24
24
|
if (closed) throw new Error('Session pool is closed')
|
|
@@ -40,7 +40,7 @@ export function createRunnerPool({ create, capacity = 8, idleMs = 300_000, now =
|
|
|
40
40
|
close() {
|
|
41
41
|
if (closed) return
|
|
42
42
|
closed = true
|
|
43
|
-
clearInterval(timer)
|
|
43
|
+
if (timer) clearInterval(timer)
|
|
44
44
|
const closing = [...entries.values()]
|
|
45
45
|
entries.clear()
|
|
46
46
|
for (const entry of closing) dispose(entry)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url'
|
|
2
|
+
import { onPath } from './lib.mjs'
|
|
3
|
+
|
|
4
|
+
const providers = {
|
|
5
|
+
claude: {
|
|
6
|
+
id: 'claude', name: 'Claude Code', kind: 'native', command: 'claude', npmPackage: '@anthropic-ai/claude-code',
|
|
7
|
+
authCommand: 'claude', defaultModel: 'sonnet', defaultChatModel: 'haiku', idleSessionMs: 300_000,
|
|
8
|
+
},
|
|
9
|
+
codex: {
|
|
10
|
+
id: 'codex', name: 'Codex', kind: 'acp', command: 'codex', npmPackage: '@openai/codex',
|
|
11
|
+
authCommand: 'codex login', defaultModel: 'gpt-5.6-sol[medium]', defaultChatModel: 'gpt-5.6-sol[medium]', idleSessionMs: 300_000,
|
|
12
|
+
},
|
|
13
|
+
opencode: {
|
|
14
|
+
id: 'opencode', name: 'OpenCode', kind: 'acp', command: 'opencode', npmPackage: 'opencode-ai',
|
|
15
|
+
authCommand: 'opencode auth login', defaultModel: '', defaultChatModel: '', idleSessionMs: 0, acpArgs: ['acp', '--print-logs', '--log-level', 'ERROR'],
|
|
16
|
+
},
|
|
17
|
+
gemini: {
|
|
18
|
+
id: 'gemini', name: 'Gemini CLI', kind: 'acp', command: 'gemini', npmPackage: '@google/gemini-cli',
|
|
19
|
+
authCommand: 'gemini', defaultModel: '', defaultChatModel: '', idleSessionMs: 300_000, acpArgs: ['--acp'],
|
|
20
|
+
},
|
|
21
|
+
qwen: {
|
|
22
|
+
id: 'qwen', name: 'Qwen Code', kind: 'acp', command: 'qwen', npmPackage: '@qwen-code/qwen-code',
|
|
23
|
+
authCommand: 'qwen', defaultModel: '', defaultChatModel: '', idleSessionMs: 300_000, acpArgs: ['--acp'],
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const RUNTIME_IDS = Object.freeze(Object.keys(providers))
|
|
28
|
+
export const ACP_RUNTIME_IDS = Object.freeze(RUNTIME_IDS.filter(id => providers[id].kind === 'acp'))
|
|
29
|
+
|
|
30
|
+
export function runtimeProvider(id) {
|
|
31
|
+
return providers[String(id || '').toLowerCase()] || null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function supportedRuntimeList(separator = ' | ') {
|
|
35
|
+
return RUNTIME_IDS.join(separator)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function acpRuntimeCommand(id) {
|
|
39
|
+
const provider = runtimeProvider(id)
|
|
40
|
+
if (!provider || provider.kind !== 'acp') return null
|
|
41
|
+
if (provider.id === 'codex') {
|
|
42
|
+
const filename = process.platform === 'win32' ? 'codex-acp.cmd' : 'codex-acp'
|
|
43
|
+
return { command: fileURLToPath(new URL(`../node_modules/.bin/${filename}`, import.meta.url)), args: [] }
|
|
44
|
+
}
|
|
45
|
+
return { command: onPath(provider.command) || provider.command, args: [...(provider.acpArgs || [])] }
|
|
46
|
+
}
|
package/src/studio-server.mjs
CHANGED
|
@@ -112,8 +112,13 @@ export async function readStudioSnapshot({ stateDir, now = Date.now, journalCach
|
|
|
112
112
|
const agentsById = new Map(configured.map((agent) => [agent.identifier, agent]))
|
|
113
113
|
for (const event of latest.values()) {
|
|
114
114
|
const age = now() - Date.parse(event.timestamp)
|
|
115
|
-
const online = age >= -5000 && age <= FRESH_MS && event.type !== 'runtime.stopped' && pidAlive(event.pid)
|
|
116
115
|
const existing = agentsById.get(event.agent.identifier)
|
|
116
|
+
// Configured watchers own a PID lock, so Studio can report their real process
|
|
117
|
+
// state without forcing an idle process to append heartbeat records forever.
|
|
118
|
+
// Freshness remains a compatibility fallback for older, unconfigured journals.
|
|
119
|
+
const configuredAlive = existing?.watcherAlive === true && existing.pid === event.pid
|
|
120
|
+
const recentUnconfigured = !existing?.configured && age >= -5000 && age <= FRESH_MS && pidAlive(event.pid)
|
|
121
|
+
const online = event.type !== 'runtime.stopped' && (configuredAlive || recentUnconfigured)
|
|
117
122
|
agentsById.set(event.agent.identifier, { ...existing, id: event.agent.identifier, identifier: event.agent.identifier, slug: existing?.slug || event.agent.slug || event.agent.identifier, name: existing?.name || event.agent.slug || event.agent.identifier, provider: event.agent.provider || 'unknown', runId: event.runId, pid: Number.isSafeInteger(event.pid) ? event.pid : null, watcherAlive: online, status: online ? 'online' : 'offline', lastSeen: event.timestamp, lastEventType: event.type })
|
|
118
123
|
}
|
|
119
124
|
const agents = [...agentsById.values()]
|
package/src/watch.mjs
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
// The OpenVisio autonomy watcher — an event-driven
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// the shell installer so behaviour is identical — now shipped in the package rather
|
|
5
|
-
// than written to disk from a pasted heredoc.
|
|
1
|
+
// The OpenVisio autonomy watcher — an event-driven loop. It waits on the team
|
|
2
|
+
// event connection and starts a runtime only for accepted work. Relay mode keeps
|
|
3
|
+
// compatibility with older inbox polling connections.
|
|
6
4
|
|
|
7
5
|
import { spawn, spawnSync } from 'node:child_process'
|
|
8
6
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
9
|
-
import { closeSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, existsSync, openSync, readFileSync, readdirSync, unlinkSync } from 'node:fs'
|
|
7
|
+
import { closeSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, existsSync, openSync, readFileSync, readdirSync, unlinkSync, watch as watchFs } from 'node:fs'
|
|
10
8
|
import { homedir, tmpdir } from 'node:os'
|
|
11
9
|
import { fileURLToPath } from 'node:url'
|
|
12
10
|
import { join, dirname } from 'node:path'
|
|
@@ -37,6 +35,7 @@ import { createAgentJournal } from './agent-journal.mjs'
|
|
|
37
35
|
import { readModelSettings } from './model-settings.mjs'
|
|
38
36
|
import { startStudioInBackground } from './studio-cli.mjs'
|
|
39
37
|
import { toolData } from './team-data.mjs'
|
|
38
|
+
import { runtimeProvider, supportedRuntimeList } from './runtime-providers.mjs'
|
|
40
39
|
|
|
41
40
|
function runtimeJournal(options, supplied) {
|
|
42
41
|
let journal
|
|
@@ -267,10 +266,11 @@ export async function runWatch({ flags }) {
|
|
|
267
266
|
const saved = slug ? readConfig(slug) : null
|
|
268
267
|
const claude = String(flags.claude || onPath('claude') || 'claude')
|
|
269
268
|
const mcpConfig = String(flags['mcp-config'] || (saved && saved.mcpConfig) || '')
|
|
270
|
-
// Which coding-agent runtime drives the cycles
|
|
271
|
-
//
|
|
269
|
+
// Which coding-agent runtime drives the cycles. Claude keeps its native stream;
|
|
270
|
+
// ACP-capable runtimes share the Mastra harness. Default claude.
|
|
272
271
|
const agent = String(flags.agent || flags.runtime || (saved && saved.agent) || 'claude').toLowerCase()
|
|
273
|
-
|
|
272
|
+
const provider = runtimeProvider(agent)
|
|
273
|
+
if (!provider) fail(`Unknown --agent "${agent}". Supported runtimes: ${supportedRuntimeList()}.`)
|
|
274
274
|
const mcpUrl = String(flags['mcp-url'] || (saved && saved.mcpUrl) || '')
|
|
275
275
|
// Code mode is the DEFAULT: an agent lives on the user's laptop and should just be
|
|
276
276
|
// able to work across the org's repos with no per-repo setup. So `workdir` resolves
|
|
@@ -286,9 +286,9 @@ export async function runWatch({ flags }) {
|
|
|
286
286
|
if (workdir) configurePrPublishing({ workspace: workdir, enabled: !flags['no-pr-push'] && saved?.prPublishing !== false })
|
|
287
287
|
if (workdir) { try { mkdirSync(workdir, { recursive: true }) } catch { /* best-effort; spawn will surface a real problem */ } }
|
|
288
288
|
|
|
289
|
-
//
|
|
290
|
-
const defaultModel =
|
|
291
|
-
const defaultChatModel =
|
|
289
|
+
// Configured chat and code tiers remain independent for every runtime.
|
|
290
|
+
const defaultModel = provider.defaultModel
|
|
291
|
+
const defaultChatModel = provider.defaultChatModel
|
|
292
292
|
const model = String(flags.model || (saved && saved.model) || defaultModel)
|
|
293
293
|
const chatModel = String(flags['chat-model'] || (saved && saved.chatModel) || defaultChatModel)
|
|
294
294
|
|
|
@@ -338,11 +338,11 @@ export async function runWatch({ flags }) {
|
|
|
338
338
|
return loop({ host, key, slug: slug || 'openvisio', claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug: !!flags.debug })
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
-
// Claude keeps its native stream;
|
|
341
|
+
// Claude keeps its native stream; every ACP provider uses the same Mastra harness.
|
|
342
342
|
export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, onEvent, systemPrompt, getContextEnvironment, canCoordinate = false, workspaceAvailable = !!workdir }) {
|
|
343
343
|
const canCode = !!workdir
|
|
344
344
|
const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
|
|
345
|
-
//
|
|
345
|
+
// ACP providers run through Mastra's shared harness. Each queue worker gets
|
|
346
346
|
// its own ACP session and worktree, so cancellation, permissions and context
|
|
347
347
|
// cannot bleed across concurrently assigned tickets.
|
|
348
348
|
const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, canCoordinate, workspaceAvailable, maxCycleMs, log, debug, model, onTool, onEvent, systemPrompt, getContextEnvironment })
|
|
@@ -583,8 +583,8 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
|
|
|
583
583
|
|
|
584
584
|
// ── the backend WS loop ──────────────────────────────────────────────────────
|
|
585
585
|
// One WebSocket identity feeds independent serialized work/reply queues. Each
|
|
586
|
-
// accepted source keeps its own context; direct MCP reconciliation
|
|
587
|
-
//
|
|
586
|
+
// accepted source keeps its own context; direct MCP reconciliation runs once
|
|
587
|
+
// when the event connection starts or reconnects, then the watcher stays idle.
|
|
588
588
|
function loopBackendWs(options) {
|
|
589
589
|
const watcher = createBackendWatcher(options)
|
|
590
590
|
const stop = () => void watcher.close().then(() => process.exit(0))
|
|
@@ -656,6 +656,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
656
656
|
// runtimes. Replies serialize within a thread while independent threads run
|
|
657
657
|
// concurrently behind the same delivery guards.
|
|
658
658
|
const MAX_CONCURRENT_WORKERS = 3
|
|
659
|
+
// OpenCode's ACP child consumes meaningful CPU and memory even between prompts.
|
|
660
|
+
// Keep the event watcher cheap by disposing it as soon as a cycle releases the
|
|
661
|
+
// runner. Other providers retain their configured warm-session window.
|
|
662
|
+
const runtimeIdleMs = runtimeProvider(agent)?.idleSessionMs ?? 300_000
|
|
659
663
|
const activeWorkRunners = new Set()
|
|
660
664
|
const recoveryWaiters = new Set()
|
|
661
665
|
const recoveryDelays = dependencies.transportRecoveryDelays || transportRecoveryDelays
|
|
@@ -668,10 +672,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
668
672
|
const workRunners = createRunnerPool({
|
|
669
673
|
create: ({ control, workdir }) => createWorkRunner(control, workdir),
|
|
670
674
|
capacity: 6,
|
|
675
|
+
idleMs: runtimeIdleMs,
|
|
671
676
|
})
|
|
672
677
|
const activeReplyRunners = new Set()
|
|
673
678
|
const replyRunners = createRunnerPool({
|
|
674
679
|
capacity: 8,
|
|
680
|
+
idleMs: runtimeIdleMs,
|
|
675
681
|
create: ({ key, canCoordinate }) => makeRunner({
|
|
676
682
|
...runnerOptions, workdir: '', canCoordinate,
|
|
677
683
|
systemPrompt: CHAT_CHARTER + '\n\n' + credNote,
|
|
@@ -2185,19 +2191,16 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2185
2191
|
deliveryTimer?.unref?.()
|
|
2186
2192
|
const authorizationTimer = autoStart ? setInterval(() => void resumeReadyAuthorizations(), 2_000) : null
|
|
2187
2193
|
authorizationTimer?.unref?.()
|
|
2188
|
-
|
|
2189
|
-
|
|
2194
|
+
let modelSettingsWatcher = null
|
|
2195
|
+
if (autoStart) {
|
|
2196
|
+
try {
|
|
2197
|
+
modelSettingsWatcher = watchFs(stateDir, { persistent: false }, (_event, filename) => {
|
|
2198
|
+
if (!filename || String(filename) === `${slug}.json`) refreshModelSettings()
|
|
2199
|
+
})
|
|
2200
|
+
} catch { /* every cycle still refreshes settings before choosing a model */ }
|
|
2201
|
+
}
|
|
2190
2202
|
emit('models.updated', { model: codeModel, chatModel: liteModel, modelSettingsRevision })
|
|
2191
|
-
|
|
2192
|
-
emit('watcher.heartbeat', { status: 'running', active: queues.work.activeSize + queues.reply.activeSize, pending: queues.work.size + queues.reply.size, model: codeModel, chatModel: liteModel, modelSettingsRevision, workdir: workdir || '', ...(agentAppearance ? { profile: agentAppearance } : {}) })
|
|
2193
|
-
// The Studio reads a bounded journal tail. Re-state current queue ownership
|
|
2194
|
-
// so a long-running cycle stays visible after its start event rotates out.
|
|
2195
|
-
for (const { data, control } of liveCycles.values()) {
|
|
2196
|
-
if (!control.cancelled) emit('cycle.snapshot', { ...data, status: control.outcome, attempt: control.attempt || 1 })
|
|
2197
|
-
}
|
|
2198
|
-
}, 15_000) : null
|
|
2199
|
-
heartbeatTimer?.unref?.()
|
|
2200
|
-
let introTimer = null, taskProbeStartTimer = null, taskProbeTimer = null
|
|
2203
|
+
let introTimer = null
|
|
2201
2204
|
if (autoStart && (mcpConfig || mcpUrl)) {
|
|
2202
2205
|
// Workspace ethics: a one-time hello the FIRST time this agent ever connects.
|
|
2203
2206
|
const introMarker = join(stateDir, 'intro-' + slugify(identifier) + '.done')
|
|
@@ -2210,13 +2213,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2210
2213
|
}).catch((e) => log('guarded introduction failed: ' + (e?.message || e)))
|
|
2211
2214
|
}, 5000) // let the socket subscribe first
|
|
2212
2215
|
}
|
|
2213
|
-
//
|
|
2214
|
-
//
|
|
2215
|
-
// coding tasks to the full lane instead of letting Luna announce it cannot code.
|
|
2216
|
-
taskProbeStartTimer = setTimeout(() => void reconcileBacklog(), 3_000)
|
|
2217
|
-
// WebSocket delivery is primary. A short bounded reconciliation interval
|
|
2218
|
-
// keeps mentions responsive when a deployment omits targeted dispatch.
|
|
2219
|
-
taskProbeTimer = setInterval(() => void reconcileBacklog(), 30_000)
|
|
2216
|
+
// The WebSocket onConnect hook performs one recovery pass. Do not poll the
|
|
2217
|
+
// backend while a healthy event connection is already waiting for work.
|
|
2220
2218
|
} else {
|
|
2221
2219
|
log('no MCP config — skipping intro + daily sweep (agent has no tools to post/act)')
|
|
2222
2220
|
}
|
|
@@ -2230,11 +2228,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2230
2228
|
closePromise = (async () => {
|
|
2231
2229
|
clearInterval(authorizationTimer)
|
|
2232
2230
|
clearInterval(deliveryTimer)
|
|
2233
|
-
|
|
2234
|
-
clearInterval(modelSettingsTimer)
|
|
2231
|
+
modelSettingsWatcher?.close()
|
|
2235
2232
|
if (introTimer) clearTimeout(introTimer)
|
|
2236
|
-
if (taskProbeStartTimer) clearTimeout(taskProbeStartTimer)
|
|
2237
|
-
if (taskProbeTimer) clearInterval(taskProbeTimer)
|
|
2238
2233
|
try { handle?.close() } catch { /* already disconnected */ }
|
|
2239
2234
|
for (const queue of Object.values(queues)) {
|
|
2240
2235
|
queue.cancel(() => true, (item) => { item.control.cancelled = true; item.control.wakeRecovery?.() })
|
|
@@ -2297,12 +2292,9 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
|
|
|
2297
2292
|
|
|
2298
2293
|
const seen = new Set()
|
|
2299
2294
|
let busy = false
|
|
2300
|
-
const heartbeatTimer = setInterval(() => emit('watcher.heartbeat', { status: 'running', active: busy ? 1 : 0, model, chatModel, workdir: workdir || '' }), 15_000)
|
|
2301
|
-
heartbeatTimer.unref?.()
|
|
2302
2295
|
const close = () => {
|
|
2303
2296
|
if (closePromise) return closePromise
|
|
2304
2297
|
stopping = true
|
|
2305
|
-
clearInterval(heartbeatTimer)
|
|
2306
2298
|
clearTimeout(introTimer)
|
|
2307
2299
|
clearInterval(sweepTimer)
|
|
2308
2300
|
closePromise = (async () => {
|
package/studio/app.mjs
CHANGED
|
@@ -393,7 +393,7 @@ function initializeStudio() {
|
|
|
393
393
|
$('open-model-settings').disabled = !agent.settingsEditable;
|
|
394
394
|
$('open-agent-removal').disabled = !agent.removable;
|
|
395
395
|
const watcherRunning = agent.status === 'online' || agent.watcherAlive === true;
|
|
396
|
-
$('toggle-watcher').textContent = watcherRunning ? 'Stop watcher' : 'Start watcher';
|
|
396
|
+
$('toggle-watcher').querySelector('span').textContent = watcherRunning ? 'Stop watcher' : 'Start watcher';
|
|
397
397
|
$('toggle-watcher').dataset.action = watcherRunning ? 'stop' : 'start';
|
|
398
398
|
$('toggle-watcher').disabled = watcherChanging || !agent.watcherControllable;
|
|
399
399
|
$('restart-watcher').disabled = watcherChanging || !agent.watcherControllable;
|
package/studio/guide.html
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
<main id="guide-content" class="guide-content" tabindex="-1">
|
|
34
34
|
<section id="start">
|
|
35
35
|
<h2>Open Studio</h2>
|
|
36
|
-
<p>Agent Studio is a local viewer for bring-your-own (BYO) agents connected to an OpenVisio team. It shows recorded actions from Claude Code, Codex, and
|
|
36
|
+
<p>Agent Studio is a local viewer for bring-your-own (BYO) agents connected to an OpenVisio team. It shows recorded actions from Claude Code, Codex, OpenCode, Gemini CLI, and Qwen Code watchers on this machine.</p>
|
|
37
37
|
<p>In the OpenVisio app, open <strong>Agents → Open Agent Studio</strong>. Agents can also include this button in chat replies. Updated backend watchers start Studio automatically on this computer.</p>
|
|
38
38
|
<p>You can also open Studio manually:</p>
|
|
39
39
|
<pre><code>openvisio-agent studio</code></pre>
|
|
@@ -46,12 +46,12 @@
|
|
|
46
46
|
<h2>Change models</h2>
|
|
47
47
|
<p>Select an agent in the sidebar, choose <strong>Model settings</strong>, select its reply and coding models, then choose <strong>Save models</strong>. Model fields let you search the runtime’s choices or enter a custom model ID.</p>
|
|
48
48
|
<p>Updated watchers load saved choices immediately and apply them to the next request without a restart. Work already running continues with its original model. After a request starts, Studio shows the model the runtime actually selected. Offline agents retain the selection for their next connection.</p>
|
|
49
|
-
<p>
|
|
49
|
+
<p>Model choices come from the selected runtime’s local catalog. Listing a model does not guarantee credentials or available quota. If a model is rate-limited, choose one with available quota or retry after the provider’s limit resets.</p>
|
|
50
50
|
<p>Studio controls agents configured on this computer. An agent running on another teammate’s computer must be managed there.</p>
|
|
51
51
|
</section>
|
|
52
52
|
<section id="remove">
|
|
53
53
|
<h2>Remove a local agent</h2>
|
|
54
|
-
<p>Select an agent, choose <strong>Remove
|
|
54
|
+
<p>Select an agent, choose <strong>Remove agent</strong>, review the local data, and type its local name to confirm. Studio stops its watcher and background service, then removes its local connection credentials, logs, memory, and cached context. This cannot be undone.</p>
|
|
55
55
|
<p>Repositories, branches, shared model-provider logins, and the team’s agent profile stay in place. Context shared with another local connection is retained. Removal is available on macOS and Linux for the default local agent storage; custom state directories and Windows require manual management.</p>
|
|
56
56
|
</section>
|
|
57
57
|
<section id="watchers">
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
<li>Select the agent in Studio and choose <strong>Start watcher</strong>. Studio installs its background watcher and starts it with the current package and saved model settings.</li>
|
|
63
63
|
<li>Return to Studio. Your agent should appear in the sidebar. Select its name to see its activity.</li>
|
|
64
64
|
</ol>
|
|
65
|
-
<p>Use <strong>Stop watcher</strong> to take an agent offline and <strong>Restart watcher</strong> after changing its runtime installation or troubleshooting a connection. Restarting preserves its configuration and model choices.</p>
|
|
65
|
+
<p>Use <strong>Stop watcher</strong> to take an agent offline and <strong>Restart watcher</strong> after changing its runtime installation or troubleshooting a connection. Restarting preserves its configuration and model choices. While connected and idle, the watcher waits for WebSocket or local settings events without starting a coding runtime or polling project activity.</p>
|
|
66
66
|
<p>Watcher controls are available for backend agents in the default local agent storage on macOS and Linux. Their earlier activity cannot be reconstructed.</p>
|
|
67
67
|
</section>
|
|
68
68
|
<section id="find-work">
|
package/studio/index.html
CHANGED
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></symbol>
|
|
35
35
|
<symbol id="i-close" viewBox="0 0 24 24"><path d="m6 6 12 12M6 18 18 6"/></symbol>
|
|
36
36
|
<symbol id="i-copy" viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="13" rx="2"/><path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3"/></symbol>
|
|
37
|
+
<symbol id="i-power" viewBox="0 0 24 24"><path d="M12 3v9"/><path d="M7.1 5.8a8 8 0 1 0 9.8 0"/></symbol>
|
|
38
|
+
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M20 7v5h-5"/><path d="M4 17v-5h5"/><path d="M6.1 8a7 7 0 0 1 11.7-1L20 12M4 12l2.2 5a7 7 0 0 0 11.7-1"/></symbol>
|
|
39
|
+
<symbol id="i-sliders" viewBox="0 0 24 24"><path d="M4 6h10M18 6h2M4 12h2m4 0h10M4 18h8m4 0h4"/><circle cx="16" cy="6" r="2"/><circle cx="8" cy="12" r="2"/><circle cx="14" cy="18" r="2"/></symbol>
|
|
40
|
+
<symbol id="i-user-minus" viewBox="0 0 24 24"><circle cx="9" cy="8" r="4"/><path d="M2 21a7 7 0 0 1 14 0M17 11h6"/></symbol>
|
|
37
41
|
</defs></svg>
|
|
38
42
|
<a class="skip-link" href="#workspace">Skip to activity</a>
|
|
39
43
|
<div class="app-shell">
|
|
@@ -61,7 +65,7 @@
|
|
|
61
65
|
<div id="notice" class="notice" role="status" hidden></div>
|
|
62
66
|
<section id="model-settings-summary" class="model-settings-summary" aria-label="Agent model settings" hidden>
|
|
63
67
|
<div><h2 id="model-settings-agent">Model settings</h2><p id="model-settings-state" role="status"></p></div>
|
|
64
|
-
<div class="header-actions"><button id="toggle-watcher" class="button" type="button">Start watcher</button><button id="restart-watcher" class="button" type="button">Restart watcher</button><button id="open-model-settings" class="button" type="button">Model settings</button><button id="open-agent-removal" class="button" type="button">Remove
|
|
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>
|
|
65
69
|
</section>
|
|
66
70
|
<dialog id="model-settings-dialog" aria-labelledby="model-settings-title">
|
|
67
71
|
<form id="model-settings-form" class="model-settings-form">
|
package/studio/style.css
CHANGED
|
@@ -360,6 +360,13 @@
|
|
|
360
360
|
.model-settings-summary:not([hidden]) { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px; padding: 18px; margin-bottom: 22px; border: 1px solid var(--border); border-radius: 20px; background: var(--surface, white); }
|
|
361
361
|
.model-settings-summary h2 { margin: 0 0 6px; font-size: 13px; font-weight: 500; }
|
|
362
362
|
.model-settings-summary p { margin: 0; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
|
|
363
|
+
.model-settings-actions-row { gap: 8px; }
|
|
364
|
+
.studio-action .icon { transition: transform .16s ease-out, color .16s ease-out; }
|
|
365
|
+
.studio-action:hover .icon { transform: translateY(-1px); }
|
|
366
|
+
#toggle-watcher[data-action="start"] .icon { color: var(--success); }
|
|
367
|
+
#toggle-watcher[data-action="stop"] .icon, .remove-action .icon { color: var(--destructive); }
|
|
368
|
+
.restart-action .icon { color: var(--orange); }
|
|
369
|
+
.settings-action .icon { color: var(--purple); }
|
|
363
370
|
#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; }
|
|
364
371
|
#model-settings-dialog::backdrop, #agent-removal-dialog::backdrop { background: #0005; }
|
|
365
372
|
.model-settings-form { display: grid; gap: 12px; }
|