openvisio-agent 0.18.3 → 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
@@ -62,6 +62,8 @@ 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
+
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.3",
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 = [
@@ -46,6 +51,11 @@ 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")],
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,6 +75,11 @@ 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')],
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/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.',
@@ -62,8 +63,8 @@ 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. ─────────
@@ -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: 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.',
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: 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.',
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 ──────────────────────────────────────────────────────
@@ -708,6 +732,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
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,7 +812,8 @@ 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.3', 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)
792
818
  let mcpToolNames = null
793
819
  let mcpToolDiscoveryPromise = null
@@ -838,12 +864,39 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
838
864
  // rendered as this agent. A persisted delivery key closes the crash/reconnect
839
865
  // gap; an in-flight promise closes the two-lane race inside one watcher.
840
866
  const messageDeliveries = new Map()
841
- 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 }) => {
842
886
  const deliveryKey = String(key || '')
843
887
  const message = String(content || '').trim()
844
888
  if (!deliveryKey || !Number.isFinite(Number(channelId)) || !message) return { posted: false, reason: 'invalid-delivery' }
845
889
  if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) return { posted: false, reason: 'remembered' }
846
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
847
900
 
848
901
  const run = (async () => {
849
902
  if (parentId != null) {
@@ -859,6 +912,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
859
912
  }
860
913
  }
861
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
+
862
920
  sendStatus(Number(channelId), 'typing')
863
921
  const result = toolData(await callMcpTool('post_message', {
864
922
  ...(Number.isFinite(Number(projectId)) ? { project_id: Number(projectId) } : {}),
@@ -1054,6 +1112,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1054
1112
  const self = agents.find((a) => String(a.identifier || a.slug || '') === identifier)
1055
1113
  if (!self?.id) throw new Error('list_agents did not return this BYO agent')
1056
1114
  selfAgentId = Number(self.id)
1115
+ for (const alias of [self.name, self.identifier, self.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
1057
1116
  const projectsData = toolData(await callMcpTool('list_projects'))
1058
1117
  const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
1059
1118
  const assigned = []
@@ -1103,13 +1162,16 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1103
1162
  const mentionNeedles = [self.name, self.identifier, self.slug, identifier].filter(Boolean).map((s) => '@' + String(s).toLowerCase())
1104
1163
  for (const item of activities) {
1105
1164
  const text = JSON.stringify(item)
1106
- const lower = text.toLowerCase()
1107
1165
  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
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)) {
1113
1175
  const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
1114
1176
  seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
1115
1177
  // The same logical mention may already have arrived over WebSocket.
@@ -1165,7 +1227,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1165
1227
 
1166
1228
  // Higher rank wins when coalescing cycles requested while one is running.
1167
1229
  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
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
1169
1233
  // Codex and OpenCode expose structured action streams. Require runtime facts
1170
1234
  // from those streams before accepting a full coding cycle. Claude's evidence
1171
1235
  // shape is different and remains on its existing completion path.
@@ -1221,6 +1285,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1221
1285
  for (const channelId of targetChannels) if (Number.isFinite(Number(channelId))) lane.targets.add(Number(channelId))
1222
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 }
1223
1287
  lane.busy = true
1288
+ lane.activeDelivery = delivery
1224
1289
  const ctx = lane.pending.splice(0)
1225
1290
  const activeTaskRef = lane.taskRefs.splice(0)[0] || null
1226
1291
  const targets = [...lane.targets]
@@ -1232,7 +1297,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1232
1297
  ? { channelId: delivery.channelId, threadId: delivery.parentId }
1233
1298
  : activeTaskRef ? { projectId: activeTaskRef.projectId, ticketId: activeTaskRef.ticketId } : {}
1234
1299
  const recalled = memory.context(memoryRefs)
1235
- 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)
1236
1301
  // Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
1237
1302
  // work (full/sweep) uses the main model.
1238
1303
  const useModel = agent === 'codex' ? codeModel : kind === 'full' ? codeModel : liteModel
@@ -1244,8 +1309,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1244
1309
  // 20 seconds so long coding runs do not create needless network/battery load.
1245
1310
  const heartbeat = targets.length ? setInterval(() => emitLaneStatus(laneName, 'working'), 20_000) : null
1246
1311
  try {
1247
- 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'] } : {})
1248
1313
  let completionResult = result
1314
+ if (result?.subtype === 'canceled') {
1315
+ log(laneName + ' cycle cancelled; no blocker or reply will be published')
1316
+ return
1317
+ }
1249
1318
  if (agent === 'codex' && result?.subtype === 'blocked' && result?.policyBlock) {
1250
1319
  log('WORK_CYCLE_BLOCKED authorization required; pausing this ticket and publishing an action-required notice')
1251
1320
  try { await reportPolicyBlock(prompt, activeTaskRef, result.policyBlock) }
@@ -1273,7 +1342,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1273
1342
  const missing = missingWorkEvidence(result, ticketCycle)
1274
1343
  if (evidenceGatedRuntime && kind === 'full' && result?.subtype === 'ok' && missing.length) {
1275
1344
  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'] } : {})
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'] } : {})
1277
1346
  const recoveredResult = combineWorkEvidence(result, recovery)
1278
1347
  const recoveryMissing = missingWorkEvidence(recoveredResult, ticketCycle)
1279
1348
  if (recovery?.subtype !== 'ok' || recoveryMissing.length) {
@@ -1304,7 +1373,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1304
1373
  log('completion report failed for ticket #' + activeTaskRef.ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry')
1305
1374
  }
1306
1375
  }
1307
- if (agent === 'codex' && delivery) {
1376
+ if (delivery?.watcherOwned) {
1308
1377
  const reply = String(completionResult?.outputText || '').trim()
1309
1378
  if (!reply) {
1310
1379
  log('guarded reply produced no final text; leaving delivery unrecorded for retry')
@@ -1317,6 +1386,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1317
1386
  if (heartbeat) clearInterval(heartbeat)
1318
1387
  laneStatusTargets[laneName].clear()
1319
1388
  lane.busy = false
1389
+ lane.activeDelivery = null
1320
1390
  const deferred = lane.deferred.shift()
1321
1391
  if (deferred) void drain(deferred.kind, deferred.context, deferred.targetChannels, deferred.taskRef, deferred.delivery)
1322
1392
  else if (lane.queued || lane.pending.length) { const next = lane.queued || (laneName === 'work' ? 'full' : 'fast'); lane.queued = null; void drain(next) }
@@ -1331,10 +1401,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1331
1401
  return (`${s.first_name || ''} ${s.last_name || ''}`.trim() || s.name || s.email || '')
1332
1402
  }
1333
1403
 
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)
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)
1338
1407
 
1339
1408
  // Engineers change the model under the hood from chat: "/model", "/model sonnet",
1340
1409
  // "use model haiku", "switch model to opus". Returns {report} | {set} | {invalid}.
@@ -1376,6 +1445,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1376
1445
  const agentsData = toolData(await callMcpTool('list_agents'))
1377
1446
  const self = (Array.isArray(agentsData.agents) ? agentsData.agents : []).find((a) => String(a.identifier || a.slug || '') === identifier)
1378
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())
1379
1449
  }
1380
1450
  const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
1381
1451
  const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
@@ -1444,15 +1514,35 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1444
1514
  // NOT trigger a second reply. Key by message id, or a channel+text signature
1445
1515
  // when the payload carries no id.
1446
1516
  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')
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
+ }
1449
1526
  return
1450
1527
  }
1451
1528
  const mentionKeys = mentionDedupeKeys(msg, cid)
1452
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)
1453
1540
  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 }
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' }
1456
1546
  : null
1457
1547
  // Under-the-hood model control from chat (view / switch the model the agent runs).
1458
1548
  const mcmd = cid != null ? parseModelCmd(text) : null
@@ -1460,11 +1550,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1460
1550
  const thread = threadRoot != null ? `, parent_id ${threadRoot}` : ''
1461
1551
  if (mcmd.report) {
1462
1552
  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)
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)
1465
1555
  } 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)
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)
1468
1558
  } else {
1469
1559
  const tgt = mcmd.target // 'chat' | 'code' | 'both'
1470
1560
  const prev = `code ${codeModel}/chat ${liteModel}`
@@ -1474,8 +1564,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1474
1564
  persistModel()
1475
1565
  const label = tgt === 'chat' ? 'chat model' : tgt === 'code' ? 'code model' : 'model'
1476
1566
  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)
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)
1479
1569
  }
1480
1570
  return
1481
1571
  }
@@ -1483,23 +1573,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1483
1573
  // Light up the live status the instant we pick this up; drain owns the
1484
1574
  // subsequent working/typing heartbeat for its lane.
1485
1575
  if (cid != null) sendStatus(cid, 'thinking')
1486
- const codingMention = canCode && needsCode(text)
1487
- const replyDelivery = guardedDelivery(codingMention ? 'result' : 'reply', !codingMention)
1576
+ const codingMention = canCode && conversationNeedsCode(text)
1577
+ const replyDelivery = conversationDelivery(codingMention ? 'result' : 'reply', !codingMention)
1488
1578
  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.`
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.`
1492
1582
  : undefined
1493
1583
  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
- }
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.
1503
1587
  void drain('full', ctx, cid == null ? [] : [cid], null, replyDelivery)
1504
1588
  } else void drain('fast', ctx, cid == null ? [] : [cid], null, replyDelivery)
1505
1589
  } else if (k === 'error') {