openvisio-agent 0.19.9 → 0.19.11

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
@@ -79,20 +79,21 @@ openvisio-agent watch --name ada --workdir ~/repo # allow REAL work on a git br
79
79
  openvisio-agent stop --name ada # stop service + every ada watcher
80
80
  ```
81
81
 
82
- The watcher can run up to three assigned coding tasks concurrently. Each ticket gets an isolated model runtime and ticket-specific worktree; tasks sharing the same mutable workspace remain serialized to prevent branch and file conflicts. Chat replies stay serialized, and cancellation targets only the matching ticket or source thread. The work pool reuses local clones and is instructed to preserve dirty/staged work, create unique branches, and stage only task-owned changes. The independent reply lane receives a chat charter and no coding workspace. Codex publishes local branches through the constrained helper, then opens a PR with `gh pr create`; linked-codebase MCP operations are a fallback when the repository cannot be obtained locally.
82
+ The watcher can run up to three assigned coding tasks concurrently. Each ticket gets an isolated model runtime and ticket-specific worktree; tasks sharing the same mutable workspace remain serialized to prevent branch and file conflicts. Replies to one thread stay serialized while up to three independent threads run concurrently, and cancellation targets only the matching ticket or source thread. The work pool reuses local clones and is instructed to preserve dirty/staged work, create unique branches, and stage only task-owned changes. The independent reply lane receives a chat charter and no coding workspace. Codex publishes local branches through the constrained helper, then opens a PR with `gh pr create`; linked-codebase MCP operations are a fallback when the repository cannot be obtained locally.
83
83
 
84
- Publishing a local branch uses an explicit, one-time authorization per repository:
84
+ PR publishing is part of normal coding setup. `connect` authorizes the selected `--workspace` (or `~/openvisio-workspace`) for routine agent-branch pushes and installs the constrained Codex helper rule. New clones and linked worktrees inherit that workspace authority. Existing coding configurations migrate on their next watcher start. Use `--no-pr-push` during setup to opt out.
85
85
 
86
- ```bash
87
- cd /path/to/private-repo
88
- openvisio-agent authorize-pr-push
89
- ```
86
+ The helper `openvisio-agent push-pr-branch` accepts no arguments. It pushes only HEAD to the matching `agent/*` branch at the verified origin push URL, disables hooks, and rejects protected branches, force pushes, local/file remotes, and credential-bearing URLs. `revoke-pr-push` revokes the repository across its worktrees and survives future starts. `authorize-pr-push --repo <path>` remains available for repositories outside the configured workspace.
87
+
88
+ Authorization recovery checks paused repositories locally every two seconds and clears both policy and failed-revision state before live ticket verification. It does not need a new watcher or a new chat message. Updating the package still requires the running service to load the new version once.
89
+
90
+ Fresh top-level messages belong in the agent’s dedicated channel; responses to existing requests stay in their source thread. The watcher resolves a unique exact identity/name match (including `agent-alex` and `alex-agent`) and never falls back to general. Use `connect ... --channel <id>` for a differently named dedicated channel. If it is missing or ambiguous, ticket results remain recorded and channel delivery stays pending; the watcher does not invent a channel-creation tool.
90
91
 
91
- That command installs a narrow Codex rule for `openvisio-agent push-pr-branch` and records the repository's exact root and `origin`. The helper accepts no arguments and can only push `HEAD` to the same `agent/*` branch on that authorized origin. It disables repository hooks and rejects main/master, other branch namespaces, changed remotes, force pushes, local/file remotes, and credential-bearing URLs. Revoke it from the repository with `openvisio-agent revoke-pr-push`.
92
+ Codex and OpenCode use Mastra ACP directly. Work sessions are retained per ticket in a six-entry pool; reply sessions in an eight-entry pool. Idle sessions expire after five minutes and active sessions cannot be evicted. Git snapshots are asynchronous, lightweight commands lazily load the watcher, and logs separate queue wait, preflight, first-event, and total runtime time. Projects reconcile with concurrency three and enqueue tickets before reading optional activity history. Coordination sessions can update the board without gaining repository access.
92
93
 
93
94
  Assignments use independent FIFO entries with stable-key duplicate suppression and bounded, worktree-aware concurrency. Backlog recovery uses the same ticket verification path as WebSocket events, and each queued ticket is checked again before a model starts. MCP calls have a 20-second deadline including response bodies; tool discovery is cached and paginated. Codex MCP failures retain a bounded, credential-redacted reason in the watcher log. Cancellation targets only its ACP session. Credentials and replay state use atomic file replacement; Mastra persists contextual memory in libSQL.
94
95
 
95
- See `docs/BYO_SYSTEM_AUDIT.md` in the repository for the audit results and remaining live-validation limits. Local certification does not certify a deployed agent's behavior.
96
+ See `docs/BYO_PERFORMANCE_AUDIT_2026-09-08.md` in the repository for the audit results and remaining live-validation limits. Local certification does not certify a deployed agent's behavior.
96
97
 
97
98
  `--install` sets up a background service (launchd on macOS, systemd `--user` on Linux) that runs `watch` and restarts on login. Logs go to `~/.openvisio/<agent>.log` (macOS) or `journalctl --user -u openvisio-<agent>` (Linux).
98
99
 
package/bin/cli.mjs CHANGED
@@ -13,9 +13,8 @@ import { spawnSync } from 'node:child_process'
13
13
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
14
14
  import { fileURLToPath } from 'node:url'
15
15
  import { dirname, join } from 'node:path'
16
- import { parseFlags, slugify, stripSlash, exchangeToken, ensureClaude, ensureCodex, writeJson, mcpConfigPath, configPath, chmodSafe, onPath, OV_DIR, fail, ok, info } from '../src/lib.mjs'
17
- import { runWatch, installService, stopWatchers } from '../src/watch.mjs'
18
- import { authorizePrPush, pushPrBranch, revokePrPush } from '../src/pr-push.mjs'
16
+ import { parseFlags, slugify, stripSlash, exchangeToken, ensureClaude, ensureCodex, writeJson, mcpConfigPath, configPath, chmodSafe, onPath, OV_DIR, DEFAULT_WORKSPACE, fail, ok, info } from '../src/lib.mjs'
17
+ import { authorizePrPush, pushPrBranch, revokePrPush, configurePrPublishing } from '../src/pr-push.mjs'
19
18
 
20
19
  const HERE = dirname(fileURLToPath(import.meta.url))
21
20
  const VERSION = (() => { try { return JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8')).version } catch { return '0.0.0' } })()
@@ -82,8 +81,13 @@ stop
82
81
  remaining watcher with that exact --name and clears its stale lock. Use this
83
82
  instead of killing changing PIDs: openvisio-agent stop --name Alex
84
83
 
84
+ Use --channel <id> during connect to select the dedicated channel for new threads.
85
+
86
+ PR publishing is enabled during connect for the coding workspace, including
87
+ future clones and worktrees. Use --no-pr-push to opt out.
88
+
85
89
  authorize-pr-push
86
- One-time, explicit authorization for the current private repository. Installs a
90
+ Optional authorization outside the configured workspace for the current private repository. Installs a
87
91
  narrow Codex command rule and records the exact repository root + origin. The
88
92
  permitted helper can only push the current agent/* branch and cannot force-push,
89
93
  choose another remote/ref, push a protected branch, or merge.
@@ -168,7 +172,8 @@ async function runConnect({ positional, flags }) {
168
172
  ensureOpencode() // the watcher writes opencode.json with the remote MCP at runtime
169
173
  }
170
174
  const wsWorkdir = flags.workdir === true ? process.cwd() : flags.workdir ? String(flags.workdir) : flags.workspace ? String(flags.workspace) : ''
171
- writeJson(configPath(slug), { host: stripSlash(host), key, mcpUrl, name, slug, ...(mcpCfg ? { mcpConfig: mcpCfg } : {}), ...(agent !== 'claude' ? { agent } : {}), ...(wsWorkdir ? { workspace: wsWorkdir } : {}), ...(flags['chat-only'] ? { chatOnly: true } : {}), ...(flags.model ? { model: String(flags.model) } : {}), ...(flags['chat-model'] ? { chatModel: String(flags['chat-model']) } : {}) }, true)
175
+ if (!flags['chat-only']) configurePrPublishing({ workspace: wsWorkdir || DEFAULT_WORKSPACE, enabled: !flags['no-pr-push'] })
176
+ writeJson(configPath(slug), { ...(flags.channel ? { dedicatedChannelId: Number(flags.channel) } : {}), prPublishing: !flags['no-pr-push'], host: stripSlash(host), key, mcpUrl, name, slug, ...(mcpCfg ? { mcpConfig: mcpCfg } : {}), ...(agent !== 'claude' ? { agent } : {}), ...(wsWorkdir ? { workspace: wsWorkdir } : {}), ...(flags['chat-only'] ? { chatOnly: true } : {}), ...(flags.model ? { model: String(flags.model) } : {}), ...(flags['chat-model'] ? { chatModel: String(flags['chat-model']) } : {}) }, true)
172
177
 
173
178
  ok(`Connected "${name}" to ${host}.`)
174
179
  info()
@@ -247,7 +252,8 @@ async function runConnectBackend({ flags }) {
247
252
  }
248
253
  }
249
254
 
250
- writeJson(configPath(slug), { mode: 'backend', backend, apiKey, identifier, name, slug, wsUrl, mcpUrl, mcpConfig, ...(agent !== 'claude' ? { agent } : {}), ...(workdir ? { workspace: workdir } : {}), ...(chatOnly ? { chatOnly: true } : {}), ...(flags.model ? { model: String(flags.model) } : {}), ...(flags['chat-model'] ? { chatModel: String(flags['chat-model']) } : {}) }, true)
255
+ if (!chatOnly) configurePrPublishing({ workspace: workdir || DEFAULT_WORKSPACE, enabled: !flags['no-pr-push'] })
256
+ writeJson(configPath(slug), { ...(flags.channel ? { dedicatedChannelId: Number(flags.channel) } : {}), prPublishing: !flags['no-pr-push'], mode: 'backend', backend, apiKey, identifier, name, slug, wsUrl, mcpUrl, mcpConfig, ...(agent !== 'claude' ? { agent } : {}), ...(workdir ? { workspace: workdir } : {}), ...(chatOnly ? { chatOnly: true } : {}), ...(flags.model ? { model: String(flags.model) } : {}), ...(flags['chat-model'] ? { chatModel: String(flags['chat-model']) } : {}) }, true)
251
257
  // A sourceable env file, matching the setup snippet OpenVisio shows.
252
258
  const envPath = join(OV_DIR, `${slug}.env`)
253
259
  mkdirSync(OV_DIR, { recursive: true })
@@ -288,6 +294,7 @@ async function runConnectBackend({ flags }) {
288
294
  } else {
289
295
  info()
290
296
  info('Setting up the always-on autonomy listener (auto-starts on login, survives reboot)…')
297
+ const { installService } = await import('../src/watch.mjs')
291
298
  installService({ slug, workdir })
292
299
  ok('Your agent is now live — it will catch @mentions and assignments even with no terminal open.')
293
300
  }
@@ -302,11 +309,11 @@ async function main() {
302
309
  const cmd = argv[0]
303
310
  const rest = parseFlags(argv.slice(1))
304
311
  if (cmd === 'connect') return runConnect(rest)
305
- if (cmd === 'watch') return runWatch(rest)
312
+ if (cmd === 'watch') return (await import('../src/watch.mjs')).runWatch(rest)
306
313
  if (cmd === 'stop') {
307
314
  const name = String(rest.flags.name || rest.positional[0] || '')
308
315
  if (!name) fail('Missing agent name.\n Usage: openvisio-agent stop --name <agent>')
309
- return stopWatchers({ slug: slugify(name) })
316
+ return (await import('../src/watch.mjs')).stopWatchers({ slug: slugify(name) })
310
317
  }
311
318
  if (cmd === 'authorize-pr-push') {
312
319
  const cwd = String(rest.flags.repo || rest.positional[0] || process.cwd())
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.19.9",
4
- "description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team MCP tools + optional autonomy in one command.",
3
+ "version": "0.19.11",
4
+ "description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team \u2014 MCP tools + optional autonomy \u2014 in one command.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "openvisio-agent": "bin/cli.mjs"
@@ -44,9 +44,14 @@ const quickReply = readFileSync(join(repo, 'frontend', 'app', 'api', 'agent', 'q
44
44
  const spec = readFileSync(join(repo, 'docs', 'CODEX_BYO_AGENT_SPEC.md'), 'utf8')
45
45
 
46
46
  const assertions = [
47
+ ['setup enables constrained workspace PR publishing', cli.includes('configurePrPublishing({ workspace:') && watcher.includes('configurePrPublishing({ workspace:') && prPush.includes("['rev-parse', '--git-common-dir']")],
48
+ ['authorization recovery is local and independent of backlog cadence', watcher.includes('resumeReadyAuthorizations(), 2_000') && watcher.includes('releaseAuthorizedPause(key')],
49
+ ['new follow-ups only deduplicate later replies', watcher.includes('repliesAfterSource(renderedAgentMessages') && watcher.includes('sourceMessageId: mid')],
50
+ ['Mastra snapshots do not block the event loop', mastraHarness.includes('await execFileAsync') && !mastraHarness.includes('spawnSync')],
51
+ ['ticket pickup precedes optional activity reads', watcher.indexOf("await handleTaskSignal('task:assigned'", watcher.indexOf('const reconcileBacklog')) < watcher.indexOf("const activityData = await callMcpTool", watcher.indexOf('const reconcileBacklog'))],
47
52
  ['task:assigned is handled', watcher.includes("k === 'task:assigned'")],
48
53
  ['task signals are verified with get_ticket', watcher.includes("callMcpTool('get_ticket'")],
49
- ['review and testing handoffs do not restart work', watcher.includes('taskIsAwaitingReview(task, reviewIds)') && watcher.includes('taskIsAwaitingReview(ticket)')],
54
+ ['review and testing handoffs do not restart work', watcher.includes('taskIsAwaitingReview(task, reviewIds)') && watcher.includes('taskIsAwaitingReview(ticket, typeSets.review)')],
50
55
  ['review handoff releases the task key for future rework', watcher.includes('seenTasks.delete(taskKey)') && watcher.includes('seenTasks.delete(key)')],
51
56
  ['assigned coding completion is posted by the watcher', watcher.includes('announceTaskCompletion') && watcher.includes('postMessageOnce({ key: `completion:${report.key}`')],
52
57
  ['completion requires review/done state and PR evidence', watcher.includes('buildTaskCompletionReport') && watcher.includes('completion report deferred')],
@@ -66,7 +71,7 @@ const assertions = [
66
71
  ['human-facing ticket references require slugs, never database ids', watcher.includes('TICKET SLUGS, NEVER DATABASE IDS') && watcher.includes('ticketDisplaySlug(task)') && events.includes('export function ticketDisplaySlug') && !events.includes('I finished ticket #${task.id}')],
67
72
  ['rendered backend replies are checked before every guarded thread post', watcher.includes("callMcpTool('list_message_thread'") && watcher.includes('renderedAgentMessages(live') && watcher.includes('same-content-rendered')],
68
73
  ['Codex cannot race the watcher with post_message', watcher.includes("disabledMcpTools: ['post_message']") && codexConfig.includes('disabled_tools = [')],
69
- ['Codex cannot continue without OpenVisio MCP tools', watcher.includes('buildCodexMcpOverride') && codexConfig.includes('required = true') && codexConfig.includes('startup_timeout_sec = 30')],
74
+ ['ACP sessions receive the authenticated OpenVisio proxy', mastraHarness.includes("name: 'openvisio-team-watcher'") && mastraHarness.includes('OPENVISIO_CODEX_MCP_URL') && watcher.includes('missingRuntimeWorkEvidence(result')],
70
75
  ['Codex discovers deferred OpenVisio actions before claiming they are missing', watcher.includes('Codex may defer MCP actions') && watcher.includes('use tool_search to find that exact action') && watcher.includes('initial-list miss is NOT evidence that the server is disconnected')],
71
76
  ['Codex authentication is injected outside the model', codexConfig.includes('OPENVISIO_CODEX_API_KEY') && codexProxy.includes('toolWithoutCredentialInputs') && codexProxy.includes('client.callTool') && watcher.includes('local OpenVisio MCP bridge injects authentication outside the model')],
72
77
  ['reply delivery keys survive reconnects', watcher.includes('deliveredReplies: [...deliveredReplies]') && watcher.includes('deliveredReplies.has(deliveryKey)')],
@@ -76,12 +81,12 @@ const assertions = [
76
81
  ['reply runner has no coding workspace or coding charter', watcher.includes("workdir: ''") && watcher.includes("systemPrompt: CHAT_CHARTER + '\\n\\n' + credNote") && opencodeConfig.includes("'*': 'deny'")],
77
82
  ['MCP requests have a deadline including response bodies', mcpHttp.includes('controller.abort()') && mcpHttp.includes('const body = await res.text()')],
78
83
  ['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 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'")],
84
+ ['Codex and OpenCode use warm scoped Mastra ACP sessions', watcher.includes('createMastraAcpRunner') && watcher.includes('workRunners.acquire(sessionKey') && watcher.includes('replyRunners.acquire(key') && mastraHarness.includes('persistSession: true')],
80
85
  ['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
- ['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)')],
86
+ ['one watcher owns bounded work and per-thread reply sessions', watcher.includes('MAX_CONCURRENT_WORKERS = 3') && watcher.includes('workRunners.release(sessionKey)') && watcher.includes('replyRunners.release(key)') && watcher.includes('groupKey: (item) => item.taskRef')],
87
+ ['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(candidate[1].group)')],
83
88
  ['work and reply cancellation targets are isolated', watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent')],
84
- ['assigned task activity resolves a project channel', watcher.includes("callMcpTool('list_channels'") && watcher.includes('projectStatusChannel(projectId)')],
89
+ ['fresh threads resolve the dedicated agent channel', watcher.includes('dedicatedChannel(Array.isArray(channels)') && watcher.includes('No dedicated agent channel available for a new thread')],
85
90
  ['presence notification requests are disabled', watcher.includes('const sendStatus = () => {}') && !watcher.includes('agentStateRequest(') && !watcher.includes("setInterval(() => emitStatusTargets")],
86
91
  ['websocket client cannot emit legacy agent_status', !websocket.includes('agent_status')],
87
92
  ['frontend consumes thinking event', activityHook.includes("'channel:agent:thinking'")],
@@ -99,15 +104,15 @@ const assertions = [
99
104
  ['quick replies preserve distinct top-level conversations', quickReply.includes('m.parentId ?? m.messageId') && quickReply.includes('...(parentId ? { parentId } : {})')],
100
105
  ['completion has an evidence failure gate', watcher.includes('WORK_CYCLE_FAILED')],
101
106
  ['failed evidence revisions persist and suppress self-triggered retries', watcher.includes('failedTaskVersions: [...failedTaskVersions]') && watcher.includes("failedTaskVersions.set(key, 'pending')") && watcher.includes('failedTaskRevisionIsCurrent(failedRevision, ticket)') && watcher.includes('ticket paused until its revision changes')],
102
- ['assigned coding tickets prefer their prepared local worktree', watcher.includes('findTicketWorktree(workdir, ticketId)') && watcher.includes('A prepared local git worktree for this ticket exists') && watcher.includes('Do not call list_codebases, codebase_tree, or get_codebase') && watcher.includes('cwd: cycleCwd') && watcher.includes('workdir: ticketWorktree')],
103
- ['OpenCode emits structured events for runtime evidence', watcher.includes("'--format', 'json'") && watcher.includes('opencodeEventEvidence(event)')],
107
+ ['assigned coding tickets prefer their prepared local worktree', watcher.includes('findTicketWorktree(workdir, ticketId)') && watcher.includes('A prepared local git worktree for this ticket exists') && mastraHarness.includes('cwd: cycleCwd') && watcher.includes('workdir: ticketWorktree')],
108
+ ['ACP evidence requires completed tools', mastraHarness.includes("if (tool.status !== 'completed') continue") && mastraHarness.includes('codexPolicyBlock(json(tool.rawOutput))')],
104
109
  ['OpenCode API-key MCP disables OAuth probing', opencodeConfig.includes("oauth: false") && opencodeConfig.includes('timeout: 15_000')],
105
- ['OpenCode identities use isolated configs outside the code workspace', watcher.includes('opencodeRuntimeLayout({ cfgKey, workdir })') && watcher.includes("'--dir', workspace") && watcher.includes('OPENCODE_CONFIG: opencodeConfigPath') && watcher.includes('OPENCODE_CONFIG_CONTENT: JSON.stringify(opencodeConfig)')],
110
+ ['OpenCode ACP isolates server identity and reply permissions', mastraHarness.includes('OPENCODE_CONFIG_CONTENT') && mastraHarness.includes('buildOpencodeConfig({ canCode })') && mastraHarness.includes('mcpServers,')],
106
111
  ['OpenCode backend prompts forbid relay and resource-discovery tools', watcher.includes('BACKEND MCP RULE') && ['get_marching_orders', 'poll_inbox', 'get_resource', 'list_mcp_resources', 'list_mcp_resource_templates'].every((name) => watcher.includes(name))],
107
- ['OpenCode tool failures retain sanitized diagnostics', events.includes('toolError: toolError.replace') && watcher.includes("opencode tool '") && watcher.includes("split(redactKey).join('[redacted]')")],
112
+ ['ACP tool failures retain sanitized diagnostics', mastraHarness.includes('for (const [name, detail] of errors)') && mastraHarness.includes('redact(detail)')],
108
113
  ['all runtime acknowledgements cannot satisfy coding completion', watcher.includes('missingRuntimeWorkEvidence(result') && watcher.includes('claudeEventEvidence(o, turnToolUses)') && watcher.includes('releaseTaskForRetry(activeTaskRef, prompt)')],
109
- ['Codex prose cannot masquerade as repository evidence', watcher.includes('codexEventEvidence(event)') && events.includes("item.type === 'agent_message'") && events.includes("event.type === 'item.completed'")],
110
- ['Codex MCP failures retain sanitized per-call diagnostics', events.includes('rawToolError = item.error') && watcher.includes('mcpErrorDetails.set(evidence.mcpTool, safe)') && watcher.includes("join('[redacted]')")],
114
+ ['ACP prose cannot masquerade as repository evidence', mastraHarness.includes("if (event.type === 'text') { outputText += event.text; continue }") && mastraHarness.includes("if (tool.status !== 'completed') continue")],
115
+ ['ACP MCP failures retain sanitized per-call diagnostics', mastraHarness.includes('mcpErrorDetails: Object.fromEntries(errors)') && mastraHarness.includes("join('[redacted]')")],
111
116
  ['coding recovery retains source context and requires a final result', watcher.includes('ORIGINAL REQUEST AND ROUTING CONTEXT') && events.includes('resultMessageRequired && !result?.didResultMessage') && events.includes("outputText: second?.outputText || first?.outputText || ''")],
112
117
  ['backend MCP accepts stateless initialize responses', mcpHttp.includes("mode = () => !initialized ? 'uninitialized' : sessionId ? 'stateful' : 'stateless'") && !watcher.includes('MCP initialize returned no session id')],
113
118
  ['MCP initialize is shared across concurrent startup probes', mcpHttp.includes('if (initializePromise) return initializePromise')],
@@ -115,20 +120,20 @@ const assertions = [
115
120
  ['backend MCP tools are discovered and cached', mcpHttp.includes("method: 'tools/list'") && mcpHttp.includes('if (!refresh && toolsCache)') && watcher.includes('discoverMcpTools')],
116
121
  ['missing optional MCP tools use compatibility fallbacks', watcher.includes("reason: 'not-advertised'") && watcher.includes('recording the blocker in the ticket description')],
117
122
  ['backend introduction is watcher-owned for every runtime', watcher.includes('void announceIntroduction().then((delivered)')],
118
- ['Codex policy rejection is captured from stderr', watcher.includes("stdio: ['ignore', 'pipe', 'pipe']") && watcher.includes('forwardDiagnostic(d)') && watcher.includes('inspectDiagnostic(incoming)')],
119
- ['Codex recoverable subprocess diagnostics are not surfaced as activity', watcher.includes('shouldSuppressCodexDiagnostic(line)') && watcher.includes("forwardDiagnostic('', true)") && watcher.includes('RECOVER DEAD COMMAND SESSIONS')],
120
- ['policy rejection cannot be logged as successful', watcher.includes("subtype = policyBlock ? 'blocked'")],
123
+ ['ACP helper rejections preserve structured authorization blockers', mastraHarness.includes('codexPolicyBlock(json(tool.rawOutput))') && watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock, delivery)')],
124
+ ['ACP account notifications stay silent', mastraHarness.includes("property === 'extNotification'") && watcher.includes('RECOVER DEAD COMMAND SESSIONS')],
125
+ ['policy rejection cannot close a work cycle', mastraHarness.includes("subtype: policyBlock ? 'blocked' : 'ok'") && watcher.includes("result?.subtype === 'blocked'")],
121
126
  ['policy-blocked tickets are persisted and paused', watcher.includes('blockedTasks: [...blockedTasks]') && watcher.includes('WORK_CYCLE_BLOCKED')],
122
127
  ['repository push authorization automatically resumes the paused ticket', watcher.includes('blockedTaskRepos: [...blockedTaskRepos]') && watcher.includes('repositoryHasPrPushAuthorization')],
123
128
  ['BYO coding prefers an existing local repository', watcher.includes('A usable local clone is your primary code surface') && watcher.includes('do not use remote codebase tools') && watcher.includes('Remote codebase tools are a fallback only when the repository cannot be obtained locally')],
124
129
  ['Codex publishes local branches with the constrained helper', watcher.includes('CODEX PR DELIVERY') && watcher.includes('openvisio-agent push-pr-branch') && watcher.includes('Use list_codebases/create_codebase_branch/create_codebase_commit/create_pull_request only as a fallback') && events.includes('const codebaseMutation')],
125
- ['private PR pushes use an explicit constrained helper', cli.includes("cmd === 'authorize-pr-push'") && cli.includes("cmd === 'push-pr-branch'") && prPush.includes("'push', '-u', 'origin', destination")],
126
- ['PR push helper rejects protected/alternate/force targets by construction', prPush.includes("/^agent\\/") && prPush.includes('entry?.root === root && entry?.remote === remote') && prPush.includes('accepts no force, remote, or ref args')],
130
+ ['private PR pushes use an explicit constrained helper', cli.includes("cmd === 'authorize-pr-push'") && cli.includes("cmd === 'push-pr-branch'") && prPush.includes("'push', '-u', identity.remote, destination")],
131
+ ['PR push helper restricts branch and destination within setup authority', prPush.includes('isSafeAgentBranch(branch)') && prPush.includes('policyAllows(') && prPush.includes('accepts no force, remote, or ref args')],
127
132
  ['Codex recognizes helper authorization as a blocker', events.includes('OPENVISIO_PR_PUSH_AUTH_REQUIRED') && watcher.includes("block?.kind === 'pr-push-authorization-required'")],
128
133
  ['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock, delivery)') && watcher.includes("Action required: I'm blocked")],
129
134
  ['blocker routing carries explicit task identity', watcher.includes('const activeTaskRef = taskRef') && watcher.includes('taskRef: activeTaskRef')],
130
135
  ['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'")],
136
+ ['pending-ticket questions use watcher-owned MCP reads without a chat model cycle', watcher.includes('conversationAsksPendingTickets(text)') && watcher.includes('pending-ticket question -> watcher-owned MCP lookup') && watcher.includes("callMcpReadWithRetry('list_tasks'") && watcher.includes("if (selfAgentId == null) {\n const agentsData = toolData(await callMcpReadWithRetry('list_agents'))")],
132
137
  ['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)')],
133
138
  ['all runtime blockers have a delivery path', watcher.includes('publishBlocker') && watcher.includes('WORK_CYCLE_BLOCKED') && watcher.includes('COORDINATION_CYCLE_BLOCKED')],
134
139
  ['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)')],
@@ -0,0 +1,32 @@
1
+ // Assignment requests are routed by the watcher, before the restricted chat
2
+ // runtime can mistake its own tool limits for the coding worker's capabilities.
3
+ export function assignmentStatusOnly(value) {
4
+ return /\b(?:do not|don't|dont|stop|cancel|stand down|status only|just (?:list|check|tell|show)|only (?:list|check|tell|show))\b/i.test(String(value || ''))
5
+ }
6
+
7
+ export function assignmentRequest(value) {
8
+ const text = String(value || '').replace(/\s+/g, ' ').trim()
9
+ if (assignmentStatusOnly(text)) return null
10
+ const slugs = [...new Set((text.match(/\b[a-z][a-z0-9]*-\d+\b/gi) || []).map((slug) => slug.toUpperCase()))]
11
+ const tickets = /\b(?:tickets?|tasks?|assignments?|backlog)\b/i.test(text) || slugs.length > 0
12
+ const action = /\b(?:start|begin|resume|continue|implement|fix|finish|complete|handle|execute|dispatch|pick\s+up|work\s+(?:on|through))\b/i.test(text)
13
+ const assigned = /\b(?:assigned|gave)\s+(?:\S+\s+){0,3}you\b|\byou\s+have\b.*\b(?:pending|assigned)\b/i.test(text)
14
+ return tickets && (action || assigned) ? { slugs } : null
15
+ }
16
+
17
+ export async function routeAssignments({ request, loadTickets, handleTask, isCancelled = () => false }) {
18
+ const tickets = await loadTickets()
19
+ const selected = tickets.filter((ticket) => !request.slugs.length || request.slugs.includes(ticket.slug))
20
+ // A project-scoped slug must resolve uniquely before any work is started.
21
+ for (const slug of request.slugs) {
22
+ if (selected.filter((ticket) => ticket.slug === slug).length !== 1) {
23
+ throw new Error(`I couldn't uniquely resolve ${slug} among my open assignments.`)
24
+ }
25
+ }
26
+ const actionable = selected.filter((ticket) => !ticket.awaitingReview)
27
+ for (const ticket of actionable) {
28
+ if (isCancelled()) return
29
+ await handleTask('task:assigned', { task: { id: ticket.id, project_id: ticket.projectId } })
30
+ }
31
+ return actionable.length
32
+ }
@@ -0,0 +1,10 @@
1
+ // Authorization recovery takes precedence over the generic failed-revision
2
+ // gate. Clearing only the policy block left tickets permanently stuck.
3
+ export function releaseAuthorizedPause(key, { blocked, repos, failed, seen, authorized }) {
4
+ if (!blocked.has(key) || !repos.has(key) || !authorized(repos.get(key))) return false
5
+ blocked.delete(key)
6
+ repos.delete(key)
7
+ failed.delete(key)
8
+ seen.delete(key)
9
+ return true
10
+ }
@@ -0,0 +1,26 @@
1
+ const normalize = (name) => String(name || '').toLowerCase().replace(/^#/, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
2
+ const validId = (id) => id != null && Number.isSafeInteger(Number(id)) && Number(id) > 0
3
+
4
+ export function repliesAfterSource(messages, sourceMessageId) {
5
+ if (sourceMessageId == null) return messages
6
+ // Backend message ids are monotonically increasing database ids. For opaque
7
+ // ids, rely on the persisted source delivery key rather than suppressing a
8
+ // new question because the agent replied to an earlier one in this thread.
9
+ if (!/^\d+$/.test(String(sourceMessageId))) return []
10
+ return messages.filter((message) => /^\d+$/.test(String(message.id)) && BigInt(message.id) > BigInt(sourceMessageId))
11
+ }
12
+
13
+ export function dedicatedChannel(channels, { id, identifier, aliases = [], channelId } = {}) {
14
+ const usable = channels.filter((channel) => validId(channel?.id))
15
+ if (validId(channelId)) return usable.find((channel) => Number(channel.id) === Number(channelId)) || null
16
+ const owned = usable.filter((channel) =>
17
+ (id != null && channel.agent_id != null && String(channel.agent_id) === String(id)) ||
18
+ (identifier && channel.agent_identifier === identifier))
19
+ if (owned.length) return owned.length === 1 ? owned[0] : null
20
+ const names = new Set([identifier, ...aliases].filter(Boolean).flatMap((alias) => {
21
+ const name = normalize(alias)
22
+ return [name, `agent-${name}`, `${name}-agent`]
23
+ }).filter((name) => !['general', 'team', 'project', 'dev', 'development'].includes(name)))
24
+ const matches = usable.filter((channel) => names.has(normalize(channel.name)))
25
+ return matches.length === 1 ? matches[0] : null
26
+ }
@@ -72,7 +72,9 @@ export function runCodexMcpProxy() {
72
72
  let result
73
73
  try { result = await client.callTool(name, request.params?.arguments || {}) }
74
74
  catch (error) {
75
- if (!(allowedTools instanceof Set) || !allowedTools.has(name)) throw error
75
+ // An allowlisted coordination mutation still must never be replayed
76
+ // after an ambiguous timeout. Retry only idempotent reads.
77
+ if (!(allowedTools instanceof Set) || !allowedTools.has(name) || !/^(?:list_|get_|codebase_tree$)/.test(name)) throw error
76
78
  result = await client.callTool(name, request.params?.arguments || {})
77
79
  }
78
80
  reply(id, result)
@@ -0,0 +1,12 @@
1
+ export async function mapConcurrent(items, concurrency, run) {
2
+ if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error('Invalid concurrency')
3
+ const results = new Array(items.length)
4
+ let next = 0
5
+ await Promise.all(Array.from({ length: Math.min(items.length, concurrency) }, async () => {
6
+ while (next < items.length) {
7
+ const index = next++
8
+ results[index] = await run(items[index], index)
9
+ }
10
+ }))
11
+ return results
12
+ }
@@ -11,7 +11,11 @@ export function createCycleQueue({ run, onError = () => {}, concurrency = 1, gro
11
11
 
12
12
  const pump = () => {
13
13
  while (active.size < concurrency && pending.size) {
14
- const available = [...pending].find(([, entry]) => !entry.group || !activeGroups.has(entry.group))
14
+ // Stop at the first runnable entry without copying the entire backlog.
15
+ let available
16
+ for (const candidate of pending) {
17
+ if (!candidate[1].group || !activeGroups.has(candidate[1].group)) { available = candidate; break }
18
+ }
15
19
  if (!available) return
16
20
  const [key, entry] = available
17
21
  pending.delete(key)
@@ -1,21 +1,25 @@
1
- import { spawnSync } from 'node:child_process'
1
+ import { execFile } from 'node:child_process'
2
+ import { promisify } from 'node:util'
2
3
  import { fileURLToPath } from 'node:url'
3
4
  import { AcpAgent } from '@mastra/acp'
4
5
  import { onPath } from './lib.mjs'
5
6
  import { resolveAvailableModel } from './model-selection.mjs'
7
+ import { codexPolicyBlock } from './events.mjs'
8
+ import { buildOpencodeConfig } from './opencode-config.mjs'
6
9
 
7
10
  const MCP_TOOL_NAMES = [
8
11
  'list_agents', 'list_projects', 'list_tasks', 'list_task_types', 'get_ticket',
9
12
  'update_ticket', 'list_channels', 'list_message_thread', 'list_activity',
10
13
  'post_message', 'react_message', 'list_codebases', 'get_codebase',
11
14
  'codebase_tree', 'create_codebase_branch', 'create_codebase_commit',
12
- 'create_pull_request',
15
+ 'create_pull_request', 'write_codebase_file', 'comment_ticket',
13
16
  ]
14
17
  const CHAT_SAFE_MCP_READS = new Set([
15
18
  'list_agents', 'list_projects', 'list_tasks', 'list_task_types', 'get_ticket',
16
19
  'list_channels', 'list_message_thread', 'list_activity', 'list_codebases',
17
20
  'get_codebase', 'codebase_tree',
18
21
  ])
22
+ const COORDINATION_TOOLS = ['update_ticket', 'post_message', 'react_message', 'comment_ticket']
19
23
 
20
24
  const clean = (value, max = 300) => String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max)
21
25
  const json = (value) => { try { return JSON.stringify(value) } catch { return String(value ?? '') } }
@@ -31,27 +35,36 @@ function commandFor(agent) {
31
35
 
32
36
  const proxyPath = fileURLToPath(new URL('./codex-mcp-proxy.mjs', import.meta.url))
33
37
 
34
- function repositorySnapshot(cwd) {
38
+ const execFileAsync = promisify(execFile)
39
+ async function repositorySnapshot(cwd) {
35
40
  if (!cwd) return ''
36
- const result = spawnSync('git', ['status', '--porcelain=v1', '--untracked-files=all'], {
37
- cwd, encoding: 'utf8', timeout: 10_000,
38
- })
39
- return result.status === 0 ? String(result.stdout || '') : ''
41
+ try {
42
+ const result = await execFileAsync('git', ['status', '--porcelain=v1', '--untracked-files=normal'], { cwd, encoding: 'utf8', timeout: 3_000 })
43
+ return String(result.stdout || '')
44
+ } catch { return null }
40
45
  }
41
46
 
42
47
  function toolName(update) {
43
- const haystack = `${update?.title || ''} ${json(update?.rawInput)}`
44
- return MCP_TOOL_NAMES.find((name) => new RegExp(`(?:^|[^a-z0-9_])${name}(?:$|[^a-z0-9_])`, 'i').test(haystack)) || ''
48
+ // Inspect names, never arbitrary argument strings: a shell search for
49
+ // "update_ticket" is not a successful ticket update.
50
+ const candidates = [update?.title, update?.rawInput?.tool, update?.rawInput?.toolName].filter((value) => typeof value === 'string')
51
+ for (const candidate of candidates) {
52
+ const value = candidate.toLowerCase().trim()
53
+ for (const name of MCP_TOOL_NAMES) {
54
+ if (value === name || (/^(?:mcp|openvisio)[\w .:/-]*?/.test(value) && new RegExp(`[_ .:/]${name}$`).test(value))) return name
55
+ }
56
+ }
57
+ return ''
45
58
  }
46
59
 
47
- export function acpPermissionResponse(request, { canCode = false } = {}) {
60
+ export function acpPermissionResponse(request, { canCode = false, canCoordinate = false, proxyProtected = true } = {}) {
48
61
  const options = Array.isArray(request?.options) ? request.options : []
49
62
  const name = toolName(request?.toolCall || {})
50
63
  const opaqueMcpApproval = request?._meta?.is_mcp_tool_approval === true
51
64
  // Codex omits the tool name from correlated ACP permission requests. Chat
52
65
  // sessions expose only CHAT_SAFE_MCP_READS through their private proxy, so
53
66
  // accepting an opaque MCP approval cannot grant a mutation or local access.
54
- const allow = canCode || CHAT_SAFE_MCP_READS.has(name) || opaqueMcpApproval
67
+ const allow = canCode || CHAT_SAFE_MCP_READS.has(name) || (canCoordinate && COORDINATION_TOOLS.includes(name)) || (opaqueMcpApproval && proxyProtected)
55
68
  const preferred = allow ? 'allow_once' : 'reject_once'
56
69
  const selected = options.find((option) => option.kind === preferred) || options.find((option) => option.kind.startsWith(allow ? 'allow' : 'reject'))
57
70
  return selected ? { outcome: { outcome: 'selected', optionId: selected.optionId } } : { outcome: { outcome: 'cancelled' } }
@@ -65,7 +78,7 @@ function contentText(content) {
65
78
  }
66
79
 
67
80
  export function createMastraAcpRunner({
68
- agent, mcpUrl, mcpHeaders = {}, workdir, canCode = !!workdir, maxCycleMs,
81
+ agent, mcpUrl, mcpHeaders = {}, workdir, canCode = !!workdir, canCoordinate = false, maxCycleMs,
69
82
  log = () => {}, debug = false, model, onTool, systemPrompt = '', AcpAgentClass = AcpAgent,
70
83
  }) {
71
84
  const runtime = commandFor(agent)
@@ -82,10 +95,14 @@ export function createMastraAcpRunner({
82
95
  const cycleCwd = cycleOptions.workdir || defaultCwd
83
96
  const controller = new AbortController()
84
97
  let timedOut = false
85
- const before = repositorySnapshot(canCode ? cycleCwd : '')
98
+ const startedAt = performance.now()
99
+ const before = canCode ? repositorySnapshot(cycleCwd) : Promise.resolve(null)
100
+ let firstEventMs = null
101
+ let policyBlock = null
86
102
  const calls = new Set()
87
103
  const errors = new Map()
88
104
  const tools = new Map()
105
+ let toolSequence = 0
89
106
  let outputText = ''
90
107
  let didCode = false
91
108
  let didRepoMutation = false
@@ -97,13 +114,14 @@ export function createMastraAcpRunner({
97
114
 
98
115
  const headers = Object.entries(mcpHeaders).filter(([, value]) => value != null && value !== '').map(([name, value]) => ({ name, value: String(value) }))
99
116
  const disabledMcpTools = Array.isArray(cycleOptions.disabledMcpTools) ? cycleOptions.disabledMcpTools.filter(Boolean) : []
100
- const mcpServers = !mcpUrl ? [] : agent === 'codex' ? [{
117
+ const proxyProtected = !!mcpHeaders['x-agent-api-key'] && !!mcpHeaders['x-agent-identifier']
118
+ const mcpServers = !mcpUrl ? [] : proxyProtected ? [{
101
119
  name: 'openvisio-team-watcher', command: process.execPath, args: [proxyPath], env: [
102
120
  { name: 'OPENVISIO_CODEX_MCP_URL', value: mcpUrl },
103
121
  { name: 'OPENVISIO_CODEX_API_KEY', value: String(mcpHeaders['x-agent-api-key'] || '') },
104
122
  { name: 'OPENVISIO_CODEX_IDENTIFIER', value: String(mcpHeaders['x-agent-identifier'] || '') },
105
123
  { name: 'OPENVISIO_CODEX_DISABLED_TOOLS', value: JSON.stringify(disabledMcpTools) },
106
- { name: 'OPENVISIO_CODEX_ALLOWED_TOOLS', value: canCode ? 'null' : JSON.stringify([...CHAT_SAFE_MCP_READS]) },
124
+ { name: 'OPENVISIO_CODEX_ALLOWED_TOOLS', value: canCode ? 'null' : JSON.stringify([...CHAT_SAFE_MCP_READS, ...(canCoordinate ? COORDINATION_TOOLS : [])]) },
107
125
  ],
108
126
  }] : [{ type: 'http', name: 'openvisio-team', url: mcpUrl, headers }]
109
127
  const sessionKey = JSON.stringify([cycleCwd, disabledMcpTools, mcpServers])
@@ -123,13 +141,20 @@ export function createMastraAcpRunner({
123
141
  // silently discards this session's authenticated stdio bridge.
124
142
  DISABLE_MCP_CONFIG_FILTERING: 'true',
125
143
  CODEX_CONFIG: JSON.stringify({ 'mcp_servers.openvisio-team.enabled': false }),
144
+ } : agent === 'opencode' ? {
145
+ // Keep local tools denied in lightweight ACP sessions, including
146
+ // installations whose ordinary OpenCode config auto-approves tools.
147
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
148
+ ...buildOpencodeConfig({ canCode }),
149
+ mcp: { 'openvisio-team': { enabled: false } },
150
+ }),
126
151
  } : {},
127
152
  session: {
128
153
  cwd: cycleCwd,
129
154
  mcpServers,
130
155
  },
131
156
  persistSession: true,
132
- onPermissionRequest: async (request) => acpPermissionResponse(request, { canCode }),
157
+ onPermissionRequest: async (request) => acpPermissionResponse(request, { canCode, canCoordinate, proxyProtected }),
133
158
  // codex-acp publishes account-status extension notifications. They are
134
159
  // useful to interactive clients but should be silent in a background
135
160
  // watcher, and the stock Mastra ACP client intentionally knows only ACP.
@@ -182,6 +207,7 @@ export function createMastraAcpRunner({
182
207
  const fullPrompt = systemPrompt && !sessionHasPrompted ? `${systemPrompt}\n\n${prompt}` : prompt
183
208
  sessionHasPrompted = true
184
209
  for await (const event of acp.connection.promptStream(fullPrompt, controller.signal)) {
210
+ firstEventMs ??= Math.round(performance.now() - startedAt)
185
211
  if (event.type === 'text') { outputText += event.text; continue }
186
212
  const update = event.update || {}
187
213
  if (update.sessionUpdate === 'agent_message_chunk') {
@@ -190,7 +216,7 @@ export function createMastraAcpRunner({
190
216
  }
191
217
  if (!['tool_call', 'tool_call_update'].includes(update.sessionUpdate)) continue
192
218
  const prior = tools.get(update.toolCallId) || {}
193
- const merged = { ...prior, ...update }
219
+ const merged = { ...prior, ...update, order: ++toolSequence }
194
220
  tools.set(update.toolCallId, merged)
195
221
  const name = toolName(merged)
196
222
  const label = name || clean(merged.title, 100) || 'tool'
@@ -200,24 +226,34 @@ export function createMastraAcpRunner({
200
226
  calls.add(name)
201
227
  if (merged.status === 'failed') errors.set(name, clean(json(merged.rawOutput) || merged.title))
202
228
  else if (merged.status === 'completed') errors.delete(name)
203
- didMessage ||= name === 'post_message'
204
- didChannelMessage ||= name === 'post_message'
205
- didMcpTaskRead ||= ['list_tasks', 'list_task_types', 'get_ticket'].includes(name)
206
- didMcpTaskUpdate ||= name === 'update_ticket'
207
229
  }
208
- const title = clean(merged.title, 200).toLowerCase()
209
- const looksLikeMutation = merged.kind === 'edit' || /\b(edit|write|patch|create|delete|commit)\b/.test(title)
210
- didCode ||= ['edit', 'execute'].includes(merged.kind) || looksLikeMutation
211
- didRepoMutation ||= looksLikeMutation
212
230
  }
213
- const after = repositorySnapshot(canCode ? cycleCwd : '')
214
- if (before !== after) { didCode = true; didRepoMutation = true }
215
- didResultMessage = didChannelMessage && didRepoMutation
231
+ // Only final, completed tool states are evidence. Started/failed edits and
232
+ // board mutations cannot stand in for completed repository work.
233
+ for (const tool of [...tools.values()].sort((a, b) => a.order - b.order)) {
234
+ const name = toolName(tool)
235
+ if (tool.status === 'failed') policyBlock ||= codexPolicyBlock(json(tool.rawOutput))
236
+ if (tool.status !== 'completed') continue
237
+ didMessage ||= name === 'post_message'
238
+ didChannelMessage ||= name === 'post_message'
239
+ didResultMessage ||= name === 'post_message' && didRepoMutation
240
+ didMcpTaskRead ||= ['list_tasks', 'get_ticket'].includes(name)
241
+ didMcpTaskUpdate ||= name === 'update_ticket'
242
+ if (!name && canCode) {
243
+ didCode ||= ['edit', 'execute'].includes(tool.kind)
244
+ didRepoMutation ||= tool.kind === 'edit'
245
+ }
246
+ didRepoMutation ||= ['create_codebase_commit', 'write_codebase_file'].includes(name)
247
+ didCode ||= ['create_codebase_commit', 'write_codebase_file'].includes(name)
248
+ }
249
+ const beforeState = await before
250
+ const after = canCode ? await repositorySnapshot(cycleCwd) : null
251
+ if (beforeState != null && after != null && beforeState !== after) { didCode = true; didRepoMutation = true }
216
252
  const redact = (value) => Object.values(mcpHeaders).filter(Boolean).reduce((safe, secret) => safe.split(String(secret)).join('[redacted]'), String(value))
217
253
  for (const [name, detail] of errors) log(`MCP ${name} failed: ${redact(detail)}`)
218
254
  log(`${agent} cycle done via Mastra ACP (${errors.size ? 'tool-errors' : 'ok'})`)
219
255
  return {
220
- type: 'result', subtype: 'ok', runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
256
+ type: 'result', subtype: policyBlock ? 'blocked' : 'ok', policyBlock, timings: { firstEventMs, totalMs: Math.round(performance.now() - startedAt) }, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
221
257
  mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
222
258
  didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage,
223
259
  didMcpTaskRead, didMcpTaskUpdate,
@@ -243,9 +279,9 @@ export function createMastraAcpRunner({
243
279
 
244
280
  const cancelCurrent = async () => {
245
281
  if (!active) return
246
- active.controller.abort()
247
- try { await active.acp.connection.cancel() } catch { /* already stopped */ }
248
- const acp = active.acp
282
+ const { acp, controller } = active
283
+ controller.abort()
284
+ try { await acp.connection.cancel() } catch { /* already stopped */ }
249
285
  acp.connection.disconnect()
250
286
  if (persistent === acp) {
251
287
  persistent = null