openvisio-agent 0.17.5 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.17.5",
3
+ "version": "0.18.0",
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": {
@@ -23,6 +23,7 @@ run('diff whitespace check', 'git', ['diff', '--check'], repo)
23
23
  const watcher = readFileSync(join(root, 'src', 'watch.mjs'), 'utf8')
24
24
  const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
25
25
  const activityHook = readFileSync(join(repo, 'frontend', 'hooks', 'useAgentActivity.ts'), 'utf8')
26
+ const taskHook = readFileSync(join(repo, 'frontend', 'hooks', 'useBackendTasks.ts'), 'utf8')
26
27
  const spec = readFileSync(join(repo, 'docs', 'CODEX_BYO_AGENT_SPEC.md'), 'utf8')
27
28
 
28
29
  const assertions = [
@@ -33,7 +34,10 @@ const assertions = [
33
34
  ['assigned coding completion is posted by the watcher', watcher.includes('announceTaskCompletion') && watcher.includes("callMcpTool('post_message', { project_id: projectId, channel_id: channelId, content: report.content })")],
34
35
  ['completion requires review/done state and PR evidence', watcher.includes('buildTaskCompletionReport') && watcher.includes('completion report deferred')],
35
36
  ['completion delivery survives reconnect and deduplicates', watcher.includes('pendingCompletionReports: [...pendingCompletionReports]') && watcher.includes('reportedCompletions: [...reportedCompletions]') && watcher.includes('reportedCompletions.has(report.key)')],
37
+ ['verified completion is persisted as one ticket comment', watcher.includes("callMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: report.content })") && watcher.includes('reportedTaskComments: [...reportedTaskComments]') && watcher.includes('reportedTaskComments.has(report.key)')],
36
38
  ['ticket comments cannot masquerade as channel completion', watcher.includes('didChannelMessage') && watcher.includes("mcpCalls.includes('post_message')")],
39
+ ['single-watcher acquisition is atomic and fails closed', watcher.includes("openSync(lockPath, 'wx')") && watcher.includes('Could not acquire the single-watcher lock')],
40
+ ['websocket and activity mention delivery share a replay guard', watcher.includes('markMentionHandled(activityMessage, activityChannelId)') && watcher.includes('markMentionHandled(msg, cid)') && watcher.includes('recentMentionSignatures')],
37
41
  ['two internal lanes exist', watcher.includes('work: createCycleRunner') && watcher.includes('reply: createCycleRunner')],
38
42
  ['work and reply activity targets are isolated', watcher.includes("laneStatusTargets = { work: new Set(), reply: new Set() }") && watcher.includes("emitLaneStatus('work', 'typing')") && watcher.includes("emitLaneStatus('reply', 'typing')")],
39
43
  ['assigned task activity resolves a project channel', watcher.includes("callMcpTool('list_channels'") && watcher.includes('projectStatusChannel(projectId)')],
@@ -44,14 +48,19 @@ const assertions = [
44
48
  ['frontend consumes working event', activityHook.includes("'channel:agent:working'")],
45
49
  ['frontend consumes typing event', activityHook.includes("'channel:agent:typing'")],
46
50
  ['frontend activity TTL distinguishes work from typing', activityHook.includes('thinking: 6_000') && activityHook.includes('typing: 5_000') && activityHook.includes('working: 30_000')],
51
+ ['frontend consumes documented task comment events', taskHook.includes("'task:comment':") && taskHook.includes("'task:comment_updated':") && taskHook.includes("'task:comment_deleted':") && taskHook.includes("'task:comment_reacted':")],
47
52
  ['completion has an evidence failure gate', watcher.includes('WORK_CYCLE_FAILED')],
48
- ['Codex policy rejection is captured from stderr', watcher.includes("stdio: ['ignore', 'pipe', 'pipe']") && watcher.includes('inspectDiagnostic(d)')],
53
+ ['OpenCode emits structured events for runtime evidence', watcher.includes("'--format', 'json'") && watcher.includes('opencodeEventEvidence(event)')],
54
+ ['OpenCode acknowledgements cannot satisfy coding completion', watcher.includes("agent === 'codex' || agent === 'opencode'") && watcher.includes('releaseTaskForRetry(activeTaskRef, prompt)')],
55
+ ['Codex policy rejection is captured from stderr', watcher.includes("stdio: ['ignore', 'pipe', 'pipe']") && watcher.includes('forwardDiagnostic(d)') && watcher.includes('inspectDiagnostic(incoming)')],
56
+ ['Codex recoverable subprocess diagnostics are not surfaced as activity', watcher.includes('shouldSuppressCodexDiagnostic(line)') && watcher.includes("forwardDiagnostic('', true)") && watcher.includes('RECOVER DEAD COMMAND SESSIONS')],
49
57
  ['policy rejection cannot be logged as successful', watcher.includes("subtype = policyBlock ? 'blocked'")],
50
58
  ['policy-blocked tickets are persisted and paused', watcher.includes('blockedTasks: [...blockedTasks]') && watcher.includes('WORK_CYCLE_BLOCKED')],
51
- ['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock)') && watcher.includes('Action required: Alex is blocked')],
59
+ ['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock)') && watcher.includes("Action required: I'm blocked")],
52
60
  ['blocker routing carries explicit task identity', watcher.includes('taskRefs: []') && watcher.includes('activeTaskRef') && watcher.includes('taskRef: activeTaskRef')],
53
61
  ['all runtime blockers have a delivery path', watcher.includes('publishBlocker') && watcher.includes('WORK_CYCLE_BLOCKED') && watcher.includes('COORDINATION_CYCLE_BLOCKED')],
54
- ['ticket blocker cannot self-authorize', watcher.includes('ticketNotice = `Alex is paused') && watcher.includes('publishBlocker({ prompt, taskRef, notice, ticketNotice, pause: true })')],
62
+ ['ticket blocker cannot self-authorize', watcher.includes("ticketNotice = `I'm paused") && watcher.includes('publishBlocker({ prompt, taskRef, notice, ticketNotice, pause: true })')],
63
+ ['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')],
55
64
  ['normative certification gates are documented', spec.includes('## Mandatory certification gates')],
56
65
  ]
57
66
  for (const [label, ok] of assertions) {
package/src/events.mjs CHANGED
@@ -46,7 +46,7 @@ export function taskIsAwaitingReview(task, reviewTypeIds = new Set()) {
46
46
  return /\b(?:review|test|testing|qa|quality\s+assurance|verification|approval)\b/i.test(state)
47
47
  }
48
48
 
49
- export function buildTaskCompletionReport(task, { projectId, agentName, fallbackText = '' } = {}) {
49
+ export function buildTaskCompletionReport(task, { projectId, fallbackText = '' } = {}) {
50
50
  if (!task || typeof task !== 'object' || task.id == null) return null
51
51
  if (!taskIsCompleted(task) && !taskIsAwaitingReview(task)) return null
52
52
 
@@ -59,13 +59,62 @@ export function buildTaskCompletionReport(task, { projectId, agentName, fallback
59
59
  const title = String(task.title || 'Untitled task').replace(/\s+/g, ' ').trim().slice(0, 180)
60
60
  const status = String(task.type?.name || task.task_type?.name || task.status || task.state || 'review').replace(/\s+/g, ' ').trim()
61
61
  const verification = /\bVerification:\s*([^\n]{1,180})/i.exec(evidence)?.[1]?.replace(/\s+/g, ' ').trim().replace(/[.]+$/, '') || ''
62
- const who = String(agentName || 'The agent').trim()
63
62
  const mention = requester ? `@${requester} ` : ''
64
- const content = `${mention}${who} finished ticket #${task.id} “${title}” and moved it to ${status}. PR: ${prUrl}.${verification ? ` Verification: ${verification}.` : ''}`
63
+ const content = `${mention}I finished ticket #${task.id} “${title}” and moved it to ${status}. PR: ${prUrl}.${verification ? ` Verification: ${verification}.` : ''}`
65
64
  const revision = task.updated_at ?? task.updatedAt ?? prUrl
66
65
  return { key: `${projectId ?? task.project_id ?? task.projectId ?? '?'}:${task.id}:${revision}`, content, prUrl }
67
66
  }
68
67
 
68
+ // OpenCode's `run --format json` emits one JSON object per completed text/tool
69
+ // part. Reduce each event to the small set of facts the watcher is allowed to
70
+ // trust. In particular, model prose and process exit 0 are never work evidence.
71
+ export function opencodeEventEvidence(event) {
72
+ if (!event || typeof event !== 'object') return {}
73
+ const part = event.part ?? event.properties?.part ?? event.item ?? event
74
+ if (!part || typeof part !== 'object') return {}
75
+
76
+ const eventType = String(event.type ?? '')
77
+ const partType = String(part.type ?? '')
78
+ if (eventType === 'text' || partType === 'text') {
79
+ return { outputText: typeof part.text === 'string' ? part.text : '' }
80
+ }
81
+ if (eventType === 'error') {
82
+ return { runtimeError: String(event.error?.message ?? event.message ?? part.error ?? 'OpenCode runtime error') }
83
+ }
84
+ if (eventType !== 'tool_use' && partType !== 'tool') return {}
85
+
86
+ const tool = String(part.tool ?? part.name ?? event.tool ?? '').trim()
87
+ if (!tool) return {}
88
+ const state = part.state && typeof part.state === 'object' ? part.state : {}
89
+ const status = String(state.status ?? part.status ?? '').toLowerCase()
90
+ const failed = /error|failed|denied|rejected/.test(status) || state.error != null || part.error != null
91
+ const completed = !failed && (!status || /completed|success|succeeded|ok/.test(status))
92
+ const input = state.input && typeof state.input === 'object' ? state.input : (part.input && typeof part.input === 'object' ? part.input : {})
93
+ const command = String(input.command ?? input.cmd ?? '')
94
+
95
+ const lowerTool = tool.toLowerCase()
96
+ const prefixed = /^(?:mcp__)?openvisio(?:-team|_team)(?:__|[_.:/-])(.+)$/i.exec(tool)
97
+ const bareTool = lowerTool.replace(/[-.]/g, '_')
98
+ const knownMcp = /^(?:get_ticket|list_tasks|list_task_types|update_ticket|post_message|comment_ticket|react_message|list_projects|list_agents|list_activity)$/
99
+ const mcpTool = (prefixed?.[1] ? prefixed[1].replace(/[-.]/g, '_') : (knownMcp.test(bareTool) ? bareTool : '')).toLowerCase()
100
+ const mutationTool = /^(?:edit|write|patch|apply_patch|multiedit|multi_edit)$/i.test(tool)
101
+ const bashTool = /^(?:bash|shell|terminal|exec|command)$/i.test(tool)
102
+ const commandMutation = /\b(?:git\s+(?:commit|push)|gh\s+pr\s+create)\b/i.test(command)
103
+
104
+ return {
105
+ tool,
106
+ ...(mcpTool ? { mcpTool } : {}),
107
+ failed,
108
+ completed,
109
+ didCode: completed && (mutationTool || bashTool),
110
+ didRepoMutation: completed && (mutationTool || (bashTool && commandMutation)),
111
+ didMcpTaskRead: completed && /^(?:get_ticket|list_tasks|list_task_types)$/.test(mcpTool),
112
+ didMcpTaskUpdate: completed && mcpTool === 'update_ticket',
113
+ didMessage: completed && /^(?:post_message|comment_ticket)$/.test(mcpTool),
114
+ didChannelMessage: completed && mcpTool === 'post_message',
115
+ }
116
+ }
117
+
69
118
  export function agentStateRequest(backend, channelId, state, apiKey, identifier) {
70
119
  if (!['thinking', 'working', 'typing'].includes(state)) throw new Error('invalid agent state')
71
120
  const id = Number(channelId)
@@ -91,6 +140,38 @@ export function codexPolicyBlock(value) {
91
140
  return { kind: 'authorization-required', command, reason: reason.slice(0, 900), commit, remote: push?.[1] || '', branch: push?.[2] || '' }
92
141
  }
93
142
 
143
+ // Codex emits this plumbing notice whenever `exec` sees non-interactive stdin,
144
+ // even when stdin is intentionally closed and there is nothing to read. It is
145
+ // not agent activity, progress, or a blocker, so keep it out of watcher logs.
146
+ export function shouldSuppressCodexDiagnostic(value) {
147
+ const line = String(value || '').trim()
148
+ if (/^Reading additional input from stdin\.\.\.$/.test(line)) return true
149
+
150
+ // `codex exec` can emit these after it has already recovered: the watcher
151
+ // pins the requested model, so a background catalog-refresh timeout does not
152
+ // change the active cycle, and an unknown write_stdin pid means that one
153
+ // command session exited before Codex polled it. The model still receives the
154
+ // tool error and can start a fresh command; these internals should not masquerade
155
+ // as agent activity or an OpenVisio work failure in the user's watcher log.
156
+ if (/\bERROR codex_models_manager::manager: failed to refresh available models: timeout waiting for child process to exit$/.test(line)) return true
157
+ return /\bERROR codex_core::tools::router: error=write_stdin failed: Unknown process id \d+$/.test(line)
158
+ }
159
+
160
+ // Build both a durable id key and a short-lived content signature for a mention.
161
+ // The backend can surface one logical message through WebSocket delivery and
162
+ // activity reconciliation with different envelope ids; the signature closes that
163
+ // gap without permanently suppressing a genuinely repeated question later.
164
+ export function mentionDedupeKeys(message, channelId) {
165
+ const m = message && typeof message === 'object' ? message : {}
166
+ const id = m.message_id ?? m.messageId ?? m.id
167
+ const parent = m.parent_id ?? m.parentId ?? ''
168
+ const text = String(m.content ?? m.body ?? m.text ?? m.message ?? '').replace(/\s+/g, ' ').trim().slice(0, 180)
169
+ return {
170
+ idKey: id != null ? `id:${id}` : '',
171
+ signatureKey: text ? `sig:${channelId ?? '?'}|${parent}|${text}` : '',
172
+ }
173
+ }
174
+
94
175
  // A mention event means this agent's name appeared somewhere, not necessarily
95
176
  // that the request was addressed to it. Reject a later-agent hand-off before a
96
177
  // model starts, while keeping explicitly shared requests addressed to both.
package/src/watch.mjs CHANGED
@@ -5,12 +5,12 @@
5
5
  // than written to disk from a pasted heredoc.
6
6
 
7
7
  import { spawn, spawnSync } from 'node:child_process'
8
- import { writeFileSync, mkdirSync, existsSync, readFileSync, unlinkSync } from 'node:fs'
8
+ import { closeSync, writeFileSync, mkdirSync, existsSync, openSync, readFileSync, unlinkSync } from 'node:fs'
9
9
  import { homedir } from 'node:os'
10
10
  import { join, dirname } from 'node:path'
11
11
  import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
12
12
  import { connectAgentWs, assertWebSocket } from './ws.mjs'
13
- import { agentStateRequest, buildTaskCompletionReport, codexPolicyBlock, requestTargetsLaterAgent, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted } from './events.mjs'
13
+ import { agentStateRequest, buildTaskCompletionReport, codexPolicyBlock, mentionDedupeKeys, opencodeEventEvidence, requestTargetsLaterAgent, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted } from './events.mjs'
14
14
 
15
15
  // Behaviour prompts. The openvisio-team MCP bridge requires the agent's
16
16
  // credentials as ARGUMENTS on every tool call — those are injected at runtime by
@@ -27,6 +27,7 @@ import { agentStateRequest, buildTaskCompletionReport, codexPolicyBlock, request
27
27
  // then walking it back.
28
28
  const REPLY_DISCIPLINE = [
29
29
  'REPLY DISCIPLINE — read the recent messages FIRST, then decide whether to speak at all:',
30
+ ' • FIRST-PERSON VOICE. Speak as yourself: use “I”, “I\'m”, and “my”. Never refer to yourself by your agent name or in the third person, and never restate your own name in introductions, acknowledgements, progress, blockers, or results. The app already shows who sent the message. Sound like a warm, accountable teammate, not a status bot.',
30
31
  ' • IS IT FOR YOU? Act ONLY on messages addressed to YOU — an @mention of your exact name, a direct question to you, or a reply to something YOU said or did. If a DIFFERENT agent or person was @mentioned or asked to do something, STAY OUT: do not answer for them and do not pick up their task. When it is not yours, posting nothing is the correct move.',
31
32
  ' • NO DUPLICATES. Before you post, scan the recent thread/channel for what YOU already said. If you already replied to or acknowledged this exact request, do NOT post again. One acknowledgement per task; one answer per question. While a task is in progress, post again ONLY when you have something genuinely NEW (a result, a link, a real blocker) — never re-post "on it".',
32
33
  ' • BE SURE BEFORE YOU SPEAK. Do not claim something is possible, done, or broken until you have actually verified it — call the tool, read the code, check the real state. Never assert then contradict yourself. If you are unsure, verify FIRST, then give ONE clear, final answer instead of thinking out loud across several messages.',
@@ -37,7 +38,7 @@ const REPLY_DISCIPLINE = [
37
38
 
38
39
  // ── CHAT-ONLY agents (no --workdir): chat/ticket tools, no code surface. ──────
39
40
  const CHAT_CHARTER = [
40
- 'YOU ARE a connected agent in an OpenVisio team, running in CHAT-ONLY mode. Use only tools that actually appear in your openvisio-team tool list. Backend MCP commonly provides post_message, react_message, list_agents, list_projects, list_tasks, get_ticket, update_ticket, and list_activity. Some relay runtimes also provide poll_inbox or get_marching_orders. Never call a tool that is absent. You have NO file/Bash/git tools in this mode, so you cannot write code yourself.',
41
+ 'YOU ARE a connected agent in an OpenVisio team, running in CHAT-ONLY mode. Use only tools that actually appear in your openvisio-team tool list. Backend MCP provides post_message, react_message, list_agents, list_projects, list_tasks, get_ticket, update_ticket, comment_ticket, and list_activity. Some relay runtimes also provide poll_inbox or get_marching_orders. Never call a tool that is absent. You have NO file/Bash/git tools in this mode, so you cannot write code yourself.',
41
42
  'WORK ETHIC — behave like a dependable teammate: never leave a promise dangling. Either ACT now (reply, or file a ticket) or say plainly you can\'t and offer to file a ticket / tag a coding agent who can. Never invent progress. Close the loop every cycle — the human should never have to remind you to circle back.',
42
43
  '',
43
44
  REPLY_DISCIPLINE,
@@ -65,7 +66,7 @@ const COORDINATE = [
65
66
  // A stable "who you are / how you work" charter prepended to every code cycle.
66
67
  const CODE_CHARTER = [
67
68
  'YOU ARE a connected CODING agent in an OpenVisio team, running ON THE USER\'S LAPTOP. You have REAL tools — use them; do NOT claim you lack a capability without checking what you actually hold. Your toolbox:',
68
- ' • openvisio-team tools — use the names actually present. Backend MCP provides project/task discovery through list_agents, list_projects, list_tasks, get_ticket, update_ticket, plus post_message/react_message/list_activity. Relay runtimes may additionally expose poll_inbox, get_marching_orders, or comment_ticket.',
69
+ ' • openvisio-team tools — use the names actually present. Backend MCP provides project/task discovery through list_agents, list_projects, list_tasks, get_ticket, update_ticket, and comment_ticket, plus post_message/react_message/list_activity. Relay runtimes may additionally expose poll_inbox or get_marching_orders.',
69
70
  ' • Read / Grep / Glob / Edit / Write / MultiEdit — inspect AND change code.',
70
71
  ' • Bash — git (branch, commit, push a branch), gh (clone repos, open PRs), run tests/builds.',
71
72
  'YOUR WORKSPACE: your working directory is a WORKSPACE ROOT that holds the org\'s repos as subfolders. Reuse existing clones and the context you already verified. Read repository AGENTS.md instructions before changing code. For any task: locate the relevant repo under the workspace; clone it only when it is genuinely absent, then work inside that subfolder. Never ask the user for a path you can discover yourself.',
@@ -76,6 +77,7 @@ const CODE_CHARTER = [
76
77
  ' 2. FINISH, then REPORT. Always update/move the ticket with update_ticket. Reply in a supplied human source thread when one exists. For backlog-assigned work, do not call post_message yourself: the watcher publishes one evidence-verified result in the project channel after the PR and ticket handoff are confirmed.',
77
78
  ' 3. Be honest and specific. Never invent progress. If you are genuinely blocked (missing repo, unclear spec, a failing tool), say exactly what you need in one message — that IS closing the loop.',
78
79
  ' 4. One reply per channel per cycle; answer several nudges together.',
80
+ ' 5. RECOVER DEAD COMMAND SESSIONS. If write_stdin reports “Unknown process id”, that command session has already exited. Never poll the same process id again. Start a fresh exec_command when more work is required, then continue the task and verify the final state.',
79
81
  '',
80
82
  REPLY_DISCIPLINE,
81
83
  ].join('\n')
@@ -89,7 +91,7 @@ const CODE_FULL = [
89
91
  ' 3. CHANGE + VERIFY: Read/Edit/Write the files; run the tests or build if the repo has them.',
90
92
  ' 4. COMMIT + PUSH YOUR BRANCH: git add -A && git commit -m "…"; then git push -u origin agent/<slug>. Only ever push your own agent/* branch. Never --force, never push to main/master, never merge.',
91
93
  ' 5. RAISE A PR: gh pr create --fill --base <default-branch> --head agent/<slug> (a clear title + a body summarizing the change and how you verified it). Never gh pr merge.',
92
- ' 6. CLOSE THE LOOP: move/update the ticket with update_ticket. Reply with the summary + PR link in a source thread explicitly supplied by the event. For backlog-only tickets, do not call post_message yourself; the watcher sends one verified project-channel completion message and deduplicates it across reconnects.',
94
+ ' 6. CLOSE THE LOOP: use comment_ticket for a concrete ticket-scoped blocker or clarification, then move/update the ticket with update_ticket. The watcher adds one evidence-verified final ticket comment after handoff. Reply with the summary + PR link in a source thread explicitly supplied by the event. For backlog-only tickets, do not call post_message yourself; the watcher sends one verified project-channel completion message and deduplicates it across reconnects.',
93
95
  'Bash is for git / gh / tests / clone ONLY — never to hunt for credentials (they are given to you above).',
94
96
  ].join('\n')
95
97
 
@@ -107,7 +109,7 @@ const CODE_FAST = [
107
109
  // missed — TASKS especially.
108
110
  const INTRO = [
109
111
  'You have just JOINED this OpenVisio workspace (your first connection). Workspace etiquette: introduce yourself so the team knows you are here and reachable.',
110
- 'Find the most general channel — call poll_inbox (or list channels) and pick the "general"/main one — then post_message there ONCE: give your name, say you are an AI teammate who picks up tasks assigned to you and answers @mentions, and invite people to mention you. 1-2 sentences, warm and professional.',
112
+ 'Find the most general channel — call poll_inbox (or list channels) and pick the "general"/main one — then post_message there ONCE: say in first person that you are here, pick up tasks assigned to you, and answer @mentions. Do not state your name; the app already displays it. Keep it to 1-2 warm, professional sentences.',
111
113
  'Post it EXACTLY ONCE, then stop. Do NOT do any other work this cycle.',
112
114
  ].join('\n')
113
115
  const SWEEP = [
@@ -177,18 +179,31 @@ function claimStartupSweep(key) {
177
179
  // are taken over. Returns { release } or { conflict: <pid> }.
178
180
  function acquireSingleInstance(key) {
179
181
  const lockPath = join(OV_DIR, 'watch-' + key + '.lock')
180
- try {
181
- mkdirSync(OV_DIR, { recursive: true })
182
- if (existsSync(lockPath)) {
183
- const pid = parseInt(String(readFileSync(lockPath, 'utf8')).trim(), 10)
184
- if (pid && pid !== process.pid) {
185
- let alive = false
186
- try { process.kill(pid, 0); alive = true } catch (e) { alive = !!(e && e.code === 'EPERM') }
187
- if (alive) return { conflict: pid }
188
- }
182
+ try { mkdirSync(OV_DIR, { recursive: true }) } catch (error) { return { error } }
183
+ // `existsSync` followed by `writeFileSync` is a race: two KeepAlive starts can
184
+ // both observe no file and both become watchers. `wx` makes creation atomic.
185
+ for (let attempt = 0; attempt < 2; attempt++) {
186
+ let fd = null
187
+ try {
188
+ fd = openSync(lockPath, 'wx')
189
+ writeFileSync(fd, String(process.pid))
190
+ closeSync(fd); fd = null
191
+ break
192
+ } catch (error) {
193
+ if (fd != null) { try { closeSync(fd) } catch { /* already closed */ } }
194
+ if (error?.code !== 'EEXIST') return { error }
195
+ let pid = 0
196
+ try { pid = parseInt(String(readFileSync(lockPath, 'utf8')).trim(), 10) } catch { /* an in-flight creator owns it */ }
197
+ if (!pid) return { conflict: 'unknown' }
198
+ let alive = false
199
+ try { process.kill(pid, 0); alive = true } catch (e) { alive = !!(e && e.code === 'EPERM') }
200
+ if (alive) return { conflict: pid }
201
+ // Exact stale lock only. If another process wins the retry, its atomic file
202
+ // remains and this process will return conflict on the next iteration.
203
+ try { unlinkSync(lockPath) } catch (e) { if (e?.code !== 'ENOENT') return { error: e } }
204
+ if (attempt === 1) return { conflict: 'unknown' }
189
205
  }
190
- writeFileSync(lockPath, String(process.pid))
191
- } catch { /* if the lock can't be written, don't block the agent from running */ }
206
+ }
192
207
  const release = () => { try { if (parseInt(String(readFileSync(lockPath, 'utf8')).trim(), 10) === process.pid) unlinkSync(lockPath) } catch { /* already gone */ } }
193
208
  return { release }
194
209
  }
@@ -228,6 +243,7 @@ export async function runWatch({ flags }) {
228
243
  // both connect as the same agent and both answer every mention. Refuse to start.
229
244
  if (!flags.install) {
230
245
  const lock = acquireSingleInstance(slug || 'openvisio')
246
+ if (lock.error) fail(`Could not acquire the single-watcher lock for "${slug || 'openvisio'}": ${lock.error.message || lock.error}`)
231
247
  if (lock.conflict) {
232
248
  const watcherName = slug || 'openvisio'
233
249
  const logs = process.platform === 'darwin'
@@ -273,7 +289,7 @@ export async function runWatch({ flags }) {
273
289
  // The openvisio-team MCP is declared in an `opencode.json` written into the run cwd
274
290
  // (opencode reads it from there). `--auto` approves tool use non-interactively.
275
291
  // Same { runCycle, canCode } contract as the Claude runner.
276
- function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, systemPrompt }) {
292
+ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt }) {
277
293
  // opencode reads opencode.json from its CWD: the code workspace, or a dedicated
278
294
  // per-agent dir for chat-only agents.
279
295
  const cwd = workdir || join(OV_DIR, 'opencode-' + (cfgKey || 'agent'))
@@ -302,18 +318,71 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
302
318
  // opencode has no system-prompt flag; each run is a fresh process, so fold the
303
319
  // charter/creds into the message (still not re-accumulated across cycles).
304
320
  const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
305
- const args = ['run', full, '--auto', ...(m ? ['--model', m] : [])]
306
- let child = null, done = false
307
- const finish = (o) => { if (done) return; done = true; clearTimeout(timer); resolve(o) }
321
+ // JSON mode is the evidence boundary. Formatted stdout only tells us that
322
+ // OpenCode exited; raw events tell us which tools actually completed.
323
+ const args = ['run', full, '--auto', '--format', 'json', ...(m ? ['--model', m] : [])]
324
+ let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, didMcpTaskRead = false, didMcpTaskUpdate = false
325
+ let outputText = '', jsonlBuffer = ''
326
+ const mcpCalls = new Set(), mcpErrors = new Set(), runtimeErrors = new Set()
327
+ const finish = (o) => {
328
+ if (done) return
329
+ done = true
330
+ clearTimeout(timer)
331
+ const calls = [...mcpCalls]
332
+ log('opencode MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (mcpErrors.size ? ' (failed: ' + [...mcpErrors].join(', ') + ')' : ''))
333
+ resolve({ ...o, didCode, didRepoMutation, didMessage, didChannelMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], outputText })
334
+ }
335
+ const inspectLine = (line) => {
336
+ const value = String(line || '').trim()
337
+ if (!value) return
338
+ let event
339
+ try { event = JSON.parse(value) } catch {
340
+ if (debug) log(' · unparsed opencode output: ' + value.slice(0, 180))
341
+ return
342
+ }
343
+ const evidence = opencodeEventEvidence(event)
344
+ if (evidence.outputText) {
345
+ outputText += ' ' + evidence.outputText
346
+ if (debug) log(' · ' + evidence.outputText.replace(/\s+/g, ' ').slice(0, 180))
347
+ }
348
+ if (evidence.runtimeError) runtimeErrors.add(evidence.runtimeError)
349
+ if (!evidence.tool) return
350
+ if (debug) log(' → tool ' + evidence.tool + (evidence.failed ? ' (failed)' : evidence.completed ? ' (completed)' : ''))
351
+ try { onTool && onTool(evidence.tool) } catch { /* activity is best-effort */ }
352
+ if (evidence.mcpTool) {
353
+ mcpCalls.add(evidence.mcpTool)
354
+ if (evidence.failed) mcpErrors.add(evidence.mcpTool)
355
+ else if (evidence.completed) mcpErrors.delete(evidence.mcpTool)
356
+ }
357
+ didCode ||= !!evidence.didCode
358
+ didRepoMutation ||= !!evidence.didRepoMutation
359
+ didMessage ||= !!evidence.didMessage
360
+ didChannelMessage ||= !!evidence.didChannelMessage
361
+ didMcpTaskRead ||= !!evidence.didMcpTaskRead
362
+ didMcpTaskUpdate ||= !!evidence.didMcpTaskUpdate
363
+ }
308
364
  const timer = setTimeout(() => {
309
365
  log('opencode cycle TIMED OUT after ' + Math.round(maxCycleMs / 1000) + 's — killing')
310
366
  try { child && child.kill() } catch { /* gone */ }
311
367
  finish({ type: 'result', subtype: 'timeout' })
312
368
  }, maxCycleMs)
313
369
  log('running opencode cycle…' + (m ? ' [' + m + ']' : ''))
314
- try { child = spawn(bin, args, { cwd, stdio: ['ignore', debug ? 'inherit' : 'ignore', 'inherit'] }) }
370
+ try {
371
+ child = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'inherit'] })
372
+ child.stdout?.on('data', (data) => {
373
+ jsonlBuffer += String(data)
374
+ const lines = jsonlBuffer.split('\n')
375
+ jsonlBuffer = lines.pop() ?? ''
376
+ for (const line of lines) inspectLine(line)
377
+ })
378
+ }
315
379
  catch (e) { log('opencode spawn failed: ' + (e && e.message ? e.message : e) + ' — is opencode installed? (npm i -g opencode-ai, then `opencode auth login`)'); return finish({ type: 'result', subtype: 'spawn-failed' }) }
316
- child.on('exit', (code) => { log('opencode cycle done (' + (code === 0 ? 'ok' : 'exit ' + code) + ')'); finish({ type: 'result', subtype: code === 0 ? 'ok' : 'error' }) })
380
+ child.on('close', (code) => {
381
+ inspectLine(jsonlBuffer); jsonlBuffer = ''
382
+ const subtype = code === 0 && runtimeErrors.size === 0 ? 'ok' : 'error'
383
+ log('opencode cycle done (' + (subtype === 'ok' ? 'ok' : 'exit ' + code) + ')')
384
+ finish({ type: 'result', subtype })
385
+ })
317
386
  child.on('error', (e) => { log('opencode error: ' + (e && e.message ? e.message : e)); finish({ type: 'result', subtype: 'error' }) })
318
387
  })
319
388
  }
@@ -345,7 +414,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
345
414
  ...(m ? ['--model', m] : []),
346
415
  ...(mcpOverride ? ['-c', mcpOverride] : []),
347
416
  full]
348
- let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = ''
417
+ let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = '', stderrLineBuffer = ''
349
418
  let policyBlock = null
350
419
  const mcpCalls = new Set(), mcpErrors = new Set()
351
420
  let didMcpTaskRead = false, didMcpTaskUpdate = false
@@ -362,6 +431,20 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
362
431
  stderrBuffer = (stderrBuffer + s).slice(-24_000)
363
432
  policyBlock = codexPolicyBlock(stderrBuffer) || policyBlock
364
433
  }
434
+ const forwardDiagnostic = (value, flush = false) => {
435
+ const incoming = String(value || '')
436
+ inspectDiagnostic(incoming)
437
+ stderrLineBuffer += incoming
438
+ const lines = stderrLineBuffer.split('\n')
439
+ stderrLineBuffer = lines.pop() ?? ''
440
+ for (const line of lines) {
441
+ if (!shouldSuppressCodexDiagnostic(line)) process.stderr.write(line + '\n')
442
+ }
443
+ if (flush && stderrLineBuffer) {
444
+ if (!shouldSuppressCodexDiagnostic(stderrLineBuffer)) process.stderr.write(stderrLineBuffer)
445
+ stderrLineBuffer = ''
446
+ }
447
+ }
365
448
  const inspectLine = (line) => {
366
449
  const s = line.trim()
367
450
  if (!s) return
@@ -402,15 +485,14 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
402
485
  for (const line of lines) inspectLine(line)
403
486
  })
404
487
  if (child.stderr) child.stderr.on('data', (d) => {
405
- inspectDiagnostic(d)
406
- process.stderr.write(d)
488
+ forwardDiagnostic(d)
407
489
  })
408
490
  } catch (e) {
409
491
  log('codex spawn failed: ' + (e && e.message ? e.message : e) + ' — is Codex installed and signed in? (`npm i -g @openai/codex`, then `codex login`)')
410
492
  return finish({ type: 'result', subtype: 'spawn-failed' })
411
493
  }
412
494
  child.on('close', (code) => {
413
- inspectLine(jsonlBuffer); jsonlBuffer = ''; inspectDiagnostic('')
495
+ inspectLine(jsonlBuffer); jsonlBuffer = ''; forwardDiagnostic('', true)
414
496
  const subtype = policyBlock ? 'blocked' : code === 0 ? 'ok' : 'error'
415
497
  log('codex cycle done (' + (subtype === 'blocked' ? 'BLOCKED: user authorization required' : subtype === 'ok' ? 'ok' : 'exit ' + code) + ')')
416
498
  finish({ type: 'result', subtype })
@@ -432,7 +514,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
432
514
  const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
433
515
  // opencode drives cycles differently — a headless `opencode run` per cycle rather
434
516
  // than a persistent stream-json session. Same { runCycle, canCode } contract.
435
- if (agent === 'opencode') return createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, systemPrompt })
517
+ if (agent === 'opencode') return createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
436
518
  if (agent === 'codex') return createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
437
519
  let child = null
438
520
  // The model the CURRENT session was spawned with. runCycle can pass a different
@@ -582,7 +664,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
582
664
  const canCode = !!workdir
583
665
  // The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
584
666
  // agent_identifier + agent_api_key as arguments. Hand them over up front.
585
- const credNote = `AUTH: the openvisio-team tools REQUIRE two arguments on EVERY call — agent_identifier: "${identifier}" and agent_api_key: "${apiKey}". Include BOTH on every openvisio-team tool call. Use ONLY names shown in the current tool list. On backend MCP, discover work with list_agents, list_projects, list_tasks, get_ticket, update_ticket, and list_activity; get_marching_orders, poll_inbox, and comment_ticket may be absent. Tools may be namespaced — call whichever names actually appear. The credentials are given here; do NOT hunt for them. Bash/git/gh ARE for code work; this rule only forbids searching for keys.`
667
+ const credNote = `AUTH: the openvisio-team tools REQUIRE two arguments on EVERY call — agent_identifier: "${identifier}" and agent_api_key: "${apiKey}". Include BOTH on every openvisio-team tool call. Use ONLY names shown in the current tool list. On backend MCP, discover and update work with list_agents, list_projects, list_tasks, get_ticket, update_ticket, comment_ticket, and list_activity; get_marching_orders and poll_inbox may be absent. Tools may be namespaced — call whichever names actually appear. The credentials are given here; do NOT hunt for them. Bash/git/gh ARE for code work; this rule only forbids searching for keys.`
586
668
  // The STATIC charter + creds are the session system prompt (cached, billed once),
587
669
  // NOT re-sent in every cycle's user message — the big token saving.
588
670
  const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
@@ -619,29 +701,50 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
619
701
  let replayState = {}
620
702
  try { replayState = JSON.parse(readFileSync(replayPath, 'utf8')) } catch { /* first run */ }
621
703
  const seenMentions = new Set(Array.isArray(replayState.seenMentions) ? replayState.seenMentions : [])
704
+ const recentMentionSignatures = new Map(Array.isArray(replayState.recentMentionSignatures) ? replayState.recentMentionSignatures : [])
622
705
  const seenActivities = new Set(Array.isArray(replayState.seenActivities) ? replayState.seenActivities : [])
623
706
  // Completion delivery is runtime-owned for assigned coding work. Persist both
624
707
  // pending and delivered keys so a reconnect can finish a missed notification
625
708
  // without re-running the model or posting the same result twice.
626
709
  const pendingCompletionReports = new Set(Array.isArray(replayState.pendingCompletionReports) ? replayState.pendingCompletionReports : [])
627
710
  const reportedCompletions = new Set(Array.isArray(replayState.reportedCompletions) ? replayState.reportedCompletions : [])
711
+ const reportedTaskComments = new Set(Array.isArray(replayState.reportedTaskComments) ? replayState.reportedTaskComments : [])
628
712
  // A policy-blocked task stays paused across reconnects. It is released only
629
713
  // after the ticket itself carries explicit authorization or is completed/
630
714
  // unassigned. This prevents a 30-minute reconciliation retry from repeatedly
631
715
  // attempting the same rejected egress action.
632
716
  const blockedTasks = new Set(Array.isArray(replayState.blockedTasks) ? replayState.blockedTasks : [])
633
717
  const trimSeen = (set) => { while (set.size > 500) set.delete(set.values().next().value) }
718
+ const MENTION_SIGNATURE_TTL_MS = 10 * 60 * 1000
719
+ const pruneMentionSignatures = () => {
720
+ const cutoff = Date.now() - MENTION_SIGNATURE_TTL_MS
721
+ for (const [key, at] of recentMentionSignatures) if (Number(at) < cutoff) recentMentionSignatures.delete(key)
722
+ while (recentMentionSignatures.size > 500) recentMentionSignatures.delete(recentMentionSignatures.keys().next().value)
723
+ }
634
724
  const persistReplay = () => {
635
725
  try {
726
+ pruneMentionSignatures()
636
727
  writeJson(replayPath, {
637
728
  seenMentions: [...seenMentions],
729
+ recentMentionSignatures: [...recentMentionSignatures],
638
730
  seenActivities: [...seenActivities],
639
731
  blockedTasks: [...blockedTasks],
640
732
  pendingCompletionReports: [...pendingCompletionReports],
641
733
  reportedCompletions: [...reportedCompletions],
734
+ reportedTaskComments: [...reportedTaskComments],
642
735
  }, true)
643
736
  } catch { /* best-effort */ }
644
737
  }
738
+ const markMentionHandled = (message, channelId) => {
739
+ pruneMentionSignatures()
740
+ const { idKey, signatureKey } = mentionDedupeKeys(message, channelId)
741
+ const duplicate = (idKey && seenMentions.has(idKey)) || (signatureKey && recentMentionSignatures.has(signatureKey))
742
+ if (duplicate) return true
743
+ if (idKey) { seenMentions.add(idKey); trimSeen(seenMentions) }
744
+ if (signatureKey) recentMentionSignatures.set(signatureKey, Date.now())
745
+ persistReplay()
746
+ return false
747
+ }
645
748
  // Context lines from the events themselves (the WS payload already carries the
646
749
  // channel + message / task), so the agent acts on THEM directly instead of
647
750
  // hoping poll_inbox re-surfaces the same item. Accumulated across coalesced
@@ -651,7 +754,6 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
651
754
  let lastTaskTriggeredAt = 0
652
755
  let lastInboxSignature = ''
653
756
  let selfAgentId = null
654
- let selfAgentName = slug
655
757
  let mcpSessionId = ''
656
758
  let mcpRpcId = 0
657
759
 
@@ -676,7 +778,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
676
778
  }
677
779
  const ensureMcpSession = async () => {
678
780
  if (mcpSessionId) return
679
- const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.17.5' } } }, false)
781
+ const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.18.0' } } }, false)
680
782
  if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
681
783
  await mcpPayload(res)
682
784
  mcpSessionId = res.headers.get('mcp-session-id') || ''
@@ -734,8 +836,16 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
734
836
  const taskKey = `${projectId}:${ticketId}`
735
837
  const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
736
838
  const ticket = current.ticket ?? current.task ?? current
737
- const report = buildTaskCompletionReport(ticket, { projectId, agentName: selfAgentName, fallbackText: result.outputText })
839
+ const report = buildTaskCompletionReport(ticket, { projectId, fallbackText: result.outputText })
738
840
  if (!report) return false
841
+ // Ticket comments are now a backend first-class surface. The watcher owns the
842
+ // final comment so every runtime (Claude, Codex, OpenCode) closes the ticket
843
+ // loop consistently, and the persisted report key prevents reconnect repeats.
844
+ if (!reportedTaskComments.has(report.key)) {
845
+ await callMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: report.content })
846
+ reportedTaskComments.add(report.key); trimSeen(reportedTaskComments); persistReplay()
847
+ log('posted verified ticket comment for #' + ticketId)
848
+ }
739
849
  if (reportedCompletions.has(report.key)) {
740
850
  pendingCompletionReports.delete(taskKey)
741
851
  persistReplay()
@@ -794,7 +904,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
794
904
  delivered = true
795
905
  return
796
906
  } catch (e) {
797
- log('comment_ticket unavailable for blocker; recording it on ticket #' + ticketId)
907
+ log('comment_ticket failed for blocker; falling back to the ticket description for #' + ticketId)
798
908
  }
799
909
  const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
800
910
  const ticket = current.ticket ?? current.task ?? current
@@ -814,10 +924,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
814
924
  const command = block?.command || 'the requested external repository action'
815
925
  const payload = [block?.commit && `commit ${block.commit}`, block?.branch && `branch ${block.branch}`, block?.remote && `remote ${block.remote}`].filter(Boolean).join(', ')
816
926
  const approval = block?.commit && block?.branch
817
- ? `Reply with: “I explicitly authorize Alex to push commit ${block.commit} to the exact repository URL for ${block.remote || 'the configured remote'}, on branch ${block.branch}.”`
927
+ ? `Reply with: “I explicitly authorize you to push commit ${block.commit} to the exact repository URL for ${block.remote || 'the configured remote'}, on branch ${block.branch}.”`
818
928
  : 'Explicitly authorize the exact repository URL, commit, and branch in your reply.'
819
- const notice = `Action required: Alex is blocked at \`${command}\`${payload ? ` (${payload})` : ''}. Codex requires confirmation before exporting private repository code. ${approval} The ticket is paused until that approval is recorded.`
820
- const ticketNotice = `Alex is paused at \`${command}\`${payload ? ` (${payload})` : ''}. Repository push confirmation is required in the project channel; no automatic retry will occur.`
929
+ const notice = `Action required: I'm blocked at \`${command}\`${payload ? ` (${payload})` : ''}. Codex requires confirmation before exporting private repository code. ${approval} I've paused the ticket until that approval is recorded.`
930
+ const ticketNotice = `I'm paused at \`${command}\`${payload ? ` (${payload})` : ''}. Repository push confirmation is required in the project channel; I won't retry automatically.`
821
931
  return publishBlocker({ prompt, taskRef, notice, ticketNotice, pause: true })
822
932
  }
823
933
 
@@ -832,11 +942,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
832
942
  const self = agents.find((a) => String(a.identifier || a.slug || '') === identifier)
833
943
  if (!self?.id) throw new Error('list_agents did not return this BYO agent')
834
944
  selfAgentId = Number(self.id)
835
- selfAgentName = String(self.name || selfAgentName)
836
945
  const projectsData = toolData(await callMcpTool('list_projects'))
837
946
  const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
838
947
  const assigned = []
839
948
  const mentionActivity = []
949
+ let activityReplayTouched = false
840
950
  for (const project of projects) {
841
951
  const [tasksData, typesData, activityData] = await Promise.all([
842
952
  callMcpTool('list_tasks', { project_id: project.id }).then(toolData),
@@ -880,12 +990,20 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
880
990
  const lower = text.toLowerCase()
881
991
  const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
882
992
  if (!seenActivities.has(activityKey) && mentionNeedles.some((needle) => lower.includes(needle)) && /message|mention|channel/i.test(text)) {
883
- seenActivities.add(activityKey); trimSeen(seenActivities)
993
+ const data = item?.data && typeof item.data === 'object' ? item.data : null
994
+ const activityMessage = item?.message && typeof item.message === 'object'
995
+ ? item.message
996
+ : data?.message && typeof data.message === 'object' ? data.message : item
997
+ const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
998
+ seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
999
+ // The same logical mention may already have arrived over WebSocket.
1000
+ // Share the id/signature guard instead of starting a second model turn.
1001
+ if (markMentionHandled(activityMessage, activityChannelId)) continue
884
1002
  mentionActivity.push({ projectId: project.id, project: project.name, activity: item })
885
1003
  }
886
1004
  }
887
1005
  }
888
- if (mentionActivity.length) persistReplay()
1006
+ if (activityReplayTouched) persistReplay()
889
1007
  if (!assigned.length) lastTaskSignature = ''
890
1008
  else {
891
1009
  const signature = JSON.stringify(assigned)
@@ -916,6 +1034,45 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
916
1034
  // Higher rank wins when coalescing cycles requested while one is running.
917
1035
  const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
918
1036
  const baseFor = (kind) => kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? COORDINATE : fastPrompt
1037
+ // Codex and OpenCode expose structured action streams. Require runtime facts
1038
+ // from those streams before accepting a full coding cycle. Claude's evidence
1039
+ // shape is different and remains on its existing completion path.
1040
+ const evidenceGatedRuntime = agent === 'codex' || agent === 'opencode'
1041
+ const missingWorkEvidence = (result, ticketCycle) => {
1042
+ const missing = [
1043
+ ticketCycle && !result?.didMcpTaskRead && 'read the ticket through get_ticket/list_tasks',
1044
+ !result?.didRepoMutation && 'perform and verify the repository change',
1045
+ ticketCycle && !result?.didMcpTaskUpdate && 'update the ticket through update_ticket',
1046
+ ].filter(Boolean)
1047
+ if (result?.mcpErrors?.length) missing.push('resolve failed MCP calls: ' + result.mcpErrors.join(', '))
1048
+ return missing
1049
+ }
1050
+ const combineWorkEvidence = (first, second) => ({
1051
+ ...second,
1052
+ didCode: !!first?.didCode || !!second?.didCode,
1053
+ didRepoMutation: !!first?.didRepoMutation || !!second?.didRepoMutation,
1054
+ didMessage: !!first?.didMessage || !!second?.didMessage,
1055
+ didChannelMessage: !!first?.didChannelMessage || !!second?.didChannelMessage,
1056
+ didMcpTaskRead: !!first?.didMcpTaskRead || !!second?.didMcpTaskRead,
1057
+ didMcpTaskUpdate: !!first?.didMcpTaskUpdate || !!second?.didMcpTaskUpdate,
1058
+ mcpCalls: [...new Set([...(first?.mcpCalls || []), ...(second?.mcpCalls || [])])],
1059
+ // A focused recovery is allowed to clear an earlier MCP failure. Only calls
1060
+ // still failing in the recovery remain blockers.
1061
+ mcpErrors: second?.mcpErrors || [],
1062
+ outputText: [first?.outputText, second?.outputText].filter(Boolean).join(' '),
1063
+ })
1064
+ const releaseTaskForRetry = (taskRef, prompt) => {
1065
+ const directProject = Number(taskRef?.projectId)
1066
+ const directTicket = Number(taskRef?.ticketId)
1067
+ if (Number.isFinite(directProject) && Number.isFinite(directTicket)) seenTasks.delete(`${directProject}:${directTicket}`)
1068
+ else {
1069
+ const match = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
1070
+ if (match) seenTasks.delete(`${match[2]}:${match[1]}`)
1071
+ }
1072
+ // The next task update or periodic reconciliation must be allowed to queue
1073
+ // this work again. An acknowledgement is not a terminal task signature.
1074
+ lastTaskSignature = ''
1075
+ }
919
1076
 
920
1077
  async function drain(kind, context, targetChannels = [], taskRef = null) {
921
1078
  const laneName = kind === 'full' ? 'work' : 'reply'
@@ -953,14 +1110,15 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
953
1110
  return
954
1111
  }
955
1112
  if (kind === 'full' && ['timeout', 'spawn-failed', 'error'].includes(result?.subtype)) {
956
- const notice = `Alex is blocked: the coding cycle ended with ${result.subtype}. No completion is being claimed and the ticket remains open for retry.`
1113
+ const notice = `I'm blocked because the coding cycle ended with ${result.subtype}. I'm not claiming completion, and I've left the ticket open for retry.`
957
1114
  log('WORK_CYCLE_BLOCKED ' + result.subtype + '; publishing blocker')
958
1115
  try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
959
1116
  catch (e) { log('failed to publish cycle blocker: ' + (e?.message || e)) }
1117
+ releaseTaskForRetry(activeTaskRef, prompt)
960
1118
  return
961
1119
  }
962
1120
  if (kind !== 'full' && result?.mcpErrors?.length) {
963
- const notice = `Alex is blocked by failed OpenVisio actions: ${result.mcpErrors.join(', ')}. No success is being claimed; the item needs retry or intervention.`
1121
+ const notice = `I'm blocked by failed OpenVisio actions: ${result.mcpErrors.join(', ')}. I'm not claiming success; this needs a retry or intervention.`
964
1122
  log('COORDINATION_CYCLE_BLOCKED failed MCP calls; publishing blocker')
965
1123
  try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
966
1124
  catch (e) { log('failed to publish coordination blocker: ' + (e?.message || e)) }
@@ -969,38 +1127,37 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
969
1127
  // Model prose never proves success or a blocker. Full cycles must produce
970
1128
  // runtime-observed ticket reads, repository evidence, and ticket updates.
971
1129
  const ticketCycle = !!activeTaskRef || /ticket\s+#?\d+.*?project\s+\d+/i.test(prompt)
972
- const incomplete = !result?.didRepoMutation || (ticketCycle && (!result?.didMcpTaskRead || !result?.didMcpTaskUpdate)) || result?.mcpErrors?.length
973
- if (agent === 'codex' && kind === 'full' && result?.subtype === 'ok' && incomplete) {
974
- const missing = [ticketCycle && !result.didMcpTaskRead && 'read the ticket through get_ticket/list_tasks', !result.didRepoMutation && 'perform and verify the repository change', ticketCycle && !result.didMcpTaskUpdate && 'update the ticket through update_ticket'].filter(Boolean)
975
- if (result.mcpErrors?.length) missing.push('resolve failed MCP calls: ' + result.mcpErrors.join(', '))
976
- log('coding cycle incomplete; recovery requires: ' + missing.join(', '))
1130
+ const missing = missingWorkEvidence(result, ticketCycle)
1131
+ if (evidenceGatedRuntime && kind === 'full' && result?.subtype === 'ok' && missing.length) {
1132
+ log(agent + ' coding cycle incomplete; recovery requires: ' + missing.join(', '))
977
1133
  const recovery = await runners.work.runCycle(`The assigned task is NOT complete. Missing runtime evidence: ${missing.join('; ')}. Do not post an acknowledgement or claim success. Resume now. Use get_ticket/list_tasks and list_task_types, perform and verify the repository work, commit and push an agent/* branch, open the PR, and call update_ticket with the correct board column. Post only when the original context supplies a source thread.`, codeModel)
978
- const recoveryIncomplete = recovery?.subtype !== 'ok' || !recovery?.didRepoMutation || (ticketCycle && (!recovery?.didMcpTaskRead || !recovery?.didMcpTaskUpdate)) || recovery?.mcpErrors?.length
979
- if (recoveryIncomplete) {
1134
+ const recoveredResult = combineWorkEvidence(result, recovery)
1135
+ const recoveryMissing = missingWorkEvidence(recoveredResult, ticketCycle)
1136
+ if (recovery?.subtype !== 'ok' || recoveryMissing.length) {
980
1137
  if (recovery?.subtype === 'blocked' && recovery?.policyBlock) {
981
1138
  try { await reportPolicyBlock(prompt, activeTaskRef, recovery.policyBlock) }
982
1139
  catch (e) { log('failed to publish recovery policy blocker: ' + (e?.message || e)) }
983
1140
  } else {
984
- const notice = `Alex is blocked after one recovery attempt. Missing required evidence: ${missing.join('; ')}. The ticket remains open and no completion is being claimed.`
1141
+ const unresolved = recoveryMissing.length ? recoveryMissing : [`the recovery cycle ended with ${recovery?.subtype || 'an unknown error'}`]
1142
+ const notice = `I'm blocked after one recovery attempt. Missing required evidence: ${unresolved.join('; ')}. I've left the ticket open and I'm not claiming completion.`
985
1143
  try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
986
1144
  catch (e) { log('failed to publish recovery blocker: ' + (e?.message || e)) }
987
1145
  }
988
- if (activeTaskRef) seenTasks.delete(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`)
989
- else {
990
- const taskMatch = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
991
- if (taskMatch) seenTasks.delete(`${taskMatch[2]}:${taskMatch[1]}`)
992
- }
993
- lastTaskSignature = ''
1146
+ releaseTaskForRetry(activeTaskRef, prompt)
994
1147
  log('WORK_CYCLE_FAILED evidence gate still incomplete after one recovery; ticket retained for retry')
995
1148
  return
996
1149
  }
997
- completionResult = recovery
1150
+ completionResult = recoveredResult
998
1151
  }
999
1152
  if (kind === 'full' && activeTaskRef) {
1000
1153
  try {
1001
1154
  const delivered = await announceTaskCompletion(activeTaskRef, completionResult)
1002
- if (!delivered) log('completion report deferred for ticket #' + activeTaskRef.ticketId + '; waiting for verified review/done state and PR evidence')
1155
+ if (!delivered) {
1156
+ releaseTaskForRetry(activeTaskRef, prompt)
1157
+ log('completion report deferred for ticket #' + activeTaskRef.ticketId + '; waiting for verified review/done state and PR evidence; ticket remains retryable')
1158
+ }
1003
1159
  } catch (e) {
1160
+ releaseTaskForRetry(activeTaskRef, prompt)
1004
1161
  log('completion report failed for ticket #' + activeTaskRef.ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry')
1005
1162
  }
1006
1163
  }
@@ -1065,7 +1222,6 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1065
1222
  const agentsData = toolData(await callMcpTool('list_agents'))
1066
1223
  const self = (Array.isArray(agentsData.agents) ? agentsData.agents : []).find((a) => String(a.identifier || a.slug || '') === identifier)
1067
1224
  selfAgentId = self?.id != null ? Number(self.id) : null
1068
- selfAgentName = String(self?.name || selfAgentName)
1069
1225
  }
1070
1226
  const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
1071
1227
  const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
@@ -1126,9 +1282,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1126
1282
  // De-dupe: the same mention re-delivered (reconnect replay / dup fan-out) must
1127
1283
  // NOT trigger a second reply. Key by message id, or a channel+text signature
1128
1284
  // when the payload carries no id.
1129
- const dedupeKey = mid != null ? 'id:' + mid : 'sig:' + (cid != null ? cid : '?') + '|' + text.slice(0, 100)
1130
- if (seenMentions.has(dedupeKey)) { log('agent:mention (dup) — skipped'); return }
1131
- seenMentions.add(dedupeKey); trimSeen(seenMentions); persistReplay()
1285
+ if (markMentionHandled(msg, cid)) { log('agent:mention (dup) skipped'); return }
1132
1286
  if (requestTargetsLaterAgent(text, [slug, identifier])) {
1133
1287
  log('agent:mention addressed to a later-mentioned agent — skipped')
1134
1288
  return