openvisio-agent 0.18.0 → 0.18.2

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
@@ -10,7 +10,11 @@ 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, opencodeEventEvidence, requestTargetsLaterAgent, shouldSuppressCodexDiagnostic, 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'
16
+ import { createMcpHttpClient } from './mcp-http.mjs'
17
+ import { buildOpencodeConfig, opencodeRuntimeLayout } from './opencode-config.mjs'
14
18
 
15
19
  // Behaviour prompts. The openvisio-team MCP bridge requires the agent's
16
20
  // credentials as ARGUMENTS on every tool call — those are injected at runtime by
@@ -286,25 +290,25 @@ export async function runWatch({ flags }) {
286
290
  // ── opencode cycle runner ─────────────────────────────────────────────────────
287
291
  // opencode (opencode.ai) has no persistent stream-json protocol like Claude Code,
288
292
  // so each cycle is a headless `opencode run <prompt> --auto [--model provider/model]`.
289
- // The openvisio-team MCP is declared in an `opencode.json` written into the run cwd
290
- // (opencode reads it from there). `--auto` approves tool use non-interactively.
293
+ // The openvisio-team MCP is declared in a private per-agent config. The actual
294
+ // code workspace is passed through --dir, so two agents never overwrite each
295
+ // other's MCP identity even when they work in the same repository.
296
+ // `--auto` approves tool use non-interactively.
291
297
  // Same { runCycle, canCode } contract as the Claude runner.
292
298
  function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt }) {
293
- // opencode reads opencode.json from its CWD: the code workspace, or a dedicated
294
- // per-agent dir for chat-only agents.
295
- const cwd = workdir || join(OV_DIR, 'opencode-' + (cfgKey || 'agent'))
299
+ const { configDir, configPath: opencodeConfigPath, workspace } = opencodeRuntimeLayout({ cfgKey, workdir })
296
300
  const bin = onPath('opencode') || 'opencode'
301
+ const redactKey = String(mcpHeaders?.['x-agent-api-key'] || '')
302
+ const opencodeConfig = buildOpencodeConfig({ mcpUrl, mcpHeaders })
297
303
  let configured = false
298
304
  const ensureConfig = () => {
299
305
  if (configured) return
300
306
  configured = true
301
307
  try {
302
- mkdirSync(cwd, { recursive: true })
303
- if (mcpUrl) {
304
- writeJson(join(cwd, 'opencode.json'), {
305
- $schema: 'https://opencode.ai/config.json',
306
- mcp: { 'openvisio-team': { type: 'remote', url: mcpUrl, enabled: true, ...(mcpHeaders && Object.keys(mcpHeaders).length ? { headers: mcpHeaders } : {}) } },
307
- }, true)
308
+ mkdirSync(configDir, { recursive: true })
309
+ mkdirSync(workspace, { recursive: true })
310
+ if (opencodeConfig) {
311
+ writeJson(opencodeConfigPath, opencodeConfig, true)
308
312
  } else {
309
313
  log('WARNING: no --mcp-url — opencode has no openvisio-team tools to act with. Re-connect with --mcp-url.')
310
314
  }
@@ -320,7 +324,7 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
320
324
  const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
321
325
  // JSON mode is the evidence boundary. Formatted stdout only tells us that
322
326
  // OpenCode exited; raw events tell us which tools actually completed.
323
- const args = ['run', full, '--auto', '--format', 'json', ...(m ? ['--model', m] : [])]
327
+ const args = ['run', full, '--auto', '--format', 'json', '--dir', workspace, ...(m ? ['--model', m] : [])]
324
328
  let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, didMcpTaskRead = false, didMcpTaskUpdate = false
325
329
  let outputText = '', jsonlBuffer = ''
326
330
  const mcpCalls = new Set(), mcpErrors = new Set(), runtimeErrors = new Set()
@@ -348,6 +352,10 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
348
352
  if (evidence.runtimeError) runtimeErrors.add(evidence.runtimeError)
349
353
  if (!evidence.tool) return
350
354
  if (debug) log(' → tool ' + evidence.tool + (evidence.failed ? ' (failed)' : evidence.completed ? ' (completed)' : ''))
355
+ if (evidence.failed && evidence.toolError) {
356
+ const safeError = (redactKey ? String(evidence.toolError).split(redactKey).join('[redacted]') : String(evidence.toolError)).replace(/\s+/g, ' ').slice(0, 240)
357
+ log(' ✗ opencode tool ' + evidence.tool + ': ' + safeError)
358
+ }
351
359
  try { onTool && onTool(evidence.tool) } catch { /* activity is best-effort */ }
352
360
  if (evidence.mcpTool) {
353
361
  mcpCalls.add(evidence.mcpTool)
@@ -368,7 +376,18 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
368
376
  }, maxCycleMs)
369
377
  log('running opencode cycle…' + (m ? ' [' + m + ']' : ''))
370
378
  try {
371
- child = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'inherit'] })
379
+ child = spawn(bin, args, {
380
+ cwd: configDir,
381
+ env: {
382
+ ...process.env,
383
+ OPENCODE_CONFIG: opencodeConfigPath,
384
+ // Inline config has higher precedence than a project opencode.json.
385
+ // This prevents a stale generated workspace file from replacing this
386
+ // agent's URL or credentials while keeping normal project settings.
387
+ ...(opencodeConfig ? { OPENCODE_CONFIG_CONTENT: JSON.stringify(opencodeConfig) } : {}),
388
+ },
389
+ stdio: ['ignore', 'pipe', 'inherit'],
390
+ })
372
391
  child.stdout?.on('data', (data) => {
373
392
  jsonlBuffer += String(data)
374
393
  const lines = jsonlBuffer.split('\n')
@@ -387,7 +406,7 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
387
406
  })
388
407
  }
389
408
 
390
- log('opencode runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + cwd + ']' : ' [CHAT-ONLY cfg ' + cwd + ']'))
409
+ log('opencode runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + workspace + '; isolated cfg ' + configDir + ']' : ' [CHAT-ONLY; isolated cfg ' + configDir + ']'))
391
410
  return { runCycle, canCode }
392
411
  }
393
412
 
@@ -402,12 +421,14 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
402
421
  const tomlString = (v) => JSON.stringify(String(v))
403
422
  const headerEntries = Object.entries(mcpHeaders || {}).map(([k, v]) => `${JSON.stringify(k)} = ${tomlString(v)}`).join(', ')
404
423
 
405
- function runCycle(prompt, cycleModel) {
424
+ function runCycle(prompt, cycleModel, cycleOptions = {}) {
406
425
  return new Promise((resolve) => {
407
426
  const m = cycleModel || model
408
427
  const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
428
+ const disabledMcpTools = Array.isArray(cycleOptions.disabledMcpTools) ? cycleOptions.disabledMcpTools.filter(Boolean) : []
429
+ const disabledToolConfig = disabledMcpTools.length ? `, disabled_tools = [${disabledMcpTools.map(tomlString).join(', ')}]` : ''
409
430
  const mcpOverride = mcpUrl
410
- ? `mcp_servers={ openvisio-team = { url = ${tomlString(mcpUrl)}${headerEntries ? `, http_headers = { ${headerEntries} }` : ''} } }`
431
+ ? `mcp_servers={ openvisio-team = { url = ${tomlString(mcpUrl)}${headerEntries ? `, http_headers = { ${headerEntries} }` : ''}${disabledToolConfig} } }`
411
432
  : ''
412
433
  const args = ['exec', '--ignore-user-config', '--skip-git-repo-check', '--ephemeral', '--json', '--color', 'never',
413
434
  ...(canCode ? ['--approve-for-me'] : ['--sandbox', 'read-only']),
@@ -448,8 +469,9 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
448
469
  const inspectLine = (line) => {
449
470
  const s = line.trim()
450
471
  if (!s) return
472
+ policyBlock = codexPolicyBlock(s) || policyBlock
451
473
  if (/command_execution|file_change|apply_patch|shell_command|exec_command/i.test(s)) didCode = true
452
- if (/file_change|apply_patch/i.test(s) || /\b(?:git\s+(?:commit|push)|gh\s+pr\s+create)\b/i.test(s)) didRepoMutation = true
474
+ 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
453
475
  if (/post_message|comment_ticket/i.test(s)) didMessage = true
454
476
  try {
455
477
  const event = JSON.parse(s)
@@ -461,6 +483,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
461
483
  try { onTool && onTool(tool) } catch { /* activity is best-effort */ }
462
484
  if (/^(?:get_ticket|list_tasks|list_task_types)$/.test(tool)) didMcpTaskRead = true
463
485
  if (tool === 'update_ticket') didMcpTaskUpdate = true
486
+ if (/^(?:create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/.test(tool)) { didCode = true; didRepoMutation = true }
464
487
  if (/post_message|comment_ticket/.test(tool)) didMessage = true
465
488
  if (/post_message/.test(tool)) didChannelMessage = true
466
489
  if (/fail|error/i.test(String(item.status || '')) || item.error) mcpErrors.add(tool)
@@ -664,7 +687,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
664
687
  const canCode = !!workdir
665
688
  // The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
666
689
  // agent_identifier + agent_api_key as arguments. Hand them over up front.
667
- const credNote = `AUTH: the openvisio-team tools REQUIRE two arguments on EVERY call — agent_identifier: "${identifier}" and agent_api_key: "${apiKey}". Include BOTH on every openvisio-team tool call. Use ONLY names shown in the current tool list. On backend MCP, discover and update work with list_agents, list_projects, list_tasks, get_ticket, update_ticket, comment_ticket, and list_activity; get_marching_orders and poll_inbox may be absent. Tools may be namespaced — call whichever names actually appear. The credentials are given here; do NOT hunt for them. Bash/git/gh ARE for code work; this rule only forbids searching for keys.`
690
+ const credNote = `AUTH: the openvisio-team tools REQUIRE two arguments on EVERY call — agent_identifier: "${identifier}" and agent_api_key: "${apiKey}". Include BOTH on every openvisio-team tool call. Use ONLY names shown in the current tool list. On backend MCP, discover and update work with list_agents, list_projects, list_tasks, list_task_types, get_ticket, update_ticket, comment_ticket, list_channels, list_message_thread, and list_activity as applicable. get_marching_orders, poll_inbox, and get_resource are relay-only and are NOT available here; never call them. Tools may be namespaced — call whichever names actually appear. The credentials are given here; do NOT hunt for them. Bash/git/gh ARE for code work; this rule only forbids searching for keys.`
668
691
  // The STATIC charter + creds are the session system prompt (cached, billed once),
669
692
  // NOT re-sent in every cycle's user message — the big token saving.
670
693
  const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
@@ -678,8 +701,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
678
701
  work: createCycleRunner({ ...runnerOptions, onTool: (name) => { if (/post_message/.test(name)) emitLaneStatus('work', 'typing') } }),
679
702
  reply: createCycleRunner({ ...runnerOptions, cfgKey: identifier + '-reply', onTool: (name) => { if (/post_message/.test(name)) emitLaneStatus('reply', 'typing') } }),
680
703
  }
681
- const fullPrompt = canCode ? CODE_FULL : CYCLE
682
- const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
704
+ const codexPushGuide = agent === 'codex' && canCode
705
+ ? '\n\nCODEX PR DELIVERY: first inspect the OpenVisio MCP tools. When list_codebases, create_codebase_branch, create_codebase_commit (or write_codebase_file), and create_pull_request are available, use that authenticated linked-codebase flow to create the agent/* branch, publish the verified changed files, and open the PR. This is the preferred path and requires no local git push. If those tools are unavailable for the repository, do not run git push directly. From the repository run `openvisio-agent push-pr-branch`. It is a user-authorized constrained fallback that can only push HEAD to the matching agent/* branch on the exact authorized origin. If it reports OPENVISIO_PR_PUSH_AUTH_REQUIRED, do not retry or route around it. Report the one-time command `openvisio-agent authorize-pr-push` as the blocker.'
706
+ : ''
707
+ const backendToolRule = 'BACKEND MCP RULE: the event and watcher already provide the work source. Never call get_marching_orders, poll_inbox, get_resource, or other relay-only tools; they are not exposed by the backend MCP. Use only names present in the current openvisio-team tool list. For discovery use list_agents, list_projects, list_tasks, list_task_types, list_activity, get_ticket, and list_channels as applicable.'
708
+ 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
+ const fastPrompt = (canCode ? CODE_FAST : CYCLE_FAST) + '\n\n' + backendToolRule
710
+ const coordinatePrompt = COORDINATE + '\n\n' + backendToolRule
683
711
  // Live model state — changeable at runtime by the in-chat `/model` command.
684
712
  // codeModel drives full/sweep cycles; chatModel (if set) the lighter fast/intro
685
713
  // ones, so routine chatter can run cheaper than real code work.
@@ -687,8 +715,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
687
715
  let liteModel = chatModel || model
688
716
 
689
717
  const lanes = {
690
- work: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [] },
691
- reply: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [] },
718
+ work: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [], deferred: [] },
719
+ reply: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [], deferred: [] },
692
720
  }
693
721
  // Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
694
722
  // different agent re-triggers), so a noisy stream of task:updated events doesn't
@@ -703,6 +731,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
703
731
  const seenMentions = new Set(Array.isArray(replayState.seenMentions) ? replayState.seenMentions : [])
704
732
  const recentMentionSignatures = new Map(Array.isArray(replayState.recentMentionSignatures) ? replayState.recentMentionSignatures : [])
705
733
  const seenActivities = new Set(Array.isArray(replayState.seenActivities) ? replayState.seenActivities : [])
734
+ const deliveredReplies = new Set(Array.isArray(replayState.deliveredReplies) ? replayState.deliveredReplies : [])
735
+ const memory = createByoMemoryGraph({ path: join(OV_DIR, 'watch-' + slug + '-memory.json') })
706
736
  // Completion delivery is runtime-owned for assigned coding work. Persist both
707
737
  // pending and delivered keys so a reconnect can finish a missed notification
708
738
  // without re-running the model or posting the same result twice.
@@ -714,6 +744,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
714
744
  // unassigned. This prevents a 30-minute reconciliation retry from repeatedly
715
745
  // attempting the same rejected egress action.
716
746
  const blockedTasks = new Set(Array.isArray(replayState.blockedTasks) ? replayState.blockedTasks : [])
747
+ const blockedTaskRepos = new Map(Array.isArray(replayState.blockedTaskRepos) ? replayState.blockedTaskRepos : [])
717
748
  const trimSeen = (set) => { while (set.size > 500) set.delete(set.values().next().value) }
718
749
  const MENTION_SIGNATURE_TTL_MS = 10 * 60 * 1000
719
750
  const pruneMentionSignatures = () => {
@@ -728,7 +759,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
728
759
  seenMentions: [...seenMentions],
729
760
  recentMentionSignatures: [...recentMentionSignatures],
730
761
  seenActivities: [...seenActivities],
762
+ deliveredReplies: [...deliveredReplies],
731
763
  blockedTasks: [...blockedTasks],
764
+ blockedTaskRepos: [...blockedTaskRepos],
732
765
  pendingCompletionReports: [...pendingCompletionReports],
733
766
  reportedCompletions: [...reportedCompletions],
734
767
  reportedTaskComments: [...reportedTaskComments],
@@ -754,60 +787,56 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
754
787
  let lastTaskTriggeredAt = 0
755
788
  let lastInboxSignature = ''
756
789
  let selfAgentId = null
757
- let mcpSessionId = ''
758
- let mcpRpcId = 0
759
-
760
- const mcpPayload = async (res) => {
761
- const body = await res.text()
762
- const data = body.split(/\r?\n/).filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trim()).pop()
763
- return JSON.parse(data || body || '{}')
764
- }
765
- const mcpPost = async (message, withSession = true) => {
766
- const res = await fetch(mcpUrl, {
767
- method: 'POST',
768
- headers: {
769
- 'content-type': 'application/json',
770
- accept: 'application/json, text/event-stream',
771
- 'x-agent-api-key': apiKey,
772
- 'x-agent-identifier': identifier,
773
- ...(withSession && mcpSessionId ? { 'mcp-session-id': mcpSessionId } : {}),
774
- },
775
- body: JSON.stringify(message),
776
- })
777
- return res
778
- }
779
- const ensureMcpSession = async () => {
780
- if (mcpSessionId) return
781
- const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.18.0' } } }, false)
782
- if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
783
- await mcpPayload(res)
784
- mcpSessionId = res.headers.get('mcp-session-id') || ''
785
- if (!mcpSessionId) throw new Error('MCP initialize returned no session id')
786
- const ready = await mcpPost({ jsonrpc: '2.0', method: 'notifications/initialized' })
787
- if (!ready.ok) throw new Error('MCP initialized HTTP ' + ready.status)
788
- }
789
- const callMcpTool = async (name, args = {}, retried = false) => {
790
- await ensureMcpSession()
791
- const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'tools/call', params: { name, arguments: { ...args, agent_api_key: apiKey, agent_identifier: identifier } } })
792
- if (!res.ok) {
793
- if (!retried && (res.status === 400 || res.status === 404)) { mcpSessionId = ''; return callMcpTool(name, args, true) }
794
- throw new Error(`MCP ${name} HTTP ${res.status}`)
795
- }
796
- const payload = await mcpPayload(res)
797
- if (payload.error) throw new Error(`MCP ${name}: ${payload.error.message || 'tool error'}`)
798
- const result = payload.result ?? payload
799
- if (result?.isError) {
800
- const detail = result.content?.find?.((c) => c?.type === 'text')?.text || 'tool error'
801
- throw new Error(`MCP ${name}: ${detail}`)
802
- }
803
- return result
804
- }
790
+ const mcpClient = createMcpHttpClient({ url: mcpUrl, apiKey, identifier, clientVersion: '0.18.2', log })
791
+ const callMcpTool = (name, args = {}) => mcpClient.callTool(name, args)
805
792
  const toolData = (result) => {
806
793
  const text = result?.content?.find?.((c) => c?.type === 'text')?.text
807
794
  if (typeof text !== 'string') return result?.structuredContent ?? result ?? {}
808
795
  try { return JSON.parse(text) } catch { return { text } }
809
796
  }
810
797
 
798
+ // All watcher-owned message delivery goes through this gate. For threaded
799
+ // replies it first reads the live backend thread and inspects rows already
800
+ // rendered as this agent. A persisted delivery key closes the crash/reconnect
801
+ // gap; an in-flight promise closes the two-lane race inside one watcher.
802
+ const messageDeliveries = new Map()
803
+ const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '' }) => {
804
+ const deliveryKey = String(key || '')
805
+ const message = String(content || '').trim()
806
+ if (!deliveryKey || !Number.isFinite(Number(channelId)) || !message) return { posted: false, reason: 'invalid-delivery' }
807
+ if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) return { posted: false, reason: 'remembered' }
808
+ if (messageDeliveries.has(deliveryKey)) return messageDeliveries.get(deliveryKey)
809
+
810
+ const run = (async () => {
811
+ if (parentId != null) {
812
+ const live = toolData(await callMcpTool('list_message_thread', { channel_id: Number(channelId), message_id: Number(parentId) }))
813
+ const rendered = renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug })
814
+ const duplicate = rendered.some((row) => normalizeRenderedMessageText(row.content) === normalizeRenderedMessageText(message))
815
+ if (duplicate || (skipIfAnyAgentReply && rendered.length)) {
816
+ deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
817
+ 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 } })
818
+ if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
819
+ log('message delivery ' + deliveryKey + ' already rendered — skipped')
820
+ return { posted: false, reason: duplicate ? 'same-content-rendered' : 'agent-reply-rendered' }
821
+ }
822
+ }
823
+
824
+ sendStatus(Number(channelId), 'typing')
825
+ const result = toolData(await callMcpTool('post_message', {
826
+ ...(Number.isFinite(Number(projectId)) ? { project_id: Number(projectId) } : {}),
827
+ channel_id: Number(channelId),
828
+ ...(parentId != null ? { parent_id: Number(parentId) } : {}),
829
+ content: message,
830
+ }))
831
+ deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
832
+ 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 } })
833
+ if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
834
+ return { posted: true, result }
835
+ })().finally(() => messageDeliveries.delete(deliveryKey))
836
+ messageDeliveries.set(deliveryKey, run)
837
+ return run
838
+ }
839
+
811
840
  const statusChannelCache = new Map()
812
841
  const projectStatusChannel = async (projectId) => {
813
842
  const key = Number(projectId)
@@ -829,6 +858,22 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
829
858
  }
830
859
  }
831
860
 
861
+ const announceIntroduction = async () => {
862
+ const projectsData = toolData(await callMcpTool('list_projects'))
863
+ const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
864
+ const project = projects.find((item) => Number.isFinite(Number(item.id)))
865
+ if (!project) { log('no project available for first-connection introduction'); return false }
866
+ const channelId = await projectStatusChannel(project.id)
867
+ if (!Number.isFinite(Number(channelId))) return false
868
+ await postMessageOnce({
869
+ key: `intro:${identifier}:${project.id}`,
870
+ projectId: Number(project.id),
871
+ channelId: Number(channelId),
872
+ content: "I'm here, I pick up tasks assigned to me, and I respond to @mentions. Send work my way whenever you need me.",
873
+ })
874
+ return true
875
+ }
876
+
832
877
  const announceTaskCompletion = async (taskRef, result = {}) => {
833
878
  const projectId = Number(taskRef?.projectId)
834
879
  const ticketId = Number(taskRef?.ticketId)
@@ -838,6 +883,15 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
838
883
  const ticket = current.ticket ?? current.task ?? current
839
884
  const report = buildTaskCompletionReport(ticket, { projectId, fallbackText: result.outputText })
840
885
  if (!report) return false
886
+ const memoryKey = `ticket:${projectId}:${ticketId}`
887
+ memory.remember({
888
+ key: memoryKey,
889
+ kind: 'ticket',
890
+ state: 'handoff',
891
+ summary: report.content,
892
+ refs: { projectId, ticketId },
893
+ meta: { reportKey: report.key, prUrl: report.prUrl },
894
+ })
841
895
  // Ticket comments are now a backend first-class surface. The watcher owns the
842
896
  // final comment so every runtime (Claude, Codex, OpenCode) closes the ticket
843
897
  // loop consistently, and the persisted report key prevents reconnect repeats.
@@ -862,10 +916,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
862
916
  }
863
917
  const channelId = Number.isFinite(Number(taskRef.channelId)) ? Number(taskRef.channelId) : await projectStatusChannel(projectId)
864
918
  if (!Number.isFinite(channelId)) return false
865
- sendStatus(channelId, 'typing')
866
- await callMcpTool('post_message', { project_id: projectId, channel_id: channelId, content: report.content })
919
+ await postMessageOnce({ key: `completion:${report.key}`, projectId, channelId, content: report.content, sourceKey: memoryKey })
867
920
  reportedCompletions.add(report.key); trimSeen(reportedCompletions)
868
921
  pendingCompletionReports.delete(taskKey); persistReplay()
922
+ memory.remember({ key: memoryKey, kind: 'ticket', state: 'reported', summary: report.content, refs: { projectId, ticketId, channelId }, meta: { reportKey: report.key, prUrl: report.prUrl } })
869
923
  log('posted verified completion for ticket #' + ticketId + ' in channel ' + channelId)
870
924
  return true
871
925
  }
@@ -882,11 +936,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
882
936
  let delivered = false
883
937
  if (Number.isFinite(channelId)) {
884
938
  try {
885
- await callMcpTool('post_message', {
886
- channel_id: channelId,
887
- ...(parentMatch ? { parent_id: Number(parentMatch[1]) } : {}),
888
- content: notice,
889
- })
939
+ const parentId = parentMatch ? Number(parentMatch[1]) : null
940
+ const blockerKey = `blocker:${channelId}:${parentId ?? 'top'}:${normalizeRenderedMessageText(notice).slice(0, 180)}`
941
+ await postMessageOnce({ key: blockerKey, channelId, parentId, projectId, content: notice, sourceKey: Number.isFinite(ticketId) && Number.isFinite(projectId) ? `ticket:${projectId}:${ticketId}` : '' })
890
942
  delivered = true
891
943
  } catch (e) {
892
944
  log('failed to post blocker in channel ' + channelId + ': ' + (e?.message || e))
@@ -899,6 +951,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
899
951
 
900
952
  const key = `${projectId}:${ticketId}`
901
953
  if (pause) { blockedTasks.add(key); persistReplay() }
954
+ memory.remember({
955
+ key: `ticket:${projectId}:${ticketId}`,
956
+ kind: 'ticket',
957
+ state: pause ? 'authorization-blocked' : 'blocked',
958
+ summary: ticketNotice,
959
+ refs: { projectId, ticketId, ...(Number.isFinite(channelId) ? { channelId } : {}) },
960
+ })
902
961
  try {
903
962
  await callMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: ticketNotice })
904
963
  delivered = true
@@ -921,6 +980,18 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
921
980
  }
922
981
 
923
982
  const reportPolicyBlock = async (prompt, taskRef, block) => {
983
+ if (block?.kind === 'pr-push-authorization-required') {
984
+ const location = block.root ? ` from \`${block.root}\`` : ' from the repository'
985
+ 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.`
986
+ 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.`
987
+ const projectId = Number(taskRef?.projectId)
988
+ const ticketId = Number(taskRef?.ticketId)
989
+ if (block.root && Number.isFinite(projectId) && Number.isFinite(ticketId)) {
990
+ blockedTaskRepos.set(`${projectId}:${ticketId}`, block.root)
991
+ persistReplay()
992
+ }
993
+ return publishBlocker({ prompt, taskRef, notice, ticketNotice, pause: true })
994
+ }
924
995
  const command = block?.command || 'the requested external repository action'
925
996
  const payload = [block?.commit && `commit ${block.commit}`, block?.branch && `branch ${block.branch}`, block?.remote && `remote ${block.remote}`].filter(Boolean).join(', ')
926
997
  const approval = block?.commit && block?.branch
@@ -962,12 +1033,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
962
1033
  if (taskAgentId !== Number(self.id) && taskIdent !== identifier) continue
963
1034
  const taskKey = `${project.id}:${task.id}`
964
1035
  if (taskIsCompleted(task, doneIds) || taskIsAwaitingReview(task, reviewIds)) {
1036
+ memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'handoff', summary: task.title, refs: { projectId: project.id, ticketId: task.id } })
965
1037
  if (pendingCompletionReports.has(taskKey)) {
966
1038
  const activityChannel = await projectStatusChannel(project.id)
967
1039
  try { await announceTaskCompletion({ projectId: project.id, ticketId: task.id, channelId: activityChannel }) }
968
1040
  catch (e) { log('completion report retry failed for ticket #' + task.id + ': ' + (e?.message || e)) }
969
1041
  }
970
- if (blockedTasks.delete(taskKey)) persistReplay()
1042
+ if (blockedTasks.delete(taskKey)) { blockedTaskRepos.delete(taskKey); persistReplay() }
971
1043
  // Release the in-flight de-dupe key at handoff. If a reviewer moves
972
1044
  // the ticket back to an actionable column, that update must start a
973
1045
  // fresh work cycle.
@@ -976,11 +1048,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
976
1048
  }
977
1049
  if (blockedTasks.has(taskKey)) {
978
1050
  const approvalText = [task.title, task.description].filter(Boolean).join(' ')
979
- 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)) {
980
- blockedTasks.delete(taskKey); seenTasks.delete(taskKey); persistReplay()
981
- log('backlog ticket #' + task.id + ' now contains explicit push authorization — resuming')
1051
+ const blockedRepo = blockedTaskRepos.get(taskKey)
1052
+ const helperAuthorized = blockedRepo && repositoryHasPrPushAuthorization({ cwd: blockedRepo })
1053
+ 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)) {
1054
+ blockedTasks.delete(taskKey); blockedTaskRepos.delete(taskKey); seenTasks.delete(taskKey); persistReplay()
1055
+ log('backlog ticket #' + task.id + (helperAuthorized ? ' has repository-scoped PR push authorization' : ' now contains explicit push authorization') + ' — resuming')
982
1056
  } else continue
983
1057
  }
1058
+ 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 } })
984
1059
  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 })
985
1060
  }
986
1061
  const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
@@ -999,7 +1074,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
999
1074
  // The same logical mention may already have arrived over WebSocket.
1000
1075
  // Share the id/signature guard instead of starting a second model turn.
1001
1076
  if (markMentionHandled(activityMessage, activityChannelId)) continue
1002
- mentionActivity.push({ projectId: project.id, project: project.name, activity: item })
1077
+ mentionActivity.push({
1078
+ projectId: project.id,
1079
+ project: project.name,
1080
+ channelId: activityChannelId,
1081
+ message: activityMessage,
1082
+ activity: item,
1083
+ })
1003
1084
  }
1004
1085
  }
1005
1086
  }
@@ -1022,18 +1103,28 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1022
1103
  const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
1023
1104
  if (mentionActivity.length && inboxSignature !== lastInboxSignature) {
1024
1105
  lastInboxSignature = inboxSignature
1025
- log('backlog reconciliation found mention activity -> reply cycle')
1026
- 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.`)
1106
+ log('backlog reconciliation found ' + mentionActivity.length + ' mention(s) -> guarded reply cycle(s)')
1107
+ // Replay each real message through the exact same delivery path as a live
1108
+ // WebSocket mention. This preserves thread ids and lets postMessageOnce
1109
+ // consult the rendered thread before any reply is emitted.
1110
+ for (const mention of mentionActivity) {
1111
+ onEvent('agent:mention', {
1112
+ project_id: mention.projectId,
1113
+ channel_id: mention.channelId,
1114
+ message: mention.message,
1115
+ _mentionAlreadyMarked: true,
1116
+ })
1117
+ }
1027
1118
  } else if (!mentionActivity.length) lastInboxSignature = ''
1028
1119
  } catch (e) {
1029
- mcpSessionId = ''
1120
+ mcpClient.reset()
1030
1121
  log('backlog reconciliation failed: ' + (e && e.message ? e.message : e))
1031
1122
  } finally { backlogProbeBusy = false }
1032
1123
  }
1033
1124
 
1034
1125
  // Higher rank wins when coalescing cycles requested while one is running.
1035
1126
  const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
1036
- const baseFor = (kind) => kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? COORDINATE : fastPrompt
1127
+ const baseFor = (kind) => kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? coordinatePrompt : fastPrompt
1037
1128
  // Codex and OpenCode expose structured action streams. Require runtime facts
1038
1129
  // from those streams before accepting a full coding cycle. Claude's evidence
1039
1130
  // shape is different and remains on its existing completion path.
@@ -1074,9 +1165,16 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1074
1165
  lastTaskSignature = ''
1075
1166
  }
1076
1167
 
1077
- async function drain(kind, context, targetChannels = [], taskRef = null) {
1168
+ async function drain(kind, context, targetChannels = [], taskRef = null, delivery = null) {
1078
1169
  const laneName = kind === 'full' ? 'work' : 'reply'
1079
1170
  const lane = lanes[laneName]
1171
+ // A guarded reply is never coalesced with another event. It carries one
1172
+ // source message and one delivery key, so queue it as an independent cycle.
1173
+ if (lane.busy && delivery) {
1174
+ lane.deferred.push({ kind, context, targetChannels, taskRef, delivery })
1175
+ log(laneName + ' lane busy — queued one guarded ' + kind + ' cycle')
1176
+ return
1177
+ }
1080
1178
  if (context) lane.pending.push(context)
1081
1179
  if (taskRef) lane.taskRefs.push(taskRef)
1082
1180
  for (const channelId of targetChannels) if (Number.isFinite(Number(channelId))) lane.targets.add(Number(channelId))
@@ -1089,7 +1187,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1089
1187
  laneStatusTargets[laneName] = new Set(targets)
1090
1188
  // credNote + charter live in the cached system prompt now — the per-cycle
1091
1189
  // message is just the event context + the small base instruction.
1092
- const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + baseFor(kind)
1190
+ const memoryRefs = delivery
1191
+ ? { channelId: delivery.channelId, threadId: delivery.parentId }
1192
+ : activeTaskRef ? { projectId: activeTaskRef.projectId, ticketId: activeTaskRef.ticketId } : {}
1193
+ const recalled = memory.context(memoryRefs)
1194
+ const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind)
1093
1195
  // Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
1094
1196
  // work (full/sweep) uses the main model.
1095
1197
  const useModel = agent === 'codex' ? codeModel : kind === 'full' ? codeModel : liteModel
@@ -1101,7 +1203,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1101
1203
  // 20 seconds so long coding runs do not create needless network/battery load.
1102
1204
  const heartbeat = targets.length ? setInterval(() => emitLaneStatus(laneName, 'working'), 20_000) : null
1103
1205
  try {
1104
- const result = await runners[laneName].runCycle(prompt, useModel)
1206
+ const result = await runners[laneName].runCycle(prompt, useModel, agent === 'codex' && delivery ? { disabledMcpTools: ['post_message'] } : {})
1105
1207
  let completionResult = result
1106
1208
  if (agent === 'codex' && result?.subtype === 'blocked' && result?.policyBlock) {
1107
1209
  log('WORK_CYCLE_BLOCKED authorization required; pausing this ticket and publishing an action-required notice')
@@ -1130,7 +1232,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1130
1232
  const missing = missingWorkEvidence(result, ticketCycle)
1131
1233
  if (evidenceGatedRuntime && kind === 'full' && result?.subtype === 'ok' && missing.length) {
1132
1234
  log(agent + ' coding cycle incomplete; recovery requires: ' + missing.join(', '))
1133
- const recovery = await runners.work.runCycle(`The assigned task is NOT complete. Missing runtime evidence: ${missing.join('; ')}. Do not post an acknowledgement or claim success. Resume now. Use get_ticket/list_tasks and list_task_types, perform and verify the repository work, commit and push an agent/* branch, open the PR, and call update_ticket with the correct board column. Post only when the original context supplies a source thread.`, codeModel)
1235
+ const recovery = await runners.work.runCycle(`The assigned task is NOT complete. Missing runtime evidence: ${missing.join('; ')}. Do not post an acknowledgement or claim success. Resume now. Use get_ticket/list_tasks and list_task_types, perform and verify the repository work, publish an agent/* branch, open the PR, and call update_ticket with the correct board column. ${agent === 'codex' ? 'Prefer the available OpenVisio create_codebase_branch/create_codebase_commit/create_pull_request tools. Only when that linked-codebase flow is unavailable, run openvisio-agent push-pr-branch; never retry a rejected direct git push.' : ''} Post only when the original context supplies a source thread.`, codeModel, agent === 'codex' && delivery ? { disabledMcpTools: ['post_message'] } : {})
1134
1236
  const recoveredResult = combineWorkEvidence(result, recovery)
1135
1237
  const recoveryMissing = missingWorkEvidence(recoveredResult, ticketCycle)
1136
1238
  if (recovery?.subtype !== 'ok' || recoveryMissing.length) {
@@ -1161,11 +1263,22 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1161
1263
  log('completion report failed for ticket #' + activeTaskRef.ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry')
1162
1264
  }
1163
1265
  }
1266
+ if (agent === 'codex' && delivery) {
1267
+ const reply = String(completionResult?.outputText || '').trim()
1268
+ if (!reply) {
1269
+ log('guarded reply produced no final text; leaving delivery unrecorded for retry')
1270
+ } else {
1271
+ try { await postMessageOnce({ ...delivery, content: reply }) }
1272
+ catch (e) { log('guarded reply delivery failed closed: ' + (e?.message || e)) }
1273
+ }
1274
+ }
1164
1275
  } finally {
1165
1276
  if (heartbeat) clearInterval(heartbeat)
1166
1277
  laneStatusTargets[laneName].clear()
1167
1278
  lane.busy = false
1168
- if (lane.queued || lane.pending.length) { const next = lane.queued || (laneName === 'work' ? 'full' : 'fast'); lane.queued = null; void drain(next) }
1279
+ const deferred = lane.deferred.shift()
1280
+ if (deferred) void drain(deferred.kind, deferred.context, deferred.targetChannels, deferred.taskRef, deferred.delivery)
1281
+ else if (lane.queued || lane.pending.length) { const next = lane.queued || (laneName === 'work' ? 'full' : 'fast'); lane.queued = null; void drain(next) }
1169
1282
  }
1170
1283
  }
1171
1284
 
@@ -1229,22 +1342,28 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1229
1342
  const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
1230
1343
  const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
1231
1344
  const key = `${projectId}:${ticketId}`
1232
- if (!belongsToSelf) { blockedTasks.delete(key); pendingCompletionReports.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return }
1345
+ if (!belongsToSelf) {
1346
+ memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'unassigned', summary: ticket.title, refs: { projectId, ticketId } })
1347
+ 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
1348
+ }
1233
1349
  if (taskIsCompleted(ticket) || taskIsAwaitingReview(ticket)) {
1350
+ memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'handoff', summary: ticket.title, refs: { projectId, ticketId } })
1234
1351
  if (pendingCompletionReports.has(key)) {
1235
1352
  const activityChannel = await projectStatusChannel(projectId)
1236
1353
  try { await announceTaskCompletion({ projectId, ticketId, channelId: activityChannel }) }
1237
1354
  catch (e) { log('completion report failed for ticket #' + ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry') }
1238
1355
  }
1239
- blockedTasks.delete(key); persistReplay(); seenTasks.delete(key)
1356
+ blockedTasks.delete(key); blockedTaskRepos.delete(key); persistReplay(); seenTasks.delete(key)
1240
1357
  log(kind + ' ticket #' + ticketId + ' is already complete or awaiting review — ignored')
1241
1358
  return
1242
1359
  }
1243
1360
  if (blockedTasks.has(key)) {
1244
1361
  const approvalText = [ticket.title, ticket.description].filter(Boolean).join(' ')
1245
- 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)) {
1246
- blockedTasks.delete(key); seenTasks.delete(key); persistReplay()
1247
- log(kind + ' ticket #' + ticketId + ' contains explicit push authorization — resuming')
1362
+ const blockedRepo = blockedTaskRepos.get(key)
1363
+ const helperAuthorized = blockedRepo && repositoryHasPrPushAuthorization({ cwd: blockedRepo })
1364
+ 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)) {
1365
+ blockedTasks.delete(key); blockedTaskRepos.delete(key); seenTasks.delete(key); persistReplay()
1366
+ log(kind + ' ticket #' + ticketId + (helperAuthorized ? ' has repository-scoped PR push authorization' : ' contains explicit push authorization') + ' — resuming')
1248
1367
  } else {
1249
1368
  log(kind + ' ticket #' + ticketId + ' is paused for explicit repository push authorization — ignored')
1250
1369
  return
@@ -1253,6 +1372,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1253
1372
  if (seenTasks.has(key)) { log(kind + ' ticket #' + ticketId + ' already queued/active — ignored'); return }
1254
1373
  seenTasks.add(key); trimSeen(seenTasks)
1255
1374
  const title = String(ticket.title || hinted.title || '')
1375
+ memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'queued', summary: title, refs: { projectId, ticketId }, meta: { sourceEvent: kind } })
1256
1376
  const taskText = [title, ticket.description, ticket.type, ticket.kind].filter(Boolean).join(' ')
1257
1377
  const cycleKind = canCode && !coordinationOnly(taskText) ? 'full' : 'coord'
1258
1378
  log(kind + ' verified ticket #' + ticketId + ' “' + title + '” -> ' + cycleKind + ' lane')
@@ -1282,20 +1402,28 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1282
1402
  // De-dupe: the same mention re-delivered (reconnect replay / dup fan-out) must
1283
1403
  // NOT trigger a second reply. Key by message id, or a channel+text signature
1284
1404
  // when the payload carries no id.
1285
- if (markMentionHandled(msg, cid)) { log('agent:mention (dup) — skipped'); return }
1405
+ if (!raw._mentionAlreadyMarked && markMentionHandled(msg, cid)) { log('agent:mention (dup) — skipped'); return }
1286
1406
  if (requestTargetsLaterAgent(text, [slug, identifier])) {
1287
1407
  log('agent:mention addressed to a later-mentioned agent — skipped')
1288
1408
  return
1289
1409
  }
1410
+ const mentionKeys = mentionDedupeKeys(msg, cid)
1411
+ const sourceKey = `mention:${cid ?? '?'}:${mentionKeys.idKey || mentionKeys.signatureKey || threadRoot || Date.now()}`
1412
+ memory.remember({ key: sourceKey, kind: 'mention', state: 'received', summary: text, refs: { channelId: cid, threadId: threadRoot, messageId: mid } })
1413
+ const guardedDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result') => agent === 'codex' && cid != null
1414
+ ? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey }
1415
+ : null
1290
1416
  // Under-the-hood model control from chat (view / switch the model the agent runs).
1291
1417
  const mcmd = cid != null ? parseModelCmd(text) : null
1292
1418
  if (mcmd) {
1293
1419
  const thread = threadRoot != null ? `, parent_id ${threadRoot}` : ''
1294
1420
  if (mcmd.report) {
1295
1421
  log('model query → code ' + codeModel + ' / chat ' + liteModel)
1296
- 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.`)
1422
+ const delivery = guardedDelivery('model')
1423
+ void drain('fast', `An engineer asked which model you're running. ${delivery ? `Return exactly this one-line final answer without calling post_message: "I'm on ${codeModel} for code work${liteModel !== codeModel ? ` and ${liteModel} for chat replies` : ' (and chat)'}.” The watcher will verify and deliver it once.` : `Reply once in channel ${cid}${thread} (with your agent creds): "I'm on ${codeModel} for code work${liteModel !== codeModel ? ` and ${liteModel} for chat replies` : ' (and chat)'}.” One line. Then stop.`}`, [cid], null, delivery)
1297
1424
  } else if (mcmd.invalid) {
1298
- 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.`)
1425
+ const delivery = guardedDelivery('model')
1426
+ void drain('fast', `An engineer tried to switch your model to "${mcmd.invalid}", which isn't one you recognize. ${delivery ? 'Return one short final answer saying you support "opus", "sonnet", "haiku", or a full model id and asking which they meant. Do not call post_message; the watcher will verify and deliver it once.' : `Reply once in channel ${cid}${thread} (with your agent creds): say you support "opus", "sonnet", "haiku", or a full "claude-…" id, and ask which they meant. One line. Then stop.`}`, [cid], null, delivery)
1299
1427
  } else {
1300
1428
  const tgt = mcmd.target // 'chat' | 'code' | 'both'
1301
1429
  const prev = `code ${codeModel}/chat ${liteModel}`
@@ -1305,7 +1433,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1305
1433
  persistModel()
1306
1434
  const label = tgt === 'chat' ? 'chat model' : tgt === 'code' ? 'code model' : 'model'
1307
1435
  log('model switched (' + tgt + ') ' + prev + ' → code ' + codeModel + '/chat ' + liteModel + (who ? ' (by ' + who + ')' : ''))
1308
- 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.`)
1436
+ const delivery = guardedDelivery('model')
1437
+ void drain('fast', `An engineer switched your ${label} to "${mcmd.set}" — active for your next ${tgt === 'chat' ? 'chat replies' : tgt === 'code' ? 'code cycles' : 'actions'}. ${delivery ? `Return one short final confirmation such as "Switched my ${label} to ${mcmd.set}. I'll use it from here." Do not call post_message; the watcher will verify and deliver it once.` : `Post ONE short confirmation in channel ${cid}${thread} (with your agent creds): e.g. "Switched my ${label} to ${mcmd.set} — I'll use it from here." Then stop.`}`, [cid], null, delivery)
1309
1438
  }
1310
1439
  return
1311
1440
  }
@@ -1314,16 +1443,24 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1314
1443
  // subsequent working/typing heartbeat for its lane.
1315
1444
  if (cid != null) sendStatus(cid, 'thinking')
1316
1445
  const codingMention = canCode && needsCode(text)
1446
+ const replyDelivery = guardedDelivery(codingMention ? 'result' : 'reply', !codingMention)
1317
1447
  const ctx = cid != null
1318
- ? `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.`
1448
+ ? replyDelivery
1449
+ ? `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. ${codingMention ? 'Complete the repository work and verification first.' : 'Answer the request.'} Do NOT call post_message; it is intentionally unavailable. Return only the final 1-3 sentence reply as your final answer. The watcher will read the real thread, check its persistent memory graph, and render that answer at most once.${who ? ` To mention the requester, use their exact full name "@${who}".` : ''} The complete message is already here; do not call get_resource, get_marching_orders, or poll_inbox.`
1450
+ : `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. ${codingMention ? 'This is repository work: complete the coding flow first, then send' : 'Send'} EXACTLY ONE reply with post_message: arguments: channel_id ${cid}${threadRoot != null ? `, parent_id ${threadRoot} (reply IN THAT THREAD, do not start a new top-level message)` : ''}, plus agent_identifier + agent_api_key from the AUTH line above, and a 1-3 sentence reply. Compose the whole answer, then post it ONCE. Do not post a first reply and then a revised version. FIRST read the recent messages in this thread: if you already answered this, or another agent was the one addressed, do NOT post at all. Be sure of your answer before sending.${who ? ` To @mention them back, write their EXACT full name "@${who}". A mention only links when the name matches exactly.` : ''} The complete message is already here. Do not call get_resource, get_marching_orders, or poll_inbox, and after your single reply, STOP.`
1319
1451
  : undefined
1320
1452
  if (codingMention) {
1321
- const ack = cid != null
1322
- ? `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.`
1323
- : undefined
1324
- void drain('coord', ack, cid == null ? [] : [cid])
1325
- void drain('full', ctx, cid == null ? [] : [cid])
1326
- } else void drain('fast', ctx, cid == null ? [] : [cid])
1453
+ if (agent === 'codex' && cid != null) {
1454
+ const ack = `${who ? `@${who} ` : ''}I've picked this up and will return here with the verified result.`
1455
+ void postMessageOnce({ ...guardedDelivery('ack'), content: ack }).catch((e) => log('guarded acknowledgement failed closed: ' + (e?.message || e)))
1456
+ } else {
1457
+ const ack = cid != null
1458
+ ? `You were asked for repository work in channel ${cid}${threadRoot != null ? `, thread ${threadRoot}` : ''}. The dedicated work lane has accepted it. Post exactly one short reply with post_message in that same thread saying you have picked it up and will return there with the verified result. Include agent_identifier + agent_api_key. Do not inspect or edit code in this reply lane.`
1459
+ : undefined
1460
+ void drain('coord', ack, cid == null ? [] : [cid])
1461
+ }
1462
+ void drain('full', ctx, cid == null ? [] : [cid], null, replyDelivery)
1463
+ } else void drain('fast', ctx, cid == null ? [] : [cid], null, replyDelivery)
1327
1464
  } else if (k === 'error') {
1328
1465
  const detail = raw && (raw.message || raw.error || raw.reason || raw.code || raw.d?.message || raw.d?.error)
1329
1466
  log('error event: ' + (detail ? String(detail) : JSON.stringify(raw)).slice(0, 220))
@@ -1345,9 +1482,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1345
1482
  // Workspace ethics: a one-time hello the FIRST time this agent ever connects.
1346
1483
  const introMarker = join(OV_DIR, 'intro-' + slugify(identifier) + '.done')
1347
1484
  if (!existsSync(introMarker)) {
1348
- try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
1349
1485
  log('first connection — introducing self to the workspace')
1350
- introTimer = setTimeout(() => void drain('intro'), 5000) // let the socket subscribe first
1486
+ introTimer = setTimeout(() => {
1487
+ void announceIntroduction().then((delivered) => {
1488
+ if (!delivered) return
1489
+ try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
1490
+ }).catch((e) => log('guarded introduction failed: ' + (e?.message || e)))
1491
+ }, 5000) // let the socket subscribe first
1351
1492
  }
1352
1493
  // Reconciliation replaces the old model-driven startup/daily sweep. It uses
1353
1494
  // supported MCP tools directly, stays silent when empty, and hands verified