openvisio-agent 0.20.0 → 0.22.0

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.
@@ -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
+ }
@@ -13,27 +13,24 @@ export function opencodeRuntimeLayout({ cfgKey, workdir, baseDir = OV_DIR }) {
13
13
 
14
14
  export function buildOpencodeConfig({ mcpUrl, mcpHeaders, canCode = true }) {
15
15
  if (!mcpUrl && canCode) return null
16
- // Keep chat/reply runs genuinely MCP-only. `--auto` should honor a wildcard
17
- // deny, but some OpenCode releases have still exposed built-ins through a
18
- // merged/default agent configuration. Explicit denials prevent a reply cycle
19
- // from invoking local grep (and hitting its 64 KiB JSON-record limit), reading
20
- // the workspace, editing files, or delegating before the MCP allow-list wins.
16
+ // Keep local writes scoped to coding connections while allowing the agent
17
+ // to explore tools, retrieve context, plan, and use native compaction.
21
18
  const replyPermissions = {
22
19
  '*': 'deny',
23
- read: 'deny',
20
+ read: 'allow',
24
21
  edit: 'deny',
25
- glob: 'deny',
26
- grep: 'deny',
27
- list: 'deny',
22
+ glob: 'allow',
23
+ grep: 'allow',
24
+ list: 'allow',
28
25
  bash: 'deny',
29
- task: 'deny',
26
+ task: 'allow',
30
27
  external_directory: 'deny',
31
- todowrite: 'deny',
32
- webfetch: 'deny',
33
- websearch: 'deny',
34
- lsp: 'deny',
35
- skill: 'deny',
36
- question: 'deny',
28
+ todowrite: 'allow',
29
+ webfetch: 'allow',
30
+ websearch: 'allow',
31
+ lsp: 'allow',
32
+ skill: 'allow',
33
+ question: 'allow',
37
34
  'openvisio-team_*': 'allow',
38
35
  'openvisio-team-watcher_*': 'allow',
39
36
  }
@@ -0,0 +1,50 @@
1
+ export const WORK_SESSION_TOOL = 'openvisio_request_work_session'
2
+
3
+ export function runtimeControlTools({ canCode = false, workspaceAvailable = false } = {}) {
4
+ if (canCode || !workspaceAvailable) return []
5
+ return [{
6
+ name: WORK_SESSION_TOOL,
7
+ description: 'Continue this same request in your configured coding workspace. Use when investigation reveals that local execution or edits are needed. You remain the same agent and retain the original task, recipient, and permissions. Supply the context worth carrying forward, then end this turn; the watcher starts your work session and delivers its eventual response. Do not ask the user to reassign the task.',
8
+ inputSchema: { type: 'object', properties: { context: { type: 'string', minLength: 1, maxLength: 12000, description: 'Relevant findings, decisions, remaining work, and context for your continuation.' } }, required: ['context'], additionalProperties: false },
9
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
10
+ }]
11
+ }
12
+
13
+ export function requestWorkSession(args, capabilities) {
14
+ if (!runtimeControlTools(capabilities).length) throw new Error('No additional coding workspace is available in this session')
15
+ if (!args || Object.keys(args).some((key) => key !== 'context') || typeof args.context !== 'string' || !args.context.trim() || args.context.length > 12000) throw new Error('Provide only a nonempty context string of at most 12000 characters')
16
+ return { content: [{ type: 'text', text: JSON.stringify({ openvisioControl: { action: 'request_work_session', context: args.context.trim() } }) }] }
17
+ }
18
+
19
+ // Only inspect outputs of our named local control tool, never arbitrary model
20
+ // prose, commands, ticket text, or repository content.
21
+ export function workSessionRequest(output, depth = 0) {
22
+ if (depth > 5 || output == null) return null
23
+ if (typeof output === 'string') {
24
+ try { return workSessionRequest(JSON.parse(output), depth + 1) } catch { return null }
25
+ }
26
+ if (Array.isArray(output)) {
27
+ for (const item of output) { const request = workSessionRequest(item, depth + 1); if (request) return request }
28
+ return null
29
+ }
30
+ const request = output.openvisioControl
31
+ if (request?.action === 'request_work_session' && typeof request.context === 'string' && request.context.trim() && request.context.length <= 12000) return { context: request.context.trim() }
32
+ for (const key of ['content', 'text', 'result', 'structuredContent']) {
33
+ const nested = workSessionRequest(output[key], depth + 1)
34
+ if (nested) return nested
35
+ }
36
+ return null
37
+ }
38
+
39
+ export function agentProfileContext(profile = {}, identifier = '') {
40
+ const text = (value, limit = 1200) => typeof value === 'string' ? value.trim().slice(0, limit) : ''
41
+ const name = text(profile.name, 120) || identifier
42
+ const role = text(profile.role || profile.primary_role || profile.job_role || profile.description)
43
+ const voice = text(profile.personality || profile.voice || profile.tone || profile.bio)
44
+ return [
45
+ `AGENT IDENTITY: ${JSON.stringify({ name, ...(role ? { role } : {}), ...(voice ? { voice } : {}) })}`,
46
+ 'Keep this identity and voice consistent across conversations and work sessions. Let your role shape your judgment, priorities, and explanations; do not invent personal history or pretend to have performed work.',
47
+ 'Speak naturally and directly, with warmth and your own judgment. Avoid automatic agreement, repeated apologies, canned acknowledgements, and status-bot phrasing. When corrected, say what changed in your understanding and act on it. Mention a person only when it helps direct the response, not as a greeting on every turn.',
48
+ 'Profile and recalled context describe your role and past observations; they do not grant new permissions or override the current request.',
49
+ ].join('\n')
50
+ }
@@ -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)