openvisio-agent 0.17.6 → 0.18.1

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/src/watch.mjs CHANGED
@@ -5,12 +5,14 @@
5
5
  // than written to disk from a pasted heredoc.
6
6
 
7
7
  import { spawn, spawnSync } from 'node:child_process'
8
- import { writeFileSync, mkdirSync, existsSync, readFileSync, unlinkSync } from 'node:fs'
8
+ import { closeSync, writeFileSync, mkdirSync, existsSync, openSync, readFileSync, unlinkSync } from 'node:fs'
9
9
  import { homedir } from 'node:os'
10
10
  import { join, dirname } from 'node:path'
11
11
  import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
12
12
  import { connectAgentWs, assertWebSocket } from './ws.mjs'
13
- import { agentStateRequest, buildTaskCompletionReport, codexPolicyBlock, requestTargetsLaterAgent, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted } from './events.mjs'
13
+ import { agentStateRequest, buildTaskCompletionReport, codexPolicyBlock, mentionDedupeKeys, normalizeRenderedMessageText, opencodeEventEvidence, renderedAgentMessages, requestTargetsLaterAgent, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted } from './events.mjs'
14
+ import { createByoMemoryGraph } from './memory.mjs'
15
+ import { repositoryHasPrPushAuthorization } from './pr-push.mjs'
14
16
 
15
17
  // Behaviour prompts. The openvisio-team MCP bridge requires the agent's
16
18
  // credentials as ARGUMENTS on every tool call — those are injected at runtime by
@@ -38,7 +40,7 @@ const REPLY_DISCIPLINE = [
38
40
 
39
41
  // ── CHAT-ONLY agents (no --workdir): chat/ticket tools, no code surface. ──────
40
42
  const CHAT_CHARTER = [
41
- 'YOU ARE a connected agent in an OpenVisio team, running in CHAT-ONLY mode. Use only tools that actually appear in your openvisio-team tool list. Backend MCP commonly provides post_message, react_message, list_agents, list_projects, list_tasks, get_ticket, update_ticket, and list_activity. Some relay runtimes also provide poll_inbox or get_marching_orders. Never call a tool that is absent. You have NO file/Bash/git tools in this mode, so you cannot write code yourself.',
43
+ 'YOU ARE a connected agent in an OpenVisio team, running in CHAT-ONLY mode. Use only tools that actually appear in your openvisio-team tool list. Backend MCP provides post_message, react_message, list_agents, list_projects, list_tasks, get_ticket, update_ticket, comment_ticket, and list_activity. Some relay runtimes also provide poll_inbox or get_marching_orders. Never call a tool that is absent. You have NO file/Bash/git tools in this mode, so you cannot write code yourself.',
42
44
  'WORK ETHIC — behave like a dependable teammate: never leave a promise dangling. Either ACT now (reply, or file a ticket) or say plainly you can\'t and offer to file a ticket / tag a coding agent who can. Never invent progress. Close the loop every cycle — the human should never have to remind you to circle back.',
43
45
  '',
44
46
  REPLY_DISCIPLINE,
@@ -66,7 +68,7 @@ const COORDINATE = [
66
68
  // A stable "who you are / how you work" charter prepended to every code cycle.
67
69
  const CODE_CHARTER = [
68
70
  'YOU ARE a connected CODING agent in an OpenVisio team, running ON THE USER\'S LAPTOP. You have REAL tools — use them; do NOT claim you lack a capability without checking what you actually hold. Your toolbox:',
69
- ' • openvisio-team tools — use the names actually present. Backend MCP provides project/task discovery through list_agents, list_projects, list_tasks, get_ticket, update_ticket, plus post_message/react_message/list_activity. Relay runtimes may additionally expose poll_inbox, get_marching_orders, or comment_ticket.',
71
+ ' • openvisio-team tools — use the names actually present. Backend MCP provides project/task discovery through list_agents, list_projects, list_tasks, get_ticket, update_ticket, and comment_ticket, plus post_message/react_message/list_activity. Relay runtimes may additionally expose poll_inbox or get_marching_orders.',
70
72
  ' • Read / Grep / Glob / Edit / Write / MultiEdit — inspect AND change code.',
71
73
  ' • Bash — git (branch, commit, push a branch), gh (clone repos, open PRs), run tests/builds.',
72
74
  'YOUR WORKSPACE: your working directory is a WORKSPACE ROOT that holds the org\'s repos as subfolders. Reuse existing clones and the context you already verified. Read repository AGENTS.md instructions before changing code. For any task: locate the relevant repo under the workspace; clone it only when it is genuinely absent, then work inside that subfolder. Never ask the user for a path you can discover yourself.',
@@ -77,6 +79,7 @@ const CODE_CHARTER = [
77
79
  ' 2. FINISH, then REPORT. Always update/move the ticket with update_ticket. Reply in a supplied human source thread when one exists. For backlog-assigned work, do not call post_message yourself: the watcher publishes one evidence-verified result in the project channel after the PR and ticket handoff are confirmed.',
78
80
  ' 3. Be honest and specific. Never invent progress. If you are genuinely blocked (missing repo, unclear spec, a failing tool), say exactly what you need in one message — that IS closing the loop.',
79
81
  ' 4. One reply per channel per cycle; answer several nudges together.',
82
+ ' 5. RECOVER DEAD COMMAND SESSIONS. If write_stdin reports “Unknown process id”, that command session has already exited. Never poll the same process id again. Start a fresh exec_command when more work is required, then continue the task and verify the final state.',
80
83
  '',
81
84
  REPLY_DISCIPLINE,
82
85
  ].join('\n')
@@ -90,7 +93,7 @@ const CODE_FULL = [
90
93
  ' 3. CHANGE + VERIFY: Read/Edit/Write the files; run the tests or build if the repo has them.',
91
94
  ' 4. COMMIT + PUSH YOUR BRANCH: git add -A && git commit -m "…"; then git push -u origin agent/<slug>. Only ever push your own agent/* branch. Never --force, never push to main/master, never merge.',
92
95
  ' 5. RAISE A PR: gh pr create --fill --base <default-branch> --head agent/<slug> (a clear title + a body summarizing the change and how you verified it). Never gh pr merge.',
93
- ' 6. CLOSE THE LOOP: move/update the ticket with update_ticket. Reply with the summary + PR link in a source thread explicitly supplied by the event. For backlog-only tickets, do not call post_message yourself; the watcher sends one verified project-channel completion message and deduplicates it across reconnects.',
96
+ ' 6. CLOSE THE LOOP: use comment_ticket for a concrete ticket-scoped blocker or clarification, then move/update the ticket with update_ticket. The watcher adds one evidence-verified final ticket comment after handoff. Reply with the summary + PR link in a source thread explicitly supplied by the event. For backlog-only tickets, do not call post_message yourself; the watcher sends one verified project-channel completion message and deduplicates it across reconnects.',
94
97
  'Bash is for git / gh / tests / clone ONLY — never to hunt for credentials (they are given to you above).',
95
98
  ].join('\n')
96
99
 
@@ -178,18 +181,31 @@ function claimStartupSweep(key) {
178
181
  // are taken over. Returns { release } or { conflict: <pid> }.
179
182
  function acquireSingleInstance(key) {
180
183
  const lockPath = join(OV_DIR, 'watch-' + key + '.lock')
181
- try {
182
- mkdirSync(OV_DIR, { recursive: true })
183
- if (existsSync(lockPath)) {
184
- const pid = parseInt(String(readFileSync(lockPath, 'utf8')).trim(), 10)
185
- if (pid && pid !== process.pid) {
186
- let alive = false
187
- try { process.kill(pid, 0); alive = true } catch (e) { alive = !!(e && e.code === 'EPERM') }
188
- if (alive) return { conflict: pid }
189
- }
184
+ try { mkdirSync(OV_DIR, { recursive: true }) } catch (error) { return { error } }
185
+ // `existsSync` followed by `writeFileSync` is a race: two KeepAlive starts can
186
+ // both observe no file and both become watchers. `wx` makes creation atomic.
187
+ for (let attempt = 0; attempt < 2; attempt++) {
188
+ let fd = null
189
+ try {
190
+ fd = openSync(lockPath, 'wx')
191
+ writeFileSync(fd, String(process.pid))
192
+ closeSync(fd); fd = null
193
+ break
194
+ } catch (error) {
195
+ if (fd != null) { try { closeSync(fd) } catch { /* already closed */ } }
196
+ if (error?.code !== 'EEXIST') return { error }
197
+ let pid = 0
198
+ try { pid = parseInt(String(readFileSync(lockPath, 'utf8')).trim(), 10) } catch { /* an in-flight creator owns it */ }
199
+ if (!pid) return { conflict: 'unknown' }
200
+ let alive = false
201
+ try { process.kill(pid, 0); alive = true } catch (e) { alive = !!(e && e.code === 'EPERM') }
202
+ if (alive) return { conflict: pid }
203
+ // Exact stale lock only. If another process wins the retry, its atomic file
204
+ // remains and this process will return conflict on the next iteration.
205
+ try { unlinkSync(lockPath) } catch (e) { if (e?.code !== 'ENOENT') return { error: e } }
206
+ if (attempt === 1) return { conflict: 'unknown' }
190
207
  }
191
- writeFileSync(lockPath, String(process.pid))
192
- } catch { /* if the lock can't be written, don't block the agent from running */ }
208
+ }
193
209
  const release = () => { try { if (parseInt(String(readFileSync(lockPath, 'utf8')).trim(), 10) === process.pid) unlinkSync(lockPath) } catch { /* already gone */ } }
194
210
  return { release }
195
211
  }
@@ -229,6 +245,7 @@ export async function runWatch({ flags }) {
229
245
  // both connect as the same agent and both answer every mention. Refuse to start.
230
246
  if (!flags.install) {
231
247
  const lock = acquireSingleInstance(slug || 'openvisio')
248
+ if (lock.error) fail(`Could not acquire the single-watcher lock for "${slug || 'openvisio'}": ${lock.error.message || lock.error}`)
232
249
  if (lock.conflict) {
233
250
  const watcherName = slug || 'openvisio'
234
251
  const logs = process.platform === 'darwin'
@@ -274,7 +291,7 @@ export async function runWatch({ flags }) {
274
291
  // The openvisio-team MCP is declared in an `opencode.json` written into the run cwd
275
292
  // (opencode reads it from there). `--auto` approves tool use non-interactively.
276
293
  // Same { runCycle, canCode } contract as the Claude runner.
277
- function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, systemPrompt }) {
294
+ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt }) {
278
295
  // opencode reads opencode.json from its CWD: the code workspace, or a dedicated
279
296
  // per-agent dir for chat-only agents.
280
297
  const cwd = workdir || join(OV_DIR, 'opencode-' + (cfgKey || 'agent'))
@@ -303,18 +320,71 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
303
320
  // opencode has no system-prompt flag; each run is a fresh process, so fold the
304
321
  // charter/creds into the message (still not re-accumulated across cycles).
305
322
  const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
306
- const args = ['run', full, '--auto', ...(m ? ['--model', m] : [])]
307
- let child = null, done = false
308
- const finish = (o) => { if (done) return; done = true; clearTimeout(timer); resolve(o) }
323
+ // JSON mode is the evidence boundary. Formatted stdout only tells us that
324
+ // OpenCode exited; raw events tell us which tools actually completed.
325
+ const args = ['run', full, '--auto', '--format', 'json', ...(m ? ['--model', m] : [])]
326
+ let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, didMcpTaskRead = false, didMcpTaskUpdate = false
327
+ let outputText = '', jsonlBuffer = ''
328
+ const mcpCalls = new Set(), mcpErrors = new Set(), runtimeErrors = new Set()
329
+ const finish = (o) => {
330
+ if (done) return
331
+ done = true
332
+ clearTimeout(timer)
333
+ const calls = [...mcpCalls]
334
+ log('opencode MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (mcpErrors.size ? ' (failed: ' + [...mcpErrors].join(', ') + ')' : ''))
335
+ resolve({ ...o, didCode, didRepoMutation, didMessage, didChannelMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], outputText })
336
+ }
337
+ const inspectLine = (line) => {
338
+ const value = String(line || '').trim()
339
+ if (!value) return
340
+ let event
341
+ try { event = JSON.parse(value) } catch {
342
+ if (debug) log(' · unparsed opencode output: ' + value.slice(0, 180))
343
+ return
344
+ }
345
+ const evidence = opencodeEventEvidence(event)
346
+ if (evidence.outputText) {
347
+ outputText += ' ' + evidence.outputText
348
+ if (debug) log(' · ' + evidence.outputText.replace(/\s+/g, ' ').slice(0, 180))
349
+ }
350
+ if (evidence.runtimeError) runtimeErrors.add(evidence.runtimeError)
351
+ if (!evidence.tool) return
352
+ if (debug) log(' → tool ' + evidence.tool + (evidence.failed ? ' (failed)' : evidence.completed ? ' (completed)' : ''))
353
+ try { onTool && onTool(evidence.tool) } catch { /* activity is best-effort */ }
354
+ if (evidence.mcpTool) {
355
+ mcpCalls.add(evidence.mcpTool)
356
+ if (evidence.failed) mcpErrors.add(evidence.mcpTool)
357
+ else if (evidence.completed) mcpErrors.delete(evidence.mcpTool)
358
+ }
359
+ didCode ||= !!evidence.didCode
360
+ didRepoMutation ||= !!evidence.didRepoMutation
361
+ didMessage ||= !!evidence.didMessage
362
+ didChannelMessage ||= !!evidence.didChannelMessage
363
+ didMcpTaskRead ||= !!evidence.didMcpTaskRead
364
+ didMcpTaskUpdate ||= !!evidence.didMcpTaskUpdate
365
+ }
309
366
  const timer = setTimeout(() => {
310
367
  log('opencode cycle TIMED OUT after ' + Math.round(maxCycleMs / 1000) + 's — killing')
311
368
  try { child && child.kill() } catch { /* gone */ }
312
369
  finish({ type: 'result', subtype: 'timeout' })
313
370
  }, maxCycleMs)
314
371
  log('running opencode cycle…' + (m ? ' [' + m + ']' : ''))
315
- try { child = spawn(bin, args, { cwd, stdio: ['ignore', debug ? 'inherit' : 'ignore', 'inherit'] }) }
372
+ try {
373
+ child = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'inherit'] })
374
+ child.stdout?.on('data', (data) => {
375
+ jsonlBuffer += String(data)
376
+ const lines = jsonlBuffer.split('\n')
377
+ jsonlBuffer = lines.pop() ?? ''
378
+ for (const line of lines) inspectLine(line)
379
+ })
380
+ }
316
381
  catch (e) { log('opencode spawn failed: ' + (e && e.message ? e.message : e) + ' — is opencode installed? (npm i -g opencode-ai, then `opencode auth login`)'); return finish({ type: 'result', subtype: 'spawn-failed' }) }
317
- child.on('exit', (code) => { log('opencode cycle done (' + (code === 0 ? 'ok' : 'exit ' + code) + ')'); finish({ type: 'result', subtype: code === 0 ? 'ok' : 'error' }) })
382
+ child.on('close', (code) => {
383
+ inspectLine(jsonlBuffer); jsonlBuffer = ''
384
+ const subtype = code === 0 && runtimeErrors.size === 0 ? 'ok' : 'error'
385
+ log('opencode cycle done (' + (subtype === 'ok' ? 'ok' : 'exit ' + code) + ')')
386
+ finish({ type: 'result', subtype })
387
+ })
318
388
  child.on('error', (e) => { log('opencode error: ' + (e && e.message ? e.message : e)); finish({ type: 'result', subtype: 'error' }) })
319
389
  })
320
390
  }
@@ -334,19 +404,21 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
334
404
  const tomlString = (v) => JSON.stringify(String(v))
335
405
  const headerEntries = Object.entries(mcpHeaders || {}).map(([k, v]) => `${JSON.stringify(k)} = ${tomlString(v)}`).join(', ')
336
406
 
337
- function runCycle(prompt, cycleModel) {
407
+ function runCycle(prompt, cycleModel, cycleOptions = {}) {
338
408
  return new Promise((resolve) => {
339
409
  const m = cycleModel || model
340
410
  const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
411
+ const disabledMcpTools = Array.isArray(cycleOptions.disabledMcpTools) ? cycleOptions.disabledMcpTools.filter(Boolean) : []
412
+ const disabledToolConfig = disabledMcpTools.length ? `, disabled_tools = [${disabledMcpTools.map(tomlString).join(', ')}]` : ''
341
413
  const mcpOverride = mcpUrl
342
- ? `mcp_servers={ openvisio-team = { url = ${tomlString(mcpUrl)}${headerEntries ? `, http_headers = { ${headerEntries} }` : ''} } }`
414
+ ? `mcp_servers={ openvisio-team = { url = ${tomlString(mcpUrl)}${headerEntries ? `, http_headers = { ${headerEntries} }` : ''}${disabledToolConfig} } }`
343
415
  : ''
344
416
  const args = ['exec', '--ignore-user-config', '--skip-git-repo-check', '--ephemeral', '--json', '--color', 'never',
345
417
  ...(canCode ? ['--approve-for-me'] : ['--sandbox', 'read-only']),
346
418
  ...(m ? ['--model', m] : []),
347
419
  ...(mcpOverride ? ['-c', mcpOverride] : []),
348
420
  full]
349
- let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = ''
421
+ let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = '', stderrLineBuffer = ''
350
422
  let policyBlock = null
351
423
  const mcpCalls = new Set(), mcpErrors = new Set()
352
424
  let didMcpTaskRead = false, didMcpTaskUpdate = false
@@ -363,11 +435,26 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
363
435
  stderrBuffer = (stderrBuffer + s).slice(-24_000)
364
436
  policyBlock = codexPolicyBlock(stderrBuffer) || policyBlock
365
437
  }
438
+ const forwardDiagnostic = (value, flush = false) => {
439
+ const incoming = String(value || '')
440
+ inspectDiagnostic(incoming)
441
+ stderrLineBuffer += incoming
442
+ const lines = stderrLineBuffer.split('\n')
443
+ stderrLineBuffer = lines.pop() ?? ''
444
+ for (const line of lines) {
445
+ if (!shouldSuppressCodexDiagnostic(line)) process.stderr.write(line + '\n')
446
+ }
447
+ if (flush && stderrLineBuffer) {
448
+ if (!shouldSuppressCodexDiagnostic(stderrLineBuffer)) process.stderr.write(stderrLineBuffer)
449
+ stderrLineBuffer = ''
450
+ }
451
+ }
366
452
  const inspectLine = (line) => {
367
453
  const s = line.trim()
368
454
  if (!s) return
455
+ policyBlock = codexPolicyBlock(s) || policyBlock
369
456
  if (/command_execution|file_change|apply_patch|shell_command|exec_command/i.test(s)) didCode = true
370
- if (/file_change|apply_patch/i.test(s) || /\b(?:git\s+(?:commit|push)|gh\s+pr\s+create)\b/i.test(s)) didRepoMutation = true
457
+ if (/file_change|apply_patch/i.test(s) || /\b(?:git\s+(?:commit|push)|gh\s+pr\s+create|openvisio-agent\s+push-pr-branch)\b/i.test(s)) didRepoMutation = true
371
458
  if (/post_message|comment_ticket/i.test(s)) didMessage = true
372
459
  try {
373
460
  const event = JSON.parse(s)
@@ -379,6 +466,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
379
466
  try { onTool && onTool(tool) } catch { /* activity is best-effort */ }
380
467
  if (/^(?:get_ticket|list_tasks|list_task_types)$/.test(tool)) didMcpTaskRead = true
381
468
  if (tool === 'update_ticket') didMcpTaskUpdate = true
469
+ if (/^(?:create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/.test(tool)) { didCode = true; didRepoMutation = true }
382
470
  if (/post_message|comment_ticket/.test(tool)) didMessage = true
383
471
  if (/post_message/.test(tool)) didChannelMessage = true
384
472
  if (/fail|error/i.test(String(item.status || '')) || item.error) mcpErrors.add(tool)
@@ -403,15 +491,14 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
403
491
  for (const line of lines) inspectLine(line)
404
492
  })
405
493
  if (child.stderr) child.stderr.on('data', (d) => {
406
- inspectDiagnostic(d)
407
- process.stderr.write(d)
494
+ forwardDiagnostic(d)
408
495
  })
409
496
  } catch (e) {
410
497
  log('codex spawn failed: ' + (e && e.message ? e.message : e) + ' — is Codex installed and signed in? (`npm i -g @openai/codex`, then `codex login`)')
411
498
  return finish({ type: 'result', subtype: 'spawn-failed' })
412
499
  }
413
500
  child.on('close', (code) => {
414
- inspectLine(jsonlBuffer); jsonlBuffer = ''; inspectDiagnostic('')
501
+ inspectLine(jsonlBuffer); jsonlBuffer = ''; forwardDiagnostic('', true)
415
502
  const subtype = policyBlock ? 'blocked' : code === 0 ? 'ok' : 'error'
416
503
  log('codex cycle done (' + (subtype === 'blocked' ? 'BLOCKED: user authorization required' : subtype === 'ok' ? 'ok' : 'exit ' + code) + ')')
417
504
  finish({ type: 'result', subtype })
@@ -433,7 +520,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
433
520
  const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
434
521
  // opencode drives cycles differently — a headless `opencode run` per cycle rather
435
522
  // than a persistent stream-json session. Same { runCycle, canCode } contract.
436
- if (agent === 'opencode') return createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, systemPrompt })
523
+ if (agent === 'opencode') return createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
437
524
  if (agent === 'codex') return createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
438
525
  let child = null
439
526
  // The model the CURRENT session was spawned with. runCycle can pass a different
@@ -583,7 +670,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
583
670
  const canCode = !!workdir
584
671
  // The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
585
672
  // agent_identifier + agent_api_key as arguments. Hand them over up front.
586
- const credNote = `AUTH: the openvisio-team tools REQUIRE two arguments on EVERY call — agent_identifier: "${identifier}" and agent_api_key: "${apiKey}". Include BOTH on every openvisio-team tool call. Use ONLY names shown in the current tool list. On backend MCP, discover work with list_agents, list_projects, list_tasks, get_ticket, update_ticket, and list_activity; get_marching_orders, poll_inbox, and comment_ticket may be absent. Tools may be namespaced — call whichever names actually appear. The credentials are given here; do NOT hunt for them. Bash/git/gh ARE for code work; this rule only forbids searching for keys.`
673
+ const credNote = `AUTH: the openvisio-team tools REQUIRE two arguments on EVERY call — agent_identifier: "${identifier}" and agent_api_key: "${apiKey}". Include BOTH on every openvisio-team tool call. Use ONLY names shown in the current tool list. On backend MCP, discover and update work with list_agents, list_projects, list_tasks, get_ticket, update_ticket, comment_ticket, and list_activity; get_marching_orders and poll_inbox may be absent. Tools may be namespaced — call whichever names actually appear. The credentials are given here; do NOT hunt for them. Bash/git/gh ARE for code work; this rule only forbids searching for keys.`
587
674
  // The STATIC charter + creds are the session system prompt (cached, billed once),
588
675
  // NOT re-sent in every cycle's user message — the big token saving.
589
676
  const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
@@ -597,7 +684,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
597
684
  work: createCycleRunner({ ...runnerOptions, onTool: (name) => { if (/post_message/.test(name)) emitLaneStatus('work', 'typing') } }),
598
685
  reply: createCycleRunner({ ...runnerOptions, cfgKey: identifier + '-reply', onTool: (name) => { if (/post_message/.test(name)) emitLaneStatus('reply', 'typing') } }),
599
686
  }
600
- const fullPrompt = canCode ? CODE_FULL : CYCLE
687
+ const codexPushGuide = agent === 'codex' && canCode
688
+ ? '\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.'
689
+ : ''
690
+ const fullPrompt = canCode ? CODE_FULL + codexPushGuide : CYCLE
601
691
  const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
602
692
  // Live model state — changeable at runtime by the in-chat `/model` command.
603
693
  // codeModel drives full/sweep cycles; chatModel (if set) the lighter fast/intro
@@ -606,8 +696,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
606
696
  let liteModel = chatModel || model
607
697
 
608
698
  const lanes = {
609
- work: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [] },
610
- reply: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [] },
699
+ work: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [], deferred: [] },
700
+ reply: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [], deferred: [] },
611
701
  }
612
702
  // Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
613
703
  // different agent re-triggers), so a noisy stream of task:updated events doesn't
@@ -620,29 +710,55 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
620
710
  let replayState = {}
621
711
  try { replayState = JSON.parse(readFileSync(replayPath, 'utf8')) } catch { /* first run */ }
622
712
  const seenMentions = new Set(Array.isArray(replayState.seenMentions) ? replayState.seenMentions : [])
713
+ const recentMentionSignatures = new Map(Array.isArray(replayState.recentMentionSignatures) ? replayState.recentMentionSignatures : [])
623
714
  const seenActivities = new Set(Array.isArray(replayState.seenActivities) ? replayState.seenActivities : [])
715
+ const deliveredReplies = new Set(Array.isArray(replayState.deliveredReplies) ? replayState.deliveredReplies : [])
716
+ const memory = createByoMemoryGraph({ path: join(OV_DIR, 'watch-' + slug + '-memory.json') })
624
717
  // Completion delivery is runtime-owned for assigned coding work. Persist both
625
718
  // pending and delivered keys so a reconnect can finish a missed notification
626
719
  // without re-running the model or posting the same result twice.
627
720
  const pendingCompletionReports = new Set(Array.isArray(replayState.pendingCompletionReports) ? replayState.pendingCompletionReports : [])
628
721
  const reportedCompletions = new Set(Array.isArray(replayState.reportedCompletions) ? replayState.reportedCompletions : [])
722
+ const reportedTaskComments = new Set(Array.isArray(replayState.reportedTaskComments) ? replayState.reportedTaskComments : [])
629
723
  // A policy-blocked task stays paused across reconnects. It is released only
630
724
  // after the ticket itself carries explicit authorization or is completed/
631
725
  // unassigned. This prevents a 30-minute reconciliation retry from repeatedly
632
726
  // attempting the same rejected egress action.
633
727
  const blockedTasks = new Set(Array.isArray(replayState.blockedTasks) ? replayState.blockedTasks : [])
728
+ const blockedTaskRepos = new Map(Array.isArray(replayState.blockedTaskRepos) ? replayState.blockedTaskRepos : [])
634
729
  const trimSeen = (set) => { while (set.size > 500) set.delete(set.values().next().value) }
730
+ const MENTION_SIGNATURE_TTL_MS = 10 * 60 * 1000
731
+ const pruneMentionSignatures = () => {
732
+ const cutoff = Date.now() - MENTION_SIGNATURE_TTL_MS
733
+ for (const [key, at] of recentMentionSignatures) if (Number(at) < cutoff) recentMentionSignatures.delete(key)
734
+ while (recentMentionSignatures.size > 500) recentMentionSignatures.delete(recentMentionSignatures.keys().next().value)
735
+ }
635
736
  const persistReplay = () => {
636
737
  try {
738
+ pruneMentionSignatures()
637
739
  writeJson(replayPath, {
638
740
  seenMentions: [...seenMentions],
741
+ recentMentionSignatures: [...recentMentionSignatures],
639
742
  seenActivities: [...seenActivities],
743
+ deliveredReplies: [...deliveredReplies],
640
744
  blockedTasks: [...blockedTasks],
745
+ blockedTaskRepos: [...blockedTaskRepos],
641
746
  pendingCompletionReports: [...pendingCompletionReports],
642
747
  reportedCompletions: [...reportedCompletions],
748
+ reportedTaskComments: [...reportedTaskComments],
643
749
  }, true)
644
750
  } catch { /* best-effort */ }
645
751
  }
752
+ const markMentionHandled = (message, channelId) => {
753
+ pruneMentionSignatures()
754
+ const { idKey, signatureKey } = mentionDedupeKeys(message, channelId)
755
+ const duplicate = (idKey && seenMentions.has(idKey)) || (signatureKey && recentMentionSignatures.has(signatureKey))
756
+ if (duplicate) return true
757
+ if (idKey) { seenMentions.add(idKey); trimSeen(seenMentions) }
758
+ if (signatureKey) recentMentionSignatures.set(signatureKey, Date.now())
759
+ persistReplay()
760
+ return false
761
+ }
646
762
  // Context lines from the events themselves (the WS payload already carries the
647
763
  // channel + message / task), so the agent acts on THEM directly instead of
648
764
  // hoping poll_inbox re-surfaces the same item. Accumulated across coalesced
@@ -676,7 +792,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
676
792
  }
677
793
  const ensureMcpSession = async () => {
678
794
  if (mcpSessionId) return
679
- const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.17.6' } } }, false)
795
+ const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.18.1' } } }, false)
680
796
  if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
681
797
  await mcpPayload(res)
682
798
  mcpSessionId = res.headers.get('mcp-session-id') || ''
@@ -695,7 +811,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
695
811
  if (payload.error) throw new Error(`MCP ${name}: ${payload.error.message || 'tool error'}`)
696
812
  const result = payload.result ?? payload
697
813
  if (result?.isError) {
698
- const detail = result.content?.find?.((c) => c?.type === 'text')?.text || 'tool error'
814
+ const detail = String(result.content?.find?.((c) => c?.type === 'text')?.text || 'tool error').split(apiKey).join('[redacted]')
699
815
  throw new Error(`MCP ${name}: ${detail}`)
700
816
  }
701
817
  return result
@@ -706,6 +822,48 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
706
822
  try { return JSON.parse(text) } catch { return { text } }
707
823
  }
708
824
 
825
+ // All watcher-owned message delivery goes through this gate. For threaded
826
+ // replies it first reads the live backend thread and inspects rows already
827
+ // rendered as this agent. A persisted delivery key closes the crash/reconnect
828
+ // gap; an in-flight promise closes the two-lane race inside one watcher.
829
+ const messageDeliveries = new Map()
830
+ const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '' }) => {
831
+ const deliveryKey = String(key || '')
832
+ const message = String(content || '').trim()
833
+ if (!deliveryKey || !Number.isFinite(Number(channelId)) || !message) return { posted: false, reason: 'invalid-delivery' }
834
+ if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) return { posted: false, reason: 'remembered' }
835
+ if (messageDeliveries.has(deliveryKey)) return messageDeliveries.get(deliveryKey)
836
+
837
+ const run = (async () => {
838
+ if (parentId != null) {
839
+ const live = toolData(await callMcpTool('list_message_thread', { channel_id: Number(channelId), message_id: Number(parentId) }))
840
+ const rendered = renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug })
841
+ const duplicate = rendered.some((row) => normalizeRenderedMessageText(row.content) === normalizeRenderedMessageText(message))
842
+ if (duplicate || (skipIfAnyAgentReply && rendered.length)) {
843
+ deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
844
+ memory.remember({ key: deliveryKey, kind: 'delivery', state: 'rendered', summary: duplicate ? message : rendered.at(-1)?.content, refs: { channelId: Number(channelId), threadId: Number(parentId) }, meta: { discoveredFromBackend: true } })
845
+ if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
846
+ log('message delivery ' + deliveryKey + ' already rendered — skipped')
847
+ return { posted: false, reason: duplicate ? 'same-content-rendered' : 'agent-reply-rendered' }
848
+ }
849
+ }
850
+
851
+ sendStatus(Number(channelId), 'typing')
852
+ const result = toolData(await callMcpTool('post_message', {
853
+ ...(Number.isFinite(Number(projectId)) ? { project_id: Number(projectId) } : {}),
854
+ channel_id: Number(channelId),
855
+ ...(parentId != null ? { parent_id: Number(parentId) } : {}),
856
+ content: message,
857
+ }))
858
+ deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
859
+ memory.remember({ key: deliveryKey, kind: 'delivery', state: 'rendered', summary: message, refs: { channelId: Number(channelId), ...(parentId != null ? { threadId: Number(parentId) } : {}), ...(Number.isFinite(Number(projectId)) ? { projectId: Number(projectId) } : {}) }, meta: { messageId: result.id ?? result.message?.id ?? null } })
860
+ if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
861
+ return { posted: true, result }
862
+ })().finally(() => messageDeliveries.delete(deliveryKey))
863
+ messageDeliveries.set(deliveryKey, run)
864
+ return run
865
+ }
866
+
709
867
  const statusChannelCache = new Map()
710
868
  const projectStatusChannel = async (projectId) => {
711
869
  const key = Number(projectId)
@@ -727,6 +885,22 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
727
885
  }
728
886
  }
729
887
 
888
+ const announceIntroduction = async () => {
889
+ const projectsData = toolData(await callMcpTool('list_projects'))
890
+ const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
891
+ const project = projects.find((item) => Number.isFinite(Number(item.id)))
892
+ if (!project) { log('no project available for first-connection introduction'); return false }
893
+ const channelId = await projectStatusChannel(project.id)
894
+ if (!Number.isFinite(Number(channelId))) return false
895
+ await postMessageOnce({
896
+ key: `intro:${identifier}:${project.id}`,
897
+ projectId: Number(project.id),
898
+ channelId: Number(channelId),
899
+ content: "I'm here, I pick up tasks assigned to me, and I respond to @mentions. Send work my way whenever you need me.",
900
+ })
901
+ return true
902
+ }
903
+
730
904
  const announceTaskCompletion = async (taskRef, result = {}) => {
731
905
  const projectId = Number(taskRef?.projectId)
732
906
  const ticketId = Number(taskRef?.ticketId)
@@ -736,6 +910,23 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
736
910
  const ticket = current.ticket ?? current.task ?? current
737
911
  const report = buildTaskCompletionReport(ticket, { projectId, fallbackText: result.outputText })
738
912
  if (!report) return false
913
+ const memoryKey = `ticket:${projectId}:${ticketId}`
914
+ memory.remember({
915
+ key: memoryKey,
916
+ kind: 'ticket',
917
+ state: 'handoff',
918
+ summary: report.content,
919
+ refs: { projectId, ticketId },
920
+ meta: { reportKey: report.key, prUrl: report.prUrl },
921
+ })
922
+ // Ticket comments are now a backend first-class surface. The watcher owns the
923
+ // final comment so every runtime (Claude, Codex, OpenCode) closes the ticket
924
+ // loop consistently, and the persisted report key prevents reconnect repeats.
925
+ if (!reportedTaskComments.has(report.key)) {
926
+ await callMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: report.content })
927
+ reportedTaskComments.add(report.key); trimSeen(reportedTaskComments); persistReplay()
928
+ log('posted verified ticket comment for #' + ticketId)
929
+ }
739
930
  if (reportedCompletions.has(report.key)) {
740
931
  pendingCompletionReports.delete(taskKey)
741
932
  persistReplay()
@@ -752,10 +943,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
752
943
  }
753
944
  const channelId = Number.isFinite(Number(taskRef.channelId)) ? Number(taskRef.channelId) : await projectStatusChannel(projectId)
754
945
  if (!Number.isFinite(channelId)) return false
755
- sendStatus(channelId, 'typing')
756
- await callMcpTool('post_message', { project_id: projectId, channel_id: channelId, content: report.content })
946
+ await postMessageOnce({ key: `completion:${report.key}`, projectId, channelId, content: report.content, sourceKey: memoryKey })
757
947
  reportedCompletions.add(report.key); trimSeen(reportedCompletions)
758
948
  pendingCompletionReports.delete(taskKey); persistReplay()
949
+ memory.remember({ key: memoryKey, kind: 'ticket', state: 'reported', summary: report.content, refs: { projectId, ticketId, channelId }, meta: { reportKey: report.key, prUrl: report.prUrl } })
759
950
  log('posted verified completion for ticket #' + ticketId + ' in channel ' + channelId)
760
951
  return true
761
952
  }
@@ -772,11 +963,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
772
963
  let delivered = false
773
964
  if (Number.isFinite(channelId)) {
774
965
  try {
775
- await callMcpTool('post_message', {
776
- channel_id: channelId,
777
- ...(parentMatch ? { parent_id: Number(parentMatch[1]) } : {}),
778
- content: notice,
779
- })
966
+ const parentId = parentMatch ? Number(parentMatch[1]) : null
967
+ const blockerKey = `blocker:${channelId}:${parentId ?? 'top'}:${normalizeRenderedMessageText(notice).slice(0, 180)}`
968
+ await postMessageOnce({ key: blockerKey, channelId, parentId, projectId, content: notice, sourceKey: Number.isFinite(ticketId) && Number.isFinite(projectId) ? `ticket:${projectId}:${ticketId}` : '' })
780
969
  delivered = true
781
970
  } catch (e) {
782
971
  log('failed to post blocker in channel ' + channelId + ': ' + (e?.message || e))
@@ -789,12 +978,19 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
789
978
 
790
979
  const key = `${projectId}:${ticketId}`
791
980
  if (pause) { blockedTasks.add(key); persistReplay() }
981
+ memory.remember({
982
+ key: `ticket:${projectId}:${ticketId}`,
983
+ kind: 'ticket',
984
+ state: pause ? 'authorization-blocked' : 'blocked',
985
+ summary: ticketNotice,
986
+ refs: { projectId, ticketId, ...(Number.isFinite(channelId) ? { channelId } : {}) },
987
+ })
792
988
  try {
793
989
  await callMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: ticketNotice })
794
990
  delivered = true
795
991
  return
796
992
  } catch (e) {
797
- log('comment_ticket unavailable for blocker; recording it on ticket #' + ticketId)
993
+ log('comment_ticket failed for blocker; falling back to the ticket description for #' + ticketId)
798
994
  }
799
995
  const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
800
996
  const ticket = current.ticket ?? current.task ?? current
@@ -811,6 +1007,18 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
811
1007
  }
812
1008
 
813
1009
  const reportPolicyBlock = async (prompt, taskRef, block) => {
1010
+ if (block?.kind === 'pr-push-authorization-required') {
1011
+ const location = block.root ? ` from \`${block.root}\`` : ' from the repository'
1012
+ const notice = `Action required: run \`openvisio-agent authorize-pr-push\`${location}. This is a one-time, repository-scoped opt-in. It permits only the constrained \`openvisio-agent push-pr-branch\` helper for the current \`agent/*\` branch, never main/master, force pushes, another remote, or merges. I've paused the ticket until it is enabled.`
1013
+ const ticketNotice = `I'm paused before the PR push. Run \`openvisio-agent authorize-pr-push\`${location}; the watcher will resume this ticket after the repository-scoped helper is authorized.`
1014
+ const projectId = Number(taskRef?.projectId)
1015
+ const ticketId = Number(taskRef?.ticketId)
1016
+ if (block.root && Number.isFinite(projectId) && Number.isFinite(ticketId)) {
1017
+ blockedTaskRepos.set(`${projectId}:${ticketId}`, block.root)
1018
+ persistReplay()
1019
+ }
1020
+ return publishBlocker({ prompt, taskRef, notice, ticketNotice, pause: true })
1021
+ }
814
1022
  const command = block?.command || 'the requested external repository action'
815
1023
  const payload = [block?.commit && `commit ${block.commit}`, block?.branch && `branch ${block.branch}`, block?.remote && `remote ${block.remote}`].filter(Boolean).join(', ')
816
1024
  const approval = block?.commit && block?.branch
@@ -836,6 +1044,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
836
1044
  const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
837
1045
  const assigned = []
838
1046
  const mentionActivity = []
1047
+ let activityReplayTouched = false
839
1048
  for (const project of projects) {
840
1049
  const [tasksData, typesData, activityData] = await Promise.all([
841
1050
  callMcpTool('list_tasks', { project_id: project.id }).then(toolData),
@@ -851,12 +1060,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
851
1060
  if (taskAgentId !== Number(self.id) && taskIdent !== identifier) continue
852
1061
  const taskKey = `${project.id}:${task.id}`
853
1062
  if (taskIsCompleted(task, doneIds) || taskIsAwaitingReview(task, reviewIds)) {
1063
+ memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'handoff', summary: task.title, refs: { projectId: project.id, ticketId: task.id } })
854
1064
  if (pendingCompletionReports.has(taskKey)) {
855
1065
  const activityChannel = await projectStatusChannel(project.id)
856
1066
  try { await announceTaskCompletion({ projectId: project.id, ticketId: task.id, channelId: activityChannel }) }
857
1067
  catch (e) { log('completion report retry failed for ticket #' + task.id + ': ' + (e?.message || e)) }
858
1068
  }
859
- if (blockedTasks.delete(taskKey)) persistReplay()
1069
+ if (blockedTasks.delete(taskKey)) { blockedTaskRepos.delete(taskKey); persistReplay() }
860
1070
  // Release the in-flight de-dupe key at handoff. If a reviewer moves
861
1071
  // the ticket back to an actionable column, that update must start a
862
1072
  // fresh work cycle.
@@ -865,11 +1075,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
865
1075
  }
866
1076
  if (blockedTasks.has(taskKey)) {
867
1077
  const approvalText = [task.title, task.description].filter(Boolean).join(' ')
868
- if (/\b(?:i\s+)?(?:explicitly\s+)?(?:approve|authorize)\b[\s\S]{0,240}\b(?:git\s+push|push(?:ing)?\s+(?:the\s+)?(?:branch|code)|github|remote)\b/i.test(approvalText)) {
869
- blockedTasks.delete(taskKey); seenTasks.delete(taskKey); persistReplay()
870
- log('backlog ticket #' + task.id + ' now contains explicit push authorization — resuming')
1078
+ const blockedRepo = blockedTaskRepos.get(taskKey)
1079
+ const helperAuthorized = blockedRepo && repositoryHasPrPushAuthorization({ cwd: blockedRepo })
1080
+ if (helperAuthorized || /\b(?:i\s+)?(?:explicitly\s+)?(?:approve|authorize)\b[\s\S]{0,240}\b(?:git\s+push|push(?:ing)?\s+(?:the\s+)?(?:branch|code)|github|remote)\b/i.test(approvalText)) {
1081
+ blockedTasks.delete(taskKey); blockedTaskRepos.delete(taskKey); seenTasks.delete(taskKey); persistReplay()
1082
+ log('backlog ticket #' + task.id + (helperAuthorized ? ' has repository-scoped PR push authorization' : ' now contains explicit push authorization') + ' — resuming')
871
1083
  } else continue
872
1084
  }
1085
+ memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'assigned', summary: task.title, refs: { projectId: project.id, ticketId: task.id }, meta: { updatedAt: task.updated_at ?? task.updatedAt } })
873
1086
  assigned.push({ id: task.id, projectId: project.id, project: project.name, title: task.title, priority: task.priority, typeId: task.type_id ?? task.typeId, updatedAt: task.updated_at ?? task.updatedAt })
874
1087
  }
875
1088
  const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
@@ -879,12 +1092,26 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
879
1092
  const lower = text.toLowerCase()
880
1093
  const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
881
1094
  if (!seenActivities.has(activityKey) && mentionNeedles.some((needle) => lower.includes(needle)) && /message|mention|channel/i.test(text)) {
882
- seenActivities.add(activityKey); trimSeen(seenActivities)
883
- mentionActivity.push({ projectId: project.id, project: project.name, activity: item })
1095
+ const data = item?.data && typeof item.data === 'object' ? item.data : null
1096
+ const activityMessage = item?.message && typeof item.message === 'object'
1097
+ ? item.message
1098
+ : data?.message && typeof data.message === 'object' ? data.message : item
1099
+ const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
1100
+ seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
1101
+ // The same logical mention may already have arrived over WebSocket.
1102
+ // Share the id/signature guard instead of starting a second model turn.
1103
+ if (markMentionHandled(activityMessage, activityChannelId)) continue
1104
+ mentionActivity.push({
1105
+ projectId: project.id,
1106
+ project: project.name,
1107
+ channelId: activityChannelId,
1108
+ message: activityMessage,
1109
+ activity: item,
1110
+ })
884
1111
  }
885
1112
  }
886
1113
  }
887
- if (mentionActivity.length) persistReplay()
1114
+ if (activityReplayTouched) persistReplay()
888
1115
  if (!assigned.length) lastTaskSignature = ''
889
1116
  else {
890
1117
  const signature = JSON.stringify(assigned)
@@ -903,8 +1130,18 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
903
1130
  const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
904
1131
  if (mentionActivity.length && inboxSignature !== lastInboxSignature) {
905
1132
  lastInboxSignature = inboxSignature
906
- log('backlog reconciliation found mention activity -> reply cycle')
907
- void drain('fast', `Recent project activity contains these messages mentioning YOU: ${inboxSignature}. Handle each still-unanswered mention once using the channel/message ids in the activity. Do not call nonexistent poll_inbox or get_marching_orders tools. Skip anything already answered by you.`)
1133
+ log('backlog reconciliation found ' + mentionActivity.length + ' mention(s) -> guarded reply cycle(s)')
1134
+ // Replay each real message through the exact same delivery path as a live
1135
+ // WebSocket mention. This preserves thread ids and lets postMessageOnce
1136
+ // consult the rendered thread before any reply is emitted.
1137
+ for (const mention of mentionActivity) {
1138
+ onEvent('agent:mention', {
1139
+ project_id: mention.projectId,
1140
+ channel_id: mention.channelId,
1141
+ message: mention.message,
1142
+ _mentionAlreadyMarked: true,
1143
+ })
1144
+ }
908
1145
  } else if (!mentionActivity.length) lastInboxSignature = ''
909
1146
  } catch (e) {
910
1147
  mcpSessionId = ''
@@ -915,10 +1152,56 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
915
1152
  // Higher rank wins when coalescing cycles requested while one is running.
916
1153
  const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
917
1154
  const baseFor = (kind) => kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? COORDINATE : fastPrompt
1155
+ // Codex and OpenCode expose structured action streams. Require runtime facts
1156
+ // from those streams before accepting a full coding cycle. Claude's evidence
1157
+ // shape is different and remains on its existing completion path.
1158
+ const evidenceGatedRuntime = agent === 'codex' || agent === 'opencode'
1159
+ const missingWorkEvidence = (result, ticketCycle) => {
1160
+ const missing = [
1161
+ ticketCycle && !result?.didMcpTaskRead && 'read the ticket through get_ticket/list_tasks',
1162
+ !result?.didRepoMutation && 'perform and verify the repository change',
1163
+ ticketCycle && !result?.didMcpTaskUpdate && 'update the ticket through update_ticket',
1164
+ ].filter(Boolean)
1165
+ if (result?.mcpErrors?.length) missing.push('resolve failed MCP calls: ' + result.mcpErrors.join(', '))
1166
+ return missing
1167
+ }
1168
+ const combineWorkEvidence = (first, second) => ({
1169
+ ...second,
1170
+ didCode: !!first?.didCode || !!second?.didCode,
1171
+ didRepoMutation: !!first?.didRepoMutation || !!second?.didRepoMutation,
1172
+ didMessage: !!first?.didMessage || !!second?.didMessage,
1173
+ didChannelMessage: !!first?.didChannelMessage || !!second?.didChannelMessage,
1174
+ didMcpTaskRead: !!first?.didMcpTaskRead || !!second?.didMcpTaskRead,
1175
+ didMcpTaskUpdate: !!first?.didMcpTaskUpdate || !!second?.didMcpTaskUpdate,
1176
+ mcpCalls: [...new Set([...(first?.mcpCalls || []), ...(second?.mcpCalls || [])])],
1177
+ // A focused recovery is allowed to clear an earlier MCP failure. Only calls
1178
+ // still failing in the recovery remain blockers.
1179
+ mcpErrors: second?.mcpErrors || [],
1180
+ outputText: [first?.outputText, second?.outputText].filter(Boolean).join(' '),
1181
+ })
1182
+ const releaseTaskForRetry = (taskRef, prompt) => {
1183
+ const directProject = Number(taskRef?.projectId)
1184
+ const directTicket = Number(taskRef?.ticketId)
1185
+ if (Number.isFinite(directProject) && Number.isFinite(directTicket)) seenTasks.delete(`${directProject}:${directTicket}`)
1186
+ else {
1187
+ const match = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
1188
+ if (match) seenTasks.delete(`${match[2]}:${match[1]}`)
1189
+ }
1190
+ // The next task update or periodic reconciliation must be allowed to queue
1191
+ // this work again. An acknowledgement is not a terminal task signature.
1192
+ lastTaskSignature = ''
1193
+ }
918
1194
 
919
- async function drain(kind, context, targetChannels = [], taskRef = null) {
1195
+ async function drain(kind, context, targetChannels = [], taskRef = null, delivery = null) {
920
1196
  const laneName = kind === 'full' ? 'work' : 'reply'
921
1197
  const lane = lanes[laneName]
1198
+ // A guarded reply is never coalesced with another event. It carries one
1199
+ // source message and one delivery key, so queue it as an independent cycle.
1200
+ if (lane.busy && delivery) {
1201
+ lane.deferred.push({ kind, context, targetChannels, taskRef, delivery })
1202
+ log(laneName + ' lane busy — queued one guarded ' + kind + ' cycle')
1203
+ return
1204
+ }
922
1205
  if (context) lane.pending.push(context)
923
1206
  if (taskRef) lane.taskRefs.push(taskRef)
924
1207
  for (const channelId of targetChannels) if (Number.isFinite(Number(channelId))) lane.targets.add(Number(channelId))
@@ -931,7 +1214,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
931
1214
  laneStatusTargets[laneName] = new Set(targets)
932
1215
  // credNote + charter live in the cached system prompt now — the per-cycle
933
1216
  // message is just the event context + the small base instruction.
934
- const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + baseFor(kind)
1217
+ const memoryRefs = delivery
1218
+ ? { channelId: delivery.channelId, threadId: delivery.parentId }
1219
+ : activeTaskRef ? { projectId: activeTaskRef.projectId, ticketId: activeTaskRef.ticketId } : {}
1220
+ const recalled = memory.context(memoryRefs)
1221
+ const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind)
935
1222
  // Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
936
1223
  // work (full/sweep) uses the main model.
937
1224
  const useModel = agent === 'codex' ? codeModel : kind === 'full' ? codeModel : liteModel
@@ -943,7 +1230,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
943
1230
  // 20 seconds so long coding runs do not create needless network/battery load.
944
1231
  const heartbeat = targets.length ? setInterval(() => emitLaneStatus(laneName, 'working'), 20_000) : null
945
1232
  try {
946
- const result = await runners[laneName].runCycle(prompt, useModel)
1233
+ const result = await runners[laneName].runCycle(prompt, useModel, agent === 'codex' && delivery ? { disabledMcpTools: ['post_message'] } : {})
947
1234
  let completionResult = result
948
1235
  if (agent === 'codex' && result?.subtype === 'blocked' && result?.policyBlock) {
949
1236
  log('WORK_CYCLE_BLOCKED authorization required; pausing this ticket and publishing an action-required notice')
@@ -956,6 +1243,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
956
1243
  log('WORK_CYCLE_BLOCKED ' + result.subtype + '; publishing blocker')
957
1244
  try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
958
1245
  catch (e) { log('failed to publish cycle blocker: ' + (e?.message || e)) }
1246
+ releaseTaskForRetry(activeTaskRef, prompt)
959
1247
  return
960
1248
  }
961
1249
  if (kind !== 'full' && result?.mcpErrors?.length) {
@@ -968,46 +1256,56 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
968
1256
  // Model prose never proves success or a blocker. Full cycles must produce
969
1257
  // runtime-observed ticket reads, repository evidence, and ticket updates.
970
1258
  const ticketCycle = !!activeTaskRef || /ticket\s+#?\d+.*?project\s+\d+/i.test(prompt)
971
- const incomplete = !result?.didRepoMutation || (ticketCycle && (!result?.didMcpTaskRead || !result?.didMcpTaskUpdate)) || result?.mcpErrors?.length
972
- if (agent === 'codex' && kind === 'full' && result?.subtype === 'ok' && incomplete) {
973
- const missing = [ticketCycle && !result.didMcpTaskRead && 'read the ticket through get_ticket/list_tasks', !result.didRepoMutation && 'perform and verify the repository change', ticketCycle && !result.didMcpTaskUpdate && 'update the ticket through update_ticket'].filter(Boolean)
974
- if (result.mcpErrors?.length) missing.push('resolve failed MCP calls: ' + result.mcpErrors.join(', '))
975
- log('coding cycle incomplete; recovery requires: ' + missing.join(', '))
976
- const recovery = await runners.work.runCycle(`The assigned task is NOT complete. Missing runtime evidence: ${missing.join('; ')}. Do not post an acknowledgement or claim success. Resume now. Use get_ticket/list_tasks and list_task_types, perform and verify the repository work, commit and push an agent/* branch, open the PR, and call update_ticket with the correct board column. Post only when the original context supplies a source thread.`, codeModel)
977
- const recoveryIncomplete = recovery?.subtype !== 'ok' || !recovery?.didRepoMutation || (ticketCycle && (!recovery?.didMcpTaskRead || !recovery?.didMcpTaskUpdate)) || recovery?.mcpErrors?.length
978
- if (recoveryIncomplete) {
1259
+ const missing = missingWorkEvidence(result, ticketCycle)
1260
+ if (evidenceGatedRuntime && kind === 'full' && result?.subtype === 'ok' && missing.length) {
1261
+ log(agent + ' coding cycle incomplete; recovery requires: ' + missing.join(', '))
1262
+ 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'] } : {})
1263
+ const recoveredResult = combineWorkEvidence(result, recovery)
1264
+ const recoveryMissing = missingWorkEvidence(recoveredResult, ticketCycle)
1265
+ if (recovery?.subtype !== 'ok' || recoveryMissing.length) {
979
1266
  if (recovery?.subtype === 'blocked' && recovery?.policyBlock) {
980
1267
  try { await reportPolicyBlock(prompt, activeTaskRef, recovery.policyBlock) }
981
1268
  catch (e) { log('failed to publish recovery policy blocker: ' + (e?.message || e)) }
982
1269
  } else {
983
- const notice = `I'm blocked after one recovery attempt. Missing required evidence: ${missing.join('; ')}. I've left the ticket open and I'm not claiming completion.`
1270
+ const unresolved = recoveryMissing.length ? recoveryMissing : [`the recovery cycle ended with ${recovery?.subtype || 'an unknown error'}`]
1271
+ const notice = `I'm blocked after one recovery attempt. Missing required evidence: ${unresolved.join('; ')}. I've left the ticket open and I'm not claiming completion.`
984
1272
  try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
985
1273
  catch (e) { log('failed to publish recovery blocker: ' + (e?.message || e)) }
986
1274
  }
987
- if (activeTaskRef) seenTasks.delete(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`)
988
- else {
989
- const taskMatch = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
990
- if (taskMatch) seenTasks.delete(`${taskMatch[2]}:${taskMatch[1]}`)
991
- }
992
- lastTaskSignature = ''
1275
+ releaseTaskForRetry(activeTaskRef, prompt)
993
1276
  log('WORK_CYCLE_FAILED evidence gate still incomplete after one recovery; ticket retained for retry')
994
1277
  return
995
1278
  }
996
- completionResult = recovery
1279
+ completionResult = recoveredResult
997
1280
  }
998
1281
  if (kind === 'full' && activeTaskRef) {
999
1282
  try {
1000
1283
  const delivered = await announceTaskCompletion(activeTaskRef, completionResult)
1001
- if (!delivered) log('completion report deferred for ticket #' + activeTaskRef.ticketId + '; waiting for verified review/done state and PR evidence')
1284
+ if (!delivered) {
1285
+ releaseTaskForRetry(activeTaskRef, prompt)
1286
+ log('completion report deferred for ticket #' + activeTaskRef.ticketId + '; waiting for verified review/done state and PR evidence; ticket remains retryable')
1287
+ }
1002
1288
  } catch (e) {
1289
+ releaseTaskForRetry(activeTaskRef, prompt)
1003
1290
  log('completion report failed for ticket #' + activeTaskRef.ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry')
1004
1291
  }
1005
1292
  }
1293
+ if (agent === 'codex' && delivery) {
1294
+ const reply = String(completionResult?.outputText || '').trim()
1295
+ if (!reply) {
1296
+ log('guarded reply produced no final text; leaving delivery unrecorded for retry')
1297
+ } else {
1298
+ try { await postMessageOnce({ ...delivery, content: reply }) }
1299
+ catch (e) { log('guarded reply delivery failed closed: ' + (e?.message || e)) }
1300
+ }
1301
+ }
1006
1302
  } finally {
1007
1303
  if (heartbeat) clearInterval(heartbeat)
1008
1304
  laneStatusTargets[laneName].clear()
1009
1305
  lane.busy = false
1010
- if (lane.queued || lane.pending.length) { const next = lane.queued || (laneName === 'work' ? 'full' : 'fast'); lane.queued = null; void drain(next) }
1306
+ const deferred = lane.deferred.shift()
1307
+ if (deferred) void drain(deferred.kind, deferred.context, deferred.targetChannels, deferred.taskRef, deferred.delivery)
1308
+ else if (lane.queued || lane.pending.length) { const next = lane.queued || (laneName === 'work' ? 'full' : 'fast'); lane.queued = null; void drain(next) }
1011
1309
  }
1012
1310
  }
1013
1311
 
@@ -1071,22 +1369,28 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1071
1369
  const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
1072
1370
  const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
1073
1371
  const key = `${projectId}:${ticketId}`
1074
- if (!belongsToSelf) { blockedTasks.delete(key); pendingCompletionReports.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return }
1372
+ if (!belongsToSelf) {
1373
+ memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'unassigned', summary: ticket.title, refs: { projectId, ticketId } })
1374
+ blockedTasks.delete(key); blockedTaskRepos.delete(key); pendingCompletionReports.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return
1375
+ }
1075
1376
  if (taskIsCompleted(ticket) || taskIsAwaitingReview(ticket)) {
1377
+ memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'handoff', summary: ticket.title, refs: { projectId, ticketId } })
1076
1378
  if (pendingCompletionReports.has(key)) {
1077
1379
  const activityChannel = await projectStatusChannel(projectId)
1078
1380
  try { await announceTaskCompletion({ projectId, ticketId, channelId: activityChannel }) }
1079
1381
  catch (e) { log('completion report failed for ticket #' + ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry') }
1080
1382
  }
1081
- blockedTasks.delete(key); persistReplay(); seenTasks.delete(key)
1383
+ blockedTasks.delete(key); blockedTaskRepos.delete(key); persistReplay(); seenTasks.delete(key)
1082
1384
  log(kind + ' ticket #' + ticketId + ' is already complete or awaiting review — ignored')
1083
1385
  return
1084
1386
  }
1085
1387
  if (blockedTasks.has(key)) {
1086
1388
  const approvalText = [ticket.title, ticket.description].filter(Boolean).join(' ')
1087
- if (/\b(?:i\s+)?(?:explicitly\s+)?(?:approve|authorize)\b[\s\S]{0,240}\b(?:git\s+push|push(?:ing)?\s+(?:the\s+)?(?:branch|code)|github|remote)\b/i.test(approvalText)) {
1088
- blockedTasks.delete(key); seenTasks.delete(key); persistReplay()
1089
- log(kind + ' ticket #' + ticketId + ' contains explicit push authorization — resuming')
1389
+ const blockedRepo = blockedTaskRepos.get(key)
1390
+ const helperAuthorized = blockedRepo && repositoryHasPrPushAuthorization({ cwd: blockedRepo })
1391
+ if (helperAuthorized || /\b(?:i\s+)?(?:explicitly\s+)?(?:approve|authorize)\b[\s\S]{0,240}\b(?:git\s+push|push(?:ing)?\s+(?:the\s+)?(?:branch|code)|github|remote)\b/i.test(approvalText)) {
1392
+ blockedTasks.delete(key); blockedTaskRepos.delete(key); seenTasks.delete(key); persistReplay()
1393
+ log(kind + ' ticket #' + ticketId + (helperAuthorized ? ' has repository-scoped PR push authorization' : ' contains explicit push authorization') + ' — resuming')
1090
1394
  } else {
1091
1395
  log(kind + ' ticket #' + ticketId + ' is paused for explicit repository push authorization — ignored')
1092
1396
  return
@@ -1095,6 +1399,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1095
1399
  if (seenTasks.has(key)) { log(kind + ' ticket #' + ticketId + ' already queued/active — ignored'); return }
1096
1400
  seenTasks.add(key); trimSeen(seenTasks)
1097
1401
  const title = String(ticket.title || hinted.title || '')
1402
+ memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'queued', summary: title, refs: { projectId, ticketId }, meta: { sourceEvent: kind } })
1098
1403
  const taskText = [title, ticket.description, ticket.type, ticket.kind].filter(Boolean).join(' ')
1099
1404
  const cycleKind = canCode && !coordinationOnly(taskText) ? 'full' : 'coord'
1100
1405
  log(kind + ' verified ticket #' + ticketId + ' “' + title + '” -> ' + cycleKind + ' lane')
@@ -1124,22 +1429,28 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1124
1429
  // De-dupe: the same mention re-delivered (reconnect replay / dup fan-out) must
1125
1430
  // NOT trigger a second reply. Key by message id, or a channel+text signature
1126
1431
  // when the payload carries no id.
1127
- const dedupeKey = mid != null ? 'id:' + mid : 'sig:' + (cid != null ? cid : '?') + '|' + text.slice(0, 100)
1128
- if (seenMentions.has(dedupeKey)) { log('agent:mention (dup) — skipped'); return }
1129
- seenMentions.add(dedupeKey); trimSeen(seenMentions); persistReplay()
1432
+ if (!raw._mentionAlreadyMarked && markMentionHandled(msg, cid)) { log('agent:mention (dup) — skipped'); return }
1130
1433
  if (requestTargetsLaterAgent(text, [slug, identifier])) {
1131
1434
  log('agent:mention addressed to a later-mentioned agent — skipped')
1132
1435
  return
1133
1436
  }
1437
+ const mentionKeys = mentionDedupeKeys(msg, cid)
1438
+ const sourceKey = `mention:${cid ?? '?'}:${mentionKeys.idKey || mentionKeys.signatureKey || threadRoot || Date.now()}`
1439
+ memory.remember({ key: sourceKey, kind: 'mention', state: 'received', summary: text, refs: { channelId: cid, threadId: threadRoot, messageId: mid } })
1440
+ const guardedDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result') => agent === 'codex' && cid != null
1441
+ ? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey }
1442
+ : null
1134
1443
  // Under-the-hood model control from chat (view / switch the model the agent runs).
1135
1444
  const mcmd = cid != null ? parseModelCmd(text) : null
1136
1445
  if (mcmd) {
1137
1446
  const thread = threadRoot != null ? `, parent_id ${threadRoot}` : ''
1138
1447
  if (mcmd.report) {
1139
1448
  log('model query → code ' + codeModel + ' / chat ' + liteModel)
1140
- void drain('fast', `An engineer asked which model you're running. 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.`)
1449
+ const delivery = guardedDelivery('model')
1450
+ 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)
1141
1451
  } else if (mcmd.invalid) {
1142
- void drain('fast', `An engineer tried to switch your model to "${mcmd.invalid}", which isn't one you recognize. 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.`)
1452
+ const delivery = guardedDelivery('model')
1453
+ 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)
1143
1454
  } else {
1144
1455
  const tgt = mcmd.target // 'chat' | 'code' | 'both'
1145
1456
  const prev = `code ${codeModel}/chat ${liteModel}`
@@ -1149,7 +1460,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1149
1460
  persistModel()
1150
1461
  const label = tgt === 'chat' ? 'chat model' : tgt === 'code' ? 'code model' : 'model'
1151
1462
  log('model switched (' + tgt + ') ' + prev + ' → code ' + codeModel + '/chat ' + liteModel + (who ? ' (by ' + who + ')' : ''))
1152
- void drain('fast', `An engineer switched your ${label} to "${mcmd.set}" — active for your next ${tgt === 'chat' ? 'chat replies' : tgt === 'code' ? 'code cycles' : 'actions'}. 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.`)
1463
+ const delivery = guardedDelivery('model')
1464
+ 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)
1153
1465
  }
1154
1466
  return
1155
1467
  }
@@ -1158,16 +1470,24 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1158
1470
  // subsequent working/typing heartbeat for its lane.
1159
1471
  if (cid != null) sendStatus(cid, 'thinking')
1160
1472
  const codingMention = canCode && needsCode(text)
1473
+ const replyDelivery = guardedDelivery(codingMention ? 'result' : 'reply', !codingMention)
1161
1474
  const ctx = cid != null
1162
- ? `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.` : ''} You ALREADY have the message here. Do not poll_inbox, and after your single reply, STOP.`
1475
+ ? replyDelivery
1476
+ ? `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}".` : ''} Do not poll_inbox.`
1477
+ : `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.` : ''} You ALREADY have the message here. Do not poll_inbox, and after your single reply, STOP.`
1163
1478
  : undefined
1164
1479
  if (codingMention) {
1165
- const ack = cid != null
1166
- ? `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.`
1167
- : undefined
1168
- void drain('coord', ack, cid == null ? [] : [cid])
1169
- void drain('full', ctx, cid == null ? [] : [cid])
1170
- } else void drain('fast', ctx, cid == null ? [] : [cid])
1480
+ if (agent === 'codex' && cid != null) {
1481
+ const ack = `${who ? `@${who} ` : ''}I've picked this up and will return here with the verified result.`
1482
+ void postMessageOnce({ ...guardedDelivery('ack'), content: ack }).catch((e) => log('guarded acknowledgement failed closed: ' + (e?.message || e)))
1483
+ } else {
1484
+ const ack = cid != null
1485
+ ? `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.`
1486
+ : undefined
1487
+ void drain('coord', ack, cid == null ? [] : [cid])
1488
+ }
1489
+ void drain('full', ctx, cid == null ? [] : [cid], null, replyDelivery)
1490
+ } else void drain('fast', ctx, cid == null ? [] : [cid], null, replyDelivery)
1171
1491
  } else if (k === 'error') {
1172
1492
  const detail = raw && (raw.message || raw.error || raw.reason || raw.code || raw.d?.message || raw.d?.error)
1173
1493
  log('error event: ' + (detail ? String(detail) : JSON.stringify(raw)).slice(0, 220))
@@ -1189,9 +1509,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1189
1509
  // Workspace ethics: a one-time hello the FIRST time this agent ever connects.
1190
1510
  const introMarker = join(OV_DIR, 'intro-' + slugify(identifier) + '.done')
1191
1511
  if (!existsSync(introMarker)) {
1192
- try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
1512
+ if (agent !== 'codex') {
1513
+ try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
1514
+ }
1193
1515
  log('first connection — introducing self to the workspace')
1194
- introTimer = setTimeout(() => void drain('intro'), 5000) // let the socket subscribe first
1516
+ introTimer = setTimeout(() => {
1517
+ if (agent !== 'codex') { void drain('intro'); return }
1518
+ void announceIntroduction().then((delivered) => {
1519
+ if (!delivered) return
1520
+ try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
1521
+ }).catch((e) => log('guarded introduction failed: ' + (e?.message || e)))
1522
+ }, 5000) // let the socket subscribe first
1195
1523
  }
1196
1524
  // Reconciliation replaces the old model-driven startup/daily sweep. It uses
1197
1525
  // supported MCP tools directly, stays silent when empty, and hands verified