openvisio-agent 0.20.0 → 0.21.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
@@ -11,7 +11,7 @@ import { homedir } from 'node:os'
11
11
  import { join, dirname } from 'node:path'
12
12
  import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
13
13
  import { connectAgentWs, assertWebSocket } from './ws.mjs'
14
- import { agentAddedByName, blockingReplyMcpErrors, buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, combineRuntimeWorkEvidence, conversationAsksPendingTickets, conversationNeedsCode, failedTaskRevisionIsCurrent, mentionDedupeKeys, messageParentId, messageSenderIsSelf, missingRuntimeWorkEvidence, normalizeRenderedMessageText, renderedAgentMessages, taskBelongsToAgent, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
14
+ import { agentAddedByName, blockingReplyMcpErrors, buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, combineRuntimeWorkEvidence, conversationAsksPendingTickets, conversationCreatesTicket, conversationNeedsCode, failedTaskRevisionIsCurrent, mentionDedupeKeys, messageParentId, messageSenderIsSelf, missingRuntimeWorkEvidence, normalizeRenderedMessageText, renderedAgentMessages, taskAgentId, taskAgentIdentifier, taskBelongsToAgent, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
15
15
  import { createMastraMemory } from './memory.mjs'
16
16
  import { repositoryHasPrPushAuthorization, configurePrPublishing } from './pr-push.mjs'
17
17
  import { createMcpHttpClient } from './mcp-http.mjs'
@@ -27,6 +27,8 @@ import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
27
27
  import { createMastraAcpRunner } from './mastra-harness.mjs'
28
28
  import { claudeFallbackModels, resolveClaudeModel } from './model-selection.mjs'
29
29
  import { createAgentJournal } from './agent-journal.mjs'
30
+ import { readModelSettings } from './model-settings.mjs'
31
+ import { startStudioInBackground } from './studio-cli.mjs'
30
32
 
31
33
  function runtimeJournal(options, supplied) {
32
34
  let journal
@@ -320,6 +322,7 @@ export async function runWatch({ flags }) {
320
322
  if (!apiKey || !identifier) fail('No saved backend credentials for that agent.\n Run `openvisio-agent connect --backend …` first, or pass --key and --id.')
321
323
  assertWebSocket(fail)
322
324
  if (flags.install) return installService({ slug: slug || 'openvisio', claude, mcpConfig, workdir })
325
+ startStudioInBackground()
323
326
  return loopBackendWs({ backend, wsUrl, apiKey, identifier, slug: slug || 'openvisio', claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug: !!flags.debug })
324
327
  }
325
328
 
@@ -593,9 +596,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
593
596
  // The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
594
597
  // agent_identifier + agent_api_key as arguments. Hand them over up front.
595
598
  const authRule = agent !== 'claude'
596
- ? 'AUTH: the local OpenVisio MCP bridge injects authentication outside the model. Never add, request, print, or search for agent credentials in tool arguments.'
599
+ ? 'AUTH: the local OpenVisio MCP bridge injects authentication outside the model. Use only this session’s openvisio-team-watcher server for OpenVisio actions; other installed OpenVisio servers do not receive this bridge’s credentials. If a discovered action requires agent_api_key or agent_identifier, it is not the authenticated watcher action: search for the openvisio-team-watcher version. Never add, request, print, or search for agent credentials in tool arguments.'
597
600
  : `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. The credentials are given here; do NOT hunt for them.`
598
- const credNote = `${authRule} Codex may defer MCP actions instead of placing them in the initial tool list. If a native tool_search is available and an expected openvisio-team action is not initially visible, use tool_search to find that exact action, then call it; an initial-list miss is NOT evidence that the server is disconnected. Do not ask a teammate to reconnect or provide a slug until tool_search and the applicable live lookup have actually failed. On backend MCP, discover and update work with list_agents, list_projects, list_tasks, list_task_types, get_ticket, update_ticket, list_channels, list_message_thread, and list_activity as applicable. Ticket comments are optional: discover an advertised ticket-comment action and follow its input schema; never invent a tool name or call an unadvertised action. get_marching_orders, poll_inbox, get_resource, list_mcp_resources, and list_mcp_resource_templates are NOT team-action tools here; never call them. Tools may be namespaced — call whichever names actually appear. Bash/git/gh ARE for code work; this rule only forbids searching for keys.`
601
+ const credNote = `${authRule} Codex may defer MCP actions instead of placing them in the initial tool list. If a native tool_search is available and an expected openvisio-team action is not initially visible, use tool_search to find that exact action, then call it; an initial-list miss is NOT evidence that the server is disconnected. Do not ask a teammate to reconnect or provide a slug until tool_search and the applicable live lookup have actually failed. On backend MCP, discover and update work with list_agents, list_projects, list_tasks, list_task_types, get_ticket, update_ticket, list_channels, list_message_thread, and list_activity as applicable. Ticket comments are optional: discover an advertised ticket-comment action and follow its input schema; never invent a tool name or call an unadvertised action. get_marching_orders, poll_inbox, get_resource, list_mcp_resources, and list_mcp_resource_templates are NOT team-action tools here; never call them. Tools may be namespaced; use advertised actions on the authenticated session server only. Bash/git/gh ARE for code work; this rule only forbids searching for keys.`
599
602
  // Keep the static charter stable so provider prompt caching can reuse it.
600
603
  // Actual cache hits and billing depend on the runtime and model provider.
601
604
  const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
@@ -630,15 +633,29 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
630
633
  ? '\n\nCODEX PR DELIVERY: when the repository exists in the local workspace, use that clone for branch creation, edits, tests, and commits; do not inspect or mutate it through linked-codebase MCP tools. To publish the local agent/* branch, run `openvisio-agent push-pr-branch` from the repository, then open the PR with `gh pr create`. The helper 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. PR publishing is enabled during connection setup for the coding workspace and its worktrees. If authorization is missing outside that workspace, report the exact repository configuration issue once; never tell the user to restart the watcher. Use list_codebases/create_codebase_branch/create_codebase_commit/create_pull_request only as a fallback when the repository cannot be obtained locally.'
631
634
  : ''
632
635
  const backendToolRule = 'BACKEND MCP RULE: the event and watcher already provide the work source. Never call get_marching_orders, poll_inbox, get_resource, list_mcp_resources, or list_mcp_resource_templates; they are not backend team actions. Codex can defer MCP actions: native tool_search is allowed and must be used to find an expected openvisio-team action that is absent from the initial list. An initial-list miss does not mean the MCP server is unavailable. For live discovery use list_agents, list_projects, list_tasks, list_task_types, list_activity, get_ticket, and list_channels as applicable. For ticket comments, discover an advertised action and follow its input schema; never invent a tool name or call an unadvertised action.'
633
- 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
634
- const fastPrompt = (canCode ? CODE_FAST : CYCLE_FAST) + '\n\n' + backendToolRule
635
- const coordinatePrompt = COORDINATE + '\n\n' + backendToolRule
636
- const guardedReplyPrompt = `WATCHER-DELIVERED REPLY: the watcher has already verified that the current source message is addressed to you. Your current OpenVisio agent identifier is "${identifier}"; do not call list_agents merely to rediscover yourself. Do not call post_message, relay inbox tools, or MCP resource APIs. OpenVisio team-state tools are allowed: when the answer depends on live projects, assignments, tickets, or status, use list_projects/list_tasks/get_ticket as needed and never ask the teammate for a slug that those tools can resolve. A failed read-only discovery call is not a completed action and must not be described as an intervention-level blocker; continue with available live data or state the narrow fact you could not verify. Return only one natural, context-specific reply of 1-3 sentences. Do not echo the request, announce a plan, or add a generic acknowledgement. If the supplied context says the work was completed, lead with the verified result; if it is blocked, name only the real blocker and next action.` + '\n\n' + backendToolRule
636
+ const studioGuide = `\n\nAGENT STUDIO: When a teammate asks to inspect agent activity or change model settings, offer [Open Agent Studio](http://127.0.0.1:4317/#agent=${encodeURIComponent(identifier)}&settings=1). The app renders this link as a button. Studio manages agents on the teammate’s own computer; it does not connect to another teammate’s machine. Prefer this UI over asking people to run model-setting CLI commands.`
637
+ 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 + studioGuide
638
+ const fastPrompt = (canCode ? CODE_FAST : CYCLE_FAST) + '\n\n' + backendToolRule + studioGuide
639
+ const coordinatePrompt = COORDINATE + '\n\n' + backendToolRule + studioGuide
640
+ const guardedReplyPrompt = `WATCHER-DELIVERED REPLY: the watcher has already verified that the current source message is addressed to you. Your current OpenVisio agent identifier is "${identifier}"; do not call list_agents merely to rediscover yourself. Do not call post_message, relay inbox tools, or MCP resource APIs. OpenVisio team-state tools are allowed: when the answer depends on live projects, assignments, tickets, or status, use list_projects/list_tasks/get_ticket as needed and never ask the teammate for a slug that those tools can resolve. A failed read-only discovery call is not a completed action and must not be described as an intervention-level blocker; continue with available live data or state the narrow fact you could not verify. Return only one natural, context-specific reply of 1-3 sentences. Do not echo the request, announce a plan, or add a generic acknowledgement. If the supplied context says the work was completed, lead with the verified result; if it is blocked, name only the real blocker and next action.` + '\n\n' + backendToolRule + studioGuide
637
641
  // Live model state — changeable at runtime by the in-chat `/model` command.
638
642
  // codeModel drives full/sweep cycles; chatModel (if set) the lighter fast/intro
639
643
  // ones, so routine chatter can run cheaper than real code work.
640
644
  let codeModel = model
641
645
  let liteModel = chatModel || model
646
+ let modelSettingsRevision = ''
647
+ const refreshModelSettings = () => {
648
+ try {
649
+ const settings = readModelSettings(stateDir, slug)
650
+ if (!settings.revision || settings.revision === modelSettingsRevision) return
651
+ codeModel = settings.model
652
+ liteModel = settings.chatModel || settings.model
653
+ modelSettingsRevision = settings.revision
654
+ emit('models.updated', { model: codeModel, chatModel: liteModel, modelSettingsRevision })
655
+ log('Model settings applied from Agent Studio; new requests will use the selected models')
656
+ } catch { /* absent, legacy, or concurrently replaced configuration */ }
657
+ }
658
+ refreshModelSettings()
642
659
 
643
660
  const queues = {
644
661
  work: createCycleQueue({
@@ -808,7 +825,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
808
825
  const selfAliases = new Set([slug, identifier].map((value) => String(value || '').toLowerCase()).filter(Boolean))
809
826
  const rememberSelfAgent = (self) => {
810
827
  if (!self || typeof self !== 'object' || String(self.identifier || self.slug || '') !== identifier) return
811
- if (self.id != null) selfAgentId = Number(self.id)
828
+ if (validBackendId(self.id)) selfAgentId = Number(self.id)
812
829
  for (const alias of [self.name, self.identifier, self.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
813
830
  const addedBy = agentAddedByName(self)
814
831
  if (addedBy) completionRecipientName = addedBy
@@ -833,15 +850,24 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
833
850
  }))
834
851
  }
835
852
  let selfRead = null
853
+ let selfReadAfter = 0
836
854
  const ensureSelfAgent = async () => {
837
855
  if (selfAgentId != null) return
856
+ if (!selfRead && Date.now() < selfReadAfter) return
838
857
  if (!selfRead) selfRead = callMcpTool('list_agents').then(toolData).then((data) => {
839
- const self = (Array.isArray(data.agents) ? data.agents : []).find((row) => String(row.identifier || row.slug || '') === identifier)
840
- if (!self?.id) throw new Error('list_agents did not return this BYO agent')
858
+ const self = (Array.isArray(data.agents) ? data.agents : []).find((row) => String(row?.identifier || row?.slug || '') === identifier)
841
859
  rememberSelfAgent(self)
842
- }).finally(() => { selfRead = null })
860
+ }).catch((error) => {
861
+ log('Agent profile unavailable; continuing with task ownership and configured aliases: ' + (error?.message || error))
862
+ }).finally(() => { selfRead = null; selfReadAfter = Date.now() + 60_000 })
843
863
  await selfRead
844
864
  }
865
+ const resolveTaskOwner = async (task) => {
866
+ // An identifier on the authoritative task is sufficient. Older backends
867
+ // emit only a numeric assignee, which needs the optional profile lookup.
868
+ if (selfAgentId == null && !taskAgentIdentifier(task) && taskAgentId(task) != null) await ensureSelfAgent()
869
+ return taskBelongsToAgent(task, { id: selfAgentId, identifier })
870
+ }
845
871
  let mcpToolNames = null
846
872
  let mcpToolDiscoveryPromise = null
847
873
  let mcpToolDiscoveryWarned = false
@@ -901,12 +927,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
901
927
  }
902
928
 
903
929
  const loadPendingTickets = async () => {
904
- if (selfAgentId == null) {
905
- const agentsData = toolData(await callMcpReadWithRetry('list_agents'))
906
- const self = (Array.isArray(agentsData.agents) ? agentsData.agents : []).find((a) => String(a.identifier || a.slug || '') === identifier)
907
- if (!self?.id) throw new Error('list_agents did not return this BYO agent')
908
- rememberSelfAgent(self)
909
- }
910
930
  const projectsData = toolData(await callMcpReadWithRetry('list_projects'))
911
931
  const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
912
932
  const groups = await mapConcurrent(projects.filter((project) => project?.id != null), 3, async (project) => {
@@ -916,6 +936,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
916
936
  ])
917
937
  const tasksData = toolData(tasksResult)
918
938
  const { done: doneIds, review: reviewIds } = typesResult
939
+ await Promise.all((Array.isArray(tasksData.tasks) ? tasksData.tasks : []).map(resolveTaskOwner))
919
940
  return (Array.isArray(tasksData.tasks) ? tasksData.tasks : []).filter((task) => {
920
941
  const assignedHere = taskBelongsToAgent(task, { id: selfAgentId, identifier })
921
942
  return assignedHere && !taskIsCompleted(task, doneIds)
@@ -1103,10 +1124,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1103
1124
  if (dedicatedChannelRead) return dedicatedChannelRead
1104
1125
  dedicatedChannelRead = (async () => {
1105
1126
  try {
1106
- if (selfAgentId == null) {
1107
- const agents = toolData(await callMcpTool('list_agents')).agents || []
1108
- rememberSelfAgent(agents.find((entry) => String(entry.identifier || entry.slug || '') === identifier))
1109
- }
1127
+ await ensureSelfAgent()
1110
1128
  const data = toolData(await callMcpTool('list_channels'))
1111
1129
  const channels = data.channels ?? data.data?.channels ?? data.data ?? data.items ?? []
1112
1130
  const channel = dedicatedChannel(Array.isArray(channels) ? channels : [], { id: selfAgentId, identifier, aliases: [...selfAliases], channelId: readConfig(slug)?.dedicatedChannelId })
@@ -1278,11 +1296,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1278
1296
  if (!mcpUrl || backlogProbeBusy) return
1279
1297
  backlogProbeBusy = true
1280
1298
  try {
1281
- const agentsData = toolData(await callMcpTool('list_agents'))
1282
- const agents = Array.isArray(agentsData.agents) ? agentsData.agents : []
1283
- const self = agents.find((a) => String(a.identifier || a.slug || '') === identifier)
1284
- if (!self?.id) throw new Error('list_agents did not return this BYO agent')
1285
- rememberSelfAgent(self)
1286
1299
  const projectsData = toolData(await callMcpTool('list_projects'))
1287
1300
  const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
1288
1301
  const mentionActivity = []
@@ -1296,7 +1309,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1296
1309
  ])
1297
1310
  const { done: doneIds, review: reviewIds } = typesData
1298
1311
  for (const task of Array.isArray(tasksData.tasks) ? tasksData.tasks : []) {
1299
- if (!taskBelongsToAgent(task, { id: selfAgentId, identifier })) continue
1312
+ if (!await resolveTaskOwner(task)) continue
1300
1313
  const taskKey = `${project.id}:${task.id}`
1301
1314
  if (taskIsCompleted(task, doneIds) || taskIsAwaitingReview(task, reviewIds)) {
1302
1315
  memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'handoff', summary: task.title, refs: { projectId: project.id, ticketId: task.id } })
@@ -1337,7 +1350,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1337
1350
  return {}
1338
1351
  })
1339
1352
  const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
1340
- const mentionNeedles = [self.name, self.identifier, self.slug, identifier].filter(Boolean).map((s) => '@' + String(s).toLowerCase())
1353
+ await ensureSelfAgent()
1354
+ const mentionNeedles = [...selfAliases].map((alias) => '@' + alias)
1341
1355
  for (const item of activities) {
1342
1356
  const text = JSON.stringify(item)
1343
1357
  const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
@@ -1437,6 +1451,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1437
1451
  }
1438
1452
 
1439
1453
  async function executeCycle(kind, context, targetChannels = [], taskRef = null, delivery = null, runner, control, preparedWorkdir = '') {
1454
+ refreshModelSettings()
1440
1455
  const laneName = kind === 'full' ? 'work' : 'reply'
1441
1456
  const cycleControl = control || { cancelled: false, runner, statusTargets: new Set() }
1442
1457
  const cycleStartedAt = performance.now()
@@ -1467,6 +1482,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1467
1482
  const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind, delivery)
1468
1483
  // Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
1469
1484
  // work (full/sweep) uses the main model.
1485
+ refreshModelSettings()
1486
+ const cycleModelRevision = modelSettingsRevision
1470
1487
  const useModel = kind === 'full' ? codeModel : liteModel
1471
1488
  log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : '') + (useModel ? ' [' + useModel + ']' : ''))
1472
1489
  // Presence notifications are intentionally disabled; durable messages and
@@ -1492,7 +1509,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1492
1509
  const result = await runner.runCycle(prompt, useModel, runnerOptions)
1493
1510
  cycleControl.outcome = result?.subtype || 'ok'
1494
1511
  if (result?.timings) log(`cycle timing runtime=${JSON.stringify(result.timings)} kind=${kind}`)
1495
- if (result?.model && result.model !== useModel) {
1512
+ refreshModelSettings()
1513
+ if (cycleModelRevision === modelSettingsRevision && result?.model && result.model !== useModel) {
1496
1514
  // Persist only a successfully selected model and only if the user has
1497
1515
  // not changed that tier while this cycle was running.
1498
1516
  let changed = false
@@ -1513,7 +1531,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1513
1531
  }
1514
1532
  if (!cycleSucceeded(result)) {
1515
1533
  const outcome = result?.subtype || 'an unknown runtime error'
1516
- const notice = `I'm blocked because the ${kind === 'full' ? 'coding' : 'reply'} cycle ended with ${outcome}. I'm not claiming completion.${activeTaskRef ? " I've paused this revision until the ticket changes." : ''}`
1534
+ const notice = `I'm blocked because the ${kind === 'full' ? 'coding' : 'reply'} cycle ended with ${outcome}.${result?.subtype === 'rate_limited' ? ` The model provider is rate-limiting this request. Choose a model with available quota in Agent Studio, or retry once the limit resets.\n\n[Open Agent Studio](http://127.0.0.1:4317/#agent=${encodeURIComponent(identifier)}&settings=1)\n\n` : ' '}I'm not claiming completion.${activeTaskRef ? " I've paused this revision until the ticket changes." : ''}`
1517
1535
  log('WORK_CYCLE_BLOCKED ' + outcome + '; publishing blocker')
1518
1536
  if (activeTaskRef) pauseFailedTask(activeTaskRef)
1519
1537
  try { await publishBlocker({ prompt, taskRef: activeTaskRef, delivery, notice }) }
@@ -1581,8 +1599,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1581
1599
  if (!reply) {
1582
1600
  log('guarded reply produced no final text; leaving delivery unrecorded for retry')
1583
1601
  } else {
1602
+ const deliveryStartedAt = performance.now()
1584
1603
  try { await postMessageOnce({ ...delivery, content: reply }) }
1585
1604
  catch (e) { log('guarded reply delivery failed closed: ' + (e?.message || e)) }
1605
+ finally { log(`cycle timing delivery=${Math.round(performance.now() - deliveryStartedAt)}ms kind=${kind}`) }
1586
1606
  }
1587
1607
  }
1588
1608
  } finally {
@@ -1637,17 +1657,11 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1637
1657
  const projectId = hinted && (hinted.project_id ?? hinted.projectId ?? raw.project_id ?? raw.projectId)
1638
1658
  if (!validBackendId(ticketId) || !validBackendId(projectId)) { log(kind + ' missing ticket/project identity — ignored'); return }
1639
1659
  try {
1640
- if (selfAgentId == null) {
1641
- const agentsData = toolData(await callMcpTool('list_agents'))
1642
- const self = (Array.isArray(agentsData.agents) ? agentsData.agents : []).find((a) => String(a.identifier || a.slug || '') === identifier)
1643
- selfAgentId = null
1644
- rememberSelfAgent(self)
1645
- }
1646
1660
  const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
1647
1661
  const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
1648
1662
  if (String(ticket.id) !== String(ticketId) || (ticket.project_id != null && String(ticket.project_id) !== String(projectId))) { log('get_ticket returned mismatched identity; ignored'); return }
1649
1663
  const typeSets = await taskTypeSets(projectId)
1650
- const belongsToSelf = taskBelongsToAgent(ticket, { id: selfAgentId, identifier })
1664
+ const belongsToSelf = await resolveTaskOwner(ticket)
1651
1665
  const key = `${projectId}:${ticketId}`
1652
1666
  if (!belongsToSelf) {
1653
1667
  for (const [name, queue] of Object.entries(queues)) {
@@ -1713,6 +1727,15 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1713
1727
  // Keep redirects ordered while a legacy root is being verified. Other
1714
1728
  // conversations can resolve and run independently.
1715
1729
  const next = (mentionIntake.get(key) || Promise.resolve()).then(async () => {
1730
+ const { idKey } = mentionDedupeKeys(message, cid)
1731
+ const sourceKey = `mention:${cid}:${idKey}`
1732
+ // A confirmed received ledger entry needs no identity/root network read.
1733
+ // Preserve the legacy replay recovery path when only the seen key exists.
1734
+ if (idKey && seenMentions.has(idKey) && memory.has(sourceKey, 'received')) {
1735
+ log(`${k} duplicate channel=${cid} message=${messageId} — original already accepted; no new cycle`)
1736
+ return { status: 'duplicate' }
1737
+ }
1738
+ const intakeStartedAt = performance.now()
1716
1739
  try { await ensureSelfAgent() }
1717
1740
  catch (error) { log('agent identity lookup unavailable; using configured aliases: ' + (error?.message || error)) }
1718
1741
  if (k === 'channel:message' && selfOrganizationId == null) return { status: 'ignored-unknown-organization' }
@@ -1723,13 +1746,30 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1723
1746
  const irrelevant = preliminary.action === 'ignore' && preliminary.reason !== 'unaddressed-thread-activity'
1724
1747
  let rootContent = ''
1725
1748
  if (parent != null && !irrelevant && preliminary.action !== 'stand_down') {
1726
- try { rootContent = await resolveOwnThread(cid, parent) }
1749
+ const optional = preliminary.explicitSelf || memory.has(threadControlKey(cid, parent), 'active')
1750
+ let contextTimer
1751
+ try {
1752
+ const context = resolveOwnThread(cid, parent)
1753
+ // An explicit recipient or durable ownership already authorizes intake.
1754
+ // Slow optional context must not consume the transport's 20s deadline
1755
+ // before a model starts. Delivery still re-reads the authoritative thread.
1756
+ rootContent = optional ? await Promise.race([context, new Promise((resolve) => {
1757
+ contextTimer = setTimeout(() => {
1758
+ log(`mention optional context pending channel=${cid} message=${messageId} — continuing with verified recipient`)
1759
+ resolve('')
1760
+ }, 1000)
1761
+ })]) : await context
1762
+ }
1727
1763
  catch (error) {
1728
1764
  if (!preliminary.explicitSelf && !memory.has(threadControlKey(cid, parent), 'active')) throw error
1729
1765
  log('optional root context unavailable; preserving verified recipient: ' + (error?.message || error))
1730
1766
  }
1767
+ finally { clearTimeout(contextTimer) }
1768
+ }
1769
+ if (!stopping) {
1770
+ if (!irrelevant) log(`mention timing intake=${Math.round(performance.now() - intakeStartedAt)}ms source=${k} channel=${cid} message=${messageId}`)
1771
+ return routeEvent('agent:mention', { ...d, _threadRootContent: rootContent })
1731
1772
  }
1732
- if (!stopping) return routeEvent('agent:mention', { ...d, _threadRootContent: rootContent })
1733
1773
  }).catch((error) => { log('mention thread verification failed: ' + (error?.message || error)); return { status: 'retry' } })
1734
1774
  .finally(() => { if (mentionIntake.get(key) === next) mentionIntake.delete(key) })
1735
1775
  mentionIntake.set(key, next)
@@ -1817,7 +1857,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1817
1857
  }
1818
1858
  return
1819
1859
  }
1820
- const assignment = assignmentRequest(text)
1860
+ const creatingTicket = conversationCreatesTicket(text)
1861
+ const assignment = creatingTicket ? null : assignmentRequest(text)
1821
1862
  if (assignment) {
1822
1863
  const delivery = conversationDelivery('assignment-routing')
1823
1864
  const isCancelled = () => !!controlKey && memory.has(controlKey, 'cancelled')
@@ -1837,7 +1878,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1837
1878
  })().catch((error) => log('assignment routing delivery failed: ' + (error?.message || error)))
1838
1879
  return
1839
1880
  }
1840
- if (cid != null && conversationAsksPendingTickets(text)) {
1881
+ if (cid != null && !creatingTicket && conversationAsksPendingTickets(text)) {
1841
1882
  log('pending-ticket question -> watcher-owned MCP lookup')
1842
1883
  void answerPendingTickets({ delivery: conversationDelivery('pending-tickets'), who, text })
1843
1884
  return
@@ -1846,20 +1887,21 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1846
1887
  // Light up the live status the instant we pick this up; drain owns the
1847
1888
  // subsequent working/typing heartbeat for its lane.
1848
1889
  if (cid != null) sendStatus(cid, 'thinking')
1849
- const codingMention = canCode && conversationNeedsCode(text, { rootContent: raw._threadRootContent })
1890
+ const codingMention = !creatingTicket && canCode && conversationNeedsCode(text, { rootContent: raw._threadRootContent })
1850
1891
  const replyDelivery = conversationDelivery(codingMention ? 'result' : 'reply', !codingMention)
1851
1892
  let ctx = cid != null
1852
1893
  ? replyDelivery?.watcherOwned
1853
1894
  ? `The watcher verified this source message is addressed to you in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". ${codingMention ? 'Complete the concrete repository work and verification first.' : 'Answer the request directly.'} Do NOT call post_message; it is intentionally unavailable. Return only the final reply as your final answer. The watcher will re-check the live thread and render it at most once.${who ? ` To mention the requester, use their exact full name "@${who}".` : ''} The complete message is already here; do not call get_resource, get_marching_orders, poll_inbox, list_mcp_resources, or list_mcp_resource_templates.`
1854
1895
  : `The watcher verified this source message is addressed to you in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". ${codingMention ? `This is concrete repository work. Start the coding flow now. You may send at most one concrete progress update after work begins, but do not stop there: continue the work and then send one distinct final result with evidence or a real blocker. Use post_message for the final result even if you already sent a progress message` : '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. Never repeat the same message.${who ? ` To @mention them back, write their EXACT full name "@${who}". A mention only links when the name matches exactly.` : ''} The complete message is already here. Do not call get_resource, get_marching_orders, poll_inbox, list_mcp_resources, or list_mcp_resource_templates.${codingMention ? ' Stop only after the work and final result/blocker.' : ' After your single reply, STOP.'}`
1855
1896
  : undefined
1897
+ if (ctx && creatingTicket) ctx += ' This is a ticket-creation request, not repository implementation. Discover create_ticket on the authenticated watcher server, resolve the project and valid type using list_projects and list_task_types, then create the requested ticket once. Follow its actual schema. If a required detail is ambiguous, ask for that detail rather than inventing it. Report the returned ticket reference. Never replay a creation after a timeout or uncertain response.'
1856
1898
  if (ctx && raw._threadRootContent) ctx += ` Verified thread root authored by you (context, not a new request): ${JSON.stringify(raw._threadRootContent)}. For requested repairs, inspect the referenced PR and its branch, run the failing verification, and fix that existing work. A review/testing handoff does not prevent an explicit human repair request.`
1857
1899
  if (codingMention) {
1858
1900
  // Activity indicators make accepted work visible. Do not add a canned
1859
1901
  // pickup message; the first channel message is the verified result or a
1860
1902
  // concrete blocker from the work lane.
1861
1903
  void drain('full', ctx, cid == null ? [] : [cid], null, replyDelivery)
1862
- } else void drain('fast', ctx, cid == null ? [] : [cid], null, replyDelivery)
1904
+ } else void drain(creatingTicket ? 'coord' : 'fast', ctx, cid == null ? [] : [cid], null, replyDelivery)
1863
1905
  } else if (k === 'error') {
1864
1906
  const detail = raw && (raw.message || raw.error || raw.reason || raw.code || raw.d?.message || raw.d?.error)
1865
1907
  log('error event: ' + (detail ? String(detail) : JSON.stringify(raw)).slice(0, 220))
@@ -1911,7 +1953,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1911
1953
  deliveryTimer?.unref?.()
1912
1954
  const authorizationTimer = autoStart ? setInterval(() => void resumeReadyAuthorizations(), 2_000) : null
1913
1955
  authorizationTimer?.unref?.()
1914
- const heartbeatTimer = autoStart ? setInterval(() => emit('watcher.heartbeat', { status: 'running', active: queues.work.activeSize + queues.reply.activeSize, pending: queues.work.size + queues.reply.size, model: codeModel, chatModel: liteModel, workdir: workdir || '' }), 15_000) : null
1956
+ const modelSettingsTimer = autoStart ? setInterval(refreshModelSettings, 2_000) : null
1957
+ modelSettingsTimer?.unref?.()
1958
+ emit('models.updated', { model: codeModel, chatModel: liteModel, modelSettingsRevision })
1959
+ const heartbeatTimer = autoStart ? setInterval(() => emit('watcher.heartbeat', { status: 'running', active: queues.work.activeSize + queues.reply.activeSize, pending: queues.work.size + queues.reply.size, model: codeModel, chatModel: liteModel, modelSettingsRevision, workdir: workdir || '' }), 15_000) : null
1915
1960
  heartbeatTimer?.unref?.()
1916
1961
  let introTimer = null, taskProbeStartTimer = null, taskProbeTimer = null
1917
1962
  if (autoStart && (mcpConfig || mcpUrl)) {
@@ -1945,6 +1990,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1945
1990
  clearInterval(authorizationTimer)
1946
1991
  clearInterval(deliveryTimer)
1947
1992
  clearInterval(heartbeatTimer)
1993
+ clearInterval(modelSettingsTimer)
1948
1994
  if (introTimer) clearTimeout(introTimer)
1949
1995
  if (taskProbeStartTimer) clearTimeout(taskProbeStartTimer)
1950
1996
  if (taskProbeTimer) clearInterval(taskProbeTimer)