openvisio-agent 0.19.3 → 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/bin/cli.mjs CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.19.3",
3
+ "version": "0.19.8",
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'")],
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 replyRunner = createCycleRunner')],
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)')],
@@ -127,6 +127,9 @@ const assertions = [
127
127
  ['Codex recognizes helper authorization as a blocker', events.includes('OPENVISIO_PR_PUSH_AUTH_REQUIRED') && watcher.includes("block?.kind === 'pr-push-authorization-required'")],
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
+ ['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)')],
130
133
  ['all runtime blockers have a delivery path', watcher.includes('publishBlocker') && watcher.includes('WORK_CYCLE_BLOCKED') && watcher.includes('COORDINATION_CYCLE_BLOCKED')],
131
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)')],
132
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,23 @@ 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
+
506
+ // Read-only discovery failures in a reply cycle are not failed mutations and
507
+ // must not be inflated into a generic user-facing blocker. The model can give a
508
+ // precise, scoped answer (or say which live fact it could not read). Failed team
509
+ // mutations remain fail-closed so prose can never masquerade as a completed act.
510
+ export function blockingReplyMcpErrors(errors) {
511
+ const mutation = /^(?:update_ticket|post_message|comment_ticket|react_message|create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/
512
+ return [...new Set((Array.isArray(errors) ? errors : []).map((name) => String(name || '').replace(/^.*(?:__|[.:/])/, '').replace(/[-.]/g, '_').toLowerCase()).filter((name) => mutation.test(name)))]
513
+ }
514
+
498
515
  // Coordination tickets are imperative board/chat actions, not implementation
499
516
  // work that merely mentions words such as "message", "status", or "label" in
500
517
  // its feature title. Requiring the coordination verb at the start prevents
@@ -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 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
+ } : {},
123
+ session: {
124
+ cwd: cycleCwd,
125
+ mcpServers,
113
126
  },
114
- }) : undefined,
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 timer = setTimeout(() => { timedOut = true; controller.abort(); acp.connection.disconnect() }, maxCycleMs)
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
- 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}]`)
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
- log(`model ${requestedModel} could not be negotiated because ${agent} did not advertise selectable models; using its session default`)
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.connection.disconnect()
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, 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 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
- })
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
  : ''
@@ -834,7 +849,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
834
849
  const fullPrompt = (canCode ? CODE_FULL + codexPushGuide : 'Handle the supplied verified backend ticket with the available OpenVisio tools. Update or comment on the ticket as requested, do not claim repository work in chat-only mode, and stop after the verified action.') + '\n\n' + backendToolRule
835
850
  const fastPrompt = (canCode ? CODE_FAST : CYCLE_FAST) + '\n\n' + backendToolRule
836
851
  const coordinatePrompt = COORDINATE + '\n\n' + backendToolRule
837
- const guardedReplyPrompt = 'WATCHER-DELIVERED REPLY: the watcher has already verified that the current source message is addressed to you. Do not call post_message, relay inbox tools, or MCP resource APIs. OpenVisio team-state tools are allowed: when the answer depends on live projects, assignments, tickets, or status, call list_agents/list_projects/list_tasks/get_ticket yourself before answering and never ask the teammate for a slug that those tools can resolve. Return only one natural, context-specific reply of 1-3 sentences. Do not echo the request, announce a plan, or add a generic acknowledgement. If the supplied context says the work was completed, lead with the verified result; if it is blocked, name only the real blocker and next action.' + '\n\n' + backendToolRule
852
+ const guardedReplyPrompt = `WATCHER-DELIVERED REPLY: the watcher has already verified that the current source message is addressed to you. Your current OpenVisio agent identifier is "${identifier}"; do not call list_agents merely to rediscover yourself. Do not call post_message, relay inbox tools, or MCP resource APIs. OpenVisio team-state tools are allowed: when the answer depends on live projects, assignments, tickets, or status, use list_projects/list_tasks/get_ticket as needed and never ask the teammate for a slug that those tools can resolve. A failed read-only discovery call is not a completed action and must not be described as an intervention-level blocker; continue with available live data or state the narrow fact you could not verify. Return only one natural, context-specific reply of 1-3 sentences. Do not echo the request, announce a plan, or add a generic acknowledgement. If the supplied context says the work was completed, lead with the verified result; if it is blocked, name only the real blocker and next action.` + '\n\n' + backendToolRule
838
853
  // Live model state — changeable at runtime by the in-chat `/model` command.
839
854
  // codeModel drives full/sweep cycles; chatModel (if set) the lighter fast/intro
840
855
  // ones, so routine chatter can run cheaper than real code work.
@@ -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) => executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, replyRunner, item.control, ''),
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
@@ -1530,13 +1605,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1530
1605
  finally { if (activeTaskRef) await finalizeFailedTaskPause(activeTaskRef) }
1531
1606
  return
1532
1607
  }
1533
- if (kind !== 'full' && result?.mcpErrors?.length) {
1534
- const notice = `I'm blocked by failed OpenVisio actions: ${result.mcpErrors.join(', ')}. I'm not claiming success; this needs a retry or intervention.`
1608
+ const replyMutationErrors = kind !== 'full' ? blockingReplyMcpErrors(result?.mcpErrors) : []
1609
+ if (replyMutationErrors.length) {
1610
+ const notice = `I couldn't complete the requested OpenVisio action: ${replyMutationErrors.join(', ')}. I haven't claimed that change succeeded.`
1535
1611
  log('COORDINATION_CYCLE_BLOCKED failed MCP calls; publishing blocker')
1536
1612
  try { await publishBlocker({ prompt, taskRef: activeTaskRef, delivery, notice }) }
1537
1613
  catch (e) { log('failed to publish coordination blocker: ' + (e?.message || e)) }
1538
1614
  return
1539
1615
  }
1616
+ if (kind !== 'full' && result?.mcpErrors?.length) {
1617
+ log('reply cycle had read-only MCP failures (' + result.mcpErrors.join(', ') + '); preserving the scoped model reply')
1618
+ }
1540
1619
  // Model prose never proves success or a blocker. Full cycles must produce
1541
1620
  // runtime-observed ticket reads, repository evidence, and ticket updates.
1542
1621
  const ticketCycle = !!activeTaskRef || /ticket\s+#?\d+.*?project\s+\d+/i.test(prompt)
@@ -1789,6 +1868,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1789
1868
  }
1790
1869
  return
1791
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
+ }
1792
1876
  log('agent:mention in channel ' + (cid != null ? cid : '?') + (threadRoot != null ? ' (thread ' + threadRoot + ')' : ''))
1793
1877
  // Light up the live status the instant we pick this up; drain owns the
1794
1878
  // subsequent working/typing heartbeat for its lane.
@@ -1861,7 +1945,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1861
1945
  void item.control.runner?.cancelCurrent?.()
1862
1946
  })
1863
1947
  }
1864
- await Promise.all([replyRunner.cancelCurrent?.(), ...[...activeWorkRunners].map((runner) => runner.cancelCurrent?.())])
1948
+ await Promise.all([...replyRunners.values(), ...activeWorkRunners].map((runner) => runner.cancelCurrent?.()))
1949
+ for (const runner of replyRunners.values()) runner.close?.()
1865
1950
  process.exit(0)
1866
1951
  }
1867
1952
  process.on('SIGTERM', bye)