openvisio-agent 0.19.4 → 0.19.8
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/package.json +1 -1
- package/scripts/certify.mjs +4 -2
- package/src/codex-mcp-proxy.mjs +20 -3
- package/src/events.mjs +8 -0
- package/src/mastra-harness.mjs +104 -41
- package/src/watch.mjs +92 -11
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
|
|
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
80
|
['runtime models are resolved before work starts', mastraHarness.includes('getAvailableModels()') && mastraHarness.includes('resolveAvailableModel(requestedModel') && mastraHarness.includes('await acp.setModel(selectedModel)') && modelSelection.includes("const DEFAULT_EFFORT = 'medium'") && watcher.includes('resolveClaudeModel') && watcher.includes("'--fallback-model'" )],
|
|
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
|
|
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 = {}) {
|
|
@@ -80,57 +103,80 @@ export function createMastraAcpRunner({
|
|
|
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
|
-
// codex-acp publishes account-status extension notifications. They are
|
|
107
|
-
// useful to interactive clients but should be silent in a background
|
|
108
|
-
// watcher, and the stock Mastra ACP client intentionally knows only ACP.
|
|
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
|
+
} : {},
|
|
123
|
+
session: {
|
|
124
|
+
cwd: cycleCwd,
|
|
125
|
+
mcpServers,
|
|
113
126
|
},
|
|
114
|
-
|
|
115
|
-
|
|
127
|
+
persistSession: true,
|
|
128
|
+
onPermissionRequest: async (request) => acpPermissionResponse(request, { canCode }),
|
|
129
|
+
// codex-acp publishes account-status extension notifications. They are
|
|
130
|
+
// useful to interactive clients but should be silent in a background
|
|
131
|
+
// watcher, and the stock Mastra ACP client intentionally knows only ACP.
|
|
132
|
+
createClient: agent === 'codex' ? (client) => new Proxy(client, {
|
|
133
|
+
get(target, property, receiver) {
|
|
134
|
+
if (property === 'extNotification') return async () => {}
|
|
135
|
+
return Reflect.get(target, property, receiver)
|
|
136
|
+
},
|
|
137
|
+
}) : undefined,
|
|
138
|
+
})
|
|
139
|
+
persistentKey = sessionKey
|
|
140
|
+
sessionHasPrompted = false
|
|
141
|
+
negotiatedRequested = ''
|
|
142
|
+
negotiatedSelected = ''
|
|
143
|
+
}
|
|
144
|
+
const acp = persistent
|
|
116
145
|
active = { acp, controller }
|
|
117
|
-
const
|
|
146
|
+
const invalidate = () => {
|
|
147
|
+
acp.connection.disconnect()
|
|
148
|
+
if (persistent === acp) {
|
|
149
|
+
persistent = null
|
|
150
|
+
persistentKey = ''
|
|
151
|
+
sessionHasPrompted = false
|
|
152
|
+
negotiatedRequested = ''
|
|
153
|
+
negotiatedSelected = ''
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort(); invalidate() }, maxCycleMs)
|
|
118
157
|
const requestedModel = cycleModel || model || ''
|
|
119
158
|
let selectedModel = ''
|
|
120
159
|
try {
|
|
121
160
|
if (requestedModel) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
161
|
+
if (requestedModel !== negotiatedRequested) {
|
|
162
|
+
const availableModels = await acp.getAvailableModels()
|
|
163
|
+
const resolution = resolveAvailableModel(requestedModel, availableModels)
|
|
164
|
+
selectedModel = resolution.selected
|
|
165
|
+
if (selectedModel) {
|
|
166
|
+
await acp.setModel(selectedModel)
|
|
167
|
+
if (selectedModel !== requestedModel) log(`model ${requestedModel} resolved to available ${selectedModel} [${resolution.reason}]`)
|
|
168
|
+
} else {
|
|
169
|
+
log(`model ${requestedModel} could not be negotiated because ${agent} did not advertise selectable models; using its session default`)
|
|
170
|
+
}
|
|
171
|
+
negotiatedRequested = requestedModel
|
|
172
|
+
negotiatedSelected = selectedModel
|
|
128
173
|
} else {
|
|
129
|
-
|
|
174
|
+
selectedModel = negotiatedSelected
|
|
130
175
|
}
|
|
131
176
|
}
|
|
132
177
|
log(`running ${agent} cycle via Mastra ACP…${selectedModel || requestedModel ? ` [${selectedModel || requestedModel}]` : ' [runtime default]'}`)
|
|
133
|
-
const fullPrompt = systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt
|
|
178
|
+
const fullPrompt = systemPrompt && !sessionHasPrompted ? `${systemPrompt}\n\n${prompt}` : prompt
|
|
179
|
+
sessionHasPrompted = true
|
|
134
180
|
for await (const event of acp.connection.promptStream(fullPrompt, controller.signal)) {
|
|
135
181
|
if (event.type === 'text') { outputText += event.text; continue }
|
|
136
182
|
const update = event.update || {}
|
|
@@ -174,6 +220,7 @@ export function createMastraAcpRunner({
|
|
|
174
220
|
const canceled = controller.signal.aborted
|
|
175
221
|
const subtype = timedOut ? 'timeout' : canceled ? 'canceled' : 'error'
|
|
176
222
|
log(`${agent} cycle done via Mastra ACP (${subtype}${!canceled && error?.message ? ': ' + clean(error.message) : ''})`)
|
|
223
|
+
invalidate()
|
|
177
224
|
return {
|
|
178
225
|
type: 'result', subtype, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
|
|
179
226
|
mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
|
|
@@ -182,7 +229,6 @@ export function createMastraAcpRunner({
|
|
|
182
229
|
}
|
|
183
230
|
} finally {
|
|
184
231
|
clearTimeout(timer)
|
|
185
|
-
acp.connection.disconnect()
|
|
186
232
|
if (active?.acp === acp) active = null
|
|
187
233
|
}
|
|
188
234
|
}
|
|
@@ -191,9 +237,26 @@ export function createMastraAcpRunner({
|
|
|
191
237
|
if (!active) return
|
|
192
238
|
active.controller.abort()
|
|
193
239
|
try { await active.acp.connection.cancel() } catch { /* already stopped */ }
|
|
194
|
-
active.acp
|
|
240
|
+
const acp = active.acp
|
|
241
|
+
acp.connection.disconnect()
|
|
242
|
+
if (persistent === acp) {
|
|
243
|
+
persistent = null
|
|
244
|
+
persistentKey = ''
|
|
245
|
+
sessionHasPrompted = false
|
|
246
|
+
negotiatedRequested = ''
|
|
247
|
+
negotiatedSelected = ''
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const close = () => {
|
|
252
|
+
persistent?.connection.disconnect()
|
|
253
|
+
persistent = null
|
|
254
|
+
persistentKey = ''
|
|
255
|
+
sessionHasPrompted = false
|
|
256
|
+
negotiatedRequested = ''
|
|
257
|
+
negotiatedSelected = ''
|
|
195
258
|
}
|
|
196
259
|
|
|
197
260
|
log(`${agent} Mastra ACP runner ready${canCode ? ` [CODE workspace ${defaultCwd}]` : ' [CHAT-ONLY]'}`)
|
|
198
|
-
return { runCycle, canCode, cancelCurrent, harness: 'mastra-acp' }
|
|
261
|
+
return { runCycle, canCode, cancelCurrent, close, harness: 'mastra-acp' }
|
|
199
262
|
}
|
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'
|
|
@@ -820,13 +820,28 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
820
820
|
...(workerWorkdir ? { workdir: workerWorkdir } : {}),
|
|
821
821
|
onTool: (name) => { if (/post_message/.test(name)) emitStatusTargets(control.statusTargets, 'typing') },
|
|
822
822
|
})
|
|
823
|
-
const
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
823
|
+
const replyRunners = new Map()
|
|
824
|
+
const replyRunnerFor = (delivery) => {
|
|
825
|
+
const key = delivery ? `${delivery.channelId ?? '?'}:${delivery.parentId ?? 'root'}` : 'global'
|
|
826
|
+
let runner = replyRunners.get(key)
|
|
827
|
+
if (!runner) {
|
|
828
|
+
runner = createCycleRunner({
|
|
829
|
+
...runnerOptions,
|
|
830
|
+
workdir: '',
|
|
831
|
+
systemPrompt: CHAT_CHARTER + '\n\n' + credNote,
|
|
832
|
+
cfgKey: identifier + '-reply-' + slugify(key),
|
|
833
|
+
onTool: (name) => { if (/post_message/.test(name)) emitStatusTargets(replyStatusTargets, 'typing') },
|
|
834
|
+
})
|
|
835
|
+
} else replyRunners.delete(key)
|
|
836
|
+
replyRunners.set(key, runner)
|
|
837
|
+
while (replyRunners.size > 8) {
|
|
838
|
+
const oldestKey = replyRunners.keys().next().value
|
|
839
|
+
const oldest = replyRunners.get(oldestKey)
|
|
840
|
+
replyRunners.delete(oldestKey)
|
|
841
|
+
oldest?.close?.()
|
|
842
|
+
}
|
|
843
|
+
return runner
|
|
844
|
+
}
|
|
830
845
|
const codexPushGuide = agent === 'codex' && canCode
|
|
831
846
|
? '\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
847
|
: ''
|
|
@@ -850,7 +865,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
850
865
|
item.control.runner = runner
|
|
851
866
|
activeWorkRunners.add(runner)
|
|
852
867
|
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 }
|
|
868
|
+
finally { runner.close?.(); activeWorkRunners.delete(runner); item.control.runner = null }
|
|
854
869
|
},
|
|
855
870
|
onError: (error, item) => {
|
|
856
871
|
pauseFailedTask(item.taskRef)
|
|
@@ -859,7 +874,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
859
874
|
},
|
|
860
875
|
}),
|
|
861
876
|
reply: createCycleQueue({
|
|
862
|
-
run: (item) =>
|
|
877
|
+
run: async (item) => {
|
|
878
|
+
const runner = replyRunnerFor(item.delivery)
|
|
879
|
+
item.control.runner = runner
|
|
880
|
+
try { return await executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, '') }
|
|
881
|
+
finally { item.control.runner = null }
|
|
882
|
+
},
|
|
863
883
|
onError: (error, item) => {
|
|
864
884
|
pauseFailedTask(item.taskRef)
|
|
865
885
|
void finalizeFailedTaskPause(item.taskRef)
|
|
@@ -1047,6 +1067,61 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1047
1067
|
try { return JSON.parse(text) } catch { return { text } }
|
|
1048
1068
|
}
|
|
1049
1069
|
|
|
1070
|
+
const callMcpReadWithRetry = async (name, args = {}) => {
|
|
1071
|
+
try { return await callMcpTool(name, args) }
|
|
1072
|
+
catch (first) {
|
|
1073
|
+
log(`${name} read failed; retrying once: ${first?.message || first}`)
|
|
1074
|
+
return callMcpTool(name, args)
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const loadPendingTickets = async () => {
|
|
1079
|
+
const projectsData = toolData(await callMcpReadWithRetry('list_projects'))
|
|
1080
|
+
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
1081
|
+
const groups = await Promise.all(projects.filter((project) => project?.id != null).map(async (project) => {
|
|
1082
|
+
const [tasksResult, typesResult] = await Promise.all([
|
|
1083
|
+
callMcpReadWithRetry('list_tasks', { project_id: project.id }),
|
|
1084
|
+
callMcpReadWithRetry('list_task_types', { project_id: project.id }).catch((error) => {
|
|
1085
|
+
log(`list_task_types read unavailable for project ${project.id}: ${error?.message || error}`)
|
|
1086
|
+
return null
|
|
1087
|
+
}),
|
|
1088
|
+
])
|
|
1089
|
+
const tasksData = toolData(tasksResult)
|
|
1090
|
+
const typesData = typesResult ? toolData(typesResult) : {}
|
|
1091
|
+
const types = Array.isArray(typesData.types) ? typesData.types : Array.isArray(typesData.task_types) ? typesData.task_types : Array.isArray(typesData.taskTypes) ? typesData.taskTypes : []
|
|
1092
|
+
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)))
|
|
1093
|
+
return (Array.isArray(tasksData.tasks) ? tasksData.tasks : []).filter((task) => {
|
|
1094
|
+
const assignedId = Number(taskAgentId(task) ?? task.agent?.id ?? task.assigned_agent?.id)
|
|
1095
|
+
const assignedIdentifier = String(task.agent?.identifier ?? task.agent?.slug ?? task.assigned_agent?.identifier ?? task.assigned_agent?.slug ?? '')
|
|
1096
|
+
const assignedHere = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
|
|
1097
|
+
return assignedHere && !taskIsCompleted(task, doneIds)
|
|
1098
|
+
}).map((task) => ({
|
|
1099
|
+
slug: ticketDisplaySlug(task),
|
|
1100
|
+
title: String(task.title || 'Untitled ticket'),
|
|
1101
|
+
state: String(task.type?.name ?? task.task_type?.name ?? task.status ?? '').trim(),
|
|
1102
|
+
project: String(project.name || ''),
|
|
1103
|
+
}))
|
|
1104
|
+
}))
|
|
1105
|
+
return groups.flat()
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
const answerPendingTickets = async ({ delivery, who }) => {
|
|
1109
|
+
try {
|
|
1110
|
+
const tickets = await loadPendingTickets()
|
|
1111
|
+
const prefix = who ? `@${who} ` : ''
|
|
1112
|
+
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}` : ''}`)
|
|
1113
|
+
const extra = tickets.length > details.length ? `, plus ${tickets.length - details.length} more` : ''
|
|
1114
|
+
const content = tickets.length
|
|
1115
|
+
? `${prefix}I have ${tickets.length} pending ticket${tickets.length === 1 ? '' : 's'}: ${details.join('; ')}${extra}.`
|
|
1116
|
+
: `${prefix}I don't have any pending tickets.`
|
|
1117
|
+
await postMessageOnce({ ...delivery, content })
|
|
1118
|
+
} catch (error) {
|
|
1119
|
+
log('watcher-owned pending-ticket lookup failed: ' + (error?.message || error))
|
|
1120
|
+
const prefix = who ? `@${who} ` : ''
|
|
1121
|
+
await postMessageOnce({ ...delivery, content: `${prefix}I couldn't read the live ticket list just now, so I can't verify the count.` })
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1050
1125
|
// All watcher-owned message delivery goes through this gate. For threaded
|
|
1051
1126
|
// replies it first reads the live backend thread and inspects rows already
|
|
1052
1127
|
// rendered as this agent. A persisted delivery key closes the crash/reconnect
|
|
@@ -1793,6 +1868,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1793
1868
|
}
|
|
1794
1869
|
return
|
|
1795
1870
|
}
|
|
1871
|
+
if (cid != null && conversationAsksPendingTickets(text)) {
|
|
1872
|
+
log('pending-ticket question -> watcher-owned MCP lookup')
|
|
1873
|
+
void answerPendingTickets({ delivery: conversationDelivery('pending-tickets'), who })
|
|
1874
|
+
return
|
|
1875
|
+
}
|
|
1796
1876
|
log('agent:mention in channel ' + (cid != null ? cid : '?') + (threadRoot != null ? ' (thread ' + threadRoot + ')' : ''))
|
|
1797
1877
|
// Light up the live status the instant we pick this up; drain owns the
|
|
1798
1878
|
// subsequent working/typing heartbeat for its lane.
|
|
@@ -1865,7 +1945,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1865
1945
|
void item.control.runner?.cancelCurrent?.()
|
|
1866
1946
|
})
|
|
1867
1947
|
}
|
|
1868
|
-
await Promise.all([
|
|
1948
|
+
await Promise.all([...replyRunners.values(), ...activeWorkRunners].map((runner) => runner.cancelCurrent?.()))
|
|
1949
|
+
for (const runner of replyRunners.values()) runner.close?.()
|
|
1869
1950
|
process.exit(0)
|
|
1870
1951
|
}
|
|
1871
1952
|
process.on('SIGTERM', bye)
|