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/CHANGELOG.md +786 -0
- package/README.md +3 -1
- package/USER_GUIDE.md +13 -4
- package/package.json +2 -1
- package/scenarios/workspace.scenarios.mjs +3 -3
- package/scripts/certify.mjs +2 -2
- package/src/events.mjs +16 -2
- package/src/mastra-harness.mjs +33 -6
- package/src/model-settings.mjs +57 -0
- package/src/studio-cli.mjs +11 -0
- package/src/studio-server.mjs +43 -7
- package/src/watch.mjs +90 -44
- package/studio/app.mjs +229 -43
- package/studio/guide.html +13 -2
- package/studio/index.html +45 -9
- package/studio/openvisio.svg +1 -0
- package/studio/satoshi-400.woff2 +0 -0
- package/studio/satoshi-500.woff2 +0 -0
- package/studio/style.css +240 -70
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 **
|
|
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
|
|
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.
|
|
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 **
|
|
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
|
|
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.
|
|
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: [] }
|
package/scripts/certify.mjs
CHANGED
|
@@ -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('
|
|
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'")
|
|
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
|
|
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
|
|
package/src/mastra-harness.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/studio-cli.mjs
CHANGED
|
@@ -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.')
|
package/src/studio-server.mjs
CHANGED
|
@@ -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
|
-
|
|
176
|
-
if (
|
|
177
|
-
const
|
|
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)
|