openvisio-agent 0.19.4 → 0.19.9
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 +1 -1
- package/package.json +1 -1
- package/scripts/certify.mjs +5 -3
- package/src/codex-mcp-proxy.mjs +20 -3
- package/src/events.mjs +8 -0
- package/src/mastra-harness.mjs +115 -44
- package/src/model-selection.mjs +12 -12
- package/src/watch.mjs +106 -18
package/README.md
CHANGED
|
@@ -58,7 +58,7 @@ Runs the **autonomy loop** — the agent replies to @mentions and picks up ticke
|
|
|
58
58
|
|
|
59
59
|
Codex and OpenCode cycles run through Mastra's ACP harness. Codex uses the packaged `codex-acp` adapter and reuses the machine's existing ChatGPT/Codex login; OpenCode uses its native `opencode acp` server. Each accepted ticket gets its own ACP session and worktree. Mastra Memory stores ticket/thread context in local libSQL under `~/.openvisio/`, while a compact JSON ledger retains only exact replay, cancellation, and delivery keys.
|
|
60
60
|
|
|
61
|
-
Model
|
|
61
|
+
Model selections are stable. Exact IDs (including reasoning effort) are preserved. An unsuffixed ACP model may resolve once to the same model with `medium` effort; the watcher saves that exact selection. Unavailable selections produce an explicit error rather than switching generations or families. Claude versioned IDs are preserved, with automatic family fallbacks disabled. Codex uses the configured chat model for replies.
|
|
62
62
|
|
|
63
63
|
Backend/BYO watchers reconcile immediately whenever the process starts or the WebSocket connects. A direct MCP session discovers and caches the backend's actual `tools/list` response, then uses available tools such as `list_agents`, `list_projects`, `list_tasks`, `list_task_types`, and `list_activity` to recover assigned tasks and recent mention activity missed while offline. Optional actions such as ticket comments are used only when advertised; their absence cannot strand a completed ticket in a retry loop. The same zero-model check runs every five minutes as a safety net; a model starts only when pending work exists.
|
|
64
64
|
|
package/package.json
CHANGED
package/scripts/certify.mjs
CHANGED
|
@@ -76,9 +76,9 @@ const assertions = [
|
|
|
76
76
|
['reply runner has no coding workspace or coding charter', watcher.includes("workdir: ''") && watcher.includes("systemPrompt: CHAT_CHARTER + '\\n\\n' + credNote") && opencodeConfig.includes("'*': 'deny'")],
|
|
77
77
|
['MCP requests have a deadline including response bodies', mcpHttp.includes('controller.abort()') && mcpHttp.includes('const body = await res.text()')],
|
|
78
78
|
['Mastra memory uses real ticket and thread identities', watcher.includes('createMastraMemory') && watcher.includes('await memory.context(memoryRefs)') && memory.includes('new Memory({ storage') && memory.includes('new LibSQLStore') && memory.includes('ticket:${refs.projectId}:${refs.ticketId}') && memory.includes('channel:${refs.channelId}:thread:')],
|
|
79
|
-
['Codex and OpenCode use
|
|
80
|
-
['runtime models are
|
|
81
|
-
['one watcher owns isolated concurrent work and serial reply runtimes', watcher.includes('MAX_CONCURRENT_WORKERS = 3') && watcher.includes('createWorkRunner(item.control, item.workdir)') && watcher.includes('const
|
|
79
|
+
['Codex and OpenCode use warm scoped Mastra ACP sessions', watcher.includes('createMastraAcpRunner') && watcher.includes('const replyRunners = new Map()') && watcher.includes('replyRunnerFor(item.delivery)') && mastraHarness.includes('new AcpAgentClass') && mastraHarness.includes('persistSession: true') && mastraHarness.includes("runtime: 'mastra-acp'")],
|
|
80
|
+
['runtime models are selected explicitly before work starts', mastraHarness.includes('getAvailableModels()') && mastraHarness.includes('resolveAvailableModel(requestedModel') && mastraHarness.includes('await acp.setModel(resolution.selected)') && modelSelection.includes('automatic model substitution is disabled') && watcher.includes('if (changed) persistModel()')],
|
|
81
|
+
['one watcher owns isolated concurrent work and thread-scoped serial reply runtimes', watcher.includes('MAX_CONCURRENT_WORKERS = 3') && watcher.includes('createWorkRunner(item.control, item.workdir)') && watcher.includes('const replyRunners = new Map()') && watcher.includes('while (replyRunners.size > 8)')],
|
|
82
82
|
['workers serialize shared worktrees while independent worktrees run concurrently', watcher.includes('groupKey: (item) => item.workdir || workdir') && cycleQueue.includes('active.size < concurrency') && cycleQueue.includes('activeGroups.has(entry.group)')],
|
|
83
83
|
['work and reply cancellation targets are isolated', watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent')],
|
|
84
84
|
['assigned task activity resolves a project channel', watcher.includes("callMcpTool('list_channels'") && watcher.includes('projectStatusChannel(projectId)')],
|
|
@@ -128,6 +128,8 @@ const assertions = [
|
|
|
128
128
|
['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock, delivery)') && watcher.includes("Action required: I'm blocked")],
|
|
129
129
|
['blocker routing carries explicit task identity', watcher.includes('const activeTaskRef = taskRef') && watcher.includes('taskRef: activeTaskRef')],
|
|
130
130
|
['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')],
|
|
131
|
+
['pending-ticket questions use watcher-owned MCP reads without a model cycle', watcher.includes('conversationAsksPendingTickets(text)') && watcher.includes('pending-ticket question -> watcher-owned MCP lookup') && watcher.includes("callMcpReadWithRetry('list_tasks'") && !watcher.includes("callMcpReadWithRetry('list_agents'")],
|
|
132
|
+
['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)')],
|
|
131
133
|
['all runtime blockers have a delivery path', watcher.includes('publishBlocker') && watcher.includes('WORK_CYCLE_BLOCKED') && watcher.includes('COORDINATION_CYCLE_BLOCKED')],
|
|
132
134
|
['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)')],
|
|
133
135
|
['agent messages use first-person voice', watcher.includes('FIRST-PERSON VOICE') && watcher.includes("I'm blocked") && !watcher.includes('Alex is blocked') && !watcher.includes('Alex is paused')],
|
package/src/codex-mcp-proxy.mjs
CHANGED
|
@@ -17,6 +17,11 @@ export function toolWithoutCredentialInputs(tool) {
|
|
|
17
17
|
return copy
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export function toolAllowed(name, { disabled = new Set(), allowed = null } = {}) {
|
|
21
|
+
const tool = String(name || '')
|
|
22
|
+
return !disabled.has(tool) && (!(allowed instanceof Set) || allowed.has(tool))
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
export function runCodexMcpProxy() {
|
|
21
26
|
const url = process.env.OPENVISIO_CODEX_MCP_URL
|
|
22
27
|
const apiKey = process.env.OPENVISIO_CODEX_API_KEY
|
|
@@ -24,6 +29,11 @@ export function runCodexMcpProxy() {
|
|
|
24
29
|
let disabledTools = []
|
|
25
30
|
try { disabledTools = JSON.parse(process.env.OPENVISIO_CODEX_DISABLED_TOOLS || '[]') } catch { /* no disabled tools */ }
|
|
26
31
|
const disabled = new Set(Array.isArray(disabledTools) ? disabledTools.map(String) : [])
|
|
32
|
+
let allowedTools = null
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(process.env.OPENVISIO_CODEX_ALLOWED_TOOLS || 'null')
|
|
35
|
+
if (Array.isArray(parsed)) allowedTools = new Set(parsed.map(String))
|
|
36
|
+
} catch { /* unrestricted except for disabled tools */ }
|
|
27
37
|
if (!url || !apiKey || !identifier) throw new Error('OpenVisio Codex MCP bridge is missing its watcher environment.')
|
|
28
38
|
const client = createMcpHttpClient({ url, apiKey, identifier, clientVersion: 'openvisio-agent-codex-proxy' })
|
|
29
39
|
const reply = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n')
|
|
@@ -55,10 +65,17 @@ export function runCodexMcpProxy() {
|
|
|
55
65
|
reply(id, {})
|
|
56
66
|
} else if (request.method === 'tools/list') {
|
|
57
67
|
const tools = await client.listTools()
|
|
58
|
-
reply(id, { tools: tools.filter((tool) =>
|
|
68
|
+
reply(id, { tools: tools.filter((tool) => toolAllowed(tool.name, { disabled, allowed: allowedTools })).map(toolWithoutCredentialInputs) })
|
|
59
69
|
} else if (request.method === 'tools/call') {
|
|
60
|
-
|
|
61
|
-
|
|
70
|
+
const name = String(request.params?.name || '')
|
|
71
|
+
if (!toolAllowed(name, { disabled, allowed: allowedTools })) throw new Error('This tool is disabled for the current delivery lane.')
|
|
72
|
+
let result
|
|
73
|
+
try { result = await client.callTool(name, request.params?.arguments || {}) }
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (!(allowedTools instanceof Set) || !allowedTools.has(name)) throw error
|
|
76
|
+
result = await client.callTool(name, request.params?.arguments || {})
|
|
77
|
+
}
|
|
78
|
+
reply(id, result)
|
|
62
79
|
} else if (id != null) {
|
|
63
80
|
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } }) + '\n')
|
|
64
81
|
}
|
package/src/events.mjs
CHANGED
|
@@ -495,6 +495,14 @@ export function conversationNeedsCode(value) {
|
|
|
495
495
|
return action && target
|
|
496
496
|
}
|
|
497
497
|
|
|
498
|
+
export function conversationAsksPendingTickets(value) {
|
|
499
|
+
const text = String(value || '').replace(/\s+/g, ' ').trim()
|
|
500
|
+
if (!/\b(?:ticket|tickets|task|tasks)\b/i.test(text)) return false
|
|
501
|
+
const pending = /\b(?:pending|open|assigned|backlog|outstanding|in[ -]?progress|working on)\b/i.test(text)
|
|
502
|
+
const question = /\?|\b(?:any|what|which|how many|do you|have you|are there|show|list|check|verify|asked|whether)\b/i.test(text)
|
|
503
|
+
return pending && question
|
|
504
|
+
}
|
|
505
|
+
|
|
498
506
|
// Read-only discovery failures in a reply cycle are not failed mutations and
|
|
499
507
|
// must not be inflated into a generic user-facing blocker. The model can give a
|
|
500
508
|
// precise, scoped answer (or say which live fact it could not read). Failed team
|
package/src/mastra-harness.mjs
CHANGED
|
@@ -11,6 +11,11 @@ const MCP_TOOL_NAMES = [
|
|
|
11
11
|
'codebase_tree', 'create_codebase_branch', 'create_codebase_commit',
|
|
12
12
|
'create_pull_request',
|
|
13
13
|
]
|
|
14
|
+
const CHAT_SAFE_MCP_READS = new Set([
|
|
15
|
+
'list_agents', 'list_projects', 'list_tasks', 'list_task_types', 'get_ticket',
|
|
16
|
+
'list_channels', 'list_message_thread', 'list_activity', 'list_codebases',
|
|
17
|
+
'get_codebase', 'codebase_tree',
|
|
18
|
+
])
|
|
14
19
|
|
|
15
20
|
const clean = (value, max = 300) => String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max)
|
|
16
21
|
const json = (value) => { try { return JSON.stringify(value) } catch { return String(value ?? '') } }
|
|
@@ -39,6 +44,19 @@ function toolName(update) {
|
|
|
39
44
|
return MCP_TOOL_NAMES.find((name) => new RegExp(`(?:^|[^a-z0-9_])${name}(?:$|[^a-z0-9_])`, 'i').test(haystack)) || ''
|
|
40
45
|
}
|
|
41
46
|
|
|
47
|
+
export function acpPermissionResponse(request, { canCode = false } = {}) {
|
|
48
|
+
const options = Array.isArray(request?.options) ? request.options : []
|
|
49
|
+
const name = toolName(request?.toolCall || {})
|
|
50
|
+
const opaqueMcpApproval = request?._meta?.is_mcp_tool_approval === true
|
|
51
|
+
// Codex omits the tool name from correlated ACP permission requests. Chat
|
|
52
|
+
// sessions expose only CHAT_SAFE_MCP_READS through their private proxy, so
|
|
53
|
+
// accepting an opaque MCP approval cannot grant a mutation or local access.
|
|
54
|
+
const allow = canCode || CHAT_SAFE_MCP_READS.has(name) || opaqueMcpApproval
|
|
55
|
+
const preferred = allow ? 'allow_once' : 'reject_once'
|
|
56
|
+
const selected = options.find((option) => option.kind === preferred) || options.find((option) => option.kind.startsWith(allow ? 'allow' : 'reject'))
|
|
57
|
+
return selected ? { outcome: { outcome: 'selected', optionId: selected.optionId } } : { outcome: { outcome: 'cancelled' } }
|
|
58
|
+
}
|
|
59
|
+
|
|
42
60
|
function contentText(content) {
|
|
43
61
|
if (!content || typeof content !== 'object') return ''
|
|
44
62
|
if (content.type === 'text') return String(content.text || '')
|
|
@@ -53,6 +71,11 @@ export function createMastraAcpRunner({
|
|
|
53
71
|
const runtime = commandFor(agent)
|
|
54
72
|
if (!runtime) return null
|
|
55
73
|
let active = null
|
|
74
|
+
let persistent = null
|
|
75
|
+
let persistentKey = ''
|
|
76
|
+
let sessionHasPrompted = false
|
|
77
|
+
let negotiatedRequested = ''
|
|
78
|
+
let negotiatedSelected = ''
|
|
56
79
|
const defaultCwd = workdir || process.cwd()
|
|
57
80
|
|
|
58
81
|
async function runCycle(prompt, cycleModel, cycleOptions = {}) {
|
|
@@ -75,62 +98,89 @@ export function createMastraAcpRunner({
|
|
|
75
98
|
const headers = Object.entries(mcpHeaders).filter(([, value]) => value != null && value !== '').map(([name, value]) => ({ name, value: String(value) }))
|
|
76
99
|
const disabledMcpTools = Array.isArray(cycleOptions.disabledMcpTools) ? cycleOptions.disabledMcpTools.filter(Boolean) : []
|
|
77
100
|
const mcpServers = !mcpUrl ? [] : agent === 'codex' ? [{
|
|
78
|
-
name: 'openvisio-team', command: process.execPath, args: [proxyPath], env: [
|
|
101
|
+
name: 'openvisio-team-watcher', command: process.execPath, args: [proxyPath], env: [
|
|
79
102
|
{ name: 'OPENVISIO_CODEX_MCP_URL', value: mcpUrl },
|
|
80
103
|
{ name: 'OPENVISIO_CODEX_API_KEY', value: String(mcpHeaders['x-agent-api-key'] || '') },
|
|
81
104
|
{ name: 'OPENVISIO_CODEX_IDENTIFIER', value: String(mcpHeaders['x-agent-identifier'] || '') },
|
|
82
105
|
{ name: 'OPENVISIO_CODEX_DISABLED_TOOLS', value: JSON.stringify(disabledMcpTools) },
|
|
106
|
+
{ name: 'OPENVISIO_CODEX_ALLOWED_TOOLS', value: canCode ? 'null' : JSON.stringify([...CHAT_SAFE_MCP_READS]) },
|
|
83
107
|
],
|
|
84
108
|
}] : [{ type: 'http', name: 'openvisio-team', url: mcpUrl, headers }]
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
NO_BROWSER: '1',
|
|
95
|
-
} : {},
|
|
96
|
-
session: {
|
|
109
|
+
const sessionKey = JSON.stringify([cycleCwd, disabledMcpTools, mcpServers])
|
|
110
|
+
if (!persistent || persistentKey !== sessionKey) {
|
|
111
|
+
persistent?.connection.disconnect()
|
|
112
|
+
persistent = new AcpAgentClass({
|
|
113
|
+
id: `openvisio-${agent}-${Date.now()}`,
|
|
114
|
+
name: `OpenVisio ${agent}`,
|
|
115
|
+
description: 'A warm, isolated OpenVisio coding-agent session.',
|
|
116
|
+
command: runtime.command,
|
|
117
|
+
args: runtime.args,
|
|
97
118
|
cwd: cycleCwd,
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
createClient: agent === 'codex' ? (client) => new Proxy(client, {
|
|
110
|
-
get(target, property, receiver) {
|
|
111
|
-
if (property === 'extNotification') return async () => {}
|
|
112
|
-
return Reflect.get(target, property, receiver)
|
|
119
|
+
env: agent === 'codex' ? {
|
|
120
|
+
INITIAL_AGENT_MODE: canCode ? 'agent' : 'read-only',
|
|
121
|
+
NO_BROWSER: '1',
|
|
122
|
+
// codex-acp otherwise keeps a pre-existing openvisio-team server and
|
|
123
|
+
// silently discards this session's authenticated stdio bridge.
|
|
124
|
+
DISABLE_MCP_CONFIG_FILTERING: 'true',
|
|
125
|
+
CODEX_CONFIG: JSON.stringify({ 'mcp_servers.openvisio-team.enabled': false }),
|
|
126
|
+
} : {},
|
|
127
|
+
session: {
|
|
128
|
+
cwd: cycleCwd,
|
|
129
|
+
mcpServers,
|
|
113
130
|
},
|
|
114
|
-
|
|
115
|
-
|
|
131
|
+
persistSession: true,
|
|
132
|
+
onPermissionRequest: async (request) => acpPermissionResponse(request, { canCode }),
|
|
133
|
+
// codex-acp publishes account-status extension notifications. They are
|
|
134
|
+
// useful to interactive clients but should be silent in a background
|
|
135
|
+
// watcher, and the stock Mastra ACP client intentionally knows only ACP.
|
|
136
|
+
createClient: agent === 'codex' ? (client) => new Proxy(client, {
|
|
137
|
+
get(target, property, receiver) {
|
|
138
|
+
if (property === 'extNotification') return async () => {}
|
|
139
|
+
return Reflect.get(target, property, receiver)
|
|
140
|
+
},
|
|
141
|
+
}) : undefined,
|
|
142
|
+
})
|
|
143
|
+
persistentKey = sessionKey
|
|
144
|
+
sessionHasPrompted = false
|
|
145
|
+
negotiatedRequested = ''
|
|
146
|
+
negotiatedSelected = ''
|
|
147
|
+
}
|
|
148
|
+
const acp = persistent
|
|
116
149
|
active = { acp, controller }
|
|
117
|
-
const
|
|
150
|
+
const invalidate = () => {
|
|
151
|
+
acp.connection.disconnect()
|
|
152
|
+
if (persistent === acp) {
|
|
153
|
+
persistent = null
|
|
154
|
+
persistentKey = ''
|
|
155
|
+
sessionHasPrompted = false
|
|
156
|
+
negotiatedRequested = ''
|
|
157
|
+
negotiatedSelected = ''
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort(); invalidate() }, maxCycleMs)
|
|
118
161
|
const requestedModel = cycleModel || model || ''
|
|
119
162
|
let selectedModel = ''
|
|
120
163
|
try {
|
|
121
164
|
if (requestedModel) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
165
|
+
if (requestedModel !== negotiatedRequested) {
|
|
166
|
+
const availableModels = await acp.getAvailableModels()
|
|
167
|
+
const resolution = resolveAvailableModel(requestedModel, availableModels)
|
|
168
|
+
if (resolution.selected) {
|
|
169
|
+
await acp.setModel(resolution.selected)
|
|
170
|
+
selectedModel = resolution.selected
|
|
171
|
+
if (selectedModel !== requestedModel) log(`model ${requestedModel} resolved to available ${selectedModel} [${resolution.reason}]`)
|
|
172
|
+
} else {
|
|
173
|
+
log(`model ${requestedModel} could not be negotiated because ${agent} did not advertise selectable models; using its session default`)
|
|
174
|
+
}
|
|
175
|
+
negotiatedRequested = requestedModel
|
|
176
|
+
negotiatedSelected = selectedModel
|
|
128
177
|
} else {
|
|
129
|
-
|
|
178
|
+
selectedModel = negotiatedSelected
|
|
130
179
|
}
|
|
131
180
|
}
|
|
132
181
|
log(`running ${agent} cycle via Mastra ACP…${selectedModel || requestedModel ? ` [${selectedModel || requestedModel}]` : ' [runtime default]'}`)
|
|
133
|
-
const fullPrompt = systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt
|
|
182
|
+
const fullPrompt = systemPrompt && !sessionHasPrompted ? `${systemPrompt}\n\n${prompt}` : prompt
|
|
183
|
+
sessionHasPrompted = true
|
|
134
184
|
for await (const event of acp.connection.promptStream(fullPrompt, controller.signal)) {
|
|
135
185
|
if (event.type === 'text') { outputText += event.text; continue }
|
|
136
186
|
const update = event.update || {}
|
|
@@ -163,7 +213,9 @@ export function createMastraAcpRunner({
|
|
|
163
213
|
const after = repositorySnapshot(canCode ? cycleCwd : '')
|
|
164
214
|
if (before !== after) { didCode = true; didRepoMutation = true }
|
|
165
215
|
didResultMessage = didChannelMessage && didRepoMutation
|
|
166
|
-
|
|
216
|
+
const redact = (value) => Object.values(mcpHeaders).filter(Boolean).reduce((safe, secret) => safe.split(String(secret)).join('[redacted]'), String(value))
|
|
217
|
+
for (const [name, detail] of errors) log(`MCP ${name} failed: ${redact(detail)}`)
|
|
218
|
+
log(`${agent} cycle done via Mastra ACP (${errors.size ? 'tool-errors' : 'ok'})`)
|
|
167
219
|
return {
|
|
168
220
|
type: 'result', subtype: 'ok', runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
|
|
169
221
|
mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
|
|
@@ -173,7 +225,10 @@ export function createMastraAcpRunner({
|
|
|
173
225
|
} catch (error) {
|
|
174
226
|
const canceled = controller.signal.aborted
|
|
175
227
|
const subtype = timedOut ? 'timeout' : canceled ? 'canceled' : 'error'
|
|
176
|
-
|
|
228
|
+
const errorDetail = [error?.message, error?.data ? json(error.data) : ''].filter(Boolean).join(': ')
|
|
229
|
+
const safeError = Object.values(mcpHeaders).filter(Boolean).reduce((safe, secret) => safe.split(String(secret)).join('[redacted]'), errorDetail)
|
|
230
|
+
log(`${agent} cycle done via Mastra ACP (${subtype}${!canceled && safeError ? ': ' + clean(safeError, 1200) : ''})`)
|
|
231
|
+
invalidate()
|
|
177
232
|
return {
|
|
178
233
|
type: 'result', subtype, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
|
|
179
234
|
mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
|
|
@@ -182,7 +237,6 @@ export function createMastraAcpRunner({
|
|
|
182
237
|
}
|
|
183
238
|
} finally {
|
|
184
239
|
clearTimeout(timer)
|
|
185
|
-
acp.connection.disconnect()
|
|
186
240
|
if (active?.acp === acp) active = null
|
|
187
241
|
}
|
|
188
242
|
}
|
|
@@ -191,9 +245,26 @@ export function createMastraAcpRunner({
|
|
|
191
245
|
if (!active) return
|
|
192
246
|
active.controller.abort()
|
|
193
247
|
try { await active.acp.connection.cancel() } catch { /* already stopped */ }
|
|
194
|
-
active.acp
|
|
248
|
+
const acp = active.acp
|
|
249
|
+
acp.connection.disconnect()
|
|
250
|
+
if (persistent === acp) {
|
|
251
|
+
persistent = null
|
|
252
|
+
persistentKey = ''
|
|
253
|
+
sessionHasPrompted = false
|
|
254
|
+
negotiatedRequested = ''
|
|
255
|
+
negotiatedSelected = ''
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const close = () => {
|
|
260
|
+
persistent?.connection.disconnect()
|
|
261
|
+
persistent = null
|
|
262
|
+
persistentKey = ''
|
|
263
|
+
sessionHasPrompted = false
|
|
264
|
+
negotiatedRequested = ''
|
|
265
|
+
negotiatedSelected = ''
|
|
195
266
|
}
|
|
196
267
|
|
|
197
268
|
log(`${agent} Mastra ACP runner ready${canCode ? ` [CODE workspace ${defaultCwd}]` : ' [CHAT-ONLY]'}`)
|
|
198
|
-
return { runCycle, canCode, cancelCurrent, harness: 'mastra-acp' }
|
|
269
|
+
return { runCycle, canCode, cancelCurrent, close, harness: 'mastra-acp' }
|
|
199
270
|
}
|
package/src/model-selection.mjs
CHANGED
|
@@ -40,10 +40,18 @@ function similarity(requested, candidate, preferredEffort) {
|
|
|
40
40
|
export function resolveAvailableModel(requestedValue, availableModels = [], { preferredEffort = DEFAULT_EFFORT } = {}) {
|
|
41
41
|
const requested = parts(requestedValue)
|
|
42
42
|
const choices = availableModels.map((entry) => typeof entry === 'string' ? entry : entry?.modelId).map(text).filter(Boolean)
|
|
43
|
-
if (!choices.length) return { requested: requested.raw, selected:
|
|
43
|
+
if (!choices.length && requested.raw) return { requested: requested.raw, selected: requested.raw, reason: 'explicit-unadvertised', available: [] }
|
|
44
|
+
if (!choices.length) return { requested: '', selected: '', reason: 'runtime-default', available: [] }
|
|
44
45
|
if (!requested.raw) return { requested: '', selected: '', reason: 'runtime-default', available: choices }
|
|
45
46
|
|
|
46
|
-
const
|
|
47
|
+
const compatible = choices.filter((id) => {
|
|
48
|
+
const candidate = parts(id)
|
|
49
|
+
return candidate.id === requested.id || (!requested.effort &&
|
|
50
|
+
candidate.leaf === requested.leaf && (!requested.provider || candidate.provider === requested.provider) &&
|
|
51
|
+
(!candidate.effort || candidate.effort === preferredEffort))
|
|
52
|
+
})
|
|
53
|
+
if (!compatible.length) throw new Error(`Configured model "${requested.raw}" is unavailable; automatic model substitution is disabled.`)
|
|
54
|
+
const ranked = compatible.map((id, index) => {
|
|
47
55
|
const candidate = parts(id)
|
|
48
56
|
return { id, index, score: similarity(requested, candidate, preferredEffort) }
|
|
49
57
|
}).sort((a, b) => b.score - a.score || a.index - b.index)
|
|
@@ -59,17 +67,9 @@ export function resolveAvailableModel(requestedValue, availableModels = [], { pr
|
|
|
59
67
|
const CLAUDE_FAMILIES = ['sonnet', 'opus', 'haiku', 'fable']
|
|
60
68
|
|
|
61
69
|
export function resolveClaudeModel(requestedValue) {
|
|
62
|
-
|
|
63
|
-
if (!requested) return ''
|
|
64
|
-
const family = CLAUDE_FAMILIES.find((name) => requested === name || requested.startsWith('claude-') && requested.includes(name))
|
|
65
|
-
return family || text(requestedValue)
|
|
70
|
+
return text(requestedValue)
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
export function claudeFallbackModels(selectedValue) {
|
|
69
|
-
|
|
70
|
-
const preferred = selected === 'opus' ? ['sonnet', 'haiku']
|
|
71
|
-
: selected === 'haiku' ? ['sonnet', 'opus']
|
|
72
|
-
: selected === 'fable' ? ['sonnet', 'opus'] : ['opus', 'haiku']
|
|
73
|
-
return preferred.filter((model) => model !== selected)
|
|
74
|
+
return []
|
|
74
75
|
}
|
|
75
|
-
|
package/src/watch.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { join, dirname } from 'node:path'
|
|
|
11
11
|
import { fileURLToPath } from 'node:url'
|
|
12
12
|
import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
|
|
13
13
|
import { connectAgentWs, assertWebSocket } from './ws.mjs'
|
|
14
|
-
import { agentAddedByName, blockingReplyMcpErrors, buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, codexEventEvidence, codexPolicyBlock, combineRuntimeWorkEvidence, conversationNeedsCode, failedTaskRevisionIsCurrent, mentionDedupeKeys, missingRuntimeWorkEvidence, normalizeRenderedMessageText, opencodeEventEvidence, renderedAgentMessages, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
|
|
14
|
+
import { agentAddedByName, blockingReplyMcpErrors, buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, codexEventEvidence, codexPolicyBlock, combineRuntimeWorkEvidence, conversationAsksPendingTickets, conversationNeedsCode, failedTaskRevisionIsCurrent, mentionDedupeKeys, missingRuntimeWorkEvidence, normalizeRenderedMessageText, opencodeEventEvidence, renderedAgentMessages, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
|
|
15
15
|
import { createMastraMemory } from './memory.mjs'
|
|
16
16
|
import { repositoryHasPrPushAuthorization } from './pr-push.mjs'
|
|
17
17
|
import { createMcpHttpClient } from './mcp-http.mjs'
|
|
@@ -261,10 +261,9 @@ export async function runWatch({ flags }) {
|
|
|
261
261
|
const workdir = chatOnly ? '' : (explicitWorkdir || (saved && (saved.workspace || saved.workdir)) || DEFAULT_WORKSPACE)
|
|
262
262
|
if (workdir) { try { mkdirSync(workdir, { recursive: true }) } catch { /* best-effort; spawn will surface a real problem */ } }
|
|
263
263
|
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
const
|
|
267
|
-
const defaultChatModel = agent === 'claude' ? 'haiku' : agent === 'codex' ? 'gpt-5.6-sol' : ''
|
|
264
|
+
// Use a specific Codex ID; configured chat and code tiers remain independent.
|
|
265
|
+
const defaultModel = agent === 'claude' ? 'sonnet' : agent === 'codex' ? 'gpt-5.6-sol[medium]' : ''
|
|
266
|
+
const defaultChatModel = agent === 'claude' ? 'haiku' : agent === 'codex' ? 'gpt-5.6-sol[medium]' : ''
|
|
268
267
|
const model = String(flags.model || (saved && saved.model) || defaultModel)
|
|
269
268
|
const chatModel = String(flags['chat-model'] || (saved && saved.chatModel) || defaultChatModel)
|
|
270
269
|
|
|
@@ -820,13 +819,28 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
820
819
|
...(workerWorkdir ? { workdir: workerWorkdir } : {}),
|
|
821
820
|
onTool: (name) => { if (/post_message/.test(name)) emitStatusTargets(control.statusTargets, 'typing') },
|
|
822
821
|
})
|
|
823
|
-
const
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
822
|
+
const replyRunners = new Map()
|
|
823
|
+
const replyRunnerFor = (delivery) => {
|
|
824
|
+
const key = delivery ? `${delivery.channelId ?? '?'}:${delivery.parentId ?? 'root'}` : 'global'
|
|
825
|
+
let runner = replyRunners.get(key)
|
|
826
|
+
if (!runner) {
|
|
827
|
+
runner = createCycleRunner({
|
|
828
|
+
...runnerOptions,
|
|
829
|
+
workdir: '',
|
|
830
|
+
systemPrompt: CHAT_CHARTER + '\n\n' + credNote,
|
|
831
|
+
cfgKey: identifier + '-reply-' + slugify(key),
|
|
832
|
+
onTool: (name) => { if (/post_message/.test(name)) emitStatusTargets(replyStatusTargets, 'typing') },
|
|
833
|
+
})
|
|
834
|
+
} else replyRunners.delete(key)
|
|
835
|
+
replyRunners.set(key, runner)
|
|
836
|
+
while (replyRunners.size > 8) {
|
|
837
|
+
const oldestKey = replyRunners.keys().next().value
|
|
838
|
+
const oldest = replyRunners.get(oldestKey)
|
|
839
|
+
replyRunners.delete(oldestKey)
|
|
840
|
+
oldest?.close?.()
|
|
841
|
+
}
|
|
842
|
+
return runner
|
|
843
|
+
}
|
|
830
844
|
const codexPushGuide = agent === 'codex' && canCode
|
|
831
845
|
? '\n\nCODEX PR DELIVERY: when the repository exists in the local workspace, use that clone for branch creation, edits, tests, and commits; do not inspect or mutate it through linked-codebase MCP tools. To publish the local agent/* branch, run `openvisio-agent push-pr-branch` from the repository, then open the PR with `gh pr create`. The helper can only push HEAD to the matching agent/* branch on the exact authorized origin. If it reports OPENVISIO_PR_PUSH_AUTH_REQUIRED, do not retry or route around it. Report the one-time command `openvisio-agent authorize-pr-push` as the blocker. Use list_codebases/create_codebase_branch/create_codebase_commit/create_pull_request only as a fallback when the repository cannot be obtained locally.'
|
|
832
846
|
: ''
|
|
@@ -850,7 +864,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
850
864
|
item.control.runner = runner
|
|
851
865
|
activeWorkRunners.add(runner)
|
|
852
866
|
try { return await executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, item.workdir) }
|
|
853
|
-
finally { activeWorkRunners.delete(runner); item.control.runner = null }
|
|
867
|
+
finally { runner.close?.(); activeWorkRunners.delete(runner); item.control.runner = null }
|
|
854
868
|
},
|
|
855
869
|
onError: (error, item) => {
|
|
856
870
|
pauseFailedTask(item.taskRef)
|
|
@@ -859,7 +873,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
859
873
|
},
|
|
860
874
|
}),
|
|
861
875
|
reply: createCycleQueue({
|
|
862
|
-
run: (item) =>
|
|
876
|
+
run: async (item) => {
|
|
877
|
+
const runner = replyRunnerFor(item.delivery)
|
|
878
|
+
item.control.runner = runner
|
|
879
|
+
try { return await executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, '') }
|
|
880
|
+
finally { item.control.runner = null }
|
|
881
|
+
},
|
|
863
882
|
onError: (error, item) => {
|
|
864
883
|
pauseFailedTask(item.taskRef)
|
|
865
884
|
void finalizeFailedTaskPause(item.taskRef)
|
|
@@ -1047,6 +1066,61 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1047
1066
|
try { return JSON.parse(text) } catch { return { text } }
|
|
1048
1067
|
}
|
|
1049
1068
|
|
|
1069
|
+
const callMcpReadWithRetry = async (name, args = {}) => {
|
|
1070
|
+
try { return await callMcpTool(name, args) }
|
|
1071
|
+
catch (first) {
|
|
1072
|
+
log(`${name} read failed; retrying once: ${first?.message || first}`)
|
|
1073
|
+
return callMcpTool(name, args)
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
const loadPendingTickets = async () => {
|
|
1078
|
+
const projectsData = toolData(await callMcpReadWithRetry('list_projects'))
|
|
1079
|
+
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
1080
|
+
const groups = await Promise.all(projects.filter((project) => project?.id != null).map(async (project) => {
|
|
1081
|
+
const [tasksResult, typesResult] = await Promise.all([
|
|
1082
|
+
callMcpReadWithRetry('list_tasks', { project_id: project.id }),
|
|
1083
|
+
callMcpReadWithRetry('list_task_types', { project_id: project.id }).catch((error) => {
|
|
1084
|
+
log(`list_task_types read unavailable for project ${project.id}: ${error?.message || error}`)
|
|
1085
|
+
return null
|
|
1086
|
+
}),
|
|
1087
|
+
])
|
|
1088
|
+
const tasksData = toolData(tasksResult)
|
|
1089
|
+
const typesData = typesResult ? toolData(typesResult) : {}
|
|
1090
|
+
const types = Array.isArray(typesData.types) ? typesData.types : Array.isArray(typesData.task_types) ? typesData.task_types : Array.isArray(typesData.taskTypes) ? typesData.taskTypes : []
|
|
1091
|
+
const doneIds = new Set(types.filter((type) => /\b(?:done|complete|completed|closed|cancelled|canceled|archived|resolved)\b/i.test(String(type.name || ''))).map((type) => Number(type.id)))
|
|
1092
|
+
return (Array.isArray(tasksData.tasks) ? tasksData.tasks : []).filter((task) => {
|
|
1093
|
+
const assignedId = Number(taskAgentId(task) ?? task.agent?.id ?? task.assigned_agent?.id)
|
|
1094
|
+
const assignedIdentifier = String(task.agent?.identifier ?? task.agent?.slug ?? task.assigned_agent?.identifier ?? task.assigned_agent?.slug ?? '')
|
|
1095
|
+
const assignedHere = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
|
|
1096
|
+
return assignedHere && !taskIsCompleted(task, doneIds)
|
|
1097
|
+
}).map((task) => ({
|
|
1098
|
+
slug: ticketDisplaySlug(task),
|
|
1099
|
+
title: String(task.title || 'Untitled ticket'),
|
|
1100
|
+
state: String(task.type?.name ?? task.task_type?.name ?? task.status ?? '').trim(),
|
|
1101
|
+
project: String(project.name || ''),
|
|
1102
|
+
}))
|
|
1103
|
+
}))
|
|
1104
|
+
return groups.flat()
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
const answerPendingTickets = async ({ delivery, who }) => {
|
|
1108
|
+
try {
|
|
1109
|
+
const tickets = await loadPendingTickets()
|
|
1110
|
+
const prefix = who ? `@${who} ` : ''
|
|
1111
|
+
const details = tickets.slice(0, 8).map((ticket) => `${ticket.slug || ticket.title}${ticket.slug ? ` “${ticket.title}”` : ''}${ticket.state ? ` (${ticket.state})` : ''}${ticket.project ? ` in ${ticket.project}` : ''}`)
|
|
1112
|
+
const extra = tickets.length > details.length ? `, plus ${tickets.length - details.length} more` : ''
|
|
1113
|
+
const content = tickets.length
|
|
1114
|
+
? `${prefix}I have ${tickets.length} pending ticket${tickets.length === 1 ? '' : 's'}: ${details.join('; ')}${extra}.`
|
|
1115
|
+
: `${prefix}I don't have any pending tickets.`
|
|
1116
|
+
await postMessageOnce({ ...delivery, content })
|
|
1117
|
+
} catch (error) {
|
|
1118
|
+
log('watcher-owned pending-ticket lookup failed: ' + (error?.message || error))
|
|
1119
|
+
const prefix = who ? `@${who} ` : ''
|
|
1120
|
+
await postMessageOnce({ ...delivery, content: `${prefix}I couldn't read the live ticket list just now, so I can't verify the count.` })
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1050
1124
|
// All watcher-owned message delivery goes through this gate. For threaded
|
|
1051
1125
|
// replies it first reads the live backend thread and inspects rows already
|
|
1052
1126
|
// rendered as this agent. A persisted delivery key closes the crash/reconnect
|
|
@@ -1487,7 +1561,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1487
1561
|
const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind, delivery)
|
|
1488
1562
|
// Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
|
|
1489
1563
|
// work (full/sweep) uses the main model.
|
|
1490
|
-
const useModel =
|
|
1564
|
+
const useModel = kind === 'full' ? codeModel : liteModel
|
|
1491
1565
|
log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : '') + (useModel ? ' [' + useModel + ']' : ''))
|
|
1492
1566
|
// Presence notifications are intentionally disabled; durable messages and
|
|
1493
1567
|
// ticket transitions are the only user-visible progress signals.
|
|
@@ -1509,6 +1583,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1509
1583
|
}
|
|
1510
1584
|
if (cycleControl.cancelled) return
|
|
1511
1585
|
const result = await runner.runCycle(prompt, useModel, runnerOptions)
|
|
1586
|
+
if (result?.model && result.model !== useModel) {
|
|
1587
|
+
// Persist only a successfully selected model and only if the user has
|
|
1588
|
+
// not changed that tier while this cycle was running.
|
|
1589
|
+
let changed = false
|
|
1590
|
+
if (codeModel === useModel) { codeModel = result.model; changed = true }
|
|
1591
|
+
if (liteModel === useModel) { liteModel = result.model; changed = true }
|
|
1592
|
+
if (changed) persistModel()
|
|
1593
|
+
}
|
|
1512
1594
|
let completionResult = result
|
|
1513
1595
|
if (cycleControl.cancelled || result?.subtype === 'canceled') {
|
|
1514
1596
|
log(laneName + ' cycle cancelled; no blocker or reply will be published')
|
|
@@ -1611,9 +1693,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1611
1693
|
const x = String(s || '').toLowerCase().replace(/[.,!?]+$/, '')
|
|
1612
1694
|
if (x === 'opus' || x === 'sonnet' || x === 'haiku') return x
|
|
1613
1695
|
if (/^claude-[a-z0-9.\-\[\]]+$/i.test(x)) return x
|
|
1614
|
-
if (/^(?:gpt|codex|o[1-9])[a-z0-9.\-:]
|
|
1696
|
+
if (/^(?:gpt|codex|o[1-9])[a-z0-9.\-:]*(?:\[(?:low|medium|high|xhigh|max|ultra)\])?$/i.test(x)) return x
|
|
1615
1697
|
// opencode models are provider/model (e.g. anthropic/claude-sonnet-4, openai/gpt-4o).
|
|
1616
|
-
return /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9.\-:]
|
|
1698
|
+
return /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9.\-:]*(?:\[(?:low|medium|high|xhigh|max|ultra)\])?$/i.test(x) ? x : null
|
|
1617
1699
|
}
|
|
1618
1700
|
// Optional target tier: "chat"/"lite" → chat cycles only, "code"/"full" → code
|
|
1619
1701
|
// cycles only, absent → both.
|
|
@@ -1793,6 +1875,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1793
1875
|
}
|
|
1794
1876
|
return
|
|
1795
1877
|
}
|
|
1878
|
+
if (cid != null && conversationAsksPendingTickets(text)) {
|
|
1879
|
+
log('pending-ticket question -> watcher-owned MCP lookup')
|
|
1880
|
+
void answerPendingTickets({ delivery: conversationDelivery('pending-tickets'), who })
|
|
1881
|
+
return
|
|
1882
|
+
}
|
|
1796
1883
|
log('agent:mention in channel ' + (cid != null ? cid : '?') + (threadRoot != null ? ' (thread ' + threadRoot + ')' : ''))
|
|
1797
1884
|
// Light up the live status the instant we pick this up; drain owns the
|
|
1798
1885
|
// subsequent working/typing heartbeat for its lane.
|
|
@@ -1865,7 +1952,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1865
1952
|
void item.control.runner?.cancelCurrent?.()
|
|
1866
1953
|
})
|
|
1867
1954
|
}
|
|
1868
|
-
await Promise.all([
|
|
1955
|
+
await Promise.all([...replyRunners.values(), ...activeWorkRunners].map((runner) => runner.cancelCurrent?.()))
|
|
1956
|
+
for (const runner of replyRunners.values()) runner.close?.()
|
|
1869
1957
|
process.exit(0)
|
|
1870
1958
|
}
|
|
1871
1959
|
process.on('SIGTERM', bye)
|