openvisio-agent 0.17.6 → 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.6",
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,8 +48,12 @@ 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
59
  ['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock)') && watcher.includes("Action required: I'm blocked")],
package/src/events.mjs CHANGED
@@ -65,6 +65,56 @@ export function buildTaskCompletionReport(task, { projectId, fallbackText = '' }
65
65
  return { key: `${projectId ?? task.project_id ?? task.projectId ?? '?'}:${task.id}:${revision}`, content, prUrl }
66
66
  }
67
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
+
68
118
  export function agentStateRequest(backend, channelId, state, apiKey, identifier) {
69
119
  if (!['thinking', 'working', 'typing'].includes(state)) throw new Error('invalid agent state')
70
120
  const id = Number(channelId)
@@ -90,6 +140,38 @@ export function codexPolicyBlock(value) {
90
140
  return { kind: 'authorization-required', command, reason: reason.slice(0, 900), commit, remote: push?.[1] || '', branch: push?.[2] || '' }
91
141
  }
92
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
+
93
175
  // A mention event means this agent's name appeared somewhere, not necessarily
94
176
  // that the request was addressed to it. Reject a later-agent hand-off before a
95
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
@@ -38,7 +38,7 @@ const REPLY_DISCIPLINE = [
38
38
 
39
39
  // ── CHAT-ONLY agents (no --workdir): chat/ticket tools, no code surface. ──────
40
40
  const CHAT_CHARTER = [
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 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.',
42
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.',
43
43
  '',
44
44
  REPLY_DISCIPLINE,
@@ -66,7 +66,7 @@ const COORDINATE = [
66
66
  // A stable "who you are / how you work" charter prepended to every code cycle.
67
67
  const CODE_CHARTER = [
68
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:',
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, 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.',
70
70
  ' • Read / Grep / Glob / Edit / Write / MultiEdit — inspect AND change code.',
71
71
  ' • Bash — git (branch, commit, push a branch), gh (clone repos, open PRs), run tests/builds.',
72
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.',
@@ -77,6 +77,7 @@ const CODE_CHARTER = [
77
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.',
78
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.',
79
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.',
80
81
  '',
81
82
  REPLY_DISCIPLINE,
82
83
  ].join('\n')
@@ -90,7 +91,7 @@ const CODE_FULL = [
90
91
  ' 3. CHANGE + VERIFY: Read/Edit/Write the files; run the tests or build if the repo has them.',
91
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.',
92
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.',
93
- ' 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.',
94
95
  'Bash is for git / gh / tests / clone ONLY — never to hunt for credentials (they are given to you above).',
95
96
  ].join('\n')
96
97
 
@@ -178,18 +179,31 @@ function claimStartupSweep(key) {
178
179
  // are taken over. Returns { release } or { conflict: <pid> }.
179
180
  function acquireSingleInstance(key) {
180
181
  const lockPath = join(OV_DIR, 'watch-' + key + '.lock')
181
- try {
182
- mkdirSync(OV_DIR, { recursive: true })
183
- if (existsSync(lockPath)) {
184
- const pid = parseInt(String(readFileSync(lockPath, 'utf8')).trim(), 10)
185
- if (pid && pid !== process.pid) {
186
- let alive = false
187
- try { process.kill(pid, 0); alive = true } catch (e) { alive = !!(e && e.code === 'EPERM') }
188
- if (alive) return { conflict: pid }
189
- }
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' }
190
205
  }
191
- writeFileSync(lockPath, String(process.pid))
192
- } catch { /* if the lock can't be written, don't block the agent from running */ }
206
+ }
193
207
  const release = () => { try { if (parseInt(String(readFileSync(lockPath, 'utf8')).trim(), 10) === process.pid) unlinkSync(lockPath) } catch { /* already gone */ } }
194
208
  return { release }
195
209
  }
@@ -229,6 +243,7 @@ export async function runWatch({ flags }) {
229
243
  // both connect as the same agent and both answer every mention. Refuse to start.
230
244
  if (!flags.install) {
231
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}`)
232
247
  if (lock.conflict) {
233
248
  const watcherName = slug || 'openvisio'
234
249
  const logs = process.platform === 'darwin'
@@ -274,7 +289,7 @@ export async function runWatch({ flags }) {
274
289
  // The openvisio-team MCP is declared in an `opencode.json` written into the run cwd
275
290
  // (opencode reads it from there). `--auto` approves tool use non-interactively.
276
291
  // Same { runCycle, canCode } contract as the Claude runner.
277
- 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 }) {
278
293
  // opencode reads opencode.json from its CWD: the code workspace, or a dedicated
279
294
  // per-agent dir for chat-only agents.
280
295
  const cwd = workdir || join(OV_DIR, 'opencode-' + (cfgKey || 'agent'))
@@ -303,18 +318,71 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
303
318
  // opencode has no system-prompt flag; each run is a fresh process, so fold the
304
319
  // charter/creds into the message (still not re-accumulated across cycles).
305
320
  const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
306
- const args = ['run', full, '--auto', ...(m ? ['--model', m] : [])]
307
- let child = null, done = false
308
- 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
+ }
309
364
  const timer = setTimeout(() => {
310
365
  log('opencode cycle TIMED OUT after ' + Math.round(maxCycleMs / 1000) + 's — killing')
311
366
  try { child && child.kill() } catch { /* gone */ }
312
367
  finish({ type: 'result', subtype: 'timeout' })
313
368
  }, maxCycleMs)
314
369
  log('running opencode cycle…' + (m ? ' [' + m + ']' : ''))
315
- 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
+ }
316
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' }) }
317
- 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
+ })
318
386
  child.on('error', (e) => { log('opencode error: ' + (e && e.message ? e.message : e)); finish({ type: 'result', subtype: 'error' }) })
319
387
  })
320
388
  }
@@ -346,7 +414,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
346
414
  ...(m ? ['--model', m] : []),
347
415
  ...(mcpOverride ? ['-c', mcpOverride] : []),
348
416
  full]
349
- 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 = ''
350
418
  let policyBlock = null
351
419
  const mcpCalls = new Set(), mcpErrors = new Set()
352
420
  let didMcpTaskRead = false, didMcpTaskUpdate = false
@@ -363,6 +431,20 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
363
431
  stderrBuffer = (stderrBuffer + s).slice(-24_000)
364
432
  policyBlock = codexPolicyBlock(stderrBuffer) || policyBlock
365
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
+ }
366
448
  const inspectLine = (line) => {
367
449
  const s = line.trim()
368
450
  if (!s) return
@@ -403,15 +485,14 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
403
485
  for (const line of lines) inspectLine(line)
404
486
  })
405
487
  if (child.stderr) child.stderr.on('data', (d) => {
406
- inspectDiagnostic(d)
407
- process.stderr.write(d)
488
+ forwardDiagnostic(d)
408
489
  })
409
490
  } catch (e) {
410
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`)')
411
492
  return finish({ type: 'result', subtype: 'spawn-failed' })
412
493
  }
413
494
  child.on('close', (code) => {
414
- inspectLine(jsonlBuffer); jsonlBuffer = ''; inspectDiagnostic('')
495
+ inspectLine(jsonlBuffer); jsonlBuffer = ''; forwardDiagnostic('', true)
415
496
  const subtype = policyBlock ? 'blocked' : code === 0 ? 'ok' : 'error'
416
497
  log('codex cycle done (' + (subtype === 'blocked' ? 'BLOCKED: user authorization required' : subtype === 'ok' ? 'ok' : 'exit ' + code) + ')')
417
498
  finish({ type: 'result', subtype })
@@ -433,7 +514,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
433
514
  const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
434
515
  // opencode drives cycles differently — a headless `opencode run` per cycle rather
435
516
  // than a persistent stream-json session. Same { runCycle, canCode } contract.
436
- 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 })
437
518
  if (agent === 'codex') return createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
438
519
  let child = null
439
520
  // The model the CURRENT session was spawned with. runCycle can pass a different
@@ -583,7 +664,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
583
664
  const canCode = !!workdir
584
665
  // The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
585
666
  // agent_identifier + agent_api_key as arguments. Hand them over up front.
586
- 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.`
587
668
  // The STATIC charter + creds are the session system prompt (cached, billed once),
588
669
  // NOT re-sent in every cycle's user message — the big token saving.
589
670
  const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
@@ -620,29 +701,50 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
620
701
  let replayState = {}
621
702
  try { replayState = JSON.parse(readFileSync(replayPath, 'utf8')) } catch { /* first run */ }
622
703
  const seenMentions = new Set(Array.isArray(replayState.seenMentions) ? replayState.seenMentions : [])
704
+ const recentMentionSignatures = new Map(Array.isArray(replayState.recentMentionSignatures) ? replayState.recentMentionSignatures : [])
623
705
  const seenActivities = new Set(Array.isArray(replayState.seenActivities) ? replayState.seenActivities : [])
624
706
  // Completion delivery is runtime-owned for assigned coding work. Persist both
625
707
  // pending and delivered keys so a reconnect can finish a missed notification
626
708
  // without re-running the model or posting the same result twice.
627
709
  const pendingCompletionReports = new Set(Array.isArray(replayState.pendingCompletionReports) ? replayState.pendingCompletionReports : [])
628
710
  const reportedCompletions = new Set(Array.isArray(replayState.reportedCompletions) ? replayState.reportedCompletions : [])
711
+ const reportedTaskComments = new Set(Array.isArray(replayState.reportedTaskComments) ? replayState.reportedTaskComments : [])
629
712
  // A policy-blocked task stays paused across reconnects. It is released only
630
713
  // after the ticket itself carries explicit authorization or is completed/
631
714
  // unassigned. This prevents a 30-minute reconciliation retry from repeatedly
632
715
  // attempting the same rejected egress action.
633
716
  const blockedTasks = new Set(Array.isArray(replayState.blockedTasks) ? replayState.blockedTasks : [])
634
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
+ }
635
724
  const persistReplay = () => {
636
725
  try {
726
+ pruneMentionSignatures()
637
727
  writeJson(replayPath, {
638
728
  seenMentions: [...seenMentions],
729
+ recentMentionSignatures: [...recentMentionSignatures],
639
730
  seenActivities: [...seenActivities],
640
731
  blockedTasks: [...blockedTasks],
641
732
  pendingCompletionReports: [...pendingCompletionReports],
642
733
  reportedCompletions: [...reportedCompletions],
734
+ reportedTaskComments: [...reportedTaskComments],
643
735
  }, true)
644
736
  } catch { /* best-effort */ }
645
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
+ }
646
748
  // Context lines from the events themselves (the WS payload already carries the
647
749
  // channel + message / task), so the agent acts on THEM directly instead of
648
750
  // hoping poll_inbox re-surfaces the same item. Accumulated across coalesced
@@ -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.6' } } }, 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') || ''
@@ -736,6 +838,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
736
838
  const ticket = current.ticket ?? current.task ?? current
737
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
@@ -836,6 +946,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
836
946
  const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
837
947
  const assigned = []
838
948
  const mentionActivity = []
949
+ let activityReplayTouched = false
839
950
  for (const project of projects) {
840
951
  const [tasksData, typesData, activityData] = await Promise.all([
841
952
  callMcpTool('list_tasks', { project_id: project.id }).then(toolData),
@@ -879,12 +990,20 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
879
990
  const lower = text.toLowerCase()
880
991
  const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
881
992
  if (!seenActivities.has(activityKey) && mentionNeedles.some((needle) => lower.includes(needle)) && /message|mention|channel/i.test(text)) {
882
- 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
883
1002
  mentionActivity.push({ projectId: project.id, project: project.name, activity: item })
884
1003
  }
885
1004
  }
886
1005
  }
887
- if (mentionActivity.length) persistReplay()
1006
+ if (activityReplayTouched) persistReplay()
888
1007
  if (!assigned.length) lastTaskSignature = ''
889
1008
  else {
890
1009
  const signature = JSON.stringify(assigned)
@@ -915,6 +1034,45 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
915
1034
  // Higher rank wins when coalescing cycles requested while one is running.
916
1035
  const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
917
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
+ }
918
1076
 
919
1077
  async function drain(kind, context, targetChannels = [], taskRef = null) {
920
1078
  const laneName = kind === 'full' ? 'work' : 'reply'
@@ -956,6 +1114,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
956
1114
  log('WORK_CYCLE_BLOCKED ' + result.subtype + '; publishing blocker')
957
1115
  try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
958
1116
  catch (e) { log('failed to publish cycle blocker: ' + (e?.message || e)) }
1117
+ releaseTaskForRetry(activeTaskRef, prompt)
959
1118
  return
960
1119
  }
961
1120
  if (kind !== 'full' && result?.mcpErrors?.length) {
@@ -968,38 +1127,37 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
968
1127
  // Model prose never proves success or a blocker. Full cycles must produce
969
1128
  // runtime-observed ticket reads, repository evidence, and ticket updates.
970
1129
  const ticketCycle = !!activeTaskRef || /ticket\s+#?\d+.*?project\s+\d+/i.test(prompt)
971
- const incomplete = !result?.didRepoMutation || (ticketCycle && (!result?.didMcpTaskRead || !result?.didMcpTaskUpdate)) || result?.mcpErrors?.length
972
- if (agent === 'codex' && kind === 'full' && result?.subtype === 'ok' && incomplete) {
973
- 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)
974
- if (result.mcpErrors?.length) missing.push('resolve failed MCP calls: ' + result.mcpErrors.join(', '))
975
- 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(', '))
976
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)
977
- const recoveryIncomplete = recovery?.subtype !== 'ok' || !recovery?.didRepoMutation || (ticketCycle && (!recovery?.didMcpTaskRead || !recovery?.didMcpTaskUpdate)) || recovery?.mcpErrors?.length
978
- if (recoveryIncomplete) {
1134
+ const recoveredResult = combineWorkEvidence(result, recovery)
1135
+ const recoveryMissing = missingWorkEvidence(recoveredResult, ticketCycle)
1136
+ if (recovery?.subtype !== 'ok' || recoveryMissing.length) {
979
1137
  if (recovery?.subtype === 'blocked' && recovery?.policyBlock) {
980
1138
  try { await reportPolicyBlock(prompt, activeTaskRef, recovery.policyBlock) }
981
1139
  catch (e) { log('failed to publish recovery policy blocker: ' + (e?.message || e)) }
982
1140
  } else {
983
- const notice = `I'm blocked after one recovery attempt. Missing required evidence: ${missing.join('; ')}. I've left the ticket open and I'm not claiming completion.`
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.`
984
1143
  try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
985
1144
  catch (e) { log('failed to publish recovery blocker: ' + (e?.message || e)) }
986
1145
  }
987
- if (activeTaskRef) seenTasks.delete(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`)
988
- else {
989
- const taskMatch = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
990
- if (taskMatch) seenTasks.delete(`${taskMatch[2]}:${taskMatch[1]}`)
991
- }
992
- lastTaskSignature = ''
1146
+ releaseTaskForRetry(activeTaskRef, prompt)
993
1147
  log('WORK_CYCLE_FAILED evidence gate still incomplete after one recovery; ticket retained for retry')
994
1148
  return
995
1149
  }
996
- completionResult = recovery
1150
+ completionResult = recoveredResult
997
1151
  }
998
1152
  if (kind === 'full' && activeTaskRef) {
999
1153
  try {
1000
1154
  const delivered = await announceTaskCompletion(activeTaskRef, completionResult)
1001
- 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
+ }
1002
1159
  } catch (e) {
1160
+ releaseTaskForRetry(activeTaskRef, prompt)
1003
1161
  log('completion report failed for ticket #' + activeTaskRef.ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry')
1004
1162
  }
1005
1163
  }
@@ -1124,9 +1282,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1124
1282
  // De-dupe: the same mention re-delivered (reconnect replay / dup fan-out) must
1125
1283
  // NOT trigger a second reply. Key by message id, or a channel+text signature
1126
1284
  // when the payload carries no id.
1127
- const dedupeKey = mid != null ? 'id:' + mid : 'sig:' + (cid != null ? cid : '?') + '|' + text.slice(0, 100)
1128
- if (seenMentions.has(dedupeKey)) { log('agent:mention (dup) — skipped'); return }
1129
- seenMentions.add(dedupeKey); trimSeen(seenMentions); persistReplay()
1285
+ if (markMentionHandled(msg, cid)) { log('agent:mention (dup) skipped'); return }
1130
1286
  if (requestTargetsLaterAgent(text, [slug, identifier])) {
1131
1287
  log('agent:mention addressed to a later-mentioned agent — skipped')
1132
1288
  return