openvisio-agent 0.18.2 → 0.18.4

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
@@ -56,12 +56,14 @@ Then `watch --name ada` auto-detects the backend agent and runs a WebSocket loop
56
56
 
57
57
  Runs the **autonomy loop** — the agent replies to @mentions and picks up tickets on its own. It cheaply polls an inbox endpoint (no model spend when idle) and pokes a single warm Claude Code session only when something new arrives.
58
58
 
59
- Backend/BYO watchers reconcile immediately whenever the process starts or the WebSocket connects. A direct MCP session uses the backend's actual tools (`list_agents`, `list_projects`, `list_tasks`, `list_task_types`, and `list_activity`) to recover assigned tasks and recent mention activity missed while offline. The same zero-model check runs every five minutes as a safety net; a model starts only when pending work exists.
59
+ Backend/BYO watchers reconcile immediately whenever the process starts or the WebSocket connects. A direct MCP session discovers and caches the backend's actual `tools/list` response, then uses available tools such as `list_agents`, `list_projects`, `list_tasks`, `list_task_types`, and `list_activity` to recover assigned tasks and recent mention activity missed while offline. Optional actions such as ticket comments are used only when advertised; their absence cannot strand a completed ticket in a retry loop. The same zero-model check runs every five minutes as a safety net; a model starts only when pending work exists.
60
60
 
61
- The backend MCP may be stateful or stateless. A successful initialize response without `Mcp-Session-Id` is accepted as stateless, so OpenCode agents do not stop with “MCP initialize returned no session id.” Each OpenCode lane keeps its MCP identity in a private per-agent config directory while the repository is supplied separately with `--dir`; stale workspace configuration therefore cannot swap one agent's credentials for another's. The generated remote configuration sends the agent headers directly, disables OAuth probing, and backend cycles never request relay-only `get_marching_orders`, `poll_inbox`, or `get_resource` tools.
61
+ The backend MCP may be stateful or stateless. A successful initialize response without `Mcp-Session-Id` is accepted as stateless, so OpenCode agents do not stop with “MCP initialize returned no session id.” Each OpenCode lane keeps its MCP identity in a private per-agent config directory while the repository is supplied separately with `--dir`; stale workspace configuration therefore cannot swap one agent's credentials for another's. The generated remote configuration sends the agent headers directly, disables OAuth probing, and backend cycles never request relay-only inbox calls or MCP resource-discovery tools in place of team actions.
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
+
65
67
  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
68
 
67
69
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.18.2",
3
+ "version": "0.18.4",
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 = [
@@ -41,11 +46,16 @@ const assertions = [
41
46
  ['assigned coding completion is posted by the watcher', watcher.includes('announceTaskCompletion') && watcher.includes('postMessageOnce({ key: `completion:${report.key}`')],
42
47
  ['completion requires review/done state and PR evidence', watcher.includes('buildTaskCompletionReport') && watcher.includes('completion report deferred')],
43
48
  ['completion delivery survives reconnect and deduplicates', watcher.includes('pendingCompletionReports: [...pendingCompletionReports]') && watcher.includes('reportedCompletions: [...reportedCompletions]') && watcher.includes('reportedCompletions.has(report.key)')],
44
- ['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)')],
49
+ ['optional ticket comments cannot block verified completion', watcher.includes("callOptionalMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: report.content })") && watcher.includes('comment_ticket is not exposed; completing ticket') && watcher.includes('reportedTaskComments: [...reportedTaskComments]') && watcher.includes('reportedTaskComments.has(report.key)')],
45
50
  ['ticket comments cannot masquerade as channel completion', watcher.includes('didChannelMessage') && watcher.includes("mcpCalls.includes('post_message')")],
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")],
49
59
  ['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
60
  ['Codex cannot race the watcher with post_message', watcher.includes("disabledMcpTools: ['post_message']") && watcher.includes('disabled_tools = [')],
51
61
  ['reply delivery keys survive reconnects', watcher.includes('deliveredReplies: [...deliveredReplies]') && watcher.includes('deliveredReplies.has(deliveryKey)')],
@@ -65,16 +75,23 @@ const assertions = [
65
75
  ['ticket comments load only on demand', !taskHook.includes('for (const task of tasks)') && taskHook.includes('loadedCommentIds.current.has(taskId)')],
66
76
  ['concurrent ticket comment loads share one request', taskHook.includes('commentRequests.current.get(requestKey)') && taskHook.includes('commentRequests.current.set(requestKey, request)')],
67
77
  ['backend ticket slugs render uppercase', liveTasks.includes("t.slug?.trim().toUpperCase()")],
78
+ ['bare-agent inbox ownership is thread scoped', agentAutonomy.includes('lastPostInThread') && !agentAutonomy.includes('lastPostInChannel')],
79
+ ['ambiguous bare-agent follow-ups fail closed', bareRuntime.includes("return { reply: false, reason: 'reply gate unavailable' }") && bareRuntime.includes('When unsure, stay silent.')],
80
+ ['bare-agent runs handle one source conversation', bareRun.includes('messageId?: string') && bareRun.includes('const targetRoot = target.parentId ?? target.messageId') && bareDriver.includes('messageId: source.messageId')],
81
+ ['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')],
82
+ ['quick replies preserve distinct top-level conversations', quickReply.includes('m.parentId ?? m.messageId') && quickReply.includes('...(parentId ? { parentId } : {})')],
68
83
  ['completion has an evidence failure gate', watcher.includes('WORK_CYCLE_FAILED')],
69
84
  ['OpenCode emits structured events for runtime evidence', watcher.includes("'--format', 'json'") && watcher.includes('opencodeEventEvidence(event)')],
70
85
  ['OpenCode API-key MCP disables OAuth probing', opencodeConfig.includes("oauth: false") && opencodeConfig.includes('timeout: 15_000')],
71
86
  ['OpenCode identities use isolated configs outside the code workspace', watcher.includes('opencodeRuntimeLayout({ cfgKey, workdir })') && watcher.includes("'--dir', workspace") && watcher.includes('OPENCODE_CONFIG: opencodeConfigPath') && watcher.includes('OPENCODE_CONFIG_CONTENT: JSON.stringify(opencodeConfig)')],
72
- ['OpenCode backend prompts forbid relay-only tools', watcher.includes('BACKEND MCP RULE') && watcher.includes('get_marching_orders, poll_inbox, and get_resource are relay-only')],
87
+ ['OpenCode backend prompts forbid relay and resource-discovery tools', watcher.includes('BACKEND MCP RULE') && watcher.includes('get_marching_orders, poll_inbox, get_resource, list_mcp_resources, list_mcp_resource_templates')],
73
88
  ['OpenCode tool failures retain sanitized diagnostics', events.includes('toolError: toolError.replace') && watcher.includes("opencode tool '") && watcher.includes("split(redactKey).join('[redacted]')")],
74
89
  ['OpenCode acknowledgements cannot satisfy coding completion', watcher.includes("agent === 'codex' || agent === 'opencode'") && watcher.includes('releaseTaskForRetry(activeTaskRef, prompt)')],
75
90
  ['backend MCP accepts stateless initialize responses', mcpHttp.includes("mode = () => !initialized ? 'uninitialized' : sessionId ? 'stateful' : 'stateless'") && !watcher.includes('MCP initialize returned no session id')],
76
91
  ['MCP initialize is shared across concurrent startup probes', mcpHttp.includes('if (initializePromise) return initializePromise')],
77
92
  ['stateless tool errors do not cause initialize loops', mcpHttp.includes('if (hadSession && !retried')],
93
+ ['backend MCP tools are discovered and cached', mcpHttp.includes("method: 'tools/list'") && mcpHttp.includes('if (!refresh && toolsCache)') && watcher.includes('discoverMcpTools')],
94
+ ['missing optional MCP tools use compatibility fallbacks', watcher.includes("reason: 'not-advertised'") && watcher.includes('recording the blocker in the ticket description')],
78
95
  ['backend introduction is watcher-owned for every runtime', watcher.includes('void announceIntroduction().then((delivered)')],
79
96
  ['Codex policy rejection is captured from stderr', watcher.includes("stdio: ['ignore', 'pipe', 'pipe']") && watcher.includes('forwardDiagnostic(d)') && watcher.includes('inspectDiagnostic(incoming)')],
80
97
  ['Codex recoverable subprocess diagnostics are not surfaced as activity', watcher.includes('shouldSuppressCodexDiagnostic(line)') && watcher.includes("forwardDiagnostic('', true)") && watcher.includes('RECOVER DEAD COMMAND SESSIONS')],
package/src/events.mjs CHANGED
@@ -234,6 +234,59 @@ export function renderedAgentMessages(value, identity = {}) {
234
234
  return rows
235
235
  }
236
236
 
237
+ const cleanAlias = (value) => String(value || '').trim().toLowerCase().replace(/^@/, '')
238
+
239
+ export function messageSenderIsAgent(message) {
240
+ const m = message && typeof message === 'object' ? message : {}
241
+ if (m.senderAgent || m.sender_agent || m.agent || m.agent_id != null || m.sender_agent_id != null || m.senderAgentId != null) return true
242
+ const sender = [m.sender, m.user, m.author].find((item) => item && typeof item === 'object') || {}
243
+ const kind = String(sender.type ?? sender.kind ?? sender.sender_type ?? m.sender_type ?? m.author_type ?? '').toLowerCase()
244
+ return /agent|bot/.test(kind) || sender.agent_id != null || sender.identifier != null || (sender.slug != null && !sender.email)
245
+ }
246
+
247
+ // WebSocket deployments may fan every reply in a watched thread as
248
+ // `agent:mention`, even when the new message names somebody else. Treat the event
249
+ // name as a wake-up hint only: recipient ownership is decided from the actual
250
+ // message before a model or work lane is allowed to run.
251
+ export function classifyConversationTarget(message, selfAliases) {
252
+ const m = message && typeof message === 'object' ? message : {}
253
+ const text = String(m.content ?? m.body ?? m.text ?? m.message ?? '').replace(/\s+/g, ' ').trim()
254
+ const aliases = new Set((selfAliases || []).map(cleanAlias).filter(Boolean))
255
+ const mentions = [...text.matchAll(/@([a-z0-9](?:[a-z0-9_.-]*[a-z0-9_-])?)/gi)]
256
+ .map((match) => ({ name: cleanAlias(match[1]), index: match.index ?? 0, end: (match.index ?? 0) + match[0].length }))
257
+ const selfMentions = mentions.filter((mention) => aliases.has(mention.name))
258
+ const otherMentions = mentions.filter((mention) => !aliases.has(mention.name))
259
+ const explicitSelf = selfMentions.length > 0
260
+
261
+ if (messageSenderIsAgent(m) && !explicitSelf) {
262
+ return { action: 'ignore', reason: 'other-agent-chatter', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
263
+ }
264
+ if (!explicitSelf && otherMentions.length) {
265
+ return { action: 'ignore', reason: 'explicit-other-recipient', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
266
+ }
267
+
268
+ 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)
269
+ if (standDown && (explicitSelf || !mentions.length)) {
270
+ return { action: 'stand_down', reason: 'explicit-stand-down', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
271
+ }
272
+
273
+ if (explicitSelf && requestTargetsLaterAgent(text, selfAliases)) {
274
+ return { action: 'ignore', reason: 'redirected-to-later-agent', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
275
+ }
276
+ return { action: 'handle', reason: explicitSelf ? 'explicit-self-recipient' : 'eligible-follow-up', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
277
+ }
278
+
279
+ // Require an actionable repository verb and a concrete code/repository object.
280
+ // Broad nouns such as "feature", "API", "file", or "documentation" on their
281
+ // own describe plenty of product conversations and must not start a coding lane.
282
+ export function conversationNeedsCode(value) {
283
+ const text = String(value || '')
284
+ if (/\b(?:do\s+not|don't|dont|stop|cancel|stand\s+down)\b/i.test(text)) return false
285
+ const action = /\b(?:implement|fix|debug|refactor|change|update|add|remove|write|edit|test|build|deploy|release|migrate|wire|integrate)\b/i.test(text)
286
+ 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)
287
+ return action && target
288
+ }
289
+
237
290
  // A mention event means this agent's name appeared somewhere, not necessarily
238
291
  // that the request was addressed to it. Reject a later-agent hand-off before a
239
292
  // model starts, while keeping explicitly shared requests addressed to both.
package/src/mcp-http.mjs CHANGED
@@ -14,6 +14,8 @@ export function createMcpHttpClient({ url, apiKey, identifier, clientVersion, fe
14
14
  let sessionId = ''
15
15
  let rpcId = 0
16
16
  let initializePromise = null
17
+ let toolsPromise = null
18
+ let toolsCache = null
17
19
 
18
20
  const post = (message, withSession = true) => fetchImpl(url, {
19
21
  method: 'POST',
@@ -27,7 +29,7 @@ export function createMcpHttpClient({ url, apiKey, identifier, clientVersion, fe
27
29
  body: JSON.stringify(message),
28
30
  })
29
31
 
30
- const reset = () => { initialized = false; sessionId = '' }
32
+ const reset = () => { initialized = false; sessionId = ''; toolsCache = null }
31
33
 
32
34
  const initialize = () => {
33
35
  if (initialized) return Promise.resolve()
@@ -80,6 +82,35 @@ export function createMcpHttpClient({ url, apiKey, identifier, clientVersion, fe
80
82
  return result
81
83
  }
82
84
 
85
+ const requestTools = async (retried = false) => {
86
+ await initialize()
87
+ const hadSession = !!sessionId
88
+ const res = await post({ jsonrpc: '2.0', id: ++rpcId, method: 'tools/list', params: {} })
89
+ if (!res.ok) {
90
+ if (hadSession && !retried && [400, 404, 409, 410].includes(res.status)) {
91
+ reset()
92
+ return requestTools(true)
93
+ }
94
+ throw new Error(`MCP tools/list HTTP ${res.status}`)
95
+ }
96
+ const payload = await parsePayload(res)
97
+ if (payload.error) throw new Error(`MCP tools/list: ${payload.error.message || 'protocol error'}`)
98
+ const tools = payload.result?.tools ?? payload.tools
99
+ if (!Array.isArray(tools)) throw new Error('MCP tools/list returned no tool array')
100
+ toolsCache = tools
101
+ return tools
102
+ }
103
+
104
+ // Capability discovery is shared and cached. BYO runtimes use it before
105
+ // optional actions so a backend deployment that lacks one tool cannot trap a
106
+ // completed ticket in a permanent retry loop.
107
+ const listTools = (refresh = false) => {
108
+ if (!refresh && toolsCache) return Promise.resolve(toolsCache)
109
+ if (toolsPromise) return toolsPromise
110
+ toolsPromise = requestTools().finally(() => { toolsPromise = null })
111
+ return toolsPromise
112
+ }
113
+
83
114
  const mode = () => !initialized ? 'uninitialized' : sessionId ? 'stateful' : 'stateless'
84
- return { callTool, initialize, reset, mode }
115
+ return { callTool, listTools, initialize, reset, mode }
85
116
  }
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 } 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,7 +33,8 @@ 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.',
39
40
  ' • 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.',
@@ -42,7 +43,7 @@ const REPLY_DISCIPLINE = [
42
43
 
43
44
  // ── CHAT-ONLY agents (no --workdir): chat/ticket tools, no code surface. ──────
44
45
  const CHAT_CHARTER = [
45
- '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.',
46
+ '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, and list_activity. A ticket-comment tool is optional and must not be assumed. 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.',
46
47
  '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.',
47
48
  '',
48
49
  REPLY_DISCIPLINE,
@@ -62,15 +63,15 @@ const CYCLE_FAST = [
62
63
  const COORDINATE = [
63
64
  'COORDINATION-ONLY cycle. Use the lightweight lane for messaging, triage, ticket comments, assignment, and board movement.',
64
65
  '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.',
66
+ '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.',
67
+ 'Respond to every direct concern assigned to you, but post only once per item and never duplicate an existing answer.',
67
68
  ].join('\n')
68
69
 
69
70
  // ── CODE agents (--workdir given): full file + Bash + git/gh surface. ─────────
70
71
  // A stable "who you are / how you work" charter prepended to every code cycle.
71
72
  const CODE_CHARTER = [
72
73
  '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:',
73
- ' • 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.',
74
+ ' • openvisio-team tools — use the names actually present. Backend MCP provides project/task discovery through list_agents, list_projects, list_tasks, get_ticket, and update_ticket, plus post_message/react_message/list_activity. Ticket comments are optional: use a comment tool only when it appears in the current tool list. Relay runtimes may additionally expose poll_inbox or get_marching_orders.',
74
75
  ' • Read / Grep / Glob / Edit / Write / MultiEdit — inspect AND change code.',
75
76
  ' • Bash — git (branch, commit, push a branch), gh (clone repos, open PRs), run tests/builds.',
76
77
  '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.',
@@ -95,7 +96,7 @@ const CODE_FULL = [
95
96
  ' 3. CHANGE + VERIFY: Read/Edit/Write the files; run the tests or build if the repo has them.',
96
97
  ' 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
98
  ' 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: 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.',
99
+ ' 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
100
  'Bash is for git / gh / tests / clone ONLY — never to hunt for credentials (they are given to you above).',
100
101
  ].join('\n')
101
102
 
@@ -119,7 +120,7 @@ const INTRO = [
119
120
  const SWEEP = [
120
121
  'DAILY CATCH-UP — you may have missed items while offline. Prioritize TASKS.',
121
122
  '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 or acknowledged: acknowledge once (comment_ticket "Catching up picking this up now"), then do the work end-to-end and report (branch/PR + a short channel note). Skip tasks assigned to other agents.',
123
+ ' 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
124
  ' 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
125
  'If there is genuinely nothing outstanding, STOP silently — do NOT post a "nothing to do" message.',
125
126
  ].join('\n')
@@ -301,6 +302,7 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
301
302
  const redactKey = String(mcpHeaders?.['x-agent-api-key'] || '')
302
303
  const opencodeConfig = buildOpencodeConfig({ mcpUrl, mcpHeaders })
303
304
  let configured = false
305
+ let cancelActive = null
304
306
  const ensureConfig = () => {
305
307
  if (configured) return
306
308
  configured = true
@@ -326,11 +328,13 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
326
328
  // OpenCode exited; raw events tell us which tools actually completed.
327
329
  const args = ['run', full, '--auto', '--format', 'json', '--dir', workspace, ...(m ? ['--model', m] : [])]
328
330
  let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, didMcpTaskRead = false, didMcpTaskUpdate = false
331
+ let cancel = null
329
332
  let outputText = '', jsonlBuffer = ''
330
333
  const mcpCalls = new Set(), mcpErrors = new Set(), runtimeErrors = new Set()
331
334
  const finish = (o) => {
332
335
  if (done) return
333
336
  done = true
337
+ if (cancelActive === cancel) cancelActive = null
334
338
  clearTimeout(timer)
335
339
  const calls = [...mcpCalls]
336
340
  log('opencode MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (mcpErrors.size ? ' (failed: ' + [...mcpErrors].join(', ') + ')' : ''))
@@ -374,6 +378,11 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
374
378
  try { child && child.kill() } catch { /* gone */ }
375
379
  finish({ type: 'result', subtype: 'timeout' })
376
380
  }, maxCycleMs)
381
+ cancel = () => {
382
+ try { child && child.kill() } catch { /* gone */ }
383
+ finish({ type: 'result', subtype: 'canceled' })
384
+ }
385
+ cancelActive = cancel
377
386
  log('running opencode cycle…' + (m ? ' [' + m + ']' : ''))
378
387
  try {
379
388
  child = spawn(bin, args, {
@@ -407,7 +416,7 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
407
416
  }
408
417
 
409
418
  log('opencode runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + workspace + '; isolated cfg ' + configDir + ']' : ' [CHAT-ONLY; isolated cfg ' + configDir + ']'))
410
- return { runCycle, canCode }
419
+ return { runCycle, canCode, cancelCurrent: () => cancelActive?.() }
411
420
  }
412
421
 
413
422
  // Codex has a purpose-built non-interactive mode. Run a fresh, ephemeral cycle
@@ -420,6 +429,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
420
429
  const cwd = workdir || OV_DIR
421
430
  const tomlString = (v) => JSON.stringify(String(v))
422
431
  const headerEntries = Object.entries(mcpHeaders || {}).map(([k, v]) => `${JSON.stringify(k)} = ${tomlString(v)}`).join(', ')
432
+ let cancelActive = null
423
433
 
424
434
  function runCycle(prompt, cycleModel, cycleOptions = {}) {
425
435
  return new Promise((resolve) => {
@@ -436,12 +446,14 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
436
446
  ...(mcpOverride ? ['-c', mcpOverride] : []),
437
447
  full]
438
448
  let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = '', stderrLineBuffer = ''
449
+ let cancel = null
439
450
  let policyBlock = null
440
451
  const mcpCalls = new Set(), mcpErrors = new Set()
441
452
  let didMcpTaskRead = false, didMcpTaskUpdate = false
442
453
  const finish = (o) => {
443
454
  if (done) return
444
455
  done = true
456
+ if (cancelActive === cancel) cancelActive = null
445
457
  clearTimeout(timer)
446
458
  const calls = [...mcpCalls]
447
459
  log('codex MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (mcpErrors.size ? ' (failed: ' + [...mcpErrors].join(', ') + ')' : ''))
@@ -496,6 +508,11 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
496
508
  try { child && child.kill() } catch { /* gone */ }
497
509
  finish({ type: 'result', subtype: 'timeout' })
498
510
  }, maxCycleMs)
511
+ cancel = () => {
512
+ try { child && child.kill() } catch { /* gone */ }
513
+ finish({ type: 'result', subtype: 'canceled' })
514
+ }
515
+ cancelActive = cancel
499
516
  log('running codex cycle…' + (m ? ' [' + m + ']' : ''))
500
517
  try {
501
518
  // Always inspect Codex JSONL so a successful process exit cannot be
@@ -526,7 +543,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
526
543
 
527
544
  if (!mcpUrl) log('WARNING: no --mcp-url — Codex has no openvisio-team tools to act with. Re-connect with --mcp-url.')
528
545
  log('codex runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + cwd + ']' : ' [CHAT-ONLY]'))
529
- return { runCycle, canCode }
546
+ return { runCycle, canCode, cancelCurrent: () => cancelActive?.() }
530
547
  }
531
548
 
532
549
  // ── Claude Code warm-session cycle runner (shared by the REST + WS loops) ─────
@@ -652,7 +669,14 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
652
669
  })
653
670
  }
654
671
 
655
- return { runCycle, canCode }
672
+ const cancelCurrent = () => {
673
+ const active = child
674
+ if (!active) return
675
+ child = null
676
+ try { active.kill() } catch { /* gone */ }
677
+ settleTurn({ type: 'result', subtype: 'canceled' })
678
+ }
679
+ return { runCycle, canCode, cancelCurrent }
656
680
  }
657
681
 
658
682
  // ── the backend WS loop ──────────────────────────────────────────────────────
@@ -687,7 +711,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
687
711
  const canCode = !!workdir
688
712
  // The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
689
713
  // agent_identifier + agent_api_key as arguments. Hand them over up front.
690
- 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, list_task_types, get_ticket, update_ticket, comment_ticket, list_channels, list_message_thread, and list_activity as applicable. get_marching_orders, poll_inbox, and get_resource are relay-only and are NOT available here; never call them. 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.`
714
+ 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, list_task_types, get_ticket, update_ticket, list_channels, list_message_thread, and list_activity as applicable. Ticket comments are optional: never invent or call comment_ticket unless that exact tool appears. get_marching_orders, poll_inbox, get_resource, list_mcp_resources, and list_mcp_resource_templates are NOT team-action tools here; never call them. 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.`
691
715
  // The STATIC charter + creds are the session system prompt (cached, billed once),
692
716
  // NOT re-sent in every cycle's user message — the big token saving.
693
717
  const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
@@ -704,10 +728,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
704
728
  const codexPushGuide = agent === 'codex' && canCode
705
729
  ? '\n\nCODEX PR DELIVERY: first inspect the OpenVisio MCP tools. When list_codebases, create_codebase_branch, create_codebase_commit (or write_codebase_file), and create_pull_request are available, use that authenticated linked-codebase flow to create the agent/* branch, publish the verified changed files, and open the PR. This is the preferred path and requires no local git push. If those tools are unavailable for the repository, do not run git push directly. From the repository run `openvisio-agent push-pr-branch`. It is a user-authorized constrained fallback that can only push HEAD to the matching agent/* branch on the exact authorized origin. If it reports OPENVISIO_PR_PUSH_AUTH_REQUIRED, do not retry or route around it. Report the one-time command `openvisio-agent authorize-pr-push` as the blocker.'
706
730
  : ''
707
- const backendToolRule = 'BACKEND MCP RULE: the event and watcher already provide the work source. Never call get_marching_orders, poll_inbox, get_resource, or other relay-only tools; they are not exposed by the backend MCP. Use only names present in the current openvisio-team tool list. For discovery use list_agents, list_projects, list_tasks, list_task_types, list_activity, get_ticket, and list_channels as applicable.'
731
+ const backendToolRule = 'BACKEND MCP RULE: the event and watcher already provide the work source. Never call get_marching_orders, poll_inbox, get_resource, list_mcp_resources, list_mcp_resource_templates, or other relay/resource-discovery tools; they are not backend team actions. Use only names present in the current openvisio-team tool list. For discovery use list_agents, list_projects, list_tasks, list_task_types, list_activity, get_ticket, and list_channels as applicable. Never call comment_ticket unless that exact optional tool is present.'
708
732
  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
733
  const fastPrompt = (canCode ? CODE_FAST : CYCLE_FAST) + '\n\n' + backendToolRule
710
734
  const coordinatePrompt = COORDINATE + '\n\n' + backendToolRule
735
+ 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
736
  // Live model state — changeable at runtime by the in-chat `/model` command.
712
737
  // codeModel drives full/sweep cycles; chatModel (if set) the lighter fast/intro
713
738
  // ones, so routine chatter can run cheaper than real code work.
@@ -715,8 +740,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
715
740
  let liteModel = chatModel || model
716
741
 
717
742
  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: [] },
743
+ work: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [], deferred: [], activeDelivery: null },
744
+ reply: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [], deferred: [], activeDelivery: null },
720
745
  }
721
746
  // Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
722
747
  // different agent re-triggers), so a noisy stream of task:updated events doesn't
@@ -787,8 +812,47 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
787
812
  let lastTaskTriggeredAt = 0
788
813
  let lastInboxSignature = ''
789
814
  let selfAgentId = null
790
- const mcpClient = createMcpHttpClient({ url: mcpUrl, apiKey, identifier, clientVersion: '0.18.2', log })
815
+ const selfAliases = new Set([slug, identifier].map((value) => String(value || '').toLowerCase()).filter(Boolean))
816
+ const mcpClient = createMcpHttpClient({ url: mcpUrl, apiKey, identifier, clientVersion: '0.18.4', log })
791
817
  const callMcpTool = (name, args = {}) => mcpClient.callTool(name, args)
818
+ let mcpToolNames = null
819
+ let mcpToolDiscoveryPromise = null
820
+ let mcpToolDiscoveryWarned = false
821
+ const discoverMcpTools = (refresh = false) => {
822
+ if (!refresh && mcpToolNames) return Promise.resolve(mcpToolNames)
823
+ if (mcpToolDiscoveryPromise) return mcpToolDiscoveryPromise
824
+ mcpToolDiscoveryPromise = mcpClient.listTools(refresh).then((tools) => {
825
+ mcpToolNames = new Set(tools.map((tool) => String(tool?.name || '')).filter(Boolean))
826
+ const required = ['list_agents', 'list_projects', 'list_tasks', 'get_ticket', 'update_ticket', 'post_message']
827
+ const missing = required.filter((name) => !mcpToolNames.has(name))
828
+ log(`MCP tools ready (${mcpToolNames.size})${missing.length ? '; missing core tools: ' + missing.join(', ') : ''}`)
829
+ return mcpToolNames
830
+ }).finally(() => { mcpToolDiscoveryPromise = null })
831
+ return mcpToolDiscoveryPromise
832
+ }
833
+ const mcpSupports = async (name) => {
834
+ try { return (await discoverMcpTools()).has(name) }
835
+ catch (e) {
836
+ if (!mcpToolDiscoveryWarned) {
837
+ mcpToolDiscoveryWarned = true
838
+ log('MCP tool discovery failed; optional actions will use compatibility fallbacks: ' + (e?.message || e))
839
+ }
840
+ return null
841
+ }
842
+ }
843
+ const callOptionalMcpTool = async (name, args) => {
844
+ const supported = await mcpSupports(name)
845
+ if (supported === false) return { called: false, reason: 'not-advertised' }
846
+ try { return { called: true, result: await callMcpTool(name, args) } }
847
+ catch (e) {
848
+ if (/\b(?:unknown|missing|unsupported) tool\b|\btool\b.*\bnot found\b/i.test(String(e?.message || e))) {
849
+ try { await discoverMcpTools(true) } catch { /* the original error is enough */ }
850
+ return { called: false, reason: 'not-available' }
851
+ }
852
+ throw e
853
+ }
854
+ }
855
+ if (mcpUrl) void discoverMcpTools().catch(() => {})
792
856
  const toolData = (result) => {
793
857
  const text = result?.content?.find?.((c) => c?.type === 'text')?.text
794
858
  if (typeof text !== 'string') return result?.structuredContent ?? result ?? {}
@@ -800,12 +864,39 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
800
864
  // rendered as this agent. A persisted delivery key closes the crash/reconnect
801
865
  // gap; an in-flight promise closes the two-lane race inside one watcher.
802
866
  const messageDeliveries = new Map()
803
- const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '' }) => {
867
+ const threadControlKey = (channelId, parentId) => Number.isFinite(Number(channelId)) && parentId != null ? `thread:${Number(channelId)}:${parentId}` : ''
868
+ const sameDeliveryThread = (delivery, channelId, parentId) => delivery && Number(delivery.channelId) === Number(channelId) && String(delivery.parentId ?? '') === String(parentId ?? '')
869
+ const cancelThread = (channelId, parentId, summary) => {
870
+ const controlKey = threadControlKey(channelId, parentId)
871
+ if (!controlKey) return
872
+ memory.remember({ key: controlKey, kind: 'thread', state: 'cancelled', summary, refs: { channelId: Number(channelId), threadId: parentId } })
873
+ for (const [laneName, lane] of Object.entries(lanes)) {
874
+ lane.deferred = lane.deferred.filter((item) => !sameDeliveryThread(item.delivery, channelId, parentId))
875
+ if (sameDeliveryThread(lane.activeDelivery, channelId, parentId)) {
876
+ log(`${laneName} lane cancelled by a newer redirect/stand-down in thread ${parentId}`)
877
+ runners[laneName].cancelCurrent?.('thread-cancelled')
878
+ }
879
+ }
880
+ }
881
+ const activateThread = (channelId, parentId, summary) => {
882
+ const controlKey = threadControlKey(channelId, parentId)
883
+ if (controlKey) memory.remember({ key: controlKey, kind: 'thread', state: 'active', summary, refs: { channelId: Number(channelId), threadId: parentId } })
884
+ }
885
+ const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', allowCancelled = false }) => {
804
886
  const deliveryKey = String(key || '')
805
887
  const message = String(content || '').trim()
806
888
  if (!deliveryKey || !Number.isFinite(Number(channelId)) || !message) return { posted: false, reason: 'invalid-delivery' }
807
889
  if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) return { posted: false, reason: 'remembered' }
808
890
  if (messageDeliveries.has(deliveryKey)) return messageDeliveries.get(deliveryKey)
891
+ const controlKey = threadControlKey(channelId, parentId)
892
+ const suppressCancelled = () => {
893
+ if (allowCancelled || !controlKey || !memory.has(controlKey, 'cancelled')) return null
894
+ deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
895
+ memory.remember({ key: deliveryKey, kind: 'delivery', state: 'suppressed', summary: 'A newer redirect or stand-down cancelled this reply.', refs: { channelId: Number(channelId), threadId: parentId } })
896
+ return { posted: false, reason: 'thread-cancelled' }
897
+ }
898
+ const initiallyCancelled = suppressCancelled()
899
+ if (initiallyCancelled) return initiallyCancelled
809
900
 
810
901
  const run = (async () => {
811
902
  if (parentId != null) {
@@ -821,6 +912,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
821
912
  }
822
913
  }
823
914
 
915
+ // A redirect can arrive while the authoritative thread read is in flight.
916
+ // Re-check immediately before the irreversible post.
917
+ const newlyCancelled = suppressCancelled()
918
+ if (newlyCancelled) return newlyCancelled
919
+
824
920
  sendStatus(Number(channelId), 'typing')
825
921
  const result = toolData(await callMcpTool('post_message', {
826
922
  ...(Number.isFinite(Number(projectId)) ? { project_id: Number(projectId) } : {}),
@@ -892,13 +988,18 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
892
988
  refs: { projectId, ticketId },
893
989
  meta: { reportKey: report.key, prUrl: report.prUrl },
894
990
  })
895
- // Ticket comments are now a backend first-class surface. The watcher owns the
896
- // final comment so every runtime (Claude, Codex, OpenCode) closes the ticket
897
- // loop consistently, and the persisted report key prevents reconnect repeats.
991
+ // Ticket comments are optional across backend deployments. Prefer the tool
992
+ // when advertised, but never let its absence block the verified channel
993
+ // handoff or trap the ticket in reconnect retries.
898
994
  if (!reportedTaskComments.has(report.key)) {
899
- await callMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: report.content })
995
+ try {
996
+ const comment = await callOptionalMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: report.content })
997
+ if (comment.called) log('posted verified ticket comment for #' + ticketId)
998
+ else log('comment_ticket is not exposed; completing ticket #' + ticketId + ' through its verified state and project-channel report')
999
+ } catch (e) {
1000
+ log('optional ticket comment failed for #' + ticketId + ': ' + (e?.message || e) + '; continuing with the verified project-channel report')
1001
+ }
900
1002
  reportedTaskComments.add(report.key); trimSeen(reportedTaskComments); persistReplay()
901
- log('posted verified ticket comment for #' + ticketId)
902
1003
  }
903
1004
  if (reportedCompletions.has(report.key)) {
904
1005
  pendingCompletionReports.delete(taskKey)
@@ -959,12 +1060,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
959
1060
  refs: { projectId, ticketId, ...(Number.isFinite(channelId) ? { channelId } : {}) },
960
1061
  })
961
1062
  try {
962
- await callMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: ticketNotice })
963
- delivered = true
964
- return
965
- } catch (e) {
966
- log('comment_ticket failed for blocker; falling back to the ticket description for #' + ticketId)
967
- }
1063
+ const comment = await callOptionalMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: ticketNotice })
1064
+ if (comment.called) { delivered = true; return }
1065
+ log('comment_ticket is not exposed; recording the blocker in the ticket description for #' + ticketId)
1066
+ } catch (e) { log('optional ticket comment failed; recording the blocker in the ticket description for #' + ticketId) }
968
1067
  const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
969
1068
  const ticket = current.ticket ?? current.task ?? current
970
1069
  const description = String(ticket.description || '')
@@ -1013,6 +1112,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1013
1112
  const self = agents.find((a) => String(a.identifier || a.slug || '') === identifier)
1014
1113
  if (!self?.id) throw new Error('list_agents did not return this BYO agent')
1015
1114
  selfAgentId = Number(self.id)
1115
+ for (const alias of [self.name, self.identifier, self.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
1016
1116
  const projectsData = toolData(await callMcpTool('list_projects'))
1017
1117
  const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
1018
1118
  const assigned = []
@@ -1062,13 +1162,16 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1062
1162
  const mentionNeedles = [self.name, self.identifier, self.slug, identifier].filter(Boolean).map((s) => '@' + String(s).toLowerCase())
1063
1163
  for (const item of activities) {
1064
1164
  const text = JSON.stringify(item)
1065
- const lower = text.toLowerCase()
1066
1165
  const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
1067
- if (!seenActivities.has(activityKey) && mentionNeedles.some((needle) => lower.includes(needle)) && /message|mention|channel/i.test(text)) {
1068
- const data = item?.data && typeof item.data === 'object' ? item.data : null
1069
- const activityMessage = item?.message && typeof item.message === 'object'
1070
- ? item.message
1071
- : data?.message && typeof data.message === 'object' ? data.message : item
1166
+ const data = item?.data && typeof item.data === 'object' ? item.data : null
1167
+ const activityMessage = item?.message && typeof item.message === 'object'
1168
+ ? item.message
1169
+ : data?.message && typeof data.message === 'object' ? data.message : item
1170
+ const activityText = String(activityMessage?.content ?? activityMessage?.body ?? activityMessage?.text ?? '').toLowerCase()
1171
+ // Reconciliation recovers explicit mentions only. Searching the whole
1172
+ // activity envelope can match an old thread root and replay a new reply
1173
+ // that is actually addressed to another agent.
1174
+ if (!seenActivities.has(activityKey) && mentionNeedles.some((needle) => activityText.includes(needle)) && /message|mention|channel/i.test(text)) {
1072
1175
  const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
1073
1176
  seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
1074
1177
  // The same logical mention may already have arrived over WebSocket.
@@ -1124,7 +1227,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1124
1227
 
1125
1228
  // Higher rank wins when coalescing cycles requested while one is running.
1126
1229
  const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
1127
- const baseFor = (kind) => kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? coordinatePrompt : fastPrompt
1230
+ const baseFor = (kind, delivery) => delivery?.watcherOwned
1231
+ ? (kind === 'full' ? fullPrompt + '\n\n' + guardedReplyPrompt : guardedReplyPrompt)
1232
+ : kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? coordinatePrompt : fastPrompt
1128
1233
  // Codex and OpenCode expose structured action streams. Require runtime facts
1129
1234
  // from those streams before accepting a full coding cycle. Claude's evidence
1130
1235
  // shape is different and remains on its existing completion path.
@@ -1180,6 +1285,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1180
1285
  for (const channelId of targetChannels) if (Number.isFinite(Number(channelId))) lane.targets.add(Number(channelId))
1181
1286
  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 }
1182
1287
  lane.busy = true
1288
+ lane.activeDelivery = delivery
1183
1289
  const ctx = lane.pending.splice(0)
1184
1290
  const activeTaskRef = lane.taskRefs.splice(0)[0] || null
1185
1291
  const targets = [...lane.targets]
@@ -1191,7 +1297,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1191
1297
  ? { channelId: delivery.channelId, threadId: delivery.parentId }
1192
1298
  : activeTaskRef ? { projectId: activeTaskRef.projectId, ticketId: activeTaskRef.ticketId } : {}
1193
1299
  const recalled = memory.context(memoryRefs)
1194
- const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind)
1300
+ const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind, delivery)
1195
1301
  // Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
1196
1302
  // work (full/sweep) uses the main model.
1197
1303
  const useModel = agent === 'codex' ? codeModel : kind === 'full' ? codeModel : liteModel
@@ -1203,8 +1309,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1203
1309
  // 20 seconds so long coding runs do not create needless network/battery load.
1204
1310
  const heartbeat = targets.length ? setInterval(() => emitLaneStatus(laneName, 'working'), 20_000) : null
1205
1311
  try {
1206
- const result = await runners[laneName].runCycle(prompt, useModel, agent === 'codex' && delivery ? { disabledMcpTools: ['post_message'] } : {})
1312
+ const result = await runners[laneName].runCycle(prompt, useModel, delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {})
1207
1313
  let completionResult = result
1314
+ if (result?.subtype === 'canceled') {
1315
+ log(laneName + ' cycle cancelled; no blocker or reply will be published')
1316
+ return
1317
+ }
1208
1318
  if (agent === 'codex' && result?.subtype === 'blocked' && result?.policyBlock) {
1209
1319
  log('WORK_CYCLE_BLOCKED authorization required; pausing this ticket and publishing an action-required notice')
1210
1320
  try { await reportPolicyBlock(prompt, activeTaskRef, result.policyBlock) }
@@ -1232,7 +1342,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1232
1342
  const missing = missingWorkEvidence(result, ticketCycle)
1233
1343
  if (evidenceGatedRuntime && kind === 'full' && result?.subtype === 'ok' && missing.length) {
1234
1344
  log(agent + ' coding cycle incomplete; recovery requires: ' + missing.join(', '))
1235
- 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'] } : {})
1345
+ 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'] } : {})
1236
1346
  const recoveredResult = combineWorkEvidence(result, recovery)
1237
1347
  const recoveryMissing = missingWorkEvidence(recoveredResult, ticketCycle)
1238
1348
  if (recovery?.subtype !== 'ok' || recoveryMissing.length) {
@@ -1263,7 +1373,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1263
1373
  log('completion report failed for ticket #' + activeTaskRef.ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry')
1264
1374
  }
1265
1375
  }
1266
- if (agent === 'codex' && delivery) {
1376
+ if (delivery?.watcherOwned) {
1267
1377
  const reply = String(completionResult?.outputText || '').trim()
1268
1378
  if (!reply) {
1269
1379
  log('guarded reply produced no final text; leaving delivery unrecorded for retry')
@@ -1276,6 +1386,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1276
1386
  if (heartbeat) clearInterval(heartbeat)
1277
1387
  laneStatusTargets[laneName].clear()
1278
1388
  lane.busy = false
1389
+ lane.activeDelivery = null
1279
1390
  const deferred = lane.deferred.shift()
1280
1391
  if (deferred) void drain(deferred.kind, deferred.context, deferred.targetChannels, deferred.taskRef, deferred.delivery)
1281
1392
  else if (lane.queued || lane.pending.length) { const next = lane.queued || (laneName === 'work' ? 'full' : 'fast'); lane.queued = null; void drain(next) }
@@ -1290,10 +1401,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1290
1401
  return (`${s.first_name || ''} ${s.last_name || ''}`.trim() || s.name || s.email || '')
1291
1402
  }
1292
1403
 
1293
- // Route obvious repository work to the coding lane. Everything else, including
1294
- // chat and board movement, stays on the lightweight coordination model.
1295
- 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 || ''))
1296
- 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)
1404
+ // Route only concrete repository requests to the coding lane. Conversation
1405
+ // ownership is classified separately before this is consulted.
1406
+ 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)
1297
1407
 
1298
1408
  // Engineers change the model under the hood from chat: "/model", "/model sonnet",
1299
1409
  // "use model haiku", "switch model to opus". Returns {report} | {set} | {invalid}.
@@ -1335,6 +1445,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1335
1445
  const agentsData = toolData(await callMcpTool('list_agents'))
1336
1446
  const self = (Array.isArray(agentsData.agents) ? agentsData.agents : []).find((a) => String(a.identifier || a.slug || '') === identifier)
1337
1447
  selfAgentId = self?.id != null ? Number(self.id) : null
1448
+ for (const alias of [self?.name, self?.identifier, self?.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
1338
1449
  }
1339
1450
  const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
1340
1451
  const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
@@ -1403,15 +1514,35 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1403
1514
  // NOT trigger a second reply. Key by message id, or a channel+text signature
1404
1515
  // when the payload carries no id.
1405
1516
  if (!raw._mentionAlreadyMarked && markMentionHandled(msg, cid)) { log('agent:mention (dup) — skipped'); return }
1406
- if (requestTargetsLaterAgent(text, [slug, identifier])) {
1407
- log('agent:mention addressed to a later-mentioned agent — skipped')
1517
+ const target = classifyConversationTarget(msg, [...selfAliases])
1518
+ if (target.action === 'ignore') {
1519
+ log('agent:mention ignored before model start (' + target.reason + ')')
1520
+ // A redirect or another agent taking over invalidates any older work or
1521
+ // draft still queued for this thread. The event name alone cannot retain
1522
+ // ownership after the actual message says otherwise.
1523
+ if (cid != null && threadRoot != null && (target.reason === 'explicit-other-recipient' || target.reason === 'other-agent-chatter' || target.reason === 'redirected-to-later-agent')) {
1524
+ cancelThread(cid, threadRoot, text || target.reason)
1525
+ }
1408
1526
  return
1409
1527
  }
1410
1528
  const mentionKeys = mentionDedupeKeys(msg, cid)
1411
1529
  const sourceKey = `mention:${cid ?? '?'}:${mentionKeys.idKey || mentionKeys.signatureKey || threadRoot || Date.now()}`
1530
+ if (target.action === 'stand_down') {
1531
+ if (cid != null && threadRoot != null) {
1532
+ cancelThread(cid, threadRoot, text || 'Explicit stand-down')
1533
+ const confirmation = `${who ? `@${who} ` : ''}Understood. I'm standing down.`
1534
+ void postMessageOnce({ key: `stand-down:${cid}:${threadRoot}`, channelId: Number(cid), parentId: threadRoot, content: confirmation, sourceKey, allowCancelled: true })
1535
+ .catch((e) => log('stand-down confirmation failed closed: ' + (e?.message || e)))
1536
+ }
1537
+ return
1538
+ }
1539
+ if (cid != null && threadRoot != null) activateThread(cid, threadRoot, text)
1412
1540
  memory.remember({ key: sourceKey, kind: 'mention', state: 'received', summary: text, refs: { channelId: cid, threadId: threadRoot, messageId: mid } })
1413
- const guardedDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result') => agent === 'codex' && cid != null
1414
- ? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey }
1541
+ // Every runtime carries the source thread through the queue so a later
1542
+ // redirect can cancel its process. Codex additionally delegates the final
1543
+ // post to the watcher for an authoritative last-moment thread check.
1544
+ const conversationDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result') => cid != null
1545
+ ? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, watcherOwned: agent === 'codex' }
1415
1546
  : null
1416
1547
  // Under-the-hood model control from chat (view / switch the model the agent runs).
1417
1548
  const mcmd = cid != null ? parseModelCmd(text) : null
@@ -1419,11 +1550,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1419
1550
  const thread = threadRoot != null ? `, parent_id ${threadRoot}` : ''
1420
1551
  if (mcmd.report) {
1421
1552
  log('model query → code ' + codeModel + ' / chat ' + liteModel)
1422
- const delivery = guardedDelivery('model')
1423
- 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)
1553
+ const delivery = conversationDelivery('model')
1554
+ 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)
1424
1555
  } else if (mcmd.invalid) {
1425
- const delivery = guardedDelivery('model')
1426
- 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)
1556
+ const delivery = conversationDelivery('model')
1557
+ 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)
1427
1558
  } else {
1428
1559
  const tgt = mcmd.target // 'chat' | 'code' | 'both'
1429
1560
  const prev = `code ${codeModel}/chat ${liteModel}`
@@ -1433,8 +1564,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1433
1564
  persistModel()
1434
1565
  const label = tgt === 'chat' ? 'chat model' : tgt === 'code' ? 'code model' : 'model'
1435
1566
  log('model switched (' + tgt + ') ' + prev + ' → code ' + codeModel + '/chat ' + liteModel + (who ? ' (by ' + who + ')' : ''))
1436
- const delivery = guardedDelivery('model')
1437
- 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)
1567
+ const delivery = conversationDelivery('model')
1568
+ 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)
1438
1569
  }
1439
1570
  return
1440
1571
  }
@@ -1442,23 +1573,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1442
1573
  // Light up the live status the instant we pick this up; drain owns the
1443
1574
  // subsequent working/typing heartbeat for its lane.
1444
1575
  if (cid != null) sendStatus(cid, 'thinking')
1445
- const codingMention = canCode && needsCode(text)
1446
- const replyDelivery = guardedDelivery(codingMention ? 'result' : 'reply', !codingMention)
1576
+ const codingMention = canCode && conversationNeedsCode(text)
1577
+ const replyDelivery = conversationDelivery(codingMention ? 'result' : 'reply', !codingMention)
1447
1578
  const ctx = cid != null
1448
- ? replyDelivery
1449
- ? `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, or poll_inbox.`
1450
- : `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, or poll_inbox, and after your single reply, STOP.`
1579
+ ? replyDelivery?.watcherOwned
1580
+ ? `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.`
1581
+ : `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.`
1451
1582
  : undefined
1452
1583
  if (codingMention) {
1453
- if (agent === 'codex' && cid != null) {
1454
- const ack = `${who ? `@${who} ` : ''}I've picked this up and will return here with the verified result.`
1455
- void postMessageOnce({ ...guardedDelivery('ack'), content: ack }).catch((e) => log('guarded acknowledgement failed closed: ' + (e?.message || e)))
1456
- } else {
1457
- const ack = cid != null
1458
- ? `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.`
1459
- : undefined
1460
- void drain('coord', ack, cid == null ? [] : [cid])
1461
- }
1584
+ // Activity indicators make accepted work visible. Do not add a canned
1585
+ // pickup message; the first channel message is the verified result or a
1586
+ // concrete blocker from the work lane.
1462
1587
  void drain('full', ctx, cid == null ? [] : [cid], null, replyDelivery)
1463
1588
  } else void drain('fast', ctx, cid == null ? [] : [cid], null, replyDelivery)
1464
1589
  } else if (k === 'error') {