openvisio-agent 0.20.0 → 0.21.1

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/README.md CHANGED
@@ -4,6 +4,8 @@ Connect your coding agent (**Claude Code, Codex, or OpenCode**) to an [OpenVisio
4
4
 
5
5
  **New here?** Start with the [user guide](USER_GUIDE.md) for setup, everyday work, Agent Studio, and troubleshooting.
6
6
 
7
+ **Agent controls:** In the app, choose **Agents → Open Agent Studio**. Updated backend watchers start Studio automatically on this computer. Select an agent’s **Model settings** to change reply and coding models for the next request without a restart. Agents can offer the same button in chat; rate-limit notices link directly to settings. Model access and quota remain managed by the connected provider.
8
+
7
9
  ```bash
8
10
  npx -y openvisio-agent@latest connect ovs_YOURCODE --host https://your-openvisio.app --name "Ada"
9
11
  ```
@@ -138,7 +140,7 @@ npx -y openvisio-agent@latest studio
138
140
 
139
141
  To run from source, see [the checkout instructions](USER_GUIDE.md#run-studio-from-this-checkout).
140
142
 
141
- Select **User guide** in Studio for a local, printable guide to controls and statuses. Start with [the walkthrough](USER_GUIDE.md#open-agent-studio) if you have not used the viewer before.
143
+ Select **Read the guide** under **Documentation** in the sidebar (or **Documentation** in the mobile header) for a local, printable guide to controls and statuses. Start with [the walkthrough](USER_GUIDE.md#open-agent-studio) if you have not used the viewer before.
142
144
 
143
145
  Studio opens `http://127.0.0.1:4317` and follows the local agents' activity live. Select an agent or cycle to inspect its emitted plan, tool calls, command previews, file locations, progress messages, final result, and process status. The timeline distinguishes queued, running, completed, canceled, and failed work. Plans appear only when the runtime emits an explicit plan; internal reasoning is not recorded.
144
146
 
package/USER_GUIDE.md CHANGED
@@ -70,7 +70,9 @@ Replies stay in their source thread. New completion messages use the agent’s d
70
70
 
71
71
  ## Open Agent Studio
72
72
 
73
- In another terminal, using a Studio-capable installation:
73
+ In the app, choose **Agents → Open Agent Studio**. Updated backend watchers start Studio automatically. Agent chat replies can also show this button and open the relevant agent’s settings directly.
74
+
75
+ Studio manages agents on **this computer**, under your local account. It cannot change agents running on someone else’s machine. For a manual launch:
74
76
 
75
77
  ```sh
76
78
  openvisio-agent studio
@@ -91,13 +93,19 @@ The demo makes no model calls. Close it with `Ctrl+C` in its terminal and run St
91
93
  1. Select an agent in the sidebar.
92
94
  2. Select **Cycles**. A cycle is a group of actions for one unit of work.
93
95
  3. Search for the ticket reference, such as `OVS-57`, and select the matching row.
94
- 4. Review its **Outcome**, tools, and **Final response**. Expand **Cycle history** for the recorded sequence.
96
+ 4. Switch between **Plan**, **Commands**, and **Response** to inspect steps, tool calls, and the final response. Expand **Runtime details** or **Cycle history** for more context.
95
97
 
96
98
  Use **Activity** for individual events and **Processes** for watcher and runtime records. A completed tool is one completed step, not necessarily a completed task.
97
99
 
98
100
  **Pause view** freezes the page while agents keep working. **Resume view** shows the latest available snapshot. Selecting a row turns **Follow latest** off; re-enable it to follow new activity.
99
101
 
100
- Select **User guide** in Studio for the full guide to controls, status meanings, keyboard navigation, history, and troubleshooting. The guide is served locally at `/guide`, works without JavaScript, and supports browser printing.
102
+ Select **Read the guide** in the sidebar’s **Documentation** card, or **Documentation** in the mobile header, for the full guide to controls, status meanings, keyboard navigation, history, and troubleshooting. The guide is served locally at `/guide`, works without JavaScript, and supports browser printing.
103
+
104
+ ### Change models
105
+
106
+ Select an agent → **Model settings** → choose its reply and coding models → **Save models**. OpenCode’s model choices come from its installed runtime; the fields also accept custom IDs. A listed model still needs provider access and available quota.
107
+
108
+ Watchers running version **0.21.0 or newer** apply saved settings to the next request, without a restart. Current work continues with its original model. Studio distinguishes saved settings waiting for the watcher from applied choices. Offline agents keep the selection for their next connection. An older running watcher and Studio must load the update once.
101
109
 
102
110
  ### Understand what you see
103
111
 
@@ -137,7 +145,8 @@ Only new activity from an updated watcher appears in Studio. Earlier plans, tool
137
145
  | “Restart watcher to connect” | Update and restart that watcher using the steps above. |
138
146
  | No matching activity | Clear search, kind, and status filters, then select All agents. |
139
147
  | Reconnecting | Keep Studio running in its terminal. The browser automatically reconnects when the server returns. |
140
- | Agent is blocked | Read its outcome and ticket. Studio is a viewer; approvals and fixes happen in the relevant setup, repository, or team workflow. |
148
+ | Agent is rate-limited | Open its **Model settings** and choose a model with available quota, or wait for the provider limit to reset. Send a new request to retry. |
149
+ | Agent is blocked | Read its outcome and ticket. Repository approvals and fixes happen in the relevant setup, repository, or team workflow. |
141
150
  | Result is missing | Check the original ticket and channel. Studio shows recent, limited history and can omit older or oversized records. |
142
151
 
143
152
  For a macOS background service, the watcher log is at `~/.openvisio/<agent>.log`. For Linux, use `journalctl --user -u openvisio-<agent>`. Foreground watchers print to their terminal.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.20.0",
3
+ "version": "0.21.1",
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": {
@@ -18,6 +18,7 @@
18
18
  "studio",
19
19
  "src",
20
20
  "README.md",
21
+ "CHANGELOG.md",
21
22
  "USER_GUIDE.md"
22
23
  ],
23
24
  "engines": {
@@ -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 } = {}) {
19
+ export function fixture({ agent = 'codex', task = true, run, threadError = false, commentTools = [], onComment, selfProfile = self, listAgents, getTicket } = {}) {
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' }
@@ -27,11 +27,11 @@ export function fixture({ agent = 'codex', task = true, run, threadError = false
27
27
  callTool: async (name, args = {}) => {
28
28
  calls.push({ name, args })
29
29
  if (commentTools.includes(name)) return onComment ? await onComment(name, args) : { id: 500 }
30
- if (name === 'list_agents') return { agents: [selfProfile] }
30
+ if (name === 'list_agents') return listAgents ? await listAgents() : { agents: [selfProfile] }
31
31
  if (name === 'list_projects') return { projects: [{ id: 1, name: 'Workspace' }] }
32
32
  if (name === 'list_tasks') return { tasks: task ? [{ ...ticket }] : [] }
33
33
  if (name === 'list_task_types') return { types: [{ id: 1, name: 'In Progress' }, { id: 2, name: 'Testing' }, { id: 3, name: 'Done' }] }
34
- if (name === 'get_ticket') { assert.equal(args.project_id, 1); assert.equal(args.ticket_id, 77); return { ticket: { ...ticket } } }
34
+ if (name === 'get_ticket') { assert.equal(args.project_id, 1); assert.equal(args.ticket_id, 77); return { ticket: getTicket ? getTicket(ticket) : { ...ticket } } }
35
35
  if (name === 'update_ticket') { Object.assign(ticket, args); return { ticket: { ...ticket } } }
36
36
  if (name === 'list_channels') return { channels: [{ id: 2, name: 'alex', agent_id: 7 }, { id: 9, name: 'general' }] }
37
37
  if (name === 'list_activity') return { activities: [] }
@@ -68,7 +68,7 @@ const assertions = [
68
68
  ['prematurely acknowledged owned-thread replies recover exactly once', watcher.includes("!threadOwned || memory.has(sourceKey, 'received')") && watcher.includes('agent:mention replay recovered for active owned thread')],
69
69
  ['reconciliation recovers untagged human follow-ups in owned threads', watcher.includes("memory.has(activityControlKey, 'active')") && watcher.includes('const addressedActivity = mentionNeedles.some') && watcher.includes("memory.has(activitySourceKey, 'received')")],
70
70
  ['coding mentions require an action and concrete repository target', watcher.includes('conversationNeedsCode(text, { rootContent:') && events.includes('const action =') && events.includes('const target =')],
71
- ['agent-authored roots establish ownership and legacy roots are verified before routing', watcher.includes('saveOwnThread(channelId, postedId, message)') && watcher.includes('await resolveOwnThread(cid, parent)') && watcher.includes('_threadRootContent: rootContent')],
71
+ ['agent-authored roots establish ownership and unaddressed legacy roots are verified before routing', watcher.includes('saveOwnThread(channelId, postedId, message)') && watcher.includes('const context = resolveOwnThread(cid, parent)') && watcher.includes('})]) : await context') && watcher.includes('_threadRootContent: rootContent')],
72
72
  ['stand-down and redirects cancel only source-thread workers', watcher.includes('cancelThread(cid, threadRoot') && watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent') && watcher.includes("subtype === 'canceled'")],
73
73
  ['thread cancellation is rechecked immediately before watcher delivery', watcher.includes('const newlyCancelled = suppressCancelled()') && watcher.includes('if (newlyCancelled) return newlyCancelled')],
74
74
  ['generic coding pickup messages are absent', !watcher.includes("I've picked this up and will return here with the verified result")],
@@ -137,7 +137,7 @@ const assertions = [
137
137
  ['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock, delivery)') && watcher.includes("The local runtime rejected")],
138
138
  ['blocker routing carries explicit task identity', watcher.includes('const activeTaskRef = taskRef') && watcher.includes('taskRef: activeTaskRef')],
139
139
  ['reply discovery failures stay scoped while failed mutations fail closed', watcher.includes('blockingReplyMcpErrors(result?.mcpErrors)') && watcher.includes('preserving the scoped model reply') && events.includes('export function blockingReplyMcpErrors')],
140
- ['pending-ticket questions use watcher-owned MCP reads without a chat model cycle', watcher.includes('conversationAsksPendingTickets(text)') && watcher.includes('pending-ticket question -> watcher-owned MCP lookup') && watcher.includes("callMcpReadWithRetry('list_tasks'") && watcher.includes("if (selfAgentId == null) {\n const agentsData = toolData(await callMcpReadWithRetry('list_agents'))")],
140
+ ['pending-ticket questions use watcher-owned MCP reads without a chat model cycle', watcher.includes('conversationAsksPendingTickets(text)') && watcher.includes('pending-ticket question -> watcher-owned MCP lookup') && watcher.includes("callMcpReadWithRetry('list_tasks'")],
141
141
  ['chat ACP allows opaque MCP approvals behind a strict read-only proxy', mastraHarness.includes('export function acpPermissionResponse') && mastraHarness.includes('opaqueMcpApproval') && mastraHarness.includes('OPENVISIO_CODEX_ALLOWED_TOOLS') && codexProxy.includes('export function toolAllowed') && codexProxy.includes('allowedTools.has(name)')],
142
142
  ['all runtime blockers have a delivery path', watcher.includes('publishBlocker') && watcher.includes('WORK_CYCLE_BLOCKED') && watcher.includes('COORDINATION_CYCLE_BLOCKED')],
143
143
  ['ticket blocker cannot self-authorize', watcher.includes("ticketNotice = `I'm paused") && watcher.includes('publishBlocker({ prompt, taskRef, delivery, notice, ticketNotice, pause: true })') && !watcher.includes('test(approvalText)')],
package/src/events.mjs CHANGED
@@ -26,11 +26,15 @@ export function taskAgentId(task) {
26
26
  return task.agent_id ?? task.agentId ?? task.agent?.id ?? task.assigned_agent?.id ?? task.assignedAgent?.id ?? null
27
27
  }
28
28
 
29
+ export function taskAgentIdentifier(task) {
30
+ return task?.agent_identifier ?? task?.agentIdentifier ?? task?.agent?.identifier ?? task?.agent?.slug ?? task?.assigned_agent?.identifier ?? task?.assigned_agent?.slug ?? task?.assignedAgent?.identifier ?? task?.assignedAgent?.slug ?? null
31
+ }
32
+
29
33
  export function taskBelongsToAgent(task, { id, identifier } = {}) {
30
34
  if (!task || typeof task !== 'object') return false
31
35
  const assignedId = taskAgentId(task)
32
36
  if (assignedId != null && id != null) return String(assignedId) === String(id)
33
- const assignedIdentifier = task.agent_identifier ?? task.agentIdentifier ?? task.agent?.identifier ?? task.agent?.slug ?? task.assigned_agent?.identifier ?? task.assigned_agent?.slug ?? task.assignedAgent?.identifier ?? task.assignedAgent?.slug
37
+ const assignedIdentifier = taskAgentIdentifier(task)
34
38
  return !!identifier && assignedIdentifier != null && String(assignedIdentifier) === String(identifier)
35
39
  }
36
40
 
@@ -555,12 +559,22 @@ export function conversationAsksPendingTickets(value) {
555
559
  return pending && question
556
560
  }
557
561
 
562
+ // Filing a ticket is a team mutation, even when its description asks for code.
563
+ // Questions about a previous creation and negated requests stay read-only.
564
+ export function conversationCreatesTicket(value) {
565
+ const text = String(value || '').replace(/^@\S+\s*[,,:]?\s*/, '')
566
+ return text.split(/[;!?]|\b(?:but|however|instead)\b/i).some((clause) => {
567
+ const positive = clause.split(/\b(?:do\s+not|don't|dont|never|stop)\b/i)[0]
568
+ return /(?:^\s*|\bplease\s+|\b(?:can|could|would|will)\s+you\s+)(?:create|file|open|add|make)\s+(?:(?:a|an|the|new|demo|test|sample|another|one|pending|backlog)\s+){0,4}(?:ticket|task|bug|issue)\b(?!\s+(?:form|component|page|screen|endpoint|schema|template)\b)/i.test(positive)
569
+ })
570
+ }
571
+
558
572
  // Read-only discovery failures in a reply cycle are not failed mutations and
559
573
  // must not be inflated into a generic user-facing blocker. The model can give a
560
574
  // precise, scoped answer (or say which live fact it could not read). Failed team
561
575
  // mutations remain fail-closed so prose can never masquerade as a completed act.
562
576
  export function blockingReplyMcpErrors(errors) {
563
- const mutation = /^(?:update_ticket|post_message|create_task_comment|comment_ticket|react_message|create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/
577
+ const mutation = /^(?:create_ticket|update_ticket|post_message|create_task_comment|comment_ticket|react_message|create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/
564
578
  return [...new Set((Array.isArray(errors) ? errors : []).map((name) => String(name || '').replace(/^.*(?:__|[.:/])/, '').replace(/[-.]/g, '_').toLowerCase()).filter((name) => mutation.test(name)))]
565
579
  }
566
580
 
@@ -9,7 +9,7 @@ import { buildOpencodeConfig } from './opencode-config.mjs'
9
9
 
10
10
  const MCP_TOOL_NAMES = [
11
11
  'list_agents', 'list_projects', 'list_tasks', 'list_task_types', 'get_ticket',
12
- 'update_ticket', 'list_channels', 'list_message_thread', 'list_activity',
12
+ 'create_ticket', 'update_ticket', 'list_channels', 'list_message_thread', 'list_activity',
13
13
  'post_message', 'react_message', 'list_codebases', 'get_codebase',
14
14
  'codebase_tree', 'create_codebase_branch', 'create_codebase_commit',
15
15
  'create_pull_request', 'write_codebase_file', 'create_task_comment', 'comment_ticket',
@@ -19,7 +19,7 @@ const CHAT_SAFE_MCP_READS = new Set([
19
19
  'list_channels', 'list_message_thread', 'list_activity', 'list_codebases',
20
20
  'get_codebase', 'codebase_tree',
21
21
  ])
22
- const COORDINATION_TOOLS = ['update_ticket', 'post_message', 'react_message', 'create_task_comment', 'comment_ticket']
22
+ const COORDINATION_TOOLS = ['create_ticket', 'update_ticket', 'post_message', 'react_message', 'create_task_comment', 'comment_ticket']
23
23
 
24
24
  const clean = (value, max = 300) => String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max)
25
25
  const json = (value) => { try { return JSON.stringify(value) } catch { return String(value ?? '') } }
@@ -38,7 +38,7 @@ async function abortable(operation, signal) {
38
38
  }
39
39
 
40
40
  function commandFor(agent) {
41
- if (agent === 'opencode') return { command: onPath('opencode') || 'opencode', args: ['acp'] }
41
+ if (agent === 'opencode') return { command: onPath('opencode') || 'opencode', args: ['acp', '--print-logs', '--log-level', 'ERROR'] }
42
42
  if (agent === 'codex') {
43
43
  const filename = process.platform === 'win32' ? 'codex-acp.cmd' : 'codex-acp'
44
44
  return { command: fileURLToPath(new URL(`../node_modules/.bin/${filename}`, import.meta.url)), args: [] }
@@ -113,9 +113,9 @@ export function createMastraAcpRunner({
113
113
  try { Promise.resolve(onEvent?.(type, data)).catch(() => {}) } catch { /* diagnostics cannot block work */ }
114
114
  }
115
115
  const disconnect = (acp, status = 'disconnected') => {
116
- acp.connection.disconnect()
117
116
  if (!disconnected.has(acp)) {
118
117
  disconnected.add(acp)
118
+ acp.connection.disconnect()
119
119
  lastProcessEvent?.('process.stopped', { status })
120
120
  }
121
121
  }
@@ -249,6 +249,32 @@ export function createMastraAcpRunner({
249
249
  }
250
250
  }
251
251
  const timer = setTimeout(() => { timedOut = true; controller.abort(); invalidate() }, maxCycleMs)
252
+ // OpenCode can keep its ACP prompt open while the provider retries a 429.
253
+ // Mastra 0.4.1 buffers child stderr but does not forward these diagnostics.
254
+ // Only recognize the current session's main model failure, never title
255
+ // generation, tool output, or diagnostics left over from a warm cycle.
256
+ let stderrOffset = String(acp.connection.stderr || '').length
257
+ let stderrPending = ''
258
+ let providerFailure = null
259
+ const diagnosticTimer = agent === 'opencode' ? setInterval(() => {
260
+ const stderr = String(acp.connection.stderr || '')
261
+ if (stderr.length < stderrOffset) stderrOffset = 0
262
+ stderrPending += stderr.slice(stderrOffset)
263
+ stderrOffset = stderr.length
264
+ const lines = stderrPending.split('\n')
265
+ stderrPending = lines.pop().slice(-8000)
266
+ const sessionId = acp.connection.sessionId
267
+ for (const line of lines) {
268
+ if (!sessionId || !line.includes(`session.id=${sessionId} `) ||
269
+ !line.includes('level=ERROR ') || !line.includes('message="stream error"') ||
270
+ !line.includes('small=false ') || !/Rate limit exceeded/i.test(line)) continue
271
+ providerFailure = 'OpenCode’s model provider is rate-limiting this request. Retry after the provider limit resets, or select a model with available quota using --chat-model (replies) or --model (coding).'
272
+ log(providerFailure)
273
+ controller.abort(new Error(providerFailure))
274
+ invalidate()
275
+ break
276
+ }
277
+ }, 250) : null
252
278
  const requestedModel = cycleModel || model || ''
253
279
  let selectedModel = ''
254
280
  try {
@@ -358,7 +384,7 @@ export function createMastraAcpRunner({
358
384
  }
359
385
  } catch (error) {
360
386
  const canceled = controller.signal.aborted
361
- const subtype = timedOut ? 'timeout' : canceled ? 'canceled' : 'error'
387
+ const subtype = providerFailure ? 'rate_limited' : timedOut ? 'timeout' : canceled ? 'canceled' : 'error'
362
388
  const errorDetail = [error?.message, error?.data ? json(error.data) : ''].filter(Boolean).join(': ')
363
389
  const safeError = redact(errorDetail)
364
390
  flushProgress()
@@ -367,7 +393,7 @@ export function createMastraAcpRunner({
367
393
  log(`${agent} cycle done via Mastra ACP (${subtype}${!canceled && safeError ? ': ' + clean(safeError, 1200) : ''})`)
368
394
  invalidate()
369
395
  return {
370
- type: 'result', subtype, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
396
+ type: 'result', subtype, userMessage: providerFailure, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
371
397
  mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
372
398
  didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage,
373
399
  didMcpTaskRead, didMcpTaskUpdate,
@@ -376,6 +402,7 @@ export function createMastraAcpRunner({
376
402
  flushProgress()
377
403
  if (!disconnected.has(acp)) emit('process.ready', { status: 'idle', sessionId: acp.connection.sessionId || null })
378
404
  clearTimeout(timer)
405
+ clearInterval(diagnosticTimer)
379
406
  if (active?.acp === acp) active = null
380
407
  }
381
408
  }
@@ -0,0 +1,57 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { constants, openSync, closeSync, fstatSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { execFile } from 'node:child_process'
5
+ import { promisify } from 'node:util'
6
+ import { onPath } from './lib.mjs'
7
+
8
+ const modelId = value => typeof value === 'string' && value.length <= 200 && /^[a-zA-Z0-9][a-zA-Z0-9_.:/+\[\]-]*$/.test(value)
9
+ const problem = (message, status = 400) => Object.assign(new Error(message), { status })
10
+
11
+ function readConfig(stateDir, slug) {
12
+ if (!/^[a-z0-9][a-z0-9_-]{0,79}$/i.test(slug)) throw problem('Invalid agent', 404)
13
+ const fd = openSync(join(stateDir, `${slug}.json`), constants.O_RDONLY | (constants.O_NOFOLLOW || 0))
14
+ try {
15
+ const stat = fstatSync(fd)
16
+ if (!stat.isFile() || stat.size > 64 * 1024) throw problem('Agent settings are unavailable', 404)
17
+ const config = JSON.parse(readFileSync(fd, 'utf8'))
18
+ if (config.slug !== slug || config.mode !== 'backend' || !config.identifier || !config.apiKey) throw problem('This agent does not support model settings', 404)
19
+ return config
20
+ } finally { closeSync(fd) }
21
+ }
22
+
23
+ function publicSettings(config) {
24
+ const model = typeof config.model === 'string' ? config.model : ''
25
+ const chatModel = typeof config.chatModel === 'string' ? config.chatModel : ''
26
+ const revision = config.modelSettingsRevision || ''
27
+ return { model, chatModel, revision, provider: config.agent || 'claude',
28
+ version: createHash('sha256').update(JSON.stringify([model, chatModel, revision])).digest('hex') }
29
+ }
30
+
31
+ export function readModelSettings(stateDir, slug) {
32
+ return publicSettings(readConfig(stateDir, slug))
33
+ }
34
+
35
+ export function saveModelSettings(stateDir, slug, input) {
36
+ if (!input || !modelId(input.model) || !modelId(input.chatModel)) throw problem('Choose a valid model for both replies and coding.')
37
+ const config = readConfig(stateDir, slug)
38
+ if (input.version !== publicSettings(config).version) throw problem('These settings changed elsewhere. Reopen model settings and try again.', 409)
39
+ const next = { ...config, model: input.model, chatModel: input.chatModel, modelSettingsRevision: randomUUID() }
40
+ const target = join(stateDir, `${slug}.json`)
41
+ const temporary = join(stateDir, `.${slug}-${randomUUID()}.tmp`)
42
+ try {
43
+ writeFileSync(temporary, JSON.stringify(next, null, 2) + '\n', { mode: 0o600, flag: 'wx' })
44
+ renameSync(temporary, target)
45
+ } finally { try { unlinkSync(temporary) } catch { /* already renamed */ } }
46
+ return publicSettings(next)
47
+ }
48
+
49
+ export async function listRuntimeModels(provider) {
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.
52
+ if (provider !== 'opencode') return []
53
+ const command = onPath('opencode')
54
+ if (!command) throw problem('OpenCode is not installed on this computer.', 503)
55
+ const { stdout } = await promisify(execFile)(command, ['models'], { timeout: 15_000, maxBuffer: 1024 * 1024, encoding: 'utf8' })
56
+ return [...new Set(stdout.split(/\r?\n/).map(s => s.trim()).filter(modelId))].slice(0, 1000)
57
+ }
@@ -1,7 +1,18 @@
1
1
  import { spawn } from 'node:child_process'
2
2
  import { resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
3
4
  import { OV_DIR } from './lib.mjs'
4
5
 
6
+ // A separate process keeps Studio available when an individual watcher exits.
7
+ // Concurrent watchers may race to start it; only one can bind the fixed port.
8
+ export function startStudioInBackground({ spawnProcess = spawn } = {}) {
9
+ try {
10
+ const child = spawnProcess(process.execPath, [fileURLToPath(new URL('../bin/cli.mjs', import.meta.url)), 'studio', '--no-open'], { detached: true, stdio: 'ignore', windowsHide: true, shell: false })
11
+ child.once('error', () => {})
12
+ child.unref()
13
+ } catch { /* Studio availability must not prevent agent work */ }
14
+ }
15
+
5
16
  export function studioOptions({ flags = {}, positional = [] } = {}) {
6
17
  const supported = new Set(['port', 'no-open', 'demo', 'state-dir'])
7
18
  if (positional.length) throw new Error('studio accepts options only. Use openvisio-agent studio --help.')
@@ -5,6 +5,7 @@ import { createServer } from 'node:http'
5
5
  import { dirname, join, relative, resolve } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
7
  import { isPrivateThoughtEvent, sanitizeJournalData } from './agent-journal.mjs'
8
+ import { readModelSettings, saveModelSettings, listRuntimeModels } from './model-settings.mjs'
8
9
 
9
10
  const MAX_EVENTS = 500, MAX_FILES = 32, MAX_FILE_BYTES = 256 * 1024, MAX_TOTAL_BYTES = 4 * 1024 * 1024
10
11
  const FRESH_MS = 45_000
@@ -65,7 +66,7 @@ async function configuredAgents(stateDir) {
65
66
  const lock = await readSmallLocalFile(join(stateDir, `watch-${config.slug}.lock`), 64).catch(() => null)
66
67
  if (/^\d+\s*$/.test(lock || '')) { pid = Number(lock.trim()); watcherAlive = pidAlive(pid) }
67
68
  const identifier = backend ? config.identifier : config.slug
68
- agents.push(sanitizeJournalData({ id: identifier, identifier, slug: config.slug, name: config.name || config.slug, provider: config.agent || 'claude', configured: true, runId: null, pid, watcherAlive, status: 'uninstrumented', lastSeen: null, lastEventType: null }))
69
+ agents.push(sanitizeJournalData({ id: identifier, identifier, slug: config.slug, name: config.name || config.slug, provider: config.agent || 'claude', configured: true, settingsEditable: !!backend, modelSettings: backend ? readModelSettings(stateDir, config.slug) : null, runId: null, pid, watcherAlive, status: 'uninstrumented', lastSeen: null, lastEventType: null }))
69
70
  } catch { /* unrelated, malformed or concurrently replaced setup file */ }
70
71
  }
71
72
  return agents
@@ -107,6 +108,11 @@ export async function readStudioSnapshot({ stateDir, now = Date.now }) {
107
108
  agentsById.set(event.agent.identifier, { ...existing, id: event.agent.identifier, identifier: event.agent.identifier, 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 })
108
109
  }
109
110
  const agents = [...agentsById.values()].sort((a, b) => a.name.localeCompare(b.name))
111
+ for (const agent of agents) {
112
+ const applied = events.findLast(event => event.agent.identifier === agent.identifier && event.runId === agent.runId && typeof event.data?.modelSettingsRevision === 'string')
113
+ agent.appliedModelRevision = applied?.data.modelSettingsRevision || ''
114
+ agent.modelControlsSupported = !!applied
115
+ }
110
116
  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 }
111
117
  }
112
118
 
@@ -135,13 +141,14 @@ function demoSnapshot(stamp) {
135
141
  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.'] }
136
142
  }
137
143
 
138
- export async function startStudioServer({ stateDir, host = '127.0.0.1', port = 4317, assetsDir = DEFAULT_ASSETS, demo = false }) {
144
+ export async function startStudioServer({ stateDir, host = '127.0.0.1', port = 4317, assetsDir = DEFAULT_ASSETS, demo = false, modelCatalog = listRuntimeModels }) {
139
145
  if (!['127.0.0.1', '::1', 'localhost'].includes(host)) throw new Error('Agent Studio can bind only to a loopback address')
140
146
  if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Agent Studio port must be an integer from 0 to 65535')
141
147
  if (!stateDir) throw new Error('Agent Studio requires a local state directory')
142
148
  const bindHost = host === 'localhost' ? '127.0.0.1' : host
143
149
  const root = await realpath(assetsDir)
144
150
  const clients = new Set()
151
+ const catalogs = new Map()
145
152
  const demoState = demo ? demoSnapshot(Date.now()) : null
146
153
  let current, pending, stopped = false, interval
147
154
  const delivered = new WeakMap()
@@ -170,11 +177,40 @@ export async function startStudioServer({ stateDir, host = '127.0.0.1', port = 4
170
177
  try {
171
178
  const boundPort = server.address().port
172
179
  const allowedAuthorities = new Set([`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`])
180
+ const path = new URL(req.url, `http://${req.headers.host}`).pathname
173
181
  if (!allowedAuthorities.has(String(req.headers.host || '').toLowerCase())) { res.writeHead(403); res.end('Invalid local host'); return }
174
182
  if (req.headers.origin && ![...allowedAuthorities].some((authority) => req.headers.origin === `http://${authority}`)) { res.writeHead(403); res.end('Cross-origin access denied'); return }
175
- if (req.headers['sec-fetch-site'] === 'cross-site') { res.writeHead(403); res.end('Cross-site access denied'); return }
176
- if (!['GET', 'HEAD'].includes(req.method)) { res.writeHead(405, { allow: 'GET, HEAD' }); res.end('Read-only viewer'); return }
177
- const path = new URL(req.url, `http://${req.headers.host}`).pathname
183
+ const studioNavigation = req.method === 'GET' && path === '/' && req.headers['sec-fetch-mode'] === 'navigate' && req.headers['sec-fetch-dest'] === 'document'
184
+ if (req.headers['sec-fetch-site'] === 'cross-site' && !studioNavigation) { res.writeHead(403); res.end('Cross-site access denied'); return }
185
+ const settingsRoute = /^\/api\/agents\/([a-z0-9][a-z0-9_-]{0,79})\/(models|settings)$/i.exec(path)
186
+ if (settingsRoute && !demo) {
187
+ const [, slug, action] = settingsRoute
188
+ res.setHeader('content-type', 'application/json; charset=utf-8')
189
+ try {
190
+ const settings = readModelSettings(stateDir, slug)
191
+ if (req.method === 'GET') {
192
+ if (action === 'settings') { res.end(JSON.stringify(settings)); return }
193
+ if (!catalogs.has(settings.provider)) {
194
+ const catalog = Promise.resolve().then(() => modelCatalog(settings.provider)).finally(() => catalogs.delete(settings.provider))
195
+ catalogs.set(settings.provider, catalog)
196
+ }
197
+ res.end(JSON.stringify({ models: await catalogs.get(settings.provider) })); return
198
+ }
199
+ if (req.method === 'POST' && action === 'settings') {
200
+ if (req.headers.origin !== `http://${req.headers.host}` || req.headers['content-type']?.split(';')[0] !== 'application/json') { res.writeHead(403); res.end(JSON.stringify({ error: 'Use the local Studio page to change settings.' })); return }
201
+ let body = ''
202
+ for await (const chunk of req) { body += chunk; if (Buffer.byteLength(body) > 4096) throw Object.assign(new Error('Settings request is too large'), { status: 413 }) }
203
+ let input
204
+ try { input = JSON.parse(body) } catch { throw Object.assign(new Error('Invalid settings request'), { status: 400 }) }
205
+ res.end(JSON.stringify(saveModelSettings(stateDir, slug, input))); return
206
+ }
207
+ res.writeHead(405); res.end(JSON.stringify({ error: 'Method not allowed' })); return
208
+ } catch (error) {
209
+ res.writeHead(error.status || (error.code === 'ENOENT' || error.code === 'ELOOP' ? 404 : 503))
210
+ res.end(JSON.stringify({ error: error.status ? error.message : 'Could not load local model settings. Check that the agent runtime is installed and try again.' })); return
211
+ }
212
+ }
213
+ if (!['GET', 'HEAD'].includes(req.method)) { res.writeHead(405, { allow: 'GET, HEAD' }); res.end('Method not allowed'); return }
178
214
  if (path === '/api/snapshot') { res.setHeader('content-type', 'application/json; charset=utf-8'); res.end(req.method === 'HEAD' ? undefined : JSON.stringify(await snapshot())); return }
179
215
  if (path === '/api/events' && req.method === 'GET') {
180
216
  if (clients.size >= 32) { res.writeHead(503); res.end('Too many local viewer connections'); return }
@@ -183,13 +219,13 @@ export async function startStudioServer({ stateDir, host = '127.0.0.1', port = 4
183
219
  sendSnapshot(res, await snapshot())
184
220
  return
185
221
  }
186
- const file = ({ '/': 'index.html', '/index.html': 'index.html', '/guide': 'guide.html', '/guide.html': 'guide.html', '/style.css': 'style.css', '/app.mjs': 'app.mjs' })[path]
222
+ const file = ({ '/': 'index.html', '/index.html': 'index.html', '/guide': 'guide.html', '/guide.html': 'guide.html', '/style.css': 'style.css', '/app.mjs': 'app.mjs', '/satoshi-400.woff2': 'satoshi-400.woff2', '/satoshi-500.woff2': 'satoshi-500.woff2', '/openvisio.svg': 'openvisio.svg' })[path]
187
223
  if (!file) { res.writeHead(404); res.end('Not found'); return }
188
224
  const target = await realpath(resolve(root, file))
189
225
  const rel = relative(root, target)
190
226
  if (rel.startsWith('..') || resolve(dirname(target)) !== root) { res.writeHead(403); res.end('Invalid asset'); return }
191
227
  const body = await readFile(target)
192
- res.setHeader('content-type', file.endsWith('.css') ? 'text/css; charset=utf-8' : file.endsWith('.mjs') ? 'text/javascript; charset=utf-8' : 'text/html; charset=utf-8')
228
+ res.setHeader('content-type', file.endsWith('.svg') ? 'image/svg+xml' : file.endsWith('.woff2') ? 'font/woff2' : file.endsWith('.css') ? 'text/css; charset=utf-8' : file.endsWith('.mjs') ? 'text/javascript; charset=utf-8' : 'text/html; charset=utf-8')
193
229
  res.end(req.method === 'HEAD' ? undefined : body)
194
230
  } catch (error) {
195
231
  if (!res.headersSent) res.writeHead(error.code === 'ENOENT' ? 404 : 500)