openvisio-agent 0.18.3 → 0.18.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -62,6 +62,10 @@ The backend MCP may be stateful or stateless. A successful initialize response w
62
62
 
63
63
  Codex BYO agents follow the repository's normative runtime specification in `docs/CODEX_BYO_AGENT_SPEC.md`: one WebSocket identity, independent Sol reply/work lanes, authoritative `get_ticket` verification for assignments, REST-backed in-app activity, persistent replay suppression, and runtime evidence gates before completion. Maintainers must run `npm run certify` before publishing.
64
64
 
65
+ An `agent:mention` event only wakes the watcher; it does not grant ownership of the conversation. The actual source message is checked before any model starts. Messages redirected to another agent and unaddressed agent chatter stay silent, while a direct stand-down cancels queued/running work for that thread. Accepted coding work uses the activity indicator instead of a generic pickup message, then returns one verified result or concrete blocker in the source thread.
66
+
67
+ Ticket references follow the board UI: BYO agents use the project-scoped slug, such as `OVS-57`, in messages, comments, PR descriptions, blockers, and results. Numeric `project_id` and `ticket_id` values remain internal MCP arguments and are never used as human-facing ticket names. If an older backend omits the slug, the agent uses the ticket title rather than inventing one.
68
+
65
69
  Claude uses Haiku for routine coordination and Sonnet for repository work. Codex uses `gpt-5.6-sol` for every cycle, including messages, mentions, triage, board movement, MCP calls, and coding. This intentionally favors reliability and consistent tool use over the cheaper Codex tiers.
66
70
 
67
71
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.18.3",
3
+ "version": "0.18.5",
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": {
@@ -31,6 +31,11 @@ const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
31
31
  const activityHook = readFileSync(join(repo, 'frontend', 'hooks', 'useAgentActivity.ts'), 'utf8')
32
32
  const taskHook = readFileSync(join(repo, 'frontend', 'hooks', 'useBackendTasks.ts'), 'utf8')
33
33
  const liveTasks = readFileSync(join(repo, 'frontend', 'lib', 'collab', 'liveTasks.ts'), 'utf8')
34
+ const agentAutonomy = readFileSync(join(repo, 'frontend', 'lib', 'collab', 'agentAutonomy.ts'), 'utf8')
35
+ const bareRuntime = readFileSync(join(repo, 'frontend', 'lib', 'agents', 'bareRuntime.ts'), 'utf8')
36
+ const bareRun = readFileSync(join(repo, 'frontend', 'app', 'api', 'agent', 'bare', 'run', 'route.ts'), 'utf8')
37
+ const bareDriver = readFileSync(join(repo, 'frontend', 'lib', 'collab', 'bareDriver.ts'), 'utf8')
38
+ const quickReply = readFileSync(join(repo, 'frontend', 'app', 'api', 'agent', 'quick-reply', 'route.ts'), 'utf8')
34
39
  const spec = readFileSync(join(repo, 'docs', 'CODEX_BYO_AGENT_SPEC.md'), 'utf8')
35
40
 
36
41
  const assertions = [
@@ -46,6 +51,12 @@ const assertions = [
46
51
  ['single-watcher acquisition is atomic and fails closed', watcher.includes("openSync(lockPath, 'wx')") && watcher.includes('Could not acquire the single-watcher lock')],
47
52
  ['websocket and activity mention delivery share a replay guard', watcher.includes('markMentionHandled(activityMessage, activityChannelId)') && watcher.includes('markMentionHandled(msg, cid)') && watcher.includes('recentMentionSignatures')],
48
53
  ['reconciled mentions reuse the guarded websocket delivery path', watcher.includes("onEvent('agent:mention'") && watcher.includes('_mentionAlreadyMarked: true')],
54
+ ['conversation wake events are recipient-filtered before model start', watcher.includes('classifyConversationTarget(msg, [...selfAliases])') && events.includes("reason: 'explicit-other-recipient'") && events.includes("reason: 'other-agent-chatter'")],
55
+ ['coding mentions require an action and concrete repository target', watcher.includes('conversationNeedsCode(text)') && events.includes('const action =') && events.includes('const target =')],
56
+ ['stand-down and redirects cancel source-thread work across runtimes', watcher.includes('cancelThread(cid, threadRoot') && watcher.includes('activeDelivery') && watcher.includes('cancelCurrent') && watcher.includes("subtype === 'canceled'")],
57
+ ['thread cancellation is rechecked immediately before watcher delivery', watcher.includes('const newlyCancelled = suppressCancelled()') && watcher.includes('if (newlyCancelled) return newlyCancelled')],
58
+ ['generic coding pickup messages are absent', !watcher.includes("I've picked this up and will return here with the verified result")],
59
+ ['human-facing ticket references require slugs, never database ids', watcher.includes('TICKET SLUGS, NEVER DATABASE IDS') && watcher.includes('ticketDisplaySlug(task)') && events.includes('export function ticketDisplaySlug') && !events.includes('I finished ticket #${task.id}')],
49
60
  ['rendered backend replies are checked before every guarded thread post', watcher.includes("callMcpTool('list_message_thread'") && watcher.includes('renderedAgentMessages(live') && watcher.includes('same-content-rendered')],
50
61
  ['Codex cannot race the watcher with post_message', watcher.includes("disabledMcpTools: ['post_message']") && watcher.includes('disabled_tools = [')],
51
62
  ['reply delivery keys survive reconnects', watcher.includes('deliveredReplies: [...deliveredReplies]') && watcher.includes('deliveredReplies.has(deliveryKey)')],
@@ -65,6 +76,11 @@ const assertions = [
65
76
  ['ticket comments load only on demand', !taskHook.includes('for (const task of tasks)') && taskHook.includes('loadedCommentIds.current.has(taskId)')],
66
77
  ['concurrent ticket comment loads share one request', taskHook.includes('commentRequests.current.get(requestKey)') && taskHook.includes('commentRequests.current.set(requestKey, request)')],
67
78
  ['backend ticket slugs render uppercase', liveTasks.includes("t.slug?.trim().toUpperCase()")],
79
+ ['bare-agent inbox ownership is thread scoped', agentAutonomy.includes('lastPostInThread') && !agentAutonomy.includes('lastPostInChannel')],
80
+ ['ambiguous bare-agent follow-ups fail closed', bareRuntime.includes("return { reply: false, reason: 'reply gate unavailable' }") && bareRuntime.includes('When unsure, stay silent.')],
81
+ ['bare-agent runs handle one source conversation', bareRun.includes('messageId?: string') && bareRun.includes('const targetRoot = target.parentId ?? target.messageId') && bareDriver.includes('messageId: source.messageId')],
82
+ ['bare-agent posts have a live conversation guard', bareRun.includes('const canPostMessage') && bareRun.includes('{ canPostMessage }') && bareRuntime.includes('reply suppressed because the live thread was redirected, cancelled, or already answered')],
83
+ ['quick replies preserve distinct top-level conversations', quickReply.includes('m.parentId ?? m.messageId') && quickReply.includes('...(parentId ? { parentId } : {})')],
68
84
  ['completion has an evidence failure gate', watcher.includes('WORK_CYCLE_FAILED')],
69
85
  ['OpenCode emits structured events for runtime evidence', watcher.includes("'--format', 'json'") && watcher.includes('opencodeEventEvidence(event)')],
70
86
  ['OpenCode API-key MCP disables OAuth probing', opencodeConfig.includes("oauth: false") && opencodeConfig.includes('timeout: 15_000')],
package/src/events.mjs CHANGED
@@ -26,6 +26,16 @@ export function taskAgentId(task) {
26
26
  return task.agent_id != null ? task.agent_id : (task.agentId != null ? task.agentId : null)
27
27
  }
28
28
 
29
+ // Backend task ids are database plumbing. Teammates identify tickets by the
30
+ // project-scoped slug shown on the board (for example, OVS-57). Never synthesize
31
+ // a slug from the numeric id: if an older backend omits it, human-facing copy
32
+ // should use the title or “the ticket” instead of inventing a reference.
33
+ export function ticketDisplaySlug(task) {
34
+ if (!task || typeof task !== 'object') return ''
35
+ const value = task.slug ?? task.ticket_slug ?? task.ticketSlug
36
+ return typeof value === 'string' ? value.trim().toUpperCase() : ''
37
+ }
38
+
29
39
  export function taskIsCompleted(task, completedTypeIds = new Set()) {
30
40
  if (!task || typeof task !== 'object') return false
31
41
  if (task.deleted_at || task.deletedAt || task.completed_at || task.completedAt || task.closed_at || task.closedAt || task.archived_at || task.archivedAt) return true
@@ -60,7 +70,9 @@ export function buildTaskCompletionReport(task, { projectId, fallbackText = '' }
60
70
  const status = String(task.type?.name || task.task_type?.name || task.status || task.state || 'review').replace(/\s+/g, ' ').trim()
61
71
  const verification = /\bVerification:\s*([^\n]{1,180})/i.exec(evidence)?.[1]?.replace(/\s+/g, ' ').trim().replace(/[.]+$/, '') || ''
62
72
  const mention = requester ? `@${requester} ` : ''
63
- const content = `${mention}I finished ticket #${task.id} “${title}” and moved it to ${status}. PR: ${prUrl}.${verification ? ` Verification: ${verification}.` : ''}`
73
+ const slug = ticketDisplaySlug(task)
74
+ const ticketLabel = slug ? `ticket ${slug} “${title}”` : `the ticket “${title}”`
75
+ const content = `${mention}I finished ${ticketLabel} and moved it to ${status}. PR: ${prUrl}.${verification ? ` Verification: ${verification}.` : ''}`
64
76
  const revision = task.updated_at ?? task.updatedAt ?? prUrl
65
77
  return { key: `${projectId ?? task.project_id ?? task.projectId ?? '?'}:${task.id}:${revision}`, content, prUrl }
66
78
  }
@@ -234,6 +246,59 @@ export function renderedAgentMessages(value, identity = {}) {
234
246
  return rows
235
247
  }
236
248
 
249
+ const cleanAlias = (value) => String(value || '').trim().toLowerCase().replace(/^@/, '')
250
+
251
+ export function messageSenderIsAgent(message) {
252
+ const m = message && typeof message === 'object' ? message : {}
253
+ if (m.senderAgent || m.sender_agent || m.agent || m.agent_id != null || m.sender_agent_id != null || m.senderAgentId != null) return true
254
+ const sender = [m.sender, m.user, m.author].find((item) => item && typeof item === 'object') || {}
255
+ const kind = String(sender.type ?? sender.kind ?? sender.sender_type ?? m.sender_type ?? m.author_type ?? '').toLowerCase()
256
+ return /agent|bot/.test(kind) || sender.agent_id != null || sender.identifier != null || (sender.slug != null && !sender.email)
257
+ }
258
+
259
+ // WebSocket deployments may fan every reply in a watched thread as
260
+ // `agent:mention`, even when the new message names somebody else. Treat the event
261
+ // name as a wake-up hint only: recipient ownership is decided from the actual
262
+ // message before a model or work lane is allowed to run.
263
+ export function classifyConversationTarget(message, selfAliases) {
264
+ const m = message && typeof message === 'object' ? message : {}
265
+ const text = String(m.content ?? m.body ?? m.text ?? m.message ?? '').replace(/\s+/g, ' ').trim()
266
+ const aliases = new Set((selfAliases || []).map(cleanAlias).filter(Boolean))
267
+ const mentions = [...text.matchAll(/@([a-z0-9](?:[a-z0-9_.-]*[a-z0-9_-])?)/gi)]
268
+ .map((match) => ({ name: cleanAlias(match[1]), index: match.index ?? 0, end: (match.index ?? 0) + match[0].length }))
269
+ const selfMentions = mentions.filter((mention) => aliases.has(mention.name))
270
+ const otherMentions = mentions.filter((mention) => !aliases.has(mention.name))
271
+ const explicitSelf = selfMentions.length > 0
272
+
273
+ if (messageSenderIsAgent(m) && !explicitSelf) {
274
+ return { action: 'ignore', reason: 'other-agent-chatter', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
275
+ }
276
+ if (!explicitSelf && otherMentions.length) {
277
+ return { action: 'ignore', reason: 'explicit-other-recipient', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
278
+ }
279
+
280
+ const standDown = /\b(?:do\s+not|don't|dont)\s+(?:pick|take|handle|work\s+on|start|continue)\b|\b(?:stand\s+down|stop\s+(?:working|handling|on\s+this)|leave\s+(?:this|it)\s+(?:alone|to\s+\S+)|cancel\s+(?:this|that|it)|not\s+(?:for|yours?))\b/i.test(text)
281
+ if (standDown && (explicitSelf || !mentions.length)) {
282
+ return { action: 'stand_down', reason: 'explicit-stand-down', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
283
+ }
284
+
285
+ if (explicitSelf && requestTargetsLaterAgent(text, selfAliases)) {
286
+ return { action: 'ignore', reason: 'redirected-to-later-agent', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
287
+ }
288
+ return { action: 'handle', reason: explicitSelf ? 'explicit-self-recipient' : 'eligible-follow-up', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
289
+ }
290
+
291
+ // Require an actionable repository verb and a concrete code/repository object.
292
+ // Broad nouns such as "feature", "API", "file", or "documentation" on their
293
+ // own describe plenty of product conversations and must not start a coding lane.
294
+ export function conversationNeedsCode(value) {
295
+ const text = String(value || '')
296
+ if (/\b(?:do\s+not|don't|dont|stop|cancel|stand\s+down)\b/i.test(text)) return false
297
+ const action = /\b(?:implement|fix|debug|refactor|change|update|add|remove|write|edit|test|build|deploy|release|migrate|wire|integrate)\b/i.test(text)
298
+ const target = /\b(?:code|codebase|repository|repo|github|branch|commit|pull\s+request|pr|endpoint|route|component|page|screen|ui|function|class|database|migration|schema|package|typescript|javascript|python|swift|rust|golang|css|html|source\s+file|tests?)\b/i.test(text)
299
+ return action && target
300
+ }
301
+
237
302
  // A mention event means this agent's name appeared somewhere, not necessarily
238
303
  // that the request was addressed to it. Reject a later-agent hand-off before a
239
304
  // model starts, while keeping explicitly shared requests addressed to both.
package/src/watch.mjs CHANGED
@@ -10,7 +10,7 @@ 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, mentionDedupeKeys, normalizeRenderedMessageText, opencodeEventEvidence, renderedAgentMessages, requestTargetsLaterAgent, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted } from './events.mjs'
13
+ import { agentStateRequest, buildTaskCompletionReport, classifyConversationTarget, codexPolicyBlock, conversationNeedsCode, mentionDedupeKeys, normalizeRenderedMessageText, opencodeEventEvidence, renderedAgentMessages, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, ticketDisplaySlug } from './events.mjs'
14
14
  import { createByoMemoryGraph } from './memory.mjs'
15
15
  import { repositoryHasPrPushAuthorization } from './pr-push.mjs'
16
16
  import { createMcpHttpClient } from './mcp-http.mjs'
@@ -33,9 +33,11 @@ const REPLY_DISCIPLINE = [
33
33
  'REPLY DISCIPLINE — read the recent messages FIRST, then decide whether to speak at all:',
34
34
  ' • 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.',
35
35
  ' • 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.',
36
- ' • 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".',
36
+ ' • EVENT NAMES ARE NOT OWNERSHIP. A transport may wake you for activity in a thread you once joined. Trust only the watcher\'s verified recipient decision for the current source message; never infer that every thread update is yours.',
37
+ ' • NO DUPLICATES OR PICKUP NOISE. Before you post, scan the recent thread/channel for what YOU already said. If you already replied to this exact request, do NOT post again. Do not send a generic pickup acknowledgement; activity shows that work is underway. The first durable task message is a verified result, a link, or a real blocker. One answer per question.',
37
38
  ' • 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.',
38
39
  ' • USE RECALL, NEVER INVENT IT. Before answering a context-dependent question, search the visible thread and use any available history, search, docs, or recall tools. Reuse verified context instead of asking the user to repeat it. If no record exists, say plainly "I don\'t have a record of that". Never fabricate past events, conversations, results, links, PR numbers, deploy URLs, or figures.',
40
+ ' • TICKET SLUGS, NEVER DATABASE IDS. In every human-facing channel message, ticket comment, PR description, summary, blocker, and result, reference a ticket by the exact project-scoped slug returned by get_ticket/list_tasks (for example, `OVS-57`). Numeric project_id and ticket_id values are internal MCP arguments only: never write `#57`, `ticket 57`, or expose a database id to teammates. If the backend omits the slug, use the ticket title or say “the ticket”; do not invent a slug.',
39
41
  ' • CLOSE CONCERNS. Never leave a concern, direct question, correction, or blocker addressed to you without a clear response. Acknowledge the concern, act if you can, then report the verified result. If blocked, name the blocker and the exact next action or owner in one message.',
40
42
  ' • WRITE PLAINLY. Prefer short sentences, commas, periods, and colons. Avoid em dashes except when reproducing quoted text.',
41
43
  ].join('\n')
@@ -62,8 +64,8 @@ const CYCLE_FAST = [
62
64
  const COORDINATE = [
63
65
  'COORDINATION-ONLY cycle. Use the lightweight lane for messaging, triage, ticket comments, assignment, and board movement.',
64
66
  'Call get_marching_orders or poll_inbox only when the event context does not already contain enough detail. Use update_ticket to move a ticket to the correct board column when requested or when non-code work is complete.',
65
- 'Do not inspect repositories, edit files, run tests, or write code in this cycle. If the request actually requires code and this cycle was misclassified, acknowledge it once and state that it needs the coding lane. Do not pretend it is complete.',
66
- 'Respond to every direct concern assigned to you, but post only once per item and never duplicate an existing acknowledgement.',
67
+ 'Do not inspect repositories, edit files, run tests, or write code in this cycle. If the request actually requires code and this cycle was misclassified, leave it retryable for the coding lane; report only a concrete routing blocker, never a generic acknowledgement. Do not pretend it is complete.',
68
+ 'Respond to every direct concern assigned to you, but post only once per item and never duplicate an existing answer.',
67
69
  ].join('\n')
68
70
 
69
71
  // ── CODE agents (--workdir given): full file + Bash + git/gh surface. ─────────
@@ -95,7 +97,7 @@ const CODE_FULL = [
95
97
  ' 3. CHANGE + VERIFY: Read/Edit/Write the files; run the tests or build if the repo has them.',
96
98
  ' 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.',
97
99
  ' 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.',
98
- ' 6. CLOSE THE LOOP: move/update the ticket with update_ticket. Use a ticket-comment tool for a blocker or clarification only when that tool actually appears; otherwise keep the blocker in the ticket update and let the watcher deliver the visible channel result. 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.',
100
+ ' 6. CLOSE THE LOOP: move/update the ticket with update_ticket. Use a ticket-comment tool for a blocker or clarification only when that tool actually appears; otherwise keep the blocker in the ticket update and let the watcher deliver the visible channel result. When a source thread is supplied, follow its explicit delivery rule: either post once or return final text for watcher delivery. For backlog-only tickets, do not call post_message yourself; the watcher sends one verified project-channel completion message and deduplicates it across reconnects.',
99
101
  'Bash is for git / gh / tests / clone ONLY — never to hunt for credentials (they are given to you above).',
100
102
  ].join('\n')
101
103
 
@@ -119,7 +121,7 @@ const INTRO = [
119
121
  const SWEEP = [
120
122
  'DAILY CATCH-UP — you may have missed items while offline. Prioritize TASKS.',
121
123
  'Use the available task/inbox tools. If get_marching_orders/poll_inbox are absent, use list_agents + list_projects + list_tasks to find tasks assigned to your agent identity, then:',
122
- ' 1. For every task assigned to YOU that you have NOT started: begin the work without inventing an acknowledgement tool. If a ticket-comment tool is present you may acknowledge once; otherwise use update_ticket and report through an actual source channel only when one is supplied. Then do the work end-to-end. Skip tasks assigned to other agents.',
124
+ ' 1. For every task assigned to YOU that you have NOT started: begin the work without a pickup acknowledgement. Use update_ticket while working and report only a verified result or concrete blocker through an actual source channel when one is supplied. Then do the work end-to-end. Skip tasks assigned to other agents.',
123
125
  ' 2. Answer only the @mentions / follow-ups that were directed at YOU and that you have not already answered — at most one reply per channel. Do not reply to threads aimed at someone else.',
124
126
  'If there is genuinely nothing outstanding, STOP silently — do NOT post a "nothing to do" message.',
125
127
  ].join('\n')
@@ -301,6 +303,7 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
301
303
  const redactKey = String(mcpHeaders?.['x-agent-api-key'] || '')
302
304
  const opencodeConfig = buildOpencodeConfig({ mcpUrl, mcpHeaders })
303
305
  let configured = false
306
+ let cancelActive = null
304
307
  const ensureConfig = () => {
305
308
  if (configured) return
306
309
  configured = true
@@ -326,11 +329,13 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
326
329
  // OpenCode exited; raw events tell us which tools actually completed.
327
330
  const args = ['run', full, '--auto', '--format', 'json', '--dir', workspace, ...(m ? ['--model', m] : [])]
328
331
  let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, didMcpTaskRead = false, didMcpTaskUpdate = false
332
+ let cancel = null
329
333
  let outputText = '', jsonlBuffer = ''
330
334
  const mcpCalls = new Set(), mcpErrors = new Set(), runtimeErrors = new Set()
331
335
  const finish = (o) => {
332
336
  if (done) return
333
337
  done = true
338
+ if (cancelActive === cancel) cancelActive = null
334
339
  clearTimeout(timer)
335
340
  const calls = [...mcpCalls]
336
341
  log('opencode MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (mcpErrors.size ? ' (failed: ' + [...mcpErrors].join(', ') + ')' : ''))
@@ -374,6 +379,11 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
374
379
  try { child && child.kill() } catch { /* gone */ }
375
380
  finish({ type: 'result', subtype: 'timeout' })
376
381
  }, maxCycleMs)
382
+ cancel = () => {
383
+ try { child && child.kill() } catch { /* gone */ }
384
+ finish({ type: 'result', subtype: 'canceled' })
385
+ }
386
+ cancelActive = cancel
377
387
  log('running opencode cycle…' + (m ? ' [' + m + ']' : ''))
378
388
  try {
379
389
  child = spawn(bin, args, {
@@ -407,7 +417,7 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
407
417
  }
408
418
 
409
419
  log('opencode runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + workspace + '; isolated cfg ' + configDir + ']' : ' [CHAT-ONLY; isolated cfg ' + configDir + ']'))
410
- return { runCycle, canCode }
420
+ return { runCycle, canCode, cancelCurrent: () => cancelActive?.() }
411
421
  }
412
422
 
413
423
  // Codex has a purpose-built non-interactive mode. Run a fresh, ephemeral cycle
@@ -420,6 +430,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
420
430
  const cwd = workdir || OV_DIR
421
431
  const tomlString = (v) => JSON.stringify(String(v))
422
432
  const headerEntries = Object.entries(mcpHeaders || {}).map(([k, v]) => `${JSON.stringify(k)} = ${tomlString(v)}`).join(', ')
433
+ let cancelActive = null
423
434
 
424
435
  function runCycle(prompt, cycleModel, cycleOptions = {}) {
425
436
  return new Promise((resolve) => {
@@ -436,12 +447,14 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
436
447
  ...(mcpOverride ? ['-c', mcpOverride] : []),
437
448
  full]
438
449
  let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = '', stderrLineBuffer = ''
450
+ let cancel = null
439
451
  let policyBlock = null
440
452
  const mcpCalls = new Set(), mcpErrors = new Set()
441
453
  let didMcpTaskRead = false, didMcpTaskUpdate = false
442
454
  const finish = (o) => {
443
455
  if (done) return
444
456
  done = true
457
+ if (cancelActive === cancel) cancelActive = null
445
458
  clearTimeout(timer)
446
459
  const calls = [...mcpCalls]
447
460
  log('codex MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (mcpErrors.size ? ' (failed: ' + [...mcpErrors].join(', ') + ')' : ''))
@@ -496,6 +509,11 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
496
509
  try { child && child.kill() } catch { /* gone */ }
497
510
  finish({ type: 'result', subtype: 'timeout' })
498
511
  }, maxCycleMs)
512
+ cancel = () => {
513
+ try { child && child.kill() } catch { /* gone */ }
514
+ finish({ type: 'result', subtype: 'canceled' })
515
+ }
516
+ cancelActive = cancel
499
517
  log('running codex cycle…' + (m ? ' [' + m + ']' : ''))
500
518
  try {
501
519
  // Always inspect Codex JSONL so a successful process exit cannot be
@@ -526,7 +544,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
526
544
 
527
545
  if (!mcpUrl) log('WARNING: no --mcp-url — Codex has no openvisio-team tools to act with. Re-connect with --mcp-url.')
528
546
  log('codex runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + cwd + ']' : ' [CHAT-ONLY]'))
529
- return { runCycle, canCode }
547
+ return { runCycle, canCode, cancelCurrent: () => cancelActive?.() }
530
548
  }
531
549
 
532
550
  // ── Claude Code warm-session cycle runner (shared by the REST + WS loops) ─────
@@ -652,7 +670,14 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
652
670
  })
653
671
  }
654
672
 
655
- return { runCycle, canCode }
673
+ const cancelCurrent = () => {
674
+ const active = child
675
+ if (!active) return
676
+ child = null
677
+ try { active.kill() } catch { /* gone */ }
678
+ settleTurn({ type: 'result', subtype: 'canceled' })
679
+ }
680
+ return { runCycle, canCode, cancelCurrent }
656
681
  }
657
682
 
658
683
  // ── the backend WS loop ──────────────────────────────────────────────────────
@@ -708,6 +733,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
708
733
  const fullPrompt = (canCode ? CODE_FULL + codexPushGuide : 'Handle the supplied verified backend ticket with the available OpenVisio tools. Update or comment on the ticket as requested, do not claim repository work in chat-only mode, and stop after the verified action.') + '\n\n' + backendToolRule
709
734
  const fastPrompt = (canCode ? CODE_FAST : CYCLE_FAST) + '\n\n' + backendToolRule
710
735
  const coordinatePrompt = COORDINATE + '\n\n' + backendToolRule
736
+ const guardedReplyPrompt = 'WATCHER-DELIVERED REPLY: the watcher has already verified that the current source message is addressed to you. Do not call post_message or any inbox/resource-discovery tool. Return only one natural, context-specific reply of 1-3 sentences. Do not echo the request, announce a plan, or add a generic acknowledgement. If the supplied context says the work was completed, lead with the verified result; if it is blocked, name only the real blocker and next action.' + '\n\n' + backendToolRule
711
737
  // Live model state — changeable at runtime by the in-chat `/model` command.
712
738
  // codeModel drives full/sweep cycles; chatModel (if set) the lighter fast/intro
713
739
  // ones, so routine chatter can run cheaper than real code work.
@@ -715,8 +741,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
715
741
  let liteModel = chatModel || model
716
742
 
717
743
  const lanes = {
718
- work: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [], deferred: [] },
719
- reply: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [], deferred: [] },
744
+ work: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [], deferred: [], activeDelivery: null },
745
+ reply: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [], deferred: [], activeDelivery: null },
720
746
  }
721
747
  // Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
722
748
  // different agent re-triggers), so a noisy stream of task:updated events doesn't
@@ -787,7 +813,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
787
813
  let lastTaskTriggeredAt = 0
788
814
  let lastInboxSignature = ''
789
815
  let selfAgentId = null
790
- const mcpClient = createMcpHttpClient({ url: mcpUrl, apiKey, identifier, clientVersion: '0.18.3', log })
816
+ const selfAliases = new Set([slug, identifier].map((value) => String(value || '').toLowerCase()).filter(Boolean))
817
+ const mcpClient = createMcpHttpClient({ url: mcpUrl, apiKey, identifier, clientVersion: '0.18.5', log })
791
818
  const callMcpTool = (name, args = {}) => mcpClient.callTool(name, args)
792
819
  let mcpToolNames = null
793
820
  let mcpToolDiscoveryPromise = null
@@ -838,12 +865,39 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
838
865
  // rendered as this agent. A persisted delivery key closes the crash/reconnect
839
866
  // gap; an in-flight promise closes the two-lane race inside one watcher.
840
867
  const messageDeliveries = new Map()
841
- const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '' }) => {
868
+ const threadControlKey = (channelId, parentId) => Number.isFinite(Number(channelId)) && parentId != null ? `thread:${Number(channelId)}:${parentId}` : ''
869
+ const sameDeliveryThread = (delivery, channelId, parentId) => delivery && Number(delivery.channelId) === Number(channelId) && String(delivery.parentId ?? '') === String(parentId ?? '')
870
+ const cancelThread = (channelId, parentId, summary) => {
871
+ const controlKey = threadControlKey(channelId, parentId)
872
+ if (!controlKey) return
873
+ memory.remember({ key: controlKey, kind: 'thread', state: 'cancelled', summary, refs: { channelId: Number(channelId), threadId: parentId } })
874
+ for (const [laneName, lane] of Object.entries(lanes)) {
875
+ lane.deferred = lane.deferred.filter((item) => !sameDeliveryThread(item.delivery, channelId, parentId))
876
+ if (sameDeliveryThread(lane.activeDelivery, channelId, parentId)) {
877
+ log(`${laneName} lane cancelled by a newer redirect/stand-down in thread ${parentId}`)
878
+ runners[laneName].cancelCurrent?.('thread-cancelled')
879
+ }
880
+ }
881
+ }
882
+ const activateThread = (channelId, parentId, summary) => {
883
+ const controlKey = threadControlKey(channelId, parentId)
884
+ if (controlKey) memory.remember({ key: controlKey, kind: 'thread', state: 'active', summary, refs: { channelId: Number(channelId), threadId: parentId } })
885
+ }
886
+ const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', allowCancelled = false }) => {
842
887
  const deliveryKey = String(key || '')
843
888
  const message = String(content || '').trim()
844
889
  if (!deliveryKey || !Number.isFinite(Number(channelId)) || !message) return { posted: false, reason: 'invalid-delivery' }
845
890
  if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) return { posted: false, reason: 'remembered' }
846
891
  if (messageDeliveries.has(deliveryKey)) return messageDeliveries.get(deliveryKey)
892
+ const controlKey = threadControlKey(channelId, parentId)
893
+ const suppressCancelled = () => {
894
+ if (allowCancelled || !controlKey || !memory.has(controlKey, 'cancelled')) return null
895
+ deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
896
+ memory.remember({ key: deliveryKey, kind: 'delivery', state: 'suppressed', summary: 'A newer redirect or stand-down cancelled this reply.', refs: { channelId: Number(channelId), threadId: parentId } })
897
+ return { posted: false, reason: 'thread-cancelled' }
898
+ }
899
+ const initiallyCancelled = suppressCancelled()
900
+ if (initiallyCancelled) return initiallyCancelled
847
901
 
848
902
  const run = (async () => {
849
903
  if (parentId != null) {
@@ -859,6 +913,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
859
913
  }
860
914
  }
861
915
 
916
+ // A redirect can arrive while the authoritative thread read is in flight.
917
+ // Re-check immediately before the irreversible post.
918
+ const newlyCancelled = suppressCancelled()
919
+ if (newlyCancelled) return newlyCancelled
920
+
862
921
  sendStatus(Number(channelId), 'typing')
863
922
  const result = toolData(await callMcpTool('post_message', {
864
923
  ...(Number.isFinite(Number(projectId)) ? { project_id: Number(projectId) } : {}),
@@ -1054,6 +1113,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1054
1113
  const self = agents.find((a) => String(a.identifier || a.slug || '') === identifier)
1055
1114
  if (!self?.id) throw new Error('list_agents did not return this BYO agent')
1056
1115
  selfAgentId = Number(self.id)
1116
+ for (const alias of [self.name, self.identifier, self.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
1057
1117
  const projectsData = toolData(await callMcpTool('list_projects'))
1058
1118
  const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
1059
1119
  const assigned = []
@@ -1097,19 +1157,22 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1097
1157
  } else continue
1098
1158
  }
1099
1159
  memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'assigned', summary: task.title, refs: { projectId: project.id, ticketId: task.id }, meta: { updatedAt: task.updated_at ?? task.updatedAt } })
1100
- assigned.push({ id: task.id, projectId: project.id, project: project.name, title: task.title, priority: task.priority, typeId: task.type_id ?? task.typeId, updatedAt: task.updated_at ?? task.updatedAt })
1160
+ assigned.push({ id: task.id, slug: ticketDisplaySlug(task), projectId: project.id, project: project.name, title: task.title, priority: task.priority, typeId: task.type_id ?? task.typeId, updatedAt: task.updated_at ?? task.updatedAt })
1101
1161
  }
1102
1162
  const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
1103
1163
  const mentionNeedles = [self.name, self.identifier, self.slug, identifier].filter(Boolean).map((s) => '@' + String(s).toLowerCase())
1104
1164
  for (const item of activities) {
1105
1165
  const text = JSON.stringify(item)
1106
- const lower = text.toLowerCase()
1107
1166
  const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
1108
- if (!seenActivities.has(activityKey) && mentionNeedles.some((needle) => lower.includes(needle)) && /message|mention|channel/i.test(text)) {
1109
- const data = item?.data && typeof item.data === 'object' ? item.data : null
1110
- const activityMessage = item?.message && typeof item.message === 'object'
1111
- ? item.message
1112
- : data?.message && typeof data.message === 'object' ? data.message : item
1167
+ const data = item?.data && typeof item.data === 'object' ? item.data : null
1168
+ const activityMessage = item?.message && typeof item.message === 'object'
1169
+ ? item.message
1170
+ : data?.message && typeof data.message === 'object' ? data.message : item
1171
+ const activityText = String(activityMessage?.content ?? activityMessage?.body ?? activityMessage?.text ?? '').toLowerCase()
1172
+ // Reconciliation recovers explicit mentions only. Searching the whole
1173
+ // activity envelope can match an old thread root and replay a new reply
1174
+ // that is actually addressed to another agent.
1175
+ if (!seenActivities.has(activityKey) && mentionNeedles.some((needle) => activityText.includes(needle)) && /message|mention|channel/i.test(text)) {
1113
1176
  const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
1114
1177
  seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
1115
1178
  // The same logical mention may already have arrived over WebSocket.
@@ -1138,7 +1201,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1138
1201
  log('backlog reconciliation found ' + assigned.length + ' assigned task(s); queueing only ticket #' + next.id)
1139
1202
  const activityChannel = await projectStatusChannel(next.projectId)
1140
1203
  pendingCompletionReports.add(`${next.projectId}:${next.id}`); trimSeen(pendingCompletionReports); persistReplay()
1141
- void drain('full', `Backlog reconciliation verified this open ticket is assigned to YOU: ${JSON.stringify(next)}. Process this ONE ticket only. This is backlog-only work with NO source message or requester thread: do NOT call post_message yourself. Call get_ticket, use list_task_types and update_ticket to move it active, complete and verify the work, open the PR, then update the ticket with evidence and move it to review or done when appropriate. The watcher will publish exactly one verified completion result to the project channel.`, activityChannel == null ? [] : [activityChannel], { projectId: next.projectId, ticketId: next.id, channelId: activityChannel })
1204
+ const humanTicketRef = next.slug ? `ticket ${next.slug}` : `the ticket “${next.title}”`
1205
+ void drain('full', `Backlog reconciliation verified ${humanTicketRef} is assigned to YOU. Internal tool identity: project_id ${next.projectId}, ticket_id ${next.id}. Process this ONE ticket only. Numeric ids are MCP arguments only and must never appear in human-facing text; use ${next.slug || 'the ticket title'} instead. This is backlog-only work with NO source message or requester thread: do NOT call post_message yourself. Call get_ticket, use list_task_types and update_ticket to move it active, complete and verify the work, open the PR, then update the ticket with evidence and move it to review or done when appropriate. The watcher will publish exactly one verified completion result to the project channel.`, activityChannel == null ? [] : [activityChannel], { projectId: next.projectId, ticketId: next.id, channelId: activityChannel })
1142
1206
  }
1143
1207
  }
1144
1208
  const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
@@ -1165,7 +1229,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1165
1229
 
1166
1230
  // Higher rank wins when coalescing cycles requested while one is running.
1167
1231
  const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
1168
- const baseFor = (kind) => kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? coordinatePrompt : fastPrompt
1232
+ const baseFor = (kind, delivery) => delivery?.watcherOwned
1233
+ ? (kind === 'full' ? fullPrompt + '\n\n' + guardedReplyPrompt : guardedReplyPrompt)
1234
+ : kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? coordinatePrompt : fastPrompt
1169
1235
  // Codex and OpenCode expose structured action streams. Require runtime facts
1170
1236
  // from those streams before accepting a full coding cycle. Claude's evidence
1171
1237
  // shape is different and remains on its existing completion path.
@@ -1221,6 +1287,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1221
1287
  for (const channelId of targetChannels) if (Number.isFinite(Number(channelId))) lane.targets.add(Number(channelId))
1222
1288
  if (lane.busy) { lane.queued = (RANK[kind] ?? 0) >= (RANK[lane.queued] ?? 0) ? kind : lane.queued; log(laneName + ' lane busy — queued a ' + kind + ' follow-up cycle'); return }
1223
1289
  lane.busy = true
1290
+ lane.activeDelivery = delivery
1224
1291
  const ctx = lane.pending.splice(0)
1225
1292
  const activeTaskRef = lane.taskRefs.splice(0)[0] || null
1226
1293
  const targets = [...lane.targets]
@@ -1232,7 +1299,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1232
1299
  ? { channelId: delivery.channelId, threadId: delivery.parentId }
1233
1300
  : activeTaskRef ? { projectId: activeTaskRef.projectId, ticketId: activeTaskRef.ticketId } : {}
1234
1301
  const recalled = memory.context(memoryRefs)
1235
- const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind)
1302
+ const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind, delivery)
1236
1303
  // Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
1237
1304
  // work (full/sweep) uses the main model.
1238
1305
  const useModel = agent === 'codex' ? codeModel : kind === 'full' ? codeModel : liteModel
@@ -1244,8 +1311,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1244
1311
  // 20 seconds so long coding runs do not create needless network/battery load.
1245
1312
  const heartbeat = targets.length ? setInterval(() => emitLaneStatus(laneName, 'working'), 20_000) : null
1246
1313
  try {
1247
- const result = await runners[laneName].runCycle(prompt, useModel, agent === 'codex' && delivery ? { disabledMcpTools: ['post_message'] } : {})
1314
+ const result = await runners[laneName].runCycle(prompt, useModel, delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {})
1248
1315
  let completionResult = result
1316
+ if (result?.subtype === 'canceled') {
1317
+ log(laneName + ' cycle cancelled; no blocker or reply will be published')
1318
+ return
1319
+ }
1249
1320
  if (agent === 'codex' && result?.subtype === 'blocked' && result?.policyBlock) {
1250
1321
  log('WORK_CYCLE_BLOCKED authorization required; pausing this ticket and publishing an action-required notice')
1251
1322
  try { await reportPolicyBlock(prompt, activeTaskRef, result.policyBlock) }
@@ -1273,7 +1344,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1273
1344
  const missing = missingWorkEvidence(result, ticketCycle)
1274
1345
  if (evidenceGatedRuntime && kind === 'full' && result?.subtype === 'ok' && missing.length) {
1275
1346
  log(agent + ' coding cycle incomplete; recovery requires: ' + missing.join(', '))
1276
- 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, publish an agent/* branch, open the PR, and call update_ticket with the correct board column. ${agent === 'codex' ? 'Prefer the available OpenVisio create_codebase_branch/create_codebase_commit/create_pull_request tools. Only when that linked-codebase flow is unavailable, run openvisio-agent push-pr-branch; never retry a rejected direct git push.' : ''} Post only when the original context supplies a source thread.`, codeModel, agent === 'codex' && delivery ? { disabledMcpTools: ['post_message'] } : {})
1347
+ 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, publish an agent/* branch, open the PR, and call update_ticket with the correct board column. ${agent === 'codex' ? 'Prefer the available OpenVisio create_codebase_branch/create_codebase_commit/create_pull_request tools. Only when that linked-codebase flow is unavailable, run openvisio-agent push-pr-branch; never retry a rejected direct git push.' : ''} Post only when the original context supplies a source thread.`, codeModel, delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {})
1277
1348
  const recoveredResult = combineWorkEvidence(result, recovery)
1278
1349
  const recoveryMissing = missingWorkEvidence(recoveredResult, ticketCycle)
1279
1350
  if (recovery?.subtype !== 'ok' || recoveryMissing.length) {
@@ -1304,7 +1375,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1304
1375
  log('completion report failed for ticket #' + activeTaskRef.ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry')
1305
1376
  }
1306
1377
  }
1307
- if (agent === 'codex' && delivery) {
1378
+ if (delivery?.watcherOwned) {
1308
1379
  const reply = String(completionResult?.outputText || '').trim()
1309
1380
  if (!reply) {
1310
1381
  log('guarded reply produced no final text; leaving delivery unrecorded for retry')
@@ -1317,6 +1388,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1317
1388
  if (heartbeat) clearInterval(heartbeat)
1318
1389
  laneStatusTargets[laneName].clear()
1319
1390
  lane.busy = false
1391
+ lane.activeDelivery = null
1320
1392
  const deferred = lane.deferred.shift()
1321
1393
  if (deferred) void drain(deferred.kind, deferred.context, deferred.targetChannels, deferred.taskRef, deferred.delivery)
1322
1394
  else if (lane.queued || lane.pending.length) { const next = lane.queued || (laneName === 'work' ? 'full' : 'fast'); lane.queued = null; void drain(next) }
@@ -1331,10 +1403,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1331
1403
  return (`${s.first_name || ''} ${s.last_name || ''}`.trim() || s.name || s.email || '')
1332
1404
  }
1333
1405
 
1334
- // Route obvious repository work to the coding lane. Everything else, including
1335
- // chat and board movement, stays on the lightweight coordination model.
1336
- const needsCode = (value) => /\b(?:code|coding|implement|implementation|create|make|design|fix|bug|debug|refactor|test|tests|build|compile|repository|repo|github|git|branch|commit|pull request|pr|endpoint|api|component|feature|page|screen|ui|function|class|database|migration|schema|deploy|release|package|npm|typescript|javascript|python|swift|rust|golang|css|html|file|files|documentation)\b/i.test(String(value || ''))
1337
- const coordinationOnly = (value) => /\b(?:move|moving|status|column|assign|reassign|unassign|comment|reply|message|triage|prioriti[sz]e|label|rename|close|reopen)\b/i.test(String(value || '')) && !needsCode(value)
1406
+ // Route only concrete repository requests to the coding lane. Conversation
1407
+ // ownership is classified separately before this is consulted.
1408
+ const coordinationOnly = (value) => /\b(?:move|moving|status|column|assign|reassign|unassign|comment|reply|message|triage|prioriti[sz]e|label|rename|close|reopen)\b/i.test(String(value || '')) && !conversationNeedsCode(value)
1338
1409
 
1339
1410
  // Engineers change the model under the hood from chat: "/model", "/model sonnet",
1340
1411
  // "use model haiku", "switch model to opus". Returns {report} | {set} | {invalid}.
@@ -1376,6 +1447,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1376
1447
  const agentsData = toolData(await callMcpTool('list_agents'))
1377
1448
  const self = (Array.isArray(agentsData.agents) ? agentsData.agents : []).find((a) => String(a.identifier || a.slug || '') === identifier)
1378
1449
  selfAgentId = self?.id != null ? Number(self.id) : null
1450
+ for (const alias of [self?.name, self?.identifier, self?.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
1379
1451
  }
1380
1452
  const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
1381
1453
  const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
@@ -1413,6 +1485,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1413
1485
  if (seenTasks.has(key)) { log(kind + ' ticket #' + ticketId + ' already queued/active — ignored'); return }
1414
1486
  seenTasks.add(key); trimSeen(seenTasks)
1415
1487
  const title = String(ticket.title || hinted.title || '')
1488
+ const ticketSlug = ticketDisplaySlug(ticket)
1416
1489
  memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'queued', summary: title, refs: { projectId, ticketId }, meta: { sourceEvent: kind } })
1417
1490
  const taskText = [title, ticket.description, ticket.type, ticket.kind].filter(Boolean).join(' ')
1418
1491
  const cycleKind = canCode && !coordinationOnly(taskText) ? 'full' : 'coord'
@@ -1420,7 +1493,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1420
1493
  const activityChannel = await projectStatusChannel(projectId)
1421
1494
  if (activityChannel != null) sendStatus(activityChannel, 'thinking')
1422
1495
  if (cycleKind === 'full') { pendingCompletionReports.add(key); trimSeen(pendingCompletionReports); persistReplay() }
1423
- void drain(cycleKind, `Authoritative get_ticket verification confirms ticket #${ticketId} in project ${projectId} is open and assigned to YOU: ${JSON.stringify({ id: ticketId, projectId, title, description: ticket.description, priority: ticket.priority, typeId: ticket.type_id ?? ticket.typeId })}. This assignment has no source thread: do not call post_message yourself. Use list_task_types and update_ticket to move it active, complete and verify the work, open the PR when applicable, then update/move the ticket with evidence. For coding work, the watcher will publish exactly one verified completion result in the project channel.`, activityChannel == null ? [] : [activityChannel], { projectId, ticketId, channelId: activityChannel })
1496
+ const humanTicketRef = ticketSlug ? `ticket ${ticketSlug}` : `the ticket “${title}”`
1497
+ void drain(cycleKind, `Authoritative get_ticket verification confirms ${humanTicketRef} is open and assigned to YOU. Internal tool identity: project_id ${projectId}, ticket_id ${ticketId}. Numeric ids are MCP arguments only and must never appear in human-facing text; use ${ticketSlug || 'the ticket title'} instead. Ticket details: ${JSON.stringify({ slug: ticketSlug, title, description: ticket.description, priority: ticket.priority, typeId: ticket.type_id ?? ticket.typeId })}. This assignment has no source thread: do not call post_message yourself. Use list_task_types and update_ticket to move it active, complete and verify the work, open the PR when applicable, then update/move the ticket with evidence. For coding work, the watcher will publish exactly one verified completion result in the project channel.`, activityChannel == null ? [] : [activityChannel], { projectId, ticketId, channelId: activityChannel })
1424
1498
  } catch (e) {
1425
1499
  log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
1426
1500
  }
@@ -1444,15 +1518,35 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1444
1518
  // NOT trigger a second reply. Key by message id, or a channel+text signature
1445
1519
  // when the payload carries no id.
1446
1520
  if (!raw._mentionAlreadyMarked && markMentionHandled(msg, cid)) { log('agent:mention (dup) — skipped'); return }
1447
- if (requestTargetsLaterAgent(text, [slug, identifier])) {
1448
- log('agent:mention addressed to a later-mentioned agent — skipped')
1521
+ const target = classifyConversationTarget(msg, [...selfAliases])
1522
+ if (target.action === 'ignore') {
1523
+ log('agent:mention ignored before model start (' + target.reason + ')')
1524
+ // A redirect or another agent taking over invalidates any older work or
1525
+ // draft still queued for this thread. The event name alone cannot retain
1526
+ // ownership after the actual message says otherwise.
1527
+ if (cid != null && threadRoot != null && (target.reason === 'explicit-other-recipient' || target.reason === 'other-agent-chatter' || target.reason === 'redirected-to-later-agent')) {
1528
+ cancelThread(cid, threadRoot, text || target.reason)
1529
+ }
1449
1530
  return
1450
1531
  }
1451
1532
  const mentionKeys = mentionDedupeKeys(msg, cid)
1452
1533
  const sourceKey = `mention:${cid ?? '?'}:${mentionKeys.idKey || mentionKeys.signatureKey || threadRoot || Date.now()}`
1534
+ if (target.action === 'stand_down') {
1535
+ if (cid != null && threadRoot != null) {
1536
+ cancelThread(cid, threadRoot, text || 'Explicit stand-down')
1537
+ const confirmation = `${who ? `@${who} ` : ''}Understood. I'm standing down.`
1538
+ void postMessageOnce({ key: `stand-down:${cid}:${threadRoot}`, channelId: Number(cid), parentId: threadRoot, content: confirmation, sourceKey, allowCancelled: true })
1539
+ .catch((e) => log('stand-down confirmation failed closed: ' + (e?.message || e)))
1540
+ }
1541
+ return
1542
+ }
1543
+ if (cid != null && threadRoot != null) activateThread(cid, threadRoot, text)
1453
1544
  memory.remember({ key: sourceKey, kind: 'mention', state: 'received', summary: text, refs: { channelId: cid, threadId: threadRoot, messageId: mid } })
1454
- const guardedDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result') => agent === 'codex' && cid != null
1455
- ? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey }
1545
+ // Every runtime carries the source thread through the queue so a later
1546
+ // redirect can cancel its process. Codex additionally delegates the final
1547
+ // post to the watcher for an authoritative last-moment thread check.
1548
+ const conversationDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result') => cid != null
1549
+ ? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, watcherOwned: agent === 'codex' }
1456
1550
  : null
1457
1551
  // Under-the-hood model control from chat (view / switch the model the agent runs).
1458
1552
  const mcmd = cid != null ? parseModelCmd(text) : null
@@ -1460,11 +1554,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1460
1554
  const thread = threadRoot != null ? `, parent_id ${threadRoot}` : ''
1461
1555
  if (mcmd.report) {
1462
1556
  log('model query → code ' + codeModel + ' / chat ' + liteModel)
1463
- const delivery = guardedDelivery('model')
1464
- void drain('fast', `An engineer asked which model you're running. ${delivery ? `Return exactly this one-line final answer without calling post_message: "I'm on ${codeModel} for code work${liteModel !== codeModel ? ` and ${liteModel} for chat replies` : ' (and chat)'}.” The watcher will verify and deliver it once.` : `Reply once in channel ${cid}${thread} (with your agent creds): "I'm on ${codeModel} for code work${liteModel !== codeModel ? ` and ${liteModel} for chat replies` : ' (and chat)'}.” One line. Then stop.`}`, [cid], null, delivery)
1557
+ const delivery = conversationDelivery('model')
1558
+ void drain('fast', `An engineer asked which model you're running. ${delivery?.watcherOwned ? `Return exactly this one-line final answer without calling post_message: "I'm on ${codeModel} for code work${liteModel !== codeModel ? ` and ${liteModel} for chat replies` : ' (and chat)'}.” The watcher will verify and deliver it once.` : `Reply once in channel ${cid}${thread} (with your agent creds): "I'm on ${codeModel} for code work${liteModel !== codeModel ? ` and ${liteModel} for chat replies` : ' (and chat)'}.” One line. Then stop.`}`, [cid], null, delivery)
1465
1559
  } else if (mcmd.invalid) {
1466
- const delivery = guardedDelivery('model')
1467
- void drain('fast', `An engineer tried to switch your model to "${mcmd.invalid}", which isn't one you recognize. ${delivery ? 'Return one short final answer saying you support "opus", "sonnet", "haiku", or a full model id and asking which they meant. Do not call post_message; the watcher will verify and deliver it once.' : `Reply once in channel ${cid}${thread} (with your agent creds): say you support "opus", "sonnet", "haiku", or a full "claude-…" id, and ask which they meant. One line. Then stop.`}`, [cid], null, delivery)
1560
+ const delivery = conversationDelivery('model')
1561
+ void drain('fast', `An engineer tried to switch your model to "${mcmd.invalid}", which isn't one you recognize. ${delivery?.watcherOwned ? 'Return one short final answer saying you support "opus", "sonnet", "haiku", or a full model id and asking which they meant. Do not call post_message; the watcher will verify and deliver it once.' : `Reply once in channel ${cid}${thread} (with your agent creds): say you support "opus", "sonnet", "haiku", or a full "claude-…" id, and ask which they meant. One line. Then stop.`}`, [cid], null, delivery)
1468
1562
  } else {
1469
1563
  const tgt = mcmd.target // 'chat' | 'code' | 'both'
1470
1564
  const prev = `code ${codeModel}/chat ${liteModel}`
@@ -1474,8 +1568,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1474
1568
  persistModel()
1475
1569
  const label = tgt === 'chat' ? 'chat model' : tgt === 'code' ? 'code model' : 'model'
1476
1570
  log('model switched (' + tgt + ') ' + prev + ' → code ' + codeModel + '/chat ' + liteModel + (who ? ' (by ' + who + ')' : ''))
1477
- const delivery = guardedDelivery('model')
1478
- void drain('fast', `An engineer switched your ${label} to "${mcmd.set}" — active for your next ${tgt === 'chat' ? 'chat replies' : tgt === 'code' ? 'code cycles' : 'actions'}. ${delivery ? `Return one short final confirmation such as "Switched my ${label} to ${mcmd.set}. I'll use it from here." Do not call post_message; the watcher will verify and deliver it once.` : `Post ONE short confirmation in channel ${cid}${thread} (with your agent creds): e.g. "Switched my ${label} to ${mcmd.set} — I'll use it from here." Then stop.`}`, [cid], null, delivery)
1571
+ const delivery = conversationDelivery('model')
1572
+ void drain('fast', `An engineer switched your ${label} to "${mcmd.set}" — active for your next ${tgt === 'chat' ? 'chat replies' : tgt === 'code' ? 'code cycles' : 'actions'}. ${delivery?.watcherOwned ? `Return one short final confirmation such as "Switched my ${label} to ${mcmd.set}. I'll use it from here." Do not call post_message; the watcher will verify and deliver it once.` : `Post ONE short confirmation in channel ${cid}${thread} (with your agent creds): e.g. "Switched my ${label} to ${mcmd.set} — I'll use it from here." Then stop.`}`, [cid], null, delivery)
1479
1573
  }
1480
1574
  return
1481
1575
  }
@@ -1483,23 +1577,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1483
1577
  // Light up the live status the instant we pick this up; drain owns the
1484
1578
  // subsequent working/typing heartbeat for its lane.
1485
1579
  if (cid != null) sendStatus(cid, 'thinking')
1486
- const codingMention = canCode && needsCode(text)
1487
- const replyDelivery = guardedDelivery(codingMention ? 'result' : 'reply', !codingMention)
1580
+ const codingMention = canCode && conversationNeedsCode(text)
1581
+ const replyDelivery = conversationDelivery(codingMention ? 'result' : 'reply', !codingMention)
1488
1582
  const ctx = cid != null
1489
- ? replyDelivery
1490
- ? `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. ${codingMention ? 'Complete the repository work and verification first.' : 'Answer the request.'} Do NOT call post_message; it is intentionally unavailable. Return only the final 1-3 sentence reply as your final answer. The watcher will read the real thread, check its persistent memory graph, and render that answer at most once.${who ? ` To mention the requester, use their exact full name "@${who}".` : ''} The complete message is already here; do not call get_resource, get_marching_orders, poll_inbox, list_mcp_resources, or list_mcp_resource_templates.`
1491
- : `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. ${codingMention ? 'This is repository work: complete the coding flow first, then send' : 'Send'} EXACTLY ONE reply with post_message: arguments: channel_id ${cid}${threadRoot != null ? `, parent_id ${threadRoot} (reply IN THAT THREAD, do not start a new top-level message)` : ''}, plus agent_identifier + agent_api_key from the AUTH line above, and a 1-3 sentence reply. Compose the whole answer, then post it ONCE. Do not post a first reply and then a revised version. FIRST read the recent messages in this thread: if you already answered this, or another agent was the one addressed, do NOT post at all. Be sure of your answer before sending.${who ? ` To @mention them back, write their EXACT full name "@${who}". A mention only links when the name matches exactly.` : ''} The complete message is already here. Do not call get_resource, get_marching_orders, poll_inbox, list_mcp_resources, or list_mcp_resource_templates, and after your single reply, STOP.`
1583
+ ? replyDelivery?.watcherOwned
1584
+ ? `The watcher verified this source message is addressed to you in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". ${codingMention ? 'Complete the concrete repository work and verification first.' : 'Answer the request directly.'} Do NOT call post_message; it is intentionally unavailable. Return only the final reply as your final answer. The watcher will re-check the live thread and render it at most once.${who ? ` To mention the requester, use their exact full name "@${who}".` : ''} The complete message is already here; do not call get_resource, get_marching_orders, poll_inbox, list_mcp_resources, or list_mcp_resource_templates.`
1585
+ : `The watcher verified this source message is addressed to you in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". ${codingMention ? 'This is concrete repository work: complete the coding flow first, then send' : 'Send'} EXACTLY ONE reply with post_message: arguments: channel_id ${cid}${threadRoot != null ? `, parent_id ${threadRoot} (reply IN THAT THREAD, do not start a new top-level message)` : ''}, plus agent_identifier + agent_api_key from the AUTH line above, and a 1-3 sentence reply. Compose the whole answer, then post it ONCE. Do not post a first reply and then a revised version. Be sure of your answer before sending.${who ? ` To @mention them back, write their EXACT full name "@${who}". A mention only links when the name matches exactly.` : ''} The complete message is already here. Do not call get_resource, get_marching_orders, poll_inbox, list_mcp_resources, or list_mcp_resource_templates, and after your single reply, STOP.`
1492
1586
  : undefined
1493
1587
  if (codingMention) {
1494
- if (agent === 'codex' && cid != null) {
1495
- const ack = `${who ? `@${who} ` : ''}I've picked this up and will return here with the verified result.`
1496
- void postMessageOnce({ ...guardedDelivery('ack'), content: ack }).catch((e) => log('guarded acknowledgement failed closed: ' + (e?.message || e)))
1497
- } else {
1498
- const ack = cid != null
1499
- ? `You were asked for repository work in channel ${cid}${threadRoot != null ? `, thread ${threadRoot}` : ''}. The dedicated work lane has accepted it. Post exactly one short reply with post_message in that same thread saying you have picked it up and will return there with the verified result. Include agent_identifier + agent_api_key. Do not inspect or edit code in this reply lane.`
1500
- : undefined
1501
- void drain('coord', ack, cid == null ? [] : [cid])
1502
- }
1588
+ // Activity indicators make accepted work visible. Do not add a canned
1589
+ // pickup message; the first channel message is the verified result or a
1590
+ // concrete blocker from the work lane.
1503
1591
  void drain('full', ctx, cid == null ? [] : [cid], null, replyDelivery)
1504
1592
  } else void drain('fast', ctx, cid == null ? [] : [cid], null, replyDelivery)
1505
1593
  } else if (k === 'error') {