openvisio-agent 0.25.3 → 0.25.5

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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## [0.25.5] — 2026-09-21
6
+
7
+ - Add Gemini CLI and Qwen Code as coding-agent providers through their native ACP modes.
8
+ - Centralize provider commands, installation, authentication guidance, model defaults, Studio model discovery, and cleanup in one runtime registry.
9
+ - Give every ACP provider the same authenticated OpenVisio MCP bridge, watcher lifecycle, workspace isolation, session reuse, and live Studio model controls.
10
+ - Stop OpenCode ACP children as soon as their cycle finishes, leaving only the lightweight event watcher running while idle.
11
+
12
+ ## [0.25.4] — 2026-09-21
13
+
14
+ - Route the backend's `agent:message` events through the same verified recipient, deduplication, queue, model, and delivery path as targeted mentions.
15
+ - Normalize both nested and direct targeted-message envelopes so valid requests cannot stop after the WebSocket event log without starting a cycle.
16
+
3
17
  ## [0.25.3] — 2026-09-21
4
18
 
5
19
  - Normalize structured and compact Markdown MCP list results through one provider-neutral discovery path, so every runtime can resolve projects, agents, boards, tasks, task types, and channels from the same saved setup.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # openvisio-agent
2
2
 
3
- Connect your coding agent (**Claude Code, Codex, or OpenCode**) to an [OpenVisio](https://openvisio.app) team — in one command. No shell scripts, no `curl | bash`.
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; Codex and OpenCode receive an isolated MCP configuration for each autonomy cycle.
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 OpenCode cycles run through Mastra's ACP harness. Codex uses the packaged `codex-acp` adapter and reuses the machine's existing ChatGPT/Codex login; OpenCode uses its native `opencode acp` server. 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.
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
 
@@ -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. Claude, Codex, and OpenCode keep 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.
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 OpenCode use Mastra ACP directly. Work sessions are retained per ticket in a six-entry pool; reply sessions in an eight-entry pool. 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. Projects reconcile with concurrency three and enqueue tickets before reading optional activity history. Coordination sessions can update the board without gaining repository access.
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. 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 OpenCode (the selected CLI is auto-installed if missing)
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 OpenCode. 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.
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 claude|codex|opencode]
28
- openvisio-agent connect --backend <url> --key <api-key> --id <identifier> [--name "<agent>"] [--ws <wss-url>] [--mcp-url <url>] [--agent claude|codex|opencode]
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: claude (default), codex, or opencode.
59
- Codex authenticates separately with \`codex login\`; opencode
60
- authenticates with \`opencode auth login\`.
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; OpenCode uses its configured default.
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 !== 'claude' && a !== 'codex' && a !== 'opencode') fail(`Unknown --agent "${a}". Supported runtimes: claude | codex | opencode.`)
139
+ if (!runtimeProvider(a)) fail(`Unknown --agent "${a}". Supported runtimes: ${supportedRuntimeList()}.`)
139
140
  return a
140
141
  }
141
142
 
142
- // opencode runtime: ensure the `opencode` CLI exists (best-effort install), and
143
- // remind about its one-time auth. Unlike Claude Code we don't register an MCP here
144
- // — the watcher writes an `opencode.json` with the openvisio-team server at runtime.
145
- function ensureOpencode() {
146
- if (onPath('opencode')) return
147
- info('opencode not found on PATH — installing (npm i -g opencode-ai)…')
148
- spawnSync('npm', ['i', '-g', 'opencode-ai'], { stdio: 'inherit', shell: process.platform === 'win32' })
149
- if (!onPath('opencode')) info('Could not auto-install opencode. Install it — https://opencode.ai/docs/ — then run: opencode auth login')
150
- else info('Installed opencode. Authenticate it once if you haven\'t: opencode auth login')
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
- ensureOpencode() // the watcher writes opencode.json with the remote MCP at runtime
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 === 'opencode'
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.3",
4
- "description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team \u2014 MCP tools + optional autonomy \u2014 in one command.",
3
+ "version": "0.25.5",
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 }], ['zero-expiry', { idleMs: 0 }], ['negative-expiry', { idleMs: -1 }],
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 }
@@ -33,6 +33,7 @@ const cli = readFileSync(join(root, 'bin', 'cli.mjs'), 'utf8')
33
33
  const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
34
34
  const cycleQueue = readFileSync(join(root, 'src', 'cycle-queue.mjs'), 'utf8')
35
35
  const mastraHarness = readFileSync(join(root, 'src', 'mastra-harness.mjs'), 'utf8')
36
+ const runtimeProviders = readFileSync(join(root, 'src', 'runtime-providers.mjs'), 'utf8')
36
37
  const modelSelection = readFileSync(join(root, 'src', 'model-selection.mjs'), 'utf8')
37
38
  const activityHook = readFileSync(join(repo, 'frontend', 'hooks', 'useAgentActivity.ts'), 'utf8')
38
39
  const taskHook = readFileSync(join(repo, 'frontend', 'hooks', 'useBackendTasks.ts'), 'utf8')
@@ -49,6 +50,7 @@ const assertions = [
49
50
  ['authorization recovery is local and independent of backlog cadence', watcher.includes('resumeReadyAuthorizations(), 2_000') && watcher.includes('releaseAuthorizedPause(key')],
50
51
  ['new follow-ups only deduplicate later replies', watcher.includes('repliesAfterSource(renderedAgentMessages') && watcher.includes('sourceMessageId: mid')],
51
52
  ['Mastra snapshots do not block the event loop', mastraHarness.includes('await execFileAsync') && !mastraHarness.includes('spawnSync')],
53
+ ['OpenCode exits after work while the event watcher stays idle', runtimeProviders.includes("id: 'opencode'") && runtimeProviders.includes('idleSessionMs: 0') && watcher.includes('idleMs: runtimeIdleMs')],
52
54
  ['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
55
  ['task:assigned is handled', watcher.includes("k === 'task:assigned'")],
54
56
  ['task signals are verified with get_ticket', watcher.includes("callMcpTool('get_ticket'")],
@@ -85,7 +87,7 @@ const assertions = [
85
87
  ['reply runner has no coding workspace or coding charter', watcher.includes("workdir: ''") && watcher.includes("systemPrompt: CHAT_CHARTER + '\\n\\n' + credNote") && opencodeConfig.includes("'*': 'deny'")],
86
88
  ['MCP requests have a deadline including response bodies', mcpHttp.includes('controller.abort()') && mcpHttp.includes('res.text()')],
87
89
  ['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
- ['Codex and OpenCode use warm scoped Mastra ACP sessions', watcher.includes('createMastraAcpRunner') && watcher.includes('workRunners.acquire(sessionKey') && watcher.includes('replyRunners.acquire(key') && mastraHarness.includes('persistSession: true')],
90
+ ['ACP providers use scoped Mastra sessions', watcher.includes('createMastraAcpRunner') && watcher.includes('workRunners.acquire(sessionKey') && watcher.includes('replyRunners.acquire(key') && mastraHarness.includes('persistSession: true')],
89
91
  ['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
92
  ['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
93
  ['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)')],
@@ -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(['claude', 'codex', 'opencode', config.agent || 'claude'])].map(provider => `${slug.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 60)}-${hash(`${identifier}:${provider}`).slice(0, 12)}`)
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
  }
@@ -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 = commandFor(agent)
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 = commandFor(agent)
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
@@ -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 !== 'opencode') return []
60
- const command = onPath('opencode')
61
- if (!command) throw problem('OpenCode is not installed on this computer.', 503)
62
- const { stdout } = await promisify(execFile)(command, ['models'], { timeout: 15_000, maxBuffer: 1024 * 1024, encoding: 'utf8' })
63
- return [...new Set(stdout.split(/\r?\n/).map(s => s.trim()).filter(modelId))].slice(0, 1000)
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
  }
@@ -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 <= 0) throw new Error('runner pool idleMs must be positive and finite')
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.unref?.()
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/watch.mjs CHANGED
@@ -37,6 +37,7 @@ import { createAgentJournal } from './agent-journal.mjs'
37
37
  import { readModelSettings } from './model-settings.mjs'
38
38
  import { startStudioInBackground } from './studio-cli.mjs'
39
39
  import { toolData } from './team-data.mjs'
40
+ import { runtimeProvider, supportedRuntimeList } from './runtime-providers.mjs'
40
41
 
41
42
  function runtimeJournal(options, supplied) {
42
43
  let journal
@@ -267,10 +268,11 @@ export async function runWatch({ flags }) {
267
268
  const saved = slug ? readConfig(slug) : null
268
269
  const claude = String(flags.claude || onPath('claude') || 'claude')
269
270
  const mcpConfig = String(flags['mcp-config'] || (saved && saved.mcpConfig) || '')
270
- // Which coding-agent runtime drives the cycles: Claude Code (warm stream-json),
271
- // Codex (`codex exec`), or opencode (`opencode run`). Default claude.
271
+ // Which coding-agent runtime drives the cycles. Claude keeps its native stream;
272
+ // ACP-capable runtimes share the Mastra harness. Default claude.
272
273
  const agent = String(flags.agent || flags.runtime || (saved && saved.agent) || 'claude').toLowerCase()
273
- if (agent !== 'claude' && agent !== 'codex' && agent !== 'opencode') fail(`Unknown --agent "${agent}". Supported runtimes: claude | codex | opencode.`)
274
+ const provider = runtimeProvider(agent)
275
+ if (!provider) fail(`Unknown --agent "${agent}". Supported runtimes: ${supportedRuntimeList()}.`)
274
276
  const mcpUrl = String(flags['mcp-url'] || (saved && saved.mcpUrl) || '')
275
277
  // Code mode is the DEFAULT: an agent lives on the user's laptop and should just be
276
278
  // able to work across the org's repos with no per-repo setup. So `workdir` resolves
@@ -286,9 +288,9 @@ export async function runWatch({ flags }) {
286
288
  if (workdir) configurePrPublishing({ workspace: workdir, enabled: !flags['no-pr-push'] && saved?.prPublishing !== false })
287
289
  if (workdir) { try { mkdirSync(workdir, { recursive: true }) } catch { /* best-effort; spawn will surface a real problem */ } }
288
290
 
289
- // Use a specific Codex ID; configured chat and code tiers remain independent.
290
- const defaultModel = agent === 'claude' ? 'sonnet' : agent === 'codex' ? 'gpt-5.6-sol[medium]' : ''
291
- const defaultChatModel = agent === 'claude' ? 'haiku' : agent === 'codex' ? 'gpt-5.6-sol[medium]' : ''
291
+ // Configured chat and code tiers remain independent for every runtime.
292
+ const defaultModel = provider.defaultModel
293
+ const defaultChatModel = provider.defaultChatModel
292
294
  const model = String(flags.model || (saved && saved.model) || defaultModel)
293
295
  const chatModel = String(flags['chat-model'] || (saved && saved.chatModel) || defaultChatModel)
294
296
 
@@ -338,11 +340,11 @@ export async function runWatch({ flags }) {
338
340
  return loop({ host, key, slug: slug || 'openvisio', claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug: !!flags.debug })
339
341
  }
340
342
 
341
- // Claude keeps its native stream; Codex and OpenCode use Mastra ACP.
343
+ // Claude keeps its native stream; every ACP provider uses the same Mastra harness.
342
344
  export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, onEvent, systemPrompt, getContextEnvironment, canCoordinate = false, workspaceAvailable = !!workdir }) {
343
345
  const canCode = !!workdir
344
346
  const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
345
- // Codex and OpenCode run through Mastra's ACP harness. Each queue worker gets
347
+ // ACP providers run through Mastra's shared harness. Each queue worker gets
346
348
  // its own ACP session and worktree, so cancellation, permissions and context
347
349
  // cannot bleed across concurrently assigned tickets.
348
350
  const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, canCoordinate, workspaceAvailable, maxCycleMs, log, debug, model, onTool, onEvent, systemPrompt, getContextEnvironment })
@@ -656,6 +658,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
656
658
  // runtimes. Replies serialize within a thread while independent threads run
657
659
  // concurrently behind the same delivery guards.
658
660
  const MAX_CONCURRENT_WORKERS = 3
661
+ // OpenCode's ACP child consumes meaningful CPU and memory even between prompts.
662
+ // Keep the event watcher cheap by disposing it as soon as a cycle releases the
663
+ // runner. Other providers retain their configured warm-session window.
664
+ const runtimeIdleMs = runtimeProvider(agent)?.idleSessionMs ?? 300_000
659
665
  const activeWorkRunners = new Set()
660
666
  const recoveryWaiters = new Set()
661
667
  const recoveryDelays = dependencies.transportRecoveryDelays || transportRecoveryDelays
@@ -668,10 +674,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
668
674
  const workRunners = createRunnerPool({
669
675
  create: ({ control, workdir }) => createWorkRunner(control, workdir),
670
676
  capacity: 6,
677
+ idleMs: runtimeIdleMs,
671
678
  })
672
679
  const activeReplyRunners = new Set()
673
680
  const replyRunners = createRunnerPool({
674
681
  capacity: 8,
682
+ idleMs: runtimeIdleMs,
675
683
  create: ({ key, canCoordinate }) => makeRunner({
676
684
  ...runnerOptions, workdir: '', canCoordinate,
677
685
  systemPrompt: CHAT_CHARTER + '\n\n' + credNote,
@@ -747,7 +755,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
747
755
  // re-acknowledge the same assignment.
748
756
  const seenTasks = new Set()
749
757
  // Mentions we've already reacted to (by message id) — the backend can re-deliver
750
- // an agent:mention (reconnect replay, dup fan-out), which otherwise makes the
758
+ // an agent:mention/agent:message (reconnect replay, dup fan-out), which otherwise makes the
751
759
  // agent reply to the SAME message twice.
752
760
  const replayPath = join(stateDir, 'watch-' + slug + '-replay.json')
753
761
  let replayState = {}
@@ -1896,12 +1904,27 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1896
1904
  }
1897
1905
  }
1898
1906
 
1907
+ const conversationEventData = (value) => {
1908
+ const outer = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
1909
+ const data = outer.data && typeof outer.data === 'object' && !Array.isArray(outer.data) ? outer.data : {}
1910
+ const payload = outer.payload && typeof outer.payload === 'object' && !Array.isArray(outer.payload) ? outer.payload : {}
1911
+ const source = { ...data, ...payload, ...outer }
1912
+ const messageCandidate = [outer.message, data.message, payload.message].find((item) => item && typeof item === 'object' && !Array.isArray(item))
1913
+ const directId = source.message_id ?? source.messageId ?? source.id
1914
+ const directContent = source.content ?? source.body ?? source.text ?? (typeof source.message === 'string' ? source.message : null)
1915
+ const message = messageCandidate || (directId != null && directContent != null ? { ...source, content: directContent } : {})
1916
+ const channelId = source.channel_id ?? source.channelId ?? source.channel?.id ?? message.channel_id ?? message.channelId ?? message.channel?.id
1917
+ return { ...source, channel_id: channelId, message }
1918
+ }
1919
+
1899
1920
  const mentionIntake = new Map()
1900
1921
  function onEvent(k, d) {
1901
1922
  if (stopping) return
1902
- if (k !== 'agent:mention' && k !== 'channel:message') return trackOperation(routeEvent(k, d))
1903
- const cid = d?.channel_id ?? d?.channelId
1904
- const message = d?.message || {}
1923
+ const conversationEvent = k === 'agent:mention' || k === 'agent:message' || k === 'channel:message'
1924
+ if (!conversationEvent) return trackOperation(routeEvent(k, d))
1925
+ const eventData = conversationEventData(d)
1926
+ const cid = eventData.channel_id ?? eventData.channelId
1927
+ const message = eventData.message || {}
1905
1928
  const parent = messageParentId(message)
1906
1929
  const messageId = message.message_id ?? message.messageId ?? message.id
1907
1930
  const malformedParent = parent == null && [message.parent_id, message.parentId, message.thread_id, message.threadId, message.reply_to, message.replyTo].some((value) => value != null)
@@ -1951,7 +1974,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1951
1974
  }
1952
1975
  if (!stopping) {
1953
1976
  if (!irrelevant) log(`mention timing intake=${Math.round(performance.now() - intakeStartedAt)}ms source=${k} channel=${cid} message=${messageId}`)
1954
- return routeEvent('agent:mention', { ...d, _threadRootContent: rootContent })
1977
+ return routeEvent('agent:mention', { ...eventData, _sourceEvent: k, _threadRootContent: rootContent })
1955
1978
  }
1956
1979
  }).catch((error) => { log('mention thread verification failed: ' + (error?.message || error)); return { status: 'retry' } })
1957
1980
  .finally(() => { if (mentionIntake.get(key) === next) mentionIntake.delete(key) })
@@ -2103,9 +2126,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2103
2126
  }
2104
2127
 
2105
2128
  // The WebSocket drives real-time reactions (task:created/updated assigned to us,
2106
- // agent:mention and channel:message). Verified organization subscriptions
2129
+ // agent:mention, agent:message, and channel:message). Verified organization subscriptions
2107
2130
  // recover channel mentions even when the backend omits targeted dispatch.
2108
- // Both event types share recipient, ownership and deduplication guards.
2131
+ // All conversation event types share recipient, ownership and deduplication guards.
2109
2132
  log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
2110
2133
  handle = (dependencies.connect || connectAgentWs)({ wsUrl, apiKey, identifier, onEvent,
2111
2134
  onDisconnect: () => {
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 OpenCode watchers on this machine.</p>
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,7 +46,7 @@
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>OpenCode choices come from its local model 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>
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">