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 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 names are negotiated at session start. Codex and OpenCode match the configured name against the models advertised by ACP, preferring the same family and reasoning effort (`medium` for an unsuffixed family); provider-qualified and nearby generation names are resolved automatically. Claude versioned names are normalized to its stable `sonnet`, `opus`, `haiku`, or `fable` aliases and use the CLI's native ordered fallback chain.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.19.4",
3
+ "version": "0.19.9",
4
4
  "description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team — MCP tools + optional autonomy — in one command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 isolated Mastra ACP sessions', watcher.includes('createMastraAcpRunner') && mastraHarness.includes('new AcpAgentClass') && mastraHarness.includes('persistSession: false') && mastraHarness.includes("runtime: 'mastra-acp'")],
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 replyRunner = createCycleRunner')],
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')],
@@ -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) => !disabled.has(String(tool.name))).map(toolWithoutCredentialInputs) })
68
+ reply(id, { tools: tools.filter((tool) => toolAllowed(tool.name, { disabled, allowed: allowedTools })).map(toolWithoutCredentialInputs) })
59
69
  } else if (request.method === 'tools/call') {
60
- if (disabled.has(String(request.params?.name || ''))) throw new Error('This tool is disabled for the current delivery lane.')
61
- reply(id, await client.callTool(request.params?.name, request.params?.arguments || {}))
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
@@ -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 acp = new AcpAgentClass({
86
- id: `openvisio-${agent}-${Date.now()}`,
87
- name: `OpenVisio ${agent}`,
88
- description: 'An isolated OpenVisio coding-agent run.',
89
- command: runtime.command,
90
- args: runtime.args,
91
- cwd: cycleCwd,
92
- env: agent === 'codex' ? {
93
- INITIAL_AGENT_MODE: canCode ? 'agent' : 'read-only',
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
- mcpServers,
99
- },
100
- persistSession: false,
101
- onPermissionRequest: async ({ options }) => {
102
- const kind = canCode ? 'allow_once' : 'reject_once'
103
- const selected = options.find((option) => option.kind === kind) || options.find((option) => option.kind.startsWith(canCode ? 'allow' : 'reject'))
104
- return selected ? { outcome: { outcome: 'selected', optionId: selected.optionId } } : { outcome: { outcome: 'cancelled' } }
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
+ // 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
- }) : undefined,
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 timer = setTimeout(() => { timedOut = true; controller.abort(); acp.connection.disconnect() }, maxCycleMs)
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
- const availableModels = await acp.getAvailableModels()
123
- const resolution = resolveAvailableModel(requestedModel, availableModels)
124
- selectedModel = resolution.selected
125
- if (selectedModel) {
126
- await acp.setModel(selectedModel)
127
- if (selectedModel !== requestedModel) log(`model ${requestedModel} resolved to available ${selectedModel} [${resolution.reason}]`)
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
- log(`model ${requestedModel} could not be negotiated because ${agent} did not advertise selectable models; using its session default`)
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
- log(`${agent} cycle done via Mastra ACP (ok)`)
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
- log(`${agent} cycle done via Mastra ACP (${subtype}${!canceled && error?.message ? ': ' + clean(error.message) : ''})`)
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.connection.disconnect()
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
  }
@@ -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: '', reason: 'runtime-default', available: [] }
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 ranked = choices.map((id, index) => {
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
- const requested = lower(requestedValue)
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
- const selected = lower(selectedValue)
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
- // Codex uses Sol for every watcher cycle. Reliability and consistent MCP/tool
265
- // behaviour take priority over maintaining a cheaper coordination lane.
266
- const defaultModel = agent === 'claude' ? 'sonnet' : agent === 'codex' ? 'gpt-5.6-sol' : ''
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 replyRunner = createCycleRunner({
824
- ...runnerOptions,
825
- workdir: '',
826
- systemPrompt: CHAT_CHARTER + '\n\n' + credNote,
827
- cfgKey: identifier + '-reply',
828
- onTool: (name) => { if (/post_message/.test(name)) emitStatusTargets(replyStatusTargets, 'typing') },
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) => executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, replyRunner, item.control, ''),
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 = agent === 'codex' ? codeModel : kind === 'full' ? codeModel : liteModel
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.\-:]*$/i.test(x)) return x
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.\-:]*$/i.test(x) ? x : null
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([replyRunner.cancelCurrent?.(), ...[...activeWorkRunners].map((runner) => runner.cancelCurrent?.())])
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)