openvisio-agent 0.21.1 → 0.23.0

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
@@ -6,13 +6,18 @@
6
6
 
7
7
  import { spawn, spawnSync } from 'node:child_process'
8
8
  import { AsyncLocalStorage } from 'node:async_hooks'
9
- import { closeSync, writeFileSync, mkdirSync, existsSync, openSync, readFileSync, readdirSync, unlinkSync } from 'node:fs'
10
- import { homedir } from 'node:os'
9
+ import { closeSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, existsSync, openSync, readFileSync, readdirSync, unlinkSync } from 'node:fs'
10
+ import { homedir, tmpdir } from 'node:os'
11
+ import { fileURLToPath } from 'node:url'
11
12
  import { join, dirname } from 'node:path'
12
13
  import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
13
14
  import { connectAgentWs, assertWebSocket } from './ws.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
+ import { buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, conversationAsksPendingTickets, conversationCreatesTicket, conversationNeedsCode, failedTaskRevisionIsCurrent, mentionDedupeKeys, messageParentId, messageSenderIsSelf, normalizeRenderedMessageText, renderedAgentMessages, taskAgentId, taskAgentIdentifier, taskBelongsToAgent, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
16
+ import { WORK_SESSION_TOOL, workSessionRequest, agentProfileContext } from './runtime-control.mjs'
15
17
  import { createMastraMemory } from './memory.mjs'
18
+ import { createContextService } from './context-service.mjs'
19
+ import { contextToolGuide } from './context-tools.mjs'
20
+ import { toolWithoutCredentialInputs } from './codex-mcp-proxy.mjs'
16
21
  import { repositoryHasPrPushAuthorization, configurePrPublishing } from './pr-push.mjs'
17
22
  import { createMcpHttpClient } from './mcp-http.mjs'
18
23
  import { createCycleQueue } from './cycle-queue.mjs'
@@ -75,9 +80,9 @@ const REPLY_DISCIPLINE = [
75
80
  ' • IS IT FOR YOU? Act ONLY on messages addressed to YOU — an @mention of your exact name, a direct question to you, or a reply to something YOU said or did. If a DIFFERENT agent or person was @mentioned or asked to do something, STAY OUT: do not answer for them and do not pick up their task. When it is not yours, posting nothing is the correct move.',
76
81
  ' • EVENT NAMES ARE NOT OWNERSHIP. A transport may wake you for activity in a thread you once joined. Trust only the watcher\'s verified recipient decision for the current source message; never infer that every thread update is yours.',
77
82
  ' • NO DUPLICATES OR PICKUP NOISE. Before you post, scan the recent thread/channel for what YOU already said. If you already replied to this exact request, do NOT repeat the same message. Do not send a generic pickup acknowledgement; activity shows that work is underway. For longer code work, you may send at most one concrete progress update after work has actually begun, but that update NEVER completes the cycle: keep using tools, then send one distinct verified result or real blocker. One final answer per question.',
78
- ' • BE SURE BEFORE YOU SPEAK. Do not claim something is possible, done, or broken until you have actually verified it — call the tool, read the code, check the real state. Never assert then contradict yourself. If you are unsure, verify FIRST, then give ONE clear, final answer instead of thinking out loud across several messages.',
83
+ ' • BE SURE BEFORE YOU SPEAK. Do not claim something is possible, done, or broken until you have actually verified it — call the tool, read the code, check the real state. Be willing to disagree or revise a conclusion when evidence changes. If you are unsure, investigate and distinguish a finding from an assumption.',
79
84
  ' • USE RECALL, NEVER INVENT IT. Before answering a context-dependent question, search the visible thread and use any available history, search, docs, or recall tools. Reuse verified context instead of asking the user to repeat it. If no record exists, say plainly "I don\'t have a record of that". Never fabricate past events, conversations, results, links, PR numbers, deploy URLs, or figures.',
80
- ' • LOOK UP ASSIGNED WORK. If someone says they assigned you a task, asks which task is yours, or asks for its status, check the live board yourself with list_agents + list_projects + list_tasks and then get_ticket as needed. Match assignments to your authenticated agent identity. Do not ask the teammate for a project slug, ticket slug, or numeric id before trying those MCP tools; ask only if the live lookup fails or returns genuinely ambiguous matches.',
85
+ ' • LOOK UP ASSIGNED WORK. If someone says they assigned you a task, asks which task is yours, or asks for its status, check the live board yourself with list_projects + list_tasks and then get_ticket as needed. Use list_agents only if assignment data requires a numeric identity lookup. Match assignments to your authenticated agent identity. Do not ask the teammate for a project slug, ticket slug, or numeric id before trying those MCP tools; ask only if the live lookup fails or returns genuinely ambiguous matches.',
81
86
  ' • TICKET SLUGS, NEVER DATABASE IDS. In every human-facing channel message, ticket comment, PR description, summary, blocker, and result, reference a ticket by the exact project-scoped slug returned by get_ticket/list_tasks (for example, `OVS-57`). Numeric project_id and ticket_id values are internal MCP arguments only: never write `#57`, `ticket 57`, or expose a database id to teammates. If the backend omits the slug, use the ticket title or say “the ticket”; do not invent a slug.',
82
87
  ' • CLOSE CONCERNS. Never leave a concern, direct question, correction, or blocker addressed to you without a clear response. Acknowledge the concern, act if you can, then report the verified result. If blocked, name the blocker and the exact next action or owner in one message.',
83
88
  ' • NEW THREADS BELONG IN YOUR DEDICATED CHANNEL. Reply to an existing request in its original thread. For any fresh top-level thread (results, blockers, or an introduction), use only your own dedicated agent channel, resolved from live list_channels by your exact identity/name. Never open a fresh thread in general, a project channel, or another agent’s channel. If no unique dedicated channel exists, keep results on the ticket and report the missing channel in an existing source thread. Never invent a channel or tool.',
@@ -86,9 +91,9 @@ const REPLY_DISCIPLINE = [
86
91
 
87
92
  // ── CHAT-ONLY agents (no --workdir): chat/ticket tools, no code surface. ──────
88
93
  const CHAT_CHARTER = [
89
- 'YOU ARE a connected agent in an OpenVisio team, running in CHAT-ONLY mode. Use only tools that actually appear in your openvisio-team tool list. Backend MCP provides post_message, react_message, list_agents, list_projects, list_tasks, get_ticket, update_ticket, and list_activity. A ticket-comment tool is optional and must not be assumed. Some relay runtimes also provide poll_inbox or get_marching_orders. Never call a tool that is absent. You have NO local read, grep, glob, list, file, Bash, git, web, or delegation tools in this mode; use the OpenVisio MCP for live team state and do not inspect the local workspace.',
90
- 'WORK ETHIC — behave like a dependable teammate: never leave a promise dangling. Either ACT now (reply, or file a ticket) or say plainly you can\'t and offer to file a ticket / tag a coding agent who can. Never invent progress. Close the loop every cycle — the human should never have to remind you to circle back.',
91
- '',
94
+ 'You are the same connected OpenVisio teammate in every session. Your role, voice, current request, and available capabilities are supplied with the context.',
95
+ 'Discover tools, retrieve relevant context, explore, plan, and choose your next steps. Use native context management. If local execution or edits are needed and openvisio_request_work_session is advertised, use it to continue the same request in your own workspace. A reply session is an internal scheduling choice, not a reason to ask the teammate to reassign your work.',
96
+ 'Stay honest about what you have checked and what remains uncertain. Do the requested work within its authority, and give a useful final response when you choose to end your turn.',
92
97
  REPLY_DISCIPLINE,
93
98
  ].join('\n')
94
99
 
@@ -100,13 +105,13 @@ const CYCLE_FAST = [
100
105
  'New chat activity. Do EXACTLY ONE of these:',
101
106
  ' • IF a specific mention/message FOR YOU is given above: reply to THAT ONE message exactly once with post_message, then STOP. Do NOT call poll_inbox and do NOT answer anything else this cycle — you already have the message; polling would make you re-answer it and double-post.',
102
107
  ' • IF NO specific mention is given above: call poll_inbox and reply only to items truly directed at YOU (a question to you, or a reply to your own message) — SKIP chatter aimed at someone else / another agent, ignore .tasks/.claimable, at most one reply per channel.',
103
- 'Post ONE message total for the thing you are answering — compose it fully, then send once. Never send a reply and then a "better" version; never repeat a reply you already sent. Be sure of your answer before sending. If asked for work you have no tool for, say so plainly and offer to file a ticket. 1-3 sentences, no summary. Then stop.',
108
+ 'Post ONE message total for the thing you are answering — compose it fully, then send once. Never send a reply and then a "better" version; never repeat a reply you already sent. Be sure of your answer before sending. If asked for work you have no tool for, say so plainly and offer to file a ticket. Use enough detail to answer the request, then end your turn.',
104
109
  ].join('\n')
105
110
 
106
111
  const COORDINATE = [
107
112
  'COORDINATION-ONLY cycle. Use the lightweight lane for messaging, triage, ticket comments, assignment, and board movement.',
108
113
  'Call get_marching_orders or poll_inbox only when the event context does not already contain enough detail. Use update_ticket to move a ticket to the correct board column when requested or when non-code work is complete.',
109
- 'Do not inspect repositories, edit files, run tests, or write code in this cycle. If the request actually requires code and this cycle was misclassified, leave it retryable for the coding lane; report only a concrete routing blocker, never a generic acknowledgement. Do not pretend it is complete.',
114
+ 'Explore the context and available tools as needed; this session has no writable coding workspace. If local work is needed, use openvisio_request_work_session when advertised to continue in your coding workspace. Preserve the current request and recipient.',
110
115
  'Respond to every direct concern assigned to you, but post only once per item and never duplicate an existing answer.',
111
116
  ].join('\n')
112
117
 
@@ -122,7 +127,7 @@ const CODE_CHARTER = [
122
127
  '',
123
128
  'WORK ETHIC — how a reliable teammate behaves (this is the difference between useful and ignored):',
124
129
  ' 1. CLOSE THE LOOP in THIS cycle. Never say "I\'ll do X" and stop. If you commit to something, do it NOW — the human must never have to remind you to circle back. Sending an intent or progress message is not a stop condition: continue using tools and send the verified result or blocker afterward.',
125
- ' 2. FINISH, then REPORT. Always update/move the ticket with update_ticket. Reply in a supplied human source thread when one exists. For backlog-assigned work, do not call post_message yourself: the watcher publishes one evidence-verified result in the dedicated agent channel after the PR and ticket handoff are confirmed.',
130
+ ' 2. FINISH, then REPORT. Update the ticket when the actual work calls for a state change. Reply in a supplied human source thread when one exists. For backlog-assigned work, do not call post_message yourself: the watcher publishes your final response in the dedicated agent channel.',
126
131
  ' 3. Be honest and specific. Never invent progress. If you are genuinely blocked (missing repo, unclear spec, a failing tool), say exactly what you need in one message — that IS closing the loop.',
127
132
  ' 4. One final reply per request; answer several nudges together. A single concrete progress update is allowed during longer work, but it must be followed by the final result or blocker in the same cycle.',
128
133
  ' 5. RECOVER DEAD COMMAND SESSIONS. If write_stdin reports “Unknown process id”, that command session has already exited. Never poll the same process id again. Start a fresh exec_command when more work is required, then continue the task and verify the final state.',
@@ -134,24 +139,20 @@ const CODE_CHARTER = [
134
139
  ].join('\n')
135
140
 
136
141
  const CODE_FULL = [
137
- 'THIS CYCLE: process only the supplied verified ticket or source request. Do not rediscover the entire backlog when a ticket is already supplied. If no source is provided, discover assigned work using tools that actually exist and select one actionable ticket.',
138
- 'DO NOT post a promise or pre-work acknowledgement. Start the repository work immediately. Follow the source delivery rule; any permitted progress update must describe work already performed and must be followed by a verified result or concrete blocker.',
139
- 'For real code work (an assigned ticket, or a mention asking for changes), run the full flow end-to-end:',
140
- ' 1. GET THE CODE: use verified thread/history/recall context first, locate the target repo under your workspace root, and read its AGENTS.md. Reuse an existing clone; clone only if absent. When the repo exists locally, use Read/Grep/Glob and local git for all code discovery and changes; do not use remote codebase tools. Check `git status` before changing anything and preserve unrelated user work. Update from the remote only when it is safe. Do this yourself; never ask the user for a path you can discover.',
141
- ' 2. BRANCH: create a unique agent/<identity>-<ticket>-<slug> branch with git switch -c, or use git worktree add -b in a separate directory when the checkout is shared or dirty. Resume an existing branch only after verifying that it belongs to this task. Never reset an existing branch. NEVER work on, commit to, or push main/master.',
142
- ' 3. CHANGE + VERIFY: Read/Edit/Write the files; run the tests or build if the repo has them.',
143
- ' 4. COMMIT + PUSH YOUR BRANCH: stage only the specific paths or hunks changed for this task; inspect git diff --cached before committing. If unrelated changes are already staged, use an isolated worktree. Commit with a clear message, then publish only your own agent/* branch using the runtime-specific authorized push flow. Never --force, never push to main/master, never merge.',
144
- ' 5. RAISE A PR: gh pr create --fill --base <default-branch> --head agent/<slug> (a clear title + a body summarizing the change and how you verified it). Never gh pr merge.',
145
- ' 6. CLOSE THE LOOP: move/update the ticket with update_ticket. Use a ticket-comment tool for a blocker or clarification only when that tool actually appears; otherwise keep the blocker in the ticket update and let the watcher deliver the visible channel result. When a source thread is supplied, follow its explicit delivery rule: either post once or return final text for watcher delivery. For backlog-only tickets, do not call post_message yourself; the watcher sends one verified dedicated-channel completion message and deduplicates it across reconnects.',
146
- 'Bash is for git / gh / tests / clone ONLY — never to hunt for credentials (they are given to you above).',
142
+ 'Handle the supplied verified ticket or source request. You decide how to investigate, which tools to use, what context to retain or compact, and when the work is ready for a final response.',
143
+ 'There is no required sequence of tools, code edits, ticket updates, or PR creation. Research, audits, explanations, coordination, and already-satisfied requests may finish without repository changes. Choose validation and delivery appropriate to the actual request.',
144
+ 'Use available tool discovery, resources, history, search, skills, and planning tools when useful. Prefer the repository knowledge graph when available. Recover from optional tool failures using alternatives; report only a limitation that actually prevents the requested outcome.',
145
+ 'A usable local clone is your primary code surface. Preserve unrelated work and use an isolated worktree when needed. Remote codebase tools are a fallback only when the repository cannot be obtained locally. For code changes, verify the result and use the authorized branch/PR flow when the request calls for it.',
146
+ 'You own ticket state: use update_ticket when its state should change, including review, completion, or a real blocker. Ending your turn does not mark the ticket done. Your native final response ends this cycle; it can report a result, ask a necessary question, or explain a blocker. Do not describe unperformed work as complete.',
147
+ 'Follow the supplied source delivery rule. For backlog-only tickets, return your final response; the watcher delivers it once to your dedicated channel. Do not call post_message for that same result.',
147
148
  ].join('\n')
148
149
 
149
150
  const CODE_FAST = [
150
151
  'New chat activity. Do EXACTLY ONE of these:',
151
152
  ' • IF a specific mention/message FOR YOU is given above: reply to THAT ONE message exactly once with post_message, then STOP. Do NOT call poll_inbox and do NOT answer anything else this cycle — polling would re-surface the same message and make you double-post.',
152
153
  ' • IF NO specific mention is given above: call poll_inbox and reply only to items directed at YOU (asks you something, or responds to your own message) — SKIP chatter aimed at someone else / another agent; at most one reply per channel.',
153
- 'This is the reply lane: do not edit code, create branches, or publish repository changes. If the request was misclassified and requires code work, report the routing blocker in its source thread; do not claim completion or try to change the repository from this lane.',
154
- 'For a non-code question, post ONE answer and stop. For code work, do not post a generic pickup message. You may post one concrete progress update after work starts, but keep working after it; then post one distinct final result with the PR/test evidence or a real blocker. Never repeat the same message. The final update should be 1-3 sentences.',
154
+ 'Explore the request and its context. If local execution or edits are needed, use openvisio_request_work_session when advertised to continue in your own coding workspace. Do not turn an internal scheduling choice into a reassignment request.',
155
+ 'For a non-code question, post ONE answer and stop. For code work, do not post a generic pickup message. You may post one concrete progress update after work starts, but keep working after it; then post one distinct final result with the PR/test evidence or a real blocker. Never repeat the same message. Let the request determine how much detail the final response needs.',
155
156
  ].join('\n')
156
157
 
157
158
  // ── Workspace-ethics cycles (both chat-only + code agents) ───────────────────
@@ -172,7 +173,8 @@ const SWEEP = [
172
173
  ].join('\n')
173
174
 
174
175
  // Bash covers git/gh/clone/tests; the deny list is where the guardrails live.
175
- const CODE_TOOLS = ['Read', 'Grep', 'Glob', 'Edit', 'Write', 'MultiEdit', 'TodoWrite', 'Bash', 'mcp__openvisio-team__*']
176
+ const CONTEXT_TOOLS = ['Read', 'Grep', 'Glob', 'TodoWrite', 'ToolSearch', 'WebSearch', 'WebFetch', 'Task', 'Agent', 'Skill']
177
+ const CODE_TOOLS = [...CONTEXT_TOOLS, 'Edit', 'Write', 'MultiEdit', 'Bash', 'mcp__openvisio-team__*']
176
178
  // Push + PR creation ARE allowed (agents raise PRs), but main/master, force-pushes,
177
179
  // merges and destructive/publishing/repo-deleting commands stay blocked.
178
180
  const DENY_TOOLS = [
@@ -190,14 +192,9 @@ const DENY_TOOLS = [
190
192
  const FAST = 5000
191
193
  const SLOW = 60000
192
194
  const IDLE_AFTER = 30000
193
- // Recycle the warm session after ONE cycle. Autonomy cycles are independent (a
194
- // mention → a reply, a task → its work), so keeping a session across cycles just
195
- // re-bills the entire prior history on every new cycle — the #1 token sink. Fresh
196
- // per cycle keeps each cycle's cost to its own work (the static charter/creds ride
197
- // in the cached system prompt, so a fresh spawn is cheap).
198
- const MAX_TURNS = 1
195
+ // Keep source-scoped sessions warm; the native agent owns compaction.
199
196
  const SESSION_IDLE_MS = 1200000
200
- // A single cycle must finish within this or it's abandoned — otherwise a hung
197
+ // A silent runtime is stopped after this inactivity interval — otherwise a hung
201
198
  // cycle (e.g. an MCP tool stalling on a down bridge) would leave `busy` stuck
202
199
  // true forever and silently queue every later mention. Code cycles get a much
203
200
  // longer budget: a real clone → branch → test → push → PR flow legitimately takes
@@ -336,20 +333,21 @@ export async function runWatch({ flags }) {
336
333
  }
337
334
 
338
335
  // Claude keeps its native stream; Codex and OpenCode use Mastra ACP.
339
- export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, onEvent, systemPrompt, canCoordinate = false }) {
336
+ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, onEvent, systemPrompt, getContextEnvironment, canCoordinate = false, workspaceAvailable = !!workdir }) {
340
337
  const canCode = !!workdir
341
338
  const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
342
339
  // Codex and OpenCode run through Mastra's ACP harness. Each queue worker gets
343
340
  // its own ACP session and worktree, so cancellation, permissions and context
344
341
  // cannot bleed across concurrently assigned tickets.
345
- const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, canCoordinate, maxCycleMs, log, debug, model, onTool, onEvent, systemPrompt })
342
+ const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, canCoordinate, workspaceAvailable, maxCycleMs, log, debug, model, onTool, onEvent, systemPrompt, getContextEnvironment })
346
343
  if (mastraRunner) return mastraRunner
347
344
  let child = null
345
+ let privateMcpDir = ''
346
+ let contextEnvironment = {}
348
347
  // The model the CURRENT session was spawned with. runCycle can pass a different
349
348
  // model per cycle (cheap for chat, stronger for code) — a change recycles the
350
349
  // session so the new model takes effect.
351
350
  let sessionModel = resolveClaudeModel(model) || null
352
- let turnsThisSession = 0
353
351
  let sessionStartedAt = 0
354
352
  let resolveTurn = null
355
353
  let cycleTimer = null
@@ -363,6 +361,7 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
363
361
  let turnDidResultMessage = false
364
362
  let turnDidMcpTaskRead = false
365
363
  let turnDidMcpTaskUpdate = false
364
+ let turnWorkRequest = null
366
365
  let turnOutputText = ''
367
366
  let turnCycleId = null
368
367
  let turnRuntimePid = null
@@ -374,10 +373,11 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
374
373
  const clearCycleTimer = () => { if (cycleTimer) { clearTimeout(cycleTimer); cycleTimer = null } }
375
374
  const settleTurn = (o) => {
376
375
  clearCycleTimer()
376
+ sessionStartedAt = Date.now()
377
377
  const r = resolveTurn
378
378
  resolveTurn = null
379
379
  const finalText = typeof o.result === 'string' ? o.result.trim() : turnOutputText.trim()
380
- if (r && finalText) emit('output.final', { text: safeText(finalText, 8000), status: o.subtype || 'ok' })
380
+ if (r && finalText) emit('output.final', { text: safeText(finalText, 8000), status: o.is_error ? 'error' : o.subtype || 'error' })
381
381
  const mcpCalls = [...turnToolCalls]
382
382
  if (r) r({
383
383
  ...o,
@@ -390,7 +390,8 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
390
390
  didResultMessage: turnDidResultMessage,
391
391
  didMcpTaskRead: turnDidMcpTaskRead,
392
392
  didMcpTaskUpdate: turnDidMcpTaskUpdate,
393
- outputText: turnOutputText.trim(),
393
+ outputText: finalText,
394
+ workRequest: turnWorkRequest,
394
395
  })
395
396
  }
396
397
 
@@ -415,18 +416,34 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
415
416
  // The charter + creds ride in the system prompt (cacheable → not re-billed each
416
417
  // cycle), leaving only the small per-cycle instruction in the user message.
417
418
  const fallbackModels = claudeFallbackModels(sessionModel)
418
- const base = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--strict-mcp-config', '--mcp-config', mcpConfig, ...(sessionModel ? ['--model', sessionModel, ...(fallbackModels.length ? ['--fallback-model', fallbackModels.join(',')] : [])] : []), ...(systemPrompt ? ['--append-system-prompt', systemPrompt] : [])]
419
- const args = canCode ? [...base, '--allowedTools', ...CODE_TOOLS, '--disallowedTools', ...DENY_TOOLS] : [...base, '--allowedTools', 'mcp__openvisio-team__*']
419
+ let sessionMcpConfig = mcpConfig
420
+ if (mcpUrl && mcpHeaders?.['x-agent-api-key'] && mcpHeaders?.['x-agent-identifier']) {
421
+ if (!privateMcpDir) privateMcpDir = mkdtempSync(join(tmpdir(), 'openvisio-claude-mcp-'))
422
+ sessionMcpConfig = join(privateMcpDir, 'mcp.json')
423
+ writeJson(sessionMcpConfig, { mcpServers: { 'openvisio-team': {
424
+ command: process.execPath, args: [fileURLToPath(new URL('./codex-mcp-proxy.mjs', import.meta.url))],
425
+ env: {
426
+ OPENVISIO_CODEX_MCP_URL: mcpUrl,
427
+ OPENVISIO_CODEX_API_KEY: mcpHeaders['x-agent-api-key'],
428
+ OPENVISIO_CODEX_IDENTIFIER: mcpHeaders['x-agent-identifier'],
429
+ OPENVISIO_CAN_CODE: String(canCode),
430
+ OPENVISIO_WORKSPACE_AVAILABLE: String(workspaceAvailable),
431
+ ...contextEnvironment,
432
+ },
433
+ } } }, true)
434
+ }
435
+ const base = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--mcp-config', sessionMcpConfig, ...(sessionModel ? ['--model', sessionModel, ...(fallbackModels.length ? ['--fallback-model', fallbackModels.join(',')] : [])] : []), ...(systemPrompt ? ['--append-system-prompt', systemPrompt] : [])]
436
+ const args = canCode ? [...base, '--allowedTools', ...CODE_TOOLS, '--disallowedTools', ...DENY_TOOLS] : [...base, '--allowedTools', ...CONTEXT_TOOLS, 'mcp__openvisio-team__*', '--disallowedTools', 'Edit', 'Write', 'MultiEdit', 'Bash']
420
437
  const c = spawn(claude, args, { ...modelProcessOptions, cwd: workdir || undefined, stdio: ['pipe', 'pipe', 'inherit'] })
421
438
  child = c
422
439
  turnRuntimePid = c.pid || null
423
440
  childCycleIds.set(c, turnCycleId)
424
441
  emit('process.started', { status: 'starting' })
425
- turnsThisSession = 0
426
442
  sessionStartedAt = Date.now()
427
443
  let localBuf = ''
428
444
  c.stdout.on('data', (d) => {
429
445
  if (c !== child) return
446
+ cycleTimer?.refresh()
430
447
  localBuf += d
431
448
  let i
432
449
  while ((i = localBuf.indexOf('\n')) >= 0) {
@@ -448,6 +465,7 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
448
465
  if (o.type === 'user' && Array.isArray(o.message?.content)) {
449
466
  for (const block of o.message.content) if (block.type === 'tool_result') {
450
467
  const use = turnToolUses.get(block.tool_use_id)
468
+ if (!block.is_error && use?.name?.endsWith('__' + WORK_SESSION_TOOL)) turnWorkRequest = workSessionRequest(block.content) || turnWorkRequest
451
469
  const title = use?.name === 'Bash' && typeof use.input?.command === 'string' ? use.input.command : use?.name || 'tool'
452
470
  const filePath = ['Read', 'Write', 'Edit', 'MultiEdit', 'Grep', 'Glob'].includes(use?.name) ? use.input?.file_path || use.input?.path : ''
453
471
  emit('tool.finished', { toolCallId: safeText(block.tool_use_id, 200), name: safeText(use?.name || 'tool', 300), title: safeText(title), status: block.is_error ? 'failed' : 'completed', locations: typeof filePath === 'string' && filePath ? [{ path: safeText(filePath, 1000) }] : [] })
@@ -477,13 +495,7 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
477
495
  if (o.type === 'result') {
478
496
  log('cycle done (' + (o.subtype || 'ok') + (o.is_error ? ' · ERROR' : '') + ')')
479
497
  clearCycleTimer()
480
- // Autonomy cycles are independent and MAX_TURNS is one. Do not leave a
481
- // full Claude runtime resident until the next event; release its CPU,
482
- // memory and file watchers as soon as the result has been received.
483
- if (MAX_TURNS === 1 && c === child) {
484
- child = null
485
- void stopModelProcess(c).then(() => settleTurn(o))
486
- } else settleTurn(o)
498
+ settleTurn(o)
487
499
  return
488
500
  }
489
501
  }
@@ -493,10 +505,11 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
493
505
  c.on('close', (code, signal) => emit('process.stopped', { cycleId: childCycleIds.get(c) ?? null, runtimePid: c.pid || null, status: 'exited', exitCode: code, signal }))
494
506
  log('warm session started' + (sessionModel ? ' [model ' + sessionModel + ']' : '') + (canCode
495
507
  ? ' [CODE mode — workspace ' + workdir + ' — finds/clones the org\'s repos here, branches, pushes, opens PRs]'
496
- : ' [CHAT-ONLY mode (--chat-only) — chat/ticket tools only, no code work]'))
508
+ : ' [CONTEXT session — advertised tools, reading, search, and planning]'))
497
509
  }
498
510
 
499
- function runCycle(prompt, cycleModel, cycleOptions = {}) {
511
+ async function runCycle(prompt, cycleModel, cycleOptions = {}) {
512
+ contextEnvironment = await getContextEnvironment?.() || {}
500
513
  return new Promise((resolve) => {
501
514
  turnCycleId = cycleOptions.cycleId ?? null
502
515
  // A per-cycle model override (e.g. chat on a cheaper model than code) — a
@@ -507,14 +520,13 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
507
520
  if (resolvedCycleModel !== cycleModel) log('model ' + cycleModel + ' resolved to Claude family alias ' + resolvedCycleModel)
508
521
  sessionModel = resolvedCycleModel
509
522
  }
510
- if (child && (turnsThisSession >= MAX_TURNS || Date.now() - sessionStartedAt > SESSION_IDLE_MS)) {
511
- log('recycling session (turns=' + turnsThisSession + ')')
523
+ if (child && (Date.now() - sessionStartedAt > SESSION_IDLE_MS)) {
524
+ log('recycling idle session')
512
525
  try { child.kill() } catch { /* already gone */ }
513
526
  child = null
514
527
  }
515
528
  ensureSession()
516
529
  if (child) childCycleIds.set(child, turnCycleId)
517
- turnsThisSession++
518
530
  turnToolCalls = new Set()
519
531
  turnToolUses = new Map()
520
532
  turnMcpErrors = new Set()
@@ -525,6 +537,7 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
525
537
  turnDidResultMessage = false
526
538
  turnDidMcpTaskRead = false
527
539
  turnDidMcpTaskUpdate = false
540
+ turnWorkRequest = null
528
541
  turnOutputText = ''
529
542
  resolveTurn = resolve
530
543
  // Backstop: abandon a cycle that never returns a result so `busy` is released
@@ -548,7 +561,10 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
548
561
  child = null
549
562
  return stopModelProcess(active).then(() => settleTurn({ type: 'result', subtype: 'canceled' }))
550
563
  }
551
- return { runCycle, canCode, cancelCurrent, close: () => { void cancelCurrent() } }
564
+ return { runCycle, canCode, cancelCurrent, close: async () => {
565
+ await cancelCurrent()
566
+ if (privateMcpDir) { rmSync(privateMcpDir, { recursive: true, force: true }); privateMcpDir = '' }
567
+ } }
552
568
  }
553
569
 
554
570
  // ── the backend WS loop ──────────────────────────────────────────────────────
@@ -595,16 +611,15 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
595
611
  const canCode = !!workdir
596
612
  // The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
597
613
  // agent_identifier + agent_api_key as arguments. Hand them over up front.
598
- const authRule = agent !== 'claude'
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.'
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.`
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.`
614
+ const authRule = 'AUTH: the local OpenVisio MCP bridge injects authentication outside the model. Use openvisio-team-watcher for Codex/OpenCode and openvisio-team for Claude; these are the authenticated servers for this session. Never add, request, print, or search for agent credentials in tool arguments.'
615
+ 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. Use tool and resource discovery when helpful, and call only advertised capabilities. 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.`
602
616
  // Keep the static charter stable so provider prompt caching can reuse it.
603
617
  // Actual cache hits and billing depend on the runtime and model provider.
604
618
  const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
605
619
  const runnerOptions = {
606
620
  claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
607
- cfgKey: identifier, mcpConfig, workdir, log, debug, model, systemPrompt, onEvent: emit,
621
+ cfgKey: identifier, mcpConfig, workdir, workspaceAvailable: canCode, log, debug, model, systemPrompt, onEvent: emit,
622
+ getContextEnvironment: () => contextService.environment(),
608
623
  }
609
624
  // One watcher and one WS subscription, with a bounded pool of isolated coding
610
625
  // runtimes. Replies serialize within a thread while independent threads run
@@ -632,12 +647,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
632
647
  const codexPushGuide = agent === 'codex' && canCode
633
648
  ? '\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.'
634
649
  : ''
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.'
650
+ const backendToolRule = 'TOOL DISCOVERY: use the tools and resources actually advertised by your connected runtime. Native tool_search, MCP resources, history, search, planning, skills, and context compaction are available when the runtime supports them. Discover an absent action before deciding it is unavailable; never invent a tool or its schema. The supplied event identifies the request, so further discovery is your choice when it helps the task.' + '\n\n' + contextToolGuide
636
651
  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
652
  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
653
  const fastPrompt = (canCode ? CODE_FAST : CYCLE_FAST) + '\n\n' + backendToolRule + studioGuide
639
654
  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
655
+ 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. Return the final reply for watcher delivery instead of calling post_message for the same result. Discover tools and retrieve additional context as needed. 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 one natural, context-specific final response with enough detail to address the request. 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
641
656
  // Live model state — changeable at runtime by the in-chat `/model` command.
642
657
  // codeModel drives full/sweep cycles; chatModel (if set) the lighter fast/intro
643
658
  // ones, so routine chatter can run cheaper than real code work.
@@ -713,16 +728,25 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
713
728
  databasePath: join(stateDir, 'watch-' + slug + '-mastra.db'),
714
729
  resourceId: identifier,
715
730
  })
731
+ const contextBackend = new URL(backend || mcpUrl || 'http://local.invalid')
732
+ const contextService = dependencies.contextService || createContextService({
733
+ directory: stateDir, resourceId: `${contextBackend.origin}${contextBackend.pathname.replace(/\/$/, '')}:${identifier}`,
734
+ loadTasks: () => loadPendingTickets(),
735
+ listTools: async () => (await mcpClient.listTools()).map(toolWithoutCredentialInputs),
736
+ redact: (text) => apiKey ? String(text).split(apiKey).join('[redacted]') : String(text),
737
+ })
716
738
  // Completion delivery is runtime-owned for assigned coding work. Persist both
717
739
  // pending and delivered keys so a reconnect can finish a missed notification
718
740
  // without re-running the model or posting the same result twice.
719
741
  const pendingCompletionReports = new Set(Array.isArray(replayState.pendingCompletionReports) ? replayState.pendingCompletionReports : [])
742
+ const pendingAgentResults = new Map(Array.isArray(replayState.pendingAgentResults) ? replayState.pendingAgentResults : [])
743
+ const finishedTaskVersions = new Map(Array.isArray(replayState.finishedTaskVersions) ? replayState.finishedTaskVersions : [])
720
744
  const reportedCompletions = new Set(Array.isArray(replayState.reportedCompletions) ? replayState.reportedCompletions : [])
721
745
  const reportedTaskComments = new Set(Array.isArray(replayState.reportedTaskComments) ? replayState.reportedTaskComments : [])
722
- // Evidence-gate failures are held at the ticket revision that produced them.
746
+ // Transport/runtime failures are held at the ticket revision that produced them.
723
747
  // This is distinct from a policy block: any later human ticket change resumes
724
748
  // the work, but reconnects and the watcher's own blocker update do not.
725
- const failedTaskVersions = new Map(Array.isArray(replayState.failedTaskVersions) ? replayState.failedTaskVersions : [])
749
+ const failedTaskVersions = new Map(replayState.completionPolicy === 'agent' && Array.isArray(replayState.failedTaskVersions) ? replayState.failedTaskVersions : [])
726
750
  // A policy-blocked task stays paused across reconnects. Helper blocks are
727
751
  // released by verified local configuration, or cleared when completed/unassigned. This prevents a 30-minute reconciliation retry from repeatedly
728
752
  // attempting the same rejected egress action.
@@ -749,6 +773,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
749
773
  blockedTasks: [...blockedTasks],
750
774
  blockedTaskRepos: [...blockedTaskRepos],
751
775
  pendingCompletionReports: [...pendingCompletionReports],
776
+ pendingAgentResults: [...pendingAgentResults],
777
+ finishedTaskVersions: [...finishedTaskVersions],
778
+ completionPolicy: 'agent',
752
779
  reportedCompletions: [...reportedCompletions],
753
780
  reportedTaskComments: [...reportedTaskComments],
754
781
  }, true)
@@ -817,18 +844,18 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
817
844
  // Context lines from the events themselves (the WS payload already carries the
818
845
  // channel + message / task), so the agent acts on THEM directly instead of
819
846
  // hoping poll_inbox re-surfaces the same item. Each queue entry retains that
820
- // source independently through work, recovery, and delivery.
847
+ // source independently through work, continuation, and delivery.
821
848
  let backlogProbeBusy = false
822
849
  let selfAgentId = null
823
850
  let selfOrganizationId = null
824
- let completionRecipientName = ''
851
+ let identityContext = memory.get?.(`identity:${identifier}`)?.meta?.context || agentProfileContext({}, identifier)
825
852
  const selfAliases = new Set([slug, identifier].map((value) => String(value || '').toLowerCase()).filter(Boolean))
826
853
  const rememberSelfAgent = (self) => {
827
854
  if (!self || typeof self !== 'object' || String(self.identifier || self.slug || '') !== identifier) return
828
855
  if (validBackendId(self.id)) selfAgentId = Number(self.id)
829
856
  for (const alias of [self.name, self.identifier, self.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
830
- const addedBy = agentAddedByName(self)
831
- if (addedBy) completionRecipientName = addedBy
857
+ identityContext = agentProfileContext(self, identifier)
858
+ memory.remember({ key: `identity:${identifier}`, kind: 'identity', state: 'configured', summary: self.name || identifier, meta: { context: identityContext } })
832
859
  const organizationId = self.organization_id ?? self.organizationId
833
860
  if (validBackendId(organizationId)) {
834
861
  selfOrganizationId = Number(organizationId)
@@ -843,6 +870,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
843
870
  emit('tool.started', { ...metadata, status: 'in_progress' })
844
871
  return trackOperation(Promise.resolve().then(() => mcpClient.callTool(name, args)).then((result) => {
845
872
  emit('tool.finished', { ...metadata, status: 'completed' })
873
+ try { contextService.observe({ name, args, result }) } catch { /* optional conversation indexing */ }
846
874
  return result
847
875
  }, (error) => {
848
876
  emit('tool.finished', { ...metadata, status: 'failed' })
@@ -1091,6 +1119,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1091
1119
  content: message,
1092
1120
  }))
1093
1121
  const postedId = result.id ?? result.message?.id ?? result.message_id
1122
+ if (postedId != null) contextService.observe({ name: 'post_message', args: { channel_id: channelId, parent_id: parentId, content: message }, result: { id: postedId } })
1094
1123
  if (parentId == null && postedId != null) saveOwnThread(channelId, postedId, message)
1095
1124
  deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
1096
1125
  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 } })
@@ -1156,21 +1185,34 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1156
1185
  return true
1157
1186
  }
1158
1187
 
1159
- const announceTaskCompletion = async (taskRef, result = {}) => {
1188
+ const announceTaskCompletion = async (taskRef, result = {}, isCancelled = () => false) => {
1160
1189
  const projectId = Number(taskRef?.projectId)
1161
1190
  const ticketId = Number(taskRef?.ticketId)
1162
1191
  if (!Number.isFinite(projectId) || !Number.isFinite(ticketId)) return false
1163
1192
  const taskKey = `${projectId}:${ticketId}`
1193
+ if (String(result.outputText || '').trim()) {
1194
+ pendingAgentResults.set(taskKey, { outputText: result.outputText, didChannelMessage: !!result.didChannelMessage })
1195
+ pendingCompletionReports.add(taskKey)
1196
+ trimMap(pendingAgentResults); trimSeen(pendingCompletionReports); persistReplay()
1197
+ }
1198
+ result = pendingAgentResults.get(taskKey)
1199
+ if (!result) return false
1164
1200
  const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
1165
1201
  const ticket = current.ticket ?? current.task ?? current
1166
- const completionTypes = await taskTypeSets(projectId)
1167
- const report = buildTaskCompletionReport(ticket, { projectId, fallbackText: result.outputText, recipientName: completionRecipientName, completedTypeIds: completionTypes.done, reviewTypeIds: completionTypes.review })
1202
+ if (String(ticket.id) !== String(ticketId) || (ticket.project_id != null && String(ticket.project_id) !== String(projectId)) || !taskBelongsToAgent(ticket, { id: selfAgentId, identifier })) {
1203
+ pendingAgentResults.delete(taskKey); pendingCompletionReports.delete(taskKey); persistReplay()
1204
+ return false
1205
+ }
1206
+ if (isCancelled()) return false
1207
+ const report = result.report || buildTaskCompletionReport(ticket, { projectId, fallbackText: result.outputText, agentFinal: true })
1208
+ if (report && !result.report) { result.report = report; persistReplay() }
1209
+ finishedTaskVersions.set(taskKey, taskRevision(ticket)); trimMap(finishedTaskVersions); persistReplay()
1168
1210
  if (!report) return false
1169
1211
  const memoryKey = `ticket:${projectId}:${ticketId}`
1170
1212
  memory.remember({
1171
1213
  key: memoryKey,
1172
1214
  kind: 'ticket',
1173
- state: 'handoff',
1215
+ state: 'responded',
1174
1216
  summary: report.content,
1175
1217
  refs: { projectId, ticketId },
1176
1218
  meta: { reportKey: report.key, prUrl: report.prUrl },
@@ -1189,7 +1231,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1189
1231
  reportedTaskComments.add(report.key); trimSeen(reportedTaskComments); persistReplay()
1190
1232
  }
1191
1233
  if (reportedCompletions.has(report.key)) {
1192
- pendingCompletionReports.delete(taskKey)
1234
+ pendingCompletionReports.delete(taskKey); pendingAgentResults.delete(taskKey)
1193
1235
  persistReplay()
1194
1236
  return true
1195
1237
  }
@@ -1198,15 +1240,16 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1198
1240
  // second top-level copy.
1199
1241
  if (result.didChannelMessage) {
1200
1242
  reportedCompletions.add(report.key); trimSeen(reportedCompletions)
1201
- pendingCompletionReports.delete(taskKey); persistReplay()
1243
+ pendingCompletionReports.delete(taskKey); pendingAgentResults.delete(taskKey); persistReplay()
1202
1244
  log('completion for ticket #' + ticketId + ' was already posted by the work cycle')
1203
1245
  return true
1204
1246
  }
1205
1247
  const channelId = await projectStatusChannel(projectId)
1206
1248
  if (channelId == null || !Number.isFinite(channelId)) return false
1249
+ if (isCancelled()) return false
1207
1250
  await postMessageOnce({ key: `completion:${report.key}`, projectId, channelId, content: report.content, sourceKey: memoryKey })
1208
1251
  reportedCompletions.add(report.key); trimSeen(reportedCompletions)
1209
- pendingCompletionReports.delete(taskKey); persistReplay()
1252
+ pendingCompletionReports.delete(taskKey); pendingAgentResults.delete(taskKey); persistReplay()
1210
1253
  memory.remember({ key: memoryKey, kind: 'ticket', state: 'reported', summary: report.content, refs: { projectId, ticketId, channelId }, meta: { reportKey: report.key, prUrl: report.prUrl } })
1211
1254
  log('posted verified completion for ticket #' + ticketId + ' in channel ' + channelId)
1212
1255
  return true
@@ -1311,6 +1354,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1311
1354
  for (const task of Array.isArray(tasksData.tasks) ? tasksData.tasks : []) {
1312
1355
  if (!await resolveTaskOwner(task)) continue
1313
1356
  const taskKey = `${project.id}:${task.id}`
1357
+ if (pendingAgentResults.has(taskKey)) {
1358
+ try { await announceTaskCompletion({ projectId: project.id, ticketId: task.id }) }
1359
+ catch (error) { log('agent result delivery pending: ' + (error?.message || error)) }
1360
+ }
1314
1361
  if (taskIsCompleted(task, doneIds) || taskIsAwaitingReview(task, reviewIds)) {
1315
1362
  memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'handoff', summary: task.title, refs: { projectId: project.id, ticketId: task.id } })
1316
1363
  if (pendingCompletionReports.has(taskKey)) {
@@ -1418,9 +1465,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1418
1465
  const baseFor = (kind, delivery) => delivery?.watcherOwned
1419
1466
  ? (kind === 'full' ? fullPrompt + '\n\n' + guardedReplyPrompt : guardedReplyPrompt)
1420
1467
  : kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? coordinatePrompt : fastPrompt
1421
- // Every coding runtime now exposes structured action evidence. A normal CLI
1422
- // result or assistant prose never closes a work cycle by itself.
1423
- const cycleSucceeded = (result) => !!result && !result.is_error && [undefined, null, '', 'ok', 'success'].includes(result.subtype)
1468
+ // A native final turn ends a cycle. Ticket completion is a separate board
1469
+ // action chosen by the agent; tool counts never determine either decision.
1470
+ const cycleSucceeded = (result) => !!result && !result.is_error && ['ok', 'success'].includes(result.subtype)
1424
1471
  const releaseTaskForRetry = (taskRef, prompt) => {
1425
1472
  const directProject = Number(taskRef?.projectId)
1426
1473
  const directTicket = Number(taskRef?.ticketId)
@@ -1445,7 +1492,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1445
1492
  emit('cycle.queued', { ...cycleData, status: 'queued' })
1446
1493
  return queues[laneName].enqueue({ kind, context, targetChannels, taskRef, delivery, workdir: itemWorkdir, control }, key).then((result) => {
1447
1494
  const status = control.cancelled || result?.status === 'canceled' ? 'canceled' : result?.status === 'failed' ? 'error' : control.outcome
1448
- emit(status === 'canceled' ? 'cycle.cancelled' : 'cycle.finished', { ...cycleData, status, durationMs: Math.round(performance.now() - (control.startedAt ?? control.enqueuedAt)) })
1495
+ emit(status === 'canceled' ? 'cycle.cancelled' : 'cycle.finished', { ...cycleData, status, finishedBy: control.finishedBy || 'runtime', durationMs: Math.round(performance.now() - (control.startedAt ?? control.enqueuedAt)) })
1449
1496
  return result
1450
1497
  })
1451
1498
  }
@@ -1461,6 +1508,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1461
1508
  log(`cycle timing queue=${Math.round(cycleStartedAt - (cycleControl.enqueuedAt ?? cycleStartedAt))}ms kind=${kind}`)
1462
1509
  const ctx = context ? [context] : []
1463
1510
  const activeTaskRef = taskRef
1511
+ let verifiedTaskRevision = ''
1464
1512
  const ticketWorktree = preparedWorkdir || (kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : '')
1465
1513
  const runnerOptions = {
1466
1514
  cycleId: cycleControl.cycleId, attempt: 1,
@@ -1478,8 +1526,13 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1478
1526
  const memoryRefs = delivery
1479
1527
  ? { channelId: delivery.channelId, threadId: delivery.parentId }
1480
1528
  : activeTaskRef ? { projectId: activeTaskRef.projectId, ticketId: activeTaskRef.ticketId } : {}
1481
- const recalled = await memory.context(memoryRefs)
1482
- const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind, delivery)
1529
+ let recalled = ''
1530
+ try { recalled = await memory.context(memoryRefs) }
1531
+ catch (error) { log('Optional history unavailable; agent can retrieve context with its tools: ' + (error?.message || error)) }
1532
+ const capabilityContext = kind !== 'full' && canCode
1533
+ ? 'YOUR CODING WORKSPACE IS AVAILABLE. If this request needs local execution or edits, call openvisio_request_work_session with the findings and context to carry forward, then end this turn. The same agent continues the same request in its coding workspace. Do not claim to be a chat-only agent or ask anyone to reassign the ticket.'
1534
+ : canCode ? 'This session has your configured coding workspace. Choose the tools and context appropriate to the request.' : 'This connection has no configured local coding workspace. Use your available tools for the request; do not claim local changes you cannot perform.'
1535
+ const prompt = identityContext + '\n\n' + capabilityContext + '\n\n' + (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind, delivery)
1483
1536
  // Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
1484
1537
  // work (full/sweep) uses the main model.
1485
1538
  refreshModelSettings()
@@ -1494,6 +1547,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1494
1547
  if (activeTaskRef) {
1495
1548
  const data = toolData(await callMcpTool('get_ticket', { project_id: activeTaskRef.projectId, ticket_id: activeTaskRef.ticketId }))
1496
1549
  const ticket = data.ticket ?? data.task ?? data
1550
+ verifiedTaskRevision = taskRevision(ticket)
1497
1551
  const typeSets = await taskTypeSets(activeTaskRef.projectId)
1498
1552
  if (blockedTasks.has(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`) ||
1499
1553
  !taskBelongsToAgent(ticket, { id: selfAgentId, identifier }) ||
@@ -1507,7 +1561,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1507
1561
  cycleControl.outcome = 'running'
1508
1562
  log(`cycle timing preflight=${Math.round(performance.now() - cycleStartedAt)}ms kind=${kind}`)
1509
1563
  const result = await runner.runCycle(prompt, useModel, runnerOptions)
1510
- cycleControl.outcome = result?.subtype || 'ok'
1564
+ cycleControl.outcome = result?.is_error ? 'error' : result?.subtype || 'error'
1511
1565
  if (result?.timings) log(`cycle timing runtime=${JSON.stringify(result.timings)} kind=${kind}`)
1512
1566
  refreshModelSettings()
1513
1567
  if (cycleModelRevision === modelSettingsRevision && result?.model && result.model !== useModel) {
@@ -1518,7 +1572,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1518
1572
  if (liteModel === useModel) { liteModel = result.model; changed = true }
1519
1573
  if (changed) persistModel()
1520
1574
  }
1521
- let completionResult = result
1575
+ const completionResult = result
1522
1576
  if (cycleControl.cancelled || result?.subtype === 'canceled') {
1523
1577
  log(laneName + ' cycle cancelled; no blocker or reply will be published')
1524
1578
  return
@@ -1539,56 +1593,36 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1539
1593
  finally { if (activeTaskRef) await finalizeFailedTaskPause(activeTaskRef) }
1540
1594
  return
1541
1595
  }
1542
- const replyMutationErrors = kind !== 'full' ? blockingReplyMcpErrors(result?.mcpErrors) : []
1543
- if (replyMutationErrors.length) {
1544
- cycleControl.outcome = 'blocked'
1545
- const notice = `I couldn't complete the requested OpenVisio action: ${replyMutationErrors.join(', ')}. I haven't claimed that change succeeded.`
1546
- log('COORDINATION_CYCLE_BLOCKED failed MCP calls; publishing blocker')
1547
- try { await publishBlocker({ prompt, taskRef: activeTaskRef, delivery, notice }) }
1548
- catch (e) { log('failed to publish coordination blocker: ' + (e?.message || e)) }
1596
+ if (result?.workRequest && kind !== 'full' && canCode) {
1597
+ // An agent-selected continuation, not a heuristic retry or a new task.
1598
+ // Preserve the authoritative recipient and cancellation key; the tool
1599
+ // cannot select another task, channel, identity, or permission scope.
1600
+ cycleControl.outcome = 'continued'
1601
+ cycleControl.finishedBy = 'agent'
1602
+ const continuation = String(result.workRequest.context || '').slice(0, 12000)
1603
+ const handoffKey = `continuation:${cycleControl.cycleId}`
1604
+ memory.remember({ key: handoffKey, kind: 'continuation', state: 'requested', summary: continuation, refs: memoryRefs, meta: { context: continuation, requestedBy: 'agent' } })
1605
+ const sourceKey = delivery?.sourceKey || (activeTaskRef ? `ticket:${activeTaskRef.projectId}:${activeTaskRef.ticketId}` : '')
1606
+ if (sourceKey) memory.connect(sourceKey, handoffKey, 'continued_with')
1607
+ emit('cycle.continued', { cycleId: cycleControl.cycleId, status: 'continued', reason: 'agent requested coding workspace' })
1608
+ void drain('full', `${context || ''}\n\nYOUR CONTINUATION CONTEXT:\n${continuation}\nContinue the original request using your configured workspace. Preserve the same role, voice, recipient, and authorization.`, targetChannels, activeTaskRef, delivery)
1549
1609
  return
1550
1610
  }
1551
- if (kind !== 'full' && result?.mcpErrors?.length) {
1552
- log('reply cycle had read-only MCP failures (' + result.mcpErrors.join(', ') + '); preserving the scoped model reply')
1611
+ // The native runtime's successful final turn is the agent's decision to
1612
+ // yield. Tool activity is telemetry, not a universal completion checklist.
1613
+ // Optional failures and alternative workflows remain the agent's concern.
1614
+ cycleControl.outcome = 'ok'
1615
+ cycleControl.finishedBy = 'agent'
1616
+ if (result?.mcpErrors?.length) log('agent ended its turn with tool diagnostics: ' + result.mcpErrors.join(', '))
1617
+ if (activeTaskRef && !String(completionResult?.outputText || '').trim()) {
1618
+ finishedTaskVersions.set(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`, verifiedTaskRevision)
1619
+ trimMap(finishedTaskVersions); persistReplay()
1553
1620
  }
1554
- // Model prose never proves success or a blocker. Full cycles must produce
1555
- // runtime-observed ticket reads, repository evidence, and ticket updates.
1556
- const ticketCycle = !!activeTaskRef || /ticket\s+#?\d+.*?project\s+\d+/i.test(prompt)
1557
- const resultMessageRequired = kind === 'full' && !!delivery && !delivery.watcherOwned
1558
- const missing = missingRuntimeWorkEvidence(result, { ticketCycle, resultMessageRequired })
1559
- if (kind === 'full' && cycleSucceeded(result) && missing.length) {
1560
- cycleControl.outcome = 'recovering'
1561
- emit('cycle.recovery', { cycleId: cycleControl.cycleId, kind, status: 'recovering', attempt: 2, missing })
1562
- log(agent + ' coding cycle incomplete; recovery requires: ' + missing.join(', '))
1563
- const recoveryPrompt = `CONTINUE THE SAME OPENVISIO REQUEST. Your earlier output did not complete it. Missing runtime evidence: ${missing.join('; ')}. An intent or progress message is not completion. Continue the actual work now, verify it, and then provide one distinct final result or real blocker using the original delivery rule; a final result after an earlier progress message is explicitly allowed and required. Do not repeat the progress message. ${codexPushGuide}\n\nORIGINAL REQUEST AND ROUTING CONTEXT:\n${prompt}`
1564
- const recovery = await runner.runCycle(recoveryPrompt, codeModel, { ...runnerOptions, attempt: 2 })
1565
- cycleControl.outcome = recovery?.subtype || 'ok'
1566
- if (cycleControl.cancelled || recovery?.subtype === 'canceled') return
1567
- const recoveredResult = combineRuntimeWorkEvidence(result, recovery)
1568
- const recoveryMissing = missingRuntimeWorkEvidence(recoveredResult, { ticketCycle, resultMessageRequired })
1569
- if (!cycleSucceeded(recovery) || recoveryMissing.length) {
1570
- cycleControl.outcome = 'blocked'
1571
- if (recovery?.subtype === 'blocked' && recovery?.policyBlock) {
1572
- try { await reportPolicyBlock(prompt, activeTaskRef, recovery.policyBlock, delivery) }
1573
- catch (e) { log('failed to publish recovery policy blocker: ' + (e?.message || e)) }
1574
- } else {
1575
- const unresolved = recoveryMissing.length ? recoveryMissing : [`the recovery cycle ended with ${recovery?.subtype || 'an unknown error'}`]
1576
- const notice = `I'm blocked after one recovery attempt. Missing required evidence: ${unresolved.join('; ')}. I've paused this revision until the ticket changes, and I'm not claiming completion.`
1577
- if (activeTaskRef) pauseFailedTask(activeTaskRef)
1578
- try { await publishBlocker({ prompt, taskRef: activeTaskRef, delivery, notice }) }
1579
- catch (e) { log('failed to publish recovery blocker: ' + (e?.message || e)) }
1580
- finally { if (activeTaskRef) await finalizeFailedTaskPause(activeTaskRef) }
1581
- }
1582
- log('WORK_CYCLE_FAILED evidence gate still incomplete after one recovery; ticket paused until its revision changes')
1583
- return
1584
- }
1585
- completionResult = recoveredResult
1586
- }
1587
- if (kind === 'full' && activeTaskRef) {
1621
+ if (activeTaskRef) {
1588
1622
  try {
1589
- const delivered = await announceTaskCompletion(activeTaskRef, completionResult)
1623
+ const delivered = await announceTaskCompletion(activeTaskRef, completionResult, () => cycleControl.cancelled)
1590
1624
  if (!delivered) {
1591
- log('completion report deferred for ticket #' + activeTaskRef.ticketId + '; waiting for verified review/done state and PR evidence without rerunning repository work')
1625
+ log('completion report deferred for ticket #' + activeTaskRef.ticketId + '; waiting for result delivery without rerunning the agent')
1592
1626
  }
1593
1627
  } catch (e) {
1594
1628
  log('completion report failed for ticket #' + activeTaskRef.ticketId + ': ' + (e?.message || e) + '; retained for delivery retry without rerunning repository work')
@@ -1664,6 +1698,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1664
1698
  const belongsToSelf = await resolveTaskOwner(ticket)
1665
1699
  const key = `${projectId}:${ticketId}`
1666
1700
  if (!belongsToSelf) {
1701
+ finishedTaskVersions.delete(key); pendingAgentResults.delete(key)
1667
1702
  for (const [name, queue] of Object.entries(queues)) {
1668
1703
  queue.cancel((item) => String(item.taskRef?.projectId) === String(projectId) && String(item.taskRef?.ticketId) === String(ticketId), (item) => {
1669
1704
  item.control.cancelled = true
@@ -1673,6 +1708,13 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1673
1708
  memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'unassigned', summary: ticket.title, refs: { projectId, ticketId } })
1674
1709
  blockedTasks.delete(key); blockedTaskRepos.delete(key); pendingCompletionReports.delete(key); failedTaskVersions.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return
1675
1710
  }
1711
+ if (finishedTaskVersions.has(key)) {
1712
+ if (finishedTaskVersions.get(key) === taskRevision(ticket)) {
1713
+ if (pendingAgentResults.has(key)) await announceTaskCompletion({ projectId, ticketId })
1714
+ return
1715
+ }
1716
+ finishedTaskVersions.delete(key); seenTasks.delete(key); persistReplay()
1717
+ }
1676
1718
  if (taskIsCompleted(ticket, typeSets.done) || taskIsAwaitingReview(ticket, typeSets.review)) {
1677
1719
  memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'handoff', summary: ticket.title, refs: { projectId, ticketId } })
1678
1720
  if (pendingCompletionReports.has(key)) {
@@ -1704,10 +1746,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1704
1746
  log(kind + ' verified ticket #' + ticketId + ' “' + title + '” -> ' + cycleKind + ' lane')
1705
1747
  const activityChannel = await projectStatusChannel(projectId)
1706
1748
  if (activityChannel != null) sendStatus(activityChannel, 'thinking')
1707
- if (cycleKind === 'full') { pendingCompletionReports.add(key); trimSeen(pendingCompletionReports); persistReplay() }
1708
1749
  const humanTicketRef = ticketSlug ? `ticket ${ticketSlug}` : `the ticket “${title}”`
1709
1750
  const workspaceDirective = preparedWorktree ? ` A prepared local git worktree for this ticket exists at ${JSON.stringify(preparedWorktree)}. Start repository inspection and edits there. Do not call list_codebases, codebase_tree, or get_codebase while this local worktree is available.` : ''
1710
- void drain(cycleKind, `Authoritative get_ticket verification confirms ${humanTicketRef} is open and assigned to YOU. Internal tool identity: project_id ${projectId}, ticket_id ${ticketId}. For every get_ticket call pass exactly { project_id: ${projectId}, ticket_id: ${ticketId} }; for list_task_types pass exactly { project_id: ${projectId} }. Numeric ids are MCP arguments only and must never appear in human-facing text; use ${ticketSlug || 'the ticket title'} instead. Ticket details: ${JSON.stringify({ slug: ticketSlug, title, description: ticket.description, priority: ticket.priority, typeId: ticket.type_id ?? ticket.typeId })}.${workspaceDirective} This assignment has no source thread: do not call post_message yourself. Use list_task_types and update_ticket to move it active, complete and verify the work, open the PR when applicable, then update/move the ticket with evidence. For coding work, the watcher will publish exactly one verified completion result in the dedicated agent channel.`, activityChannel == null ? [] : [activityChannel], { projectId, ticketId, channelId: activityChannel })
1751
+ void drain(cycleKind, `Authoritative get_ticket verification confirms ${humanTicketRef} is open and assigned to YOU. Internal tool identity: project_id ${projectId}, ticket_id ${ticketId}. For every get_ticket call pass exactly { project_id: ${projectId}, ticket_id: ${ticketId} }; for list_task_types pass exactly { project_id: ${projectId} }. Numeric ids are MCP arguments only and must never appear in human-facing text; use ${ticketSlug || 'the ticket title'} instead. Ticket details: ${JSON.stringify({ slug: ticketSlug, title, description: ticket.description, priority: ticket.priority, typeId: ticket.type_id ?? ticket.typeId })}.${workspaceDirective} This assignment has no source thread: do not call post_message yourself. Choose the tools, context, validation, and ticket state appropriate to this request. Return your final response when you decide this turn is finished; the watcher will publish it once in the dedicated agent channel without changing ticket status.`, activityChannel == null ? [] : [activityChannel], { projectId, ticketId, channelId: activityChannel })
1711
1752
  } catch (e) {
1712
1753
  log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
1713
1754
  }
@@ -1826,6 +1867,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1826
1867
  }
1827
1868
  if (cid != null && threadRoot != null) activateThread(cid, threadRoot, text)
1828
1869
  memory.remember({ key: sourceKey, kind: 'mention', state: 'received', summary: text, refs: { channelId: cid, threadId: threadRoot, messageId: mid } })
1870
+ try { contextService.observe({ name: 'list_message_thread', args: { channel_id: cid, message_id: threadRoot }, result: { ...msg, id: mid, content: String(msg.content || msg.body || msg.text || '') } }) }
1871
+ catch { /* optional conversation indexing */ }
1829
1872
  // Every runtime carries the source thread through the queue so a later
1830
1873
  // redirect can cancel its process. Codex additionally delegates the final
1831
1874
  // post to the watcher for an authoritative last-moment thread check.
@@ -1891,8 +1934,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1891
1934
  const replyDelivery = conversationDelivery(codingMention ? 'result' : 'reply', !codingMention)
1892
1935
  let ctx = cid != null
1893
1936
  ? replyDelivery?.watcherOwned
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.`
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.'}`
1937
+ ? `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}".` : ''} Use additional discovery, resources, and history when they help answer this request.`
1938
+ : `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)` : ''}, using the authenticated session bridge. 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.` : ''} Use additional discovery, resources, and history when they help answer this request.${codingMention ? ' Stop only after the work and final result/blocker.' : ' After your single reply, STOP.'}`
1896
1939
  : undefined
1897
1940
  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.'
1898
1941
  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.`
@@ -2002,6 +2045,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2002
2045
  await Promise.allSettled([...mentionIntake.values(), ...Object.values(queues).map((queue) => queue.settled()), ...messageDeliveries.values(), ...pendingOperations])
2003
2046
  workRunners.close()
2004
2047
  replyRunners.close()
2048
+ try { await contextService.close() } catch (error) { log('context search shutdown failed: ' + (error?.message || error)) }
2005
2049
  try { await memory.settled?.() }
2006
2050
  catch (error) { log('memory shutdown failed: ' + (error?.message || error)) }
2007
2051
  finally {