openvisio-agent 0.25.1 → 0.25.2

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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.25.2] — 2026-09-21
4
+
5
+ - Load each provider's available models in Agent Studio, apply the selected model exactly to new ACP sessions, and show the model the running watcher actually uses.
6
+ - Add Start, Stop, and Restart controls for local watchers in Agent Studio, backed by lifecycle APIs that report current watcher state.
7
+ - Recognize mentions addressed to saved agent display names containing spaces or hyphens, so names such as `Andre - Opencode` reliably trigger their watcher.
8
+ - Reconcile backend activity every 30 seconds to recover missed mentions promptly while preserving immediate startup and reconnect checks.
9
+
3
10
  ## [0.25.1] — 2026-09-19
4
11
 
5
12
  - Discover task columns by board when required by the live MCP schema; remove the incorrect project-only instruction that caused failed calls and assignment lookups.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.25.1",
3
+ "version": "0.25.2",
4
4
  "description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team \u2014 MCP tools + optional autonomy \u2014 in one command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@ async function eventually(predicate, description) {
16
16
  assert.fail(`Watcher did not reach: ${description}`)
17
17
  }
18
18
 
19
- export function fixture({ agent = 'codex', task = true, run, threadError = false, commentTools = [], onComment, selfProfile = self, listAgents, getTicket, listChannels, transportRecoveryDelays, activityReporter } = {}) {
19
+ export function fixture({ agent = 'codex', name, task = true, run, threadError = false, commentTools = [], onComment, selfProfile = self, listAgents, getTicket, listChannels, transportRecoveryDelays, activityReporter } = {}) {
20
20
  const dir = mkdtempSync(join(tmpdir(), 'byo-workspace-'))
21
21
  const posts = [], runs = [], calls = [], logs = [], messages = [], subscriptions = []
22
22
  const ticket = { id: 77, project_id: 1, slug: 'OPEN-77', title: 'Repair the component', agent_id: 7, status: 'In Progress', type_id: 1, updated_at: 'revision-1' }
@@ -71,7 +71,7 @@ export function fixture({ agent = 'codex', task = true, run, threadError = false
71
71
  }
72
72
  }
73
73
  const start = () => {
74
- watcher = createBackendWatcher({ wsUrl: 'wss://unused.example.test', mcpUrl: 'https://unused.example.test/mcp', apiKey: 'test-key', identifier: 'alex', slug: 'test-alex', agent, workdir: join(dir, 'workspace'), model: 'test-code', chatModel: 'test-chat' }, {
74
+ watcher = createBackendWatcher({ wsUrl: 'wss://unused.example.test', mcpUrl: 'https://unused.example.test/mcp', apiKey: 'test-key', identifier: 'alex', slug: 'test-alex', name, agent, workdir: join(dir, 'workspace'), model: 'test-code', chatModel: 'test-chat' }, {
75
75
  stateDir: dir, memory, mcpClient: mcp, createRunner, transportRecoveryDelays, activityReporter, connect: (callbacks) => { connection = callbacks; return { close() {}, subscribeOrganization(id) { subscriptions.push(id) } } }, autoStart: false, log: (value) => logs.push(value),
76
76
  })
77
77
  return watcher
@@ -43,6 +43,38 @@ function commandFor(agent) {
43
43
  return null
44
44
  }
45
45
 
46
+ export async function listAcpRuntimeModels(agent, { AcpAgentClass = AcpAgent, cwd = process.cwd(), timeoutMs = 15_000 } = {}) {
47
+ const runtime = commandFor(agent)
48
+ if (!runtime) return []
49
+ const acp = new AcpAgentClass({
50
+ id: `openvisio-${agent}-model-catalog-${Date.now()}`,
51
+ name: `OpenVisio ${agent} model catalog`,
52
+ description: 'Reads the models advertised by the local coding runtime.',
53
+ command: runtime.command,
54
+ args: runtime.args,
55
+ cwd,
56
+ session: { cwd, mcpServers: [] },
57
+ persistSession: false,
58
+ createClient: agent === 'codex' ? (client) => new Proxy(client, {
59
+ get(target, property, receiver) {
60
+ if (property === 'extNotification') return async () => {}
61
+ return Reflect.get(target, property, receiver)
62
+ },
63
+ }) : undefined,
64
+ })
65
+ let timer
66
+ try {
67
+ const models = await Promise.race([
68
+ acp.getAvailableModels(),
69
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${agent} model discovery timed out`)), timeoutMs); timer.unref?.() }),
70
+ ])
71
+ return [...new Set((Array.isArray(models) ? models : []).map(entry => typeof entry === 'string' ? entry : entry?.modelId).map(value => String(value || '').trim()).filter(Boolean))]
72
+ } finally {
73
+ clearTimeout(timer)
74
+ try { acp.connection.disconnect() } catch { /* discovery may fail before the transport starts */ }
75
+ }
76
+ }
77
+
46
78
  const proxyPath = fileURLToPath(new URL('./codex-mcp-proxy.mjs', import.meta.url))
47
79
 
48
80
  const execFileAsync = promisify(execFile)
@@ -48,7 +48,14 @@ export function saveModelSettings(stateDir, slug, input) {
48
48
 
49
49
  export async function listRuntimeModels(provider) {
50
50
  if (provider === 'claude') return ['sonnet', 'opus', 'haiku']
51
- if (provider === 'codex') return ['gpt-5.6-sol[medium]'] // Same default as the watcher; custom IDs remain available.
51
+ if (provider === 'codex') {
52
+ try {
53
+ const { listAcpRuntimeModels } = await import('./mastra-harness.mjs')
54
+ const models = (await listAcpRuntimeModels('codex')).filter(modelId)
55
+ if (models.length) return models.slice(0, 1000)
56
+ } catch { /* keep the configured watcher default available when ACP discovery is unavailable */ }
57
+ return ['gpt-5.6-sol[medium]']
58
+ }
52
59
  if (provider !== 'opencode') return []
53
60
  const command = onPath('opencode')
54
61
  if (!command) throw problem('OpenCode is not installed on this computer.', 503)
@@ -7,6 +7,8 @@ import { fileURLToPath } from 'node:url'
7
7
  import { isPrivateThoughtEvent, sanitizeJournalData } from './agent-journal.mjs'
8
8
  import { readModelSettings, saveModelSettings, listRuntimeModels } from './model-settings.mjs'
9
9
  import { planAgentRemoval, removeLocalAgent } from './agent-removal.mjs'
10
+ import { controlLocalWatcher } from './watcher-control.mjs'
11
+ import { OV_DIR } from './lib.mjs'
10
12
 
11
13
  const MAX_EVENTS = 500, MAX_FILES = 32, MAX_FILE_BYTES = 256 * 1024, MAX_TOTAL_BYTES = 4 * 1024 * 1024
12
14
  const FRESH_MS = 45_000
@@ -67,7 +69,7 @@ async function configuredAgents(stateDir) {
67
69
  const lock = await readSmallLocalFile(join(stateDir, `watch-${config.slug}.lock`), 64).catch(() => null)
68
70
  if (/^\d+\s*$/.test(lock || '')) { pid = Number(lock.trim()); watcherAlive = pidAlive(pid) }
69
71
  const identifier = backend ? config.identifier : config.slug
70
- agents.push(sanitizeJournalData({ id: identifier, identifier, slug: config.slug, name: config.name || config.slug, provider: config.agent || 'claude', configured: true, removable: process.platform !== 'win32', settingsEditable: !!backend, modelSettings: backend ? readModelSettings(stateDir, config.slug) : null, runId: null, pid, watcherAlive, status: 'uninstrumented', lastSeen: null, lastEventType: null }))
72
+ agents.push(sanitizeJournalData({ id: identifier, identifier, slug: config.slug, name: config.name || config.slug, provider: config.agent || 'claude', configured: true, removable: process.platform !== 'win32', watcherControllable: !!backend && process.platform !== 'win32' && resolve(stateDir) === resolve(OV_DIR), settingsEditable: !!backend, modelSettings: backend ? readModelSettings(stateDir, config.slug) : null, runId: null, pid, watcherAlive, status: 'uninstrumented', lastSeen: null, lastEventType: null }))
71
73
  } catch { /* unrelated, malformed or concurrently replaced setup file */ }
72
74
  }
73
75
  return agents
@@ -120,9 +122,12 @@ export async function readStudioSnapshot({ stateDir, now = Date.now, journalCach
120
122
  const profile = profileEvent?.type === 'agent.profile' ? profileEvent.data : profileEvent?.data?.profile
121
123
  if (typeof profile?.name === 'string' && profile.name.trim()) agent.name = profile.name.trim()
122
124
  if (typeof profile?.avatarSeed === 'string' && profile.avatarSeed) agent.avatarSeed = profile.avatarSeed
123
- const applied = events.findLast(event => event.agent.identifier === agent.identifier && event.runId === agent.runId && typeof event.data?.modelSettingsRevision === 'string')
125
+ const loaded = events.findLast(event => event.agent.identifier === agent.identifier && event.runId === agent.runId && typeof event.data?.modelSettingsRevision === 'string')
126
+ const applied = events.findLast(event => event.agent.identifier === agent.identifier && event.runId === agent.runId && event.type === 'models.applied' && typeof event.data?.modelSettingsRevision === 'string')
127
+ agent.loadedModelRevision = loaded?.data.modelSettingsRevision || ''
124
128
  agent.appliedModelRevision = applied?.data.modelSettingsRevision || ''
125
- agent.modelControlsSupported = !!applied
129
+ agent.appliedModels = applied ? { requested: applied.data.requestedModel || '', selected: applied.data.model || '', tier: applied.data.tier || '' } : null
130
+ agent.modelControlsSupported = !!loaded
126
131
  }
127
132
  agents.sort((a, b) => a.name.localeCompare(b.name))
128
133
  return { schemaVersion: 1, demo: false, generatedAt: new Date(now()).toISOString(), agents, events: events.slice(-MAX_EVENTS), limits: { maxEvents: MAX_EVENTS, maxFiles: MAX_FILES, maxTotalBytes: MAX_TOTAL_BYTES }, warnings }
@@ -153,7 +158,7 @@ function demoSnapshot(stamp) {
153
158
  return { schemaVersion: 1, demo: true, generatedAt: new Date(stamp).toISOString(), agents, events: events.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)), limits: { maxEvents: MAX_EVENTS }, warnings: ['Demo mode contains simulated agent activity. No model or task is running.'] }
154
159
  }
155
160
 
156
- export async function startStudioServer({ stateDir, host = '127.0.0.1', port = 4317, assetsDir = DEFAULT_ASSETS, demo = false, modelCatalog = listRuntimeModels, removeAgent = removeLocalAgent }) {
161
+ export async function startStudioServer({ stateDir, host = '127.0.0.1', port = 4317, assetsDir = DEFAULT_ASSETS, demo = false, modelCatalog = listRuntimeModels, removeAgent = removeLocalAgent, watcherControl = controlLocalWatcher }) {
157
162
  if (!['127.0.0.1', '::1', 'localhost'].includes(host)) throw new Error('Agent Studio can bind only to a loopback address')
158
163
  if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Agent Studio port must be an integer from 0 to 65535')
159
164
  if (!stateDir) throw new Error('Agent Studio requires a local state directory')
@@ -163,6 +168,7 @@ export async function startStudioServer({ stateDir, host = '127.0.0.1', port = 4
163
168
  const catalogs = new Map()
164
169
  const journalCache = new Map()
165
170
  const removals = new Set()
171
+ const watcherChanges = new Set()
166
172
  const demoState = demo ? demoSnapshot(Date.now()) : null
167
173
  let current, pending, stopped = false, interval
168
174
  const delivered = new WeakMap()
@@ -221,6 +227,32 @@ export async function startStudioServer({ stateDir, host = '127.0.0.1', port = 4
221
227
  }
222
228
  return
223
229
  }
230
+ const watcherRoute = /^\/api\/agents\/([a-z0-9][a-z0-9_-]{0,79})\/watcher$/i.exec(path)
231
+ if (watcherRoute && !demo) {
232
+ const slug = watcherRoute[1]
233
+ res.setHeader('content-type', 'application/json; charset=utf-8')
234
+ try {
235
+ if (req.method !== 'POST') { res.writeHead(405); res.end(JSON.stringify({ error: 'Method not allowed' })); return }
236
+ if (req.headers.origin !== `http://${req.headers.host}` || req.headers['content-type']?.split(';')[0] !== 'application/json') throw Object.assign(new Error('Use the local Studio page to control a watcher.'), { status: 403 })
237
+ let body = ''
238
+ for await (const chunk of req) { body += chunk; if (Buffer.byteLength(body) > 1024) throw Object.assign(new Error('Watcher request is too large.'), { status: 413 }) }
239
+ let input
240
+ try { input = JSON.parse(body) } catch { throw Object.assign(new Error('Invalid watcher request.'), { status: 400 }) }
241
+ if (!input || !['start', 'stop', 'restart'].includes(input.action)) throw Object.assign(new Error('Choose start, stop, or restart.'), { status: 400 })
242
+ if (watcherChanges.has(slug)) throw Object.assign(new Error('This watcher is already changing state.'), { status: 409 })
243
+ watcherChanges.add(slug)
244
+ try {
245
+ const result = await watcherControl({ stateDir, slug, action: input.action })
246
+ journalCache.clear()
247
+ current = null
248
+ res.end(JSON.stringify(result))
249
+ } finally { watcherChanges.delete(slug) }
250
+ } catch (error) {
251
+ res.writeHead(error.status || 503)
252
+ res.end(JSON.stringify({ error: error.status ? error.message : `Could not control this watcher: ${error.message || error}` }))
253
+ }
254
+ return
255
+ }
224
256
  const settingsRoute = /^\/api\/agents\/([a-z0-9][a-z0-9_-]{0,79})\/(models|settings)$/i.exec(path)
225
257
  if (settingsRoute && !demo) {
226
258
  const [, slug, action] = settingsRoute
package/src/watch.mjs CHANGED
@@ -325,7 +325,7 @@ export async function runWatch({ flags }) {
325
325
  assertWebSocket(fail)
326
326
  if (flags.install) return installService({ slug: slug || 'openvisio', claude, mcpConfig, workdir })
327
327
  startStudioInBackground()
328
- return loopBackendWs({ backend, wsUrl, apiKey, identifier, slug: slug || 'openvisio', claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug: !!flags.debug })
328
+ return loopBackendWs({ backend, wsUrl, apiKey, identifier, slug: slug || 'openvisio', name: String(saved?.name || slug || identifier), claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug: !!flags.debug })
329
329
  }
330
330
 
331
331
  const host = stripSlash(flags.host || (saved && saved.host) || '')
@@ -594,7 +594,7 @@ function loopBackendWs(options) {
594
594
 
595
595
  // Production orchestration with replaceable I/O boundaries for deterministic
596
596
  // workspace simulations. Tests run this same routing, queue and delivery code.
597
- export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }, dependencies = {}) {
597
+ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug, name, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }, dependencies = {}) {
598
598
  const validBackendId = (value) => value != null && /^\d+$/.test(String(value)) && Number.isSafeInteger(Number(value)) && Number(value) > 0
599
599
  const stateDir = dependencies.stateDir || OV_DIR
600
600
  const makeRunner = dependencies.createRunner || createCycleRunner
@@ -896,7 +896,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
896
896
  let identityContext = memory.get?.(`identity:${identifier}`)?.meta?.context || agentProfileContext({}, identifier)
897
897
  let agentAppearance = memory.get?.(`identity:${identifier}`)?.meta?.appearance || null
898
898
  if (agentAppearance) emit('agent.profile', agentAppearance)
899
- const selfAliases = new Set([slug, identifier].map((value) => String(value || '').toLowerCase()).filter(Boolean))
899
+ // The saved display name is authoritative recipient context even when an older
900
+ // MCP server returns list_agents as human-readable markdown without identifiers.
901
+ const selfAliases = new Set([slug, name, identifier].map((value) => String(value || '').trim().toLowerCase()).filter(Boolean))
900
902
  const rememberSelfAgent = (self) => {
901
903
  if (!self || typeof self !== 'object' || String(self.identifier || self.slug || '') !== identifier) return
902
904
  if (validBackendId(self.id)) selfAgentId = Number(self.id)
@@ -1681,6 +1683,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1681
1683
  cycleControl.outcome = 'running'
1682
1684
  log(`cycle timing preflight=${Math.round(performance.now() - cycleStartedAt)}ms kind=${kind}`)
1683
1685
  const result = await runner.runCycle(prompt, useModel, runnerOptions)
1686
+ if (result?.model) {
1687
+ emit('models.applied', { requestedModel: useModel, model: result.model, tier: kind === 'full' ? 'coding' : 'reply', modelSettingsRevision: cycleModelRevision })
1688
+ }
1684
1689
  await cycleControl.planDelivery
1685
1690
  cycleControl.outcome = result?.is_error ? 'error' : result?.subtype || 'error'
1686
1691
  if (result?.timings) log(`cycle timing runtime=${JSON.stringify(result.timings)} kind=${kind}`)
@@ -2192,7 +2197,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2192
2197
  // supported MCP tools directly, stays silent when empty, and hands verified
2193
2198
  // coding tasks to the full lane instead of letting Luna announce it cannot code.
2194
2199
  taskProbeStartTimer = setTimeout(() => void reconcileBacklog(), 3_000)
2195
- taskProbeTimer = setInterval(() => void reconcileBacklog(), 5 * 60 * 1000)
2200
+ // WebSocket delivery is primary. A short bounded reconciliation interval
2201
+ // keeps mentions responsive when a deployment omits targeted dispatch.
2202
+ taskProbeTimer = setInterval(() => void reconcileBacklog(), 30_000)
2196
2203
  } else {
2197
2204
  log('no MCP config — skipping intro + daily sweep (agent has no tools to post/act)')
2198
2205
  }
@@ -2387,8 +2394,13 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
2387
2394
  // ── background service install (launchd / systemd) ───────────────────────────
2388
2395
  /** Stop the auto-restarting service first, then every orphaned watcher whose
2389
2396
  * --name resolves to this exact slug. Never use a broad pkill pattern. */
2390
- export function stopWatchers({ slug, quiet = false }) {
2397
+ export function stopWatchers({ slug, quiet = false, stateDir = OV_DIR, throwOnError = false }) {
2391
2398
  const key = slugify(slug || 'openvisio')
2399
+ const abort = (message) => {
2400
+ if (throwOnError) throw new Error(message)
2401
+ fail(message)
2402
+ }
2403
+ if (stateDir !== OV_DIR) abort('Watcher control is supported only in this computer’s agent storage.')
2392
2404
  let serviceStopped = false
2393
2405
 
2394
2406
  if (process.platform === 'darwin') {
@@ -2426,35 +2438,41 @@ export function stopWatchers({ slug, quiet = false }) {
2426
2438
  if (stubborn.length) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 150)
2427
2439
 
2428
2440
  const remaining = watcherPids()
2429
- if (remaining.length) fail(`Could not stop watcher${remaining.length === 1 ? '' : 's'} for "${key}": ${remaining.join(', ')}`)
2430
- try { unlinkSync(join(OV_DIR, `watch-${key}.lock`)) } catch { /* absent */ }
2441
+ if (remaining.length) abort(`Could not stop watcher${remaining.length === 1 ? '' : 's'} for "${key}": ${remaining.join(', ')}`)
2442
+ try { unlinkSync(join(stateDir, `watch-${key}.lock`)) } catch { /* absent */ }
2431
2443
  if (!quiet) ok(`Stopped ${found.length} watcher process${found.length === 1 ? '' : 'es'} for "${key}"${serviceStopped ? ' and disabled its background service' : ''}.`)
2432
2444
  return { stopped: found.length, serviceStopped }
2433
2445
  }
2434
2446
 
2435
- export function installService({ slug, workdir }) {
2447
+ export function installService({ slug, workdir, stateDir = OV_DIR, serviceCommand, throwOnError = false }) {
2448
+ const abort = (message) => {
2449
+ if (throwOnError) throw new Error(message)
2450
+ fail(message)
2451
+ }
2452
+ if (stateDir !== OV_DIR) abort('Watcher control is supported only in this computer’s agent storage.')
2436
2453
  // Replacing a service must also remove manually-started watchers. Otherwise the
2437
2454
  // new KeepAlive process repeatedly spawns, sees their lock, exits, and respawns.
2438
- stopWatchers({ slug, quiet: true })
2439
- const binPath = onPath('openvisio-agent')
2440
- if (!binPath || binPath.includes('/_npx/')) {
2455
+ stopWatchers({ slug, quiet: true, stateDir, throwOnError })
2456
+ const suppliedCommand = Array.isArray(serviceCommand) && serviceCommand.length ? serviceCommand.map(String) : null
2457
+ const binPath = suppliedCommand ? suppliedCommand[0] : onPath('openvisio-agent')
2458
+ if (!suppliedCommand && (!binPath || binPath.includes('/_npx/'))) {
2441
2459
  info('Installing openvisio-agent globally so the background service has a stable path…')
2442
2460
  spawnSync('npm', ['i', '-g', 'openvisio-agent'], { stdio: 'inherit', shell: process.platform === 'win32' })
2443
2461
  }
2444
- const bin = onPath('openvisio-agent') || 'openvisio-agent'
2462
+ const command = suppliedCommand || [onPath('openvisio-agent') || 'openvisio-agent']
2445
2463
  const nodeDir = dirname(process.execPath)
2446
2464
  const claudeBin = onPath('claude')
2447
2465
  const opencodeBin = onPath('opencode')
2448
2466
  const codexBin = onPath('codex')
2449
2467
  const runPath = [nodeDir, claudeBin ? dirname(claudeBin) : '', codexBin ? dirname(codexBin) : '', opencodeBin ? dirname(opencodeBin) : '', join(homedir(), '.opencode', 'bin'), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin'].filter(Boolean).join(':')
2450
2468
  const args = ['watch', '--name', slug, ...(workdir ? ['--workdir', workdir] : [])]
2451
- mkdirSync(OV_DIR, { recursive: true })
2452
- const logFile = join(OV_DIR, `${slug}.log`)
2469
+ mkdirSync(stateDir, { recursive: true })
2470
+ const logFile = join(stateDir, `${slug}.log`)
2453
2471
 
2454
2472
  if (process.platform === 'darwin') {
2455
2473
  const label = `io.openvisio.${slug}`
2456
2474
  const plist = join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`)
2457
- const progArgs = [bin, ...args].map((a) => ` <string>${a}</string>`).join('\n')
2475
+ const progArgs = [...command, ...args].map((a) => ` <string>${a}</string>`).join('\n')
2458
2476
  writeFileSync(plist, `<?xml version="1.0" encoding="UTF-8"?>
2459
2477
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2460
2478
  <plist version="1.0"><dict>
@@ -2471,15 +2489,16 @@ ${progArgs}
2471
2489
  `)
2472
2490
  spawnSync('launchctl', ['unload', plist], { stdio: 'ignore' })
2473
2491
  const r = spawnSync('launchctl', ['load', plist], { stdio: 'inherit' })
2474
- if (r.status !== 0) fail('launchctl load failed — check the plist at ' + plist)
2492
+ if (r.status !== 0) abort('launchctl load failed — check the plist at ' + plist)
2475
2493
  ok(`Live in the background, auto-starts at login. Logs: tail -f ${logFile}`)
2494
+ return { running: true, service: 'launchd', slug }
2476
2495
  } else if (process.platform === 'win32') {
2477
- fail('Background install isn\'t supported on Windows yet. Run it in the foreground:\n openvisio-agent watch --name ' + slug)
2496
+ abort('Background install isn\'t supported on Windows yet. Run it in the foreground:\n openvisio-agent watch --name ' + slug)
2478
2497
  } else {
2479
2498
  const dir = join(homedir(), '.config', 'systemd', 'user')
2480
2499
  mkdirSync(dir, { recursive: true })
2481
2500
  const unit = join(dir, `openvisio-${slug}.service`)
2482
- const execStart = [bin, ...args].map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(' ')
2501
+ const execStart = [...command, ...args].map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(' ')
2483
2502
  writeFileSync(unit, `[Unit]
2484
2503
  Description=OpenVisio agent watcher (${slug})
2485
2504
  After=network-online.target
@@ -2496,7 +2515,8 @@ WantedBy=default.target
2496
2515
  spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' })
2497
2516
  const r = spawnSync('systemctl', ['--user', 'enable', '--now', `openvisio-${slug}.service`], { stdio: 'inherit' })
2498
2517
  spawnSync('loginctl', ['enable-linger', process.env.USER || ''], { stdio: 'ignore' })
2499
- if (r.status !== 0) fail('systemctl enable failed — is this a systemd user session?')
2518
+ if (r.status !== 0) abort('systemctl enable failed — is this a systemd user session?')
2500
2519
  ok(`Live in the background, auto-starts at login. Logs: journalctl --user -u openvisio-${slug} -f`)
2520
+ return { running: true, service: 'systemd', slug }
2501
2521
  }
2502
2522
  }
@@ -0,0 +1,39 @@
1
+ import { constants, closeSync, fstatSync, openSync, readFileSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { OV_DIR, slugify } from './lib.mjs'
5
+ import { installService, stopWatchers } from './watch.mjs'
6
+
7
+ const problem = (message, status = 400) => Object.assign(new Error(message), { status })
8
+
9
+ function localConfig(stateDir, slug) {
10
+ if (resolve(stateDir) !== resolve(OV_DIR)) throw problem('Watcher control is supported only in this computer’s agent storage.', 403)
11
+ if (slugify(slug) !== slug) throw problem('Invalid agent.', 404)
12
+ const fd = openSync(join(stateDir, `${slug}.json`), constants.O_RDONLY | (constants.O_NOFOLLOW || 0))
13
+ try {
14
+ const stat = fstatSync(fd)
15
+ if (!stat.isFile() || stat.size > 64 * 1024) throw problem('Agent settings are unavailable.', 404)
16
+ const config = JSON.parse(readFileSync(fd, 'utf8'))
17
+ if (config.slug !== slug || config.mode !== 'backend' || !config.identifier || !config.apiKey) throw problem('This agent does not support watcher control.', 404)
18
+ return config
19
+ } finally { closeSync(fd) }
20
+ }
21
+
22
+ export async function controlLocalWatcher({ stateDir, slug, action }) {
23
+ const config = localConfig(stateDir, slug)
24
+ if (!['start', 'stop', 'restart'].includes(action)) throw problem('Choose start, stop, or restart.')
25
+ if (process.platform === 'win32') throw problem('Watcher control is not available on Windows yet.', 501)
26
+ if (action === 'stop') {
27
+ const result = stopWatchers({ slug, quiet: true, stateDir, throwOnError: true })
28
+ return { action, slug, running: false, ...result }
29
+ }
30
+ const cli = fileURLToPath(new URL('../bin/cli.mjs', import.meta.url))
31
+ const result = installService({
32
+ slug,
33
+ workdir: config.chatOnly ? '' : config.workspace || config.workdir || '',
34
+ stateDir,
35
+ serviceCommand: [process.execPath, cli],
36
+ throwOnError: true,
37
+ })
38
+ return { action, slug, ...result }
39
+ }
package/studio/app.mjs CHANGED
@@ -260,6 +260,7 @@ function initializeStudio() {
260
260
  let launchSettings = launch.get('settings') === '1';
261
261
  let editingAgent = null, editingSettings = null, settingsRequest = 0;
262
262
  let removalPlan = null, removalRequest = 0, removing = false;
263
+ let watcherChanging = false;
263
264
  const state = {
264
265
  snapshot: null, model: null, pending: null, paused: false, connected: false,
265
266
  selectedAgent: launch.get('agent') || 'all', agentSearch: '', search: '', kind: 'all', status: 'all',
@@ -391,12 +392,21 @@ function initializeStudio() {
391
392
  $('model-settings-agent').textContent = `${agentName(agent)} · Models`;
392
393
  $('open-model-settings').disabled = !agent.settingsEditable;
393
394
  $('open-agent-removal').disabled = !agent.removable;
395
+ const watcherRunning = agent.status === 'online' || agent.watcherAlive === true;
396
+ $('toggle-watcher').textContent = watcherRunning ? 'Stop watcher' : 'Start watcher';
397
+ $('toggle-watcher').dataset.action = watcherRunning ? 'stop' : 'start';
398
+ $('toggle-watcher').disabled = watcherChanging || !agent.watcherControllable;
399
+ $('restart-watcher').disabled = watcherChanging || !agent.watcherControllable;
400
+ const watcherTitle = agent.watcherControllable ? '' : 'Watcher control is available for local backend agents on macOS and Linux.';
401
+ $('toggle-watcher').title = watcherTitle;
402
+ $('restart-watcher').title = watcherTitle;
394
403
  $('open-agent-removal').title = agent.removable == null ? 'Restart Agent Studio with the updated openvisio-agent package to enable removal.' : !agent.removable ? 'Automatic removal is unavailable on this platform.' : '';
395
404
  const settings = agent.modelSettings || {};
396
405
  const pending = settings.revision && agent.appliedModelRevision !== settings.revision;
406
+ const selected = agent.appliedModels?.selected;
397
407
  $('model-settings-state').textContent = !agent.settingsEditable ? 'Model controls are available for agents configured on this computer.'
398
- : pending ? agent.modelControlsSupported && agent.status === 'online' ? 'Saved · waiting for the watcher to apply your choices.' : 'Saved for the next watcher connection. Older watchers need an update to apply changes live.'
399
- : `Replies: ${settings.chatModel || settings.model || 'Runtime default'} · Coding: ${settings.model || 'Runtime default'}`;
408
+ : pending ? agent.loadedModelRevision === settings.revision ? 'Saved · the watcher loaded your choices and will apply them to the next request.' : 'Saved for the next watcher connection.'
409
+ : `Replies: ${settings.chatModel || settings.model || 'Runtime default'} · Coding: ${settings.model || 'Runtime default'}${selected ? ` · Runtime applied: ${selected}` : ''}`;
400
410
  if (agent.removable == null) $('model-settings-state').textContent += ' Restart Agent Studio with the updated package to enable agent removal.';
401
411
  if (launchSettings && agent.settingsEditable) { launchSettings = false; void openModelSettings(agent); }
402
412
  }
@@ -879,6 +889,26 @@ function initializeStudio() {
879
889
  if (request === settingsRequest) { $('model-settings-error').hidden = false; $('model-settings-error').textContent = error.message; }
880
890
  } finally { $('save-model-settings').disabled = false; $('save-model-settings').textContent = 'Save models'; }
881
891
  });
892
+ async function changeWatcher(action) {
893
+ const agent = state.model.agents.find(agent => agentId(agent) === state.selectedAgent);
894
+ if (!agent || watcherChanging || !agent.watcherControllable) return;
895
+ watcherChanging = true;
896
+ scheduleRender();
897
+ try {
898
+ await settingsFetch(`/api/agents/${encodeURIComponent(agent.slug)}/watcher`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action }) });
899
+ await new Promise(resolve => setTimeout(resolve, 500));
900
+ receive(await settingsFetch('/api/snapshot'), true);
901
+ toast(action === 'stop' ? 'Watcher stopped.' : action === 'restart' ? 'Watcher restarted with the current models.' : 'Watcher started with the current models.');
902
+ } catch (error) {
903
+ state.error = error.message;
904
+ scheduleRender();
905
+ } finally {
906
+ watcherChanging = false;
907
+ scheduleRender();
908
+ }
909
+ }
910
+ $('toggle-watcher').addEventListener('click', () => void changeWatcher($('toggle-watcher').dataset.action || 'start'));
911
+ $('restart-watcher').addEventListener('click', () => void changeWatcher('restart'));
882
912
  $('pause-button').addEventListener('click', () => {
883
913
  state.paused = !state.paused;
884
914
  syncConnection();
package/studio/guide.html CHANGED
@@ -45,7 +45,7 @@
45
45
  <section id="models">
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
- <p>Updated watchers apply saved choices to the next request without a restart. Work already running continues with its original model. Studio shows when a save is waiting for the watcher. Offline agents retain the selection for their next connection; older watchers need an update to apply changes live.</p>
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
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>
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>
@@ -59,11 +59,11 @@
59
59
  <p>A <strong>watcher</strong> is the local program that listens for assignments and messages, then starts your coding agent when work arrives. Studio displays what the watcher records.</p>
60
60
  <ol>
61
61
  <li>Connect your agent using the setup command provided by your OpenVisio team. If you already connected it, keep that setup.</li>
62
- <li>In a separate terminal, start its watcher. Replace <code>ada</code> with the name used during setup:<pre><code>openvisio-agent watch --name ada</code></pre></li>
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>Starting a watcher enables it to act on existing assignments and messages. If your watcher is already running, you do not need to start another one.</p>
66
- <p>Older watchers need a Studio-capable update and a restart before they record activity. Their earlier activity cannot be reconstructed. See <a href="#troubleshooting">restart instructions</a> below.</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.</p>
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">
69
69
  <h2>Find and inspect work</h2>
package/studio/index.html CHANGED
@@ -61,7 +61,7 @@
61
61
  <div id="notice" class="notice" role="status" hidden></div>
62
62
  <section id="model-settings-summary" class="model-settings-summary" aria-label="Agent model settings" hidden>
63
63
  <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="open-model-settings" class="button" type="button">Model settings</button><button id="open-agent-removal" class="button" type="button">Remove from computer</button></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 from computer</button></div>
65
65
  </section>
66
66
  <dialog id="model-settings-dialog" aria-labelledby="model-settings-title">
67
67
  <form id="model-settings-form" class="model-settings-form">