openvisio-agent 0.18.1 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,6 +58,8 @@ Runs the **autonomy loop** — the agent replies to @mentions and picks up ticke
58
58
 
59
59
  Backend/BYO watchers reconcile immediately whenever the process starts or the WebSocket connects. A direct MCP session uses the backend's actual tools (`list_agents`, `list_projects`, `list_tasks`, `list_task_types`, and `list_activity`) to recover assigned tasks and recent mention activity missed while offline. The same zero-model check runs every five minutes as a safety net; a model starts only when pending work exists.
60
60
 
61
+ The backend MCP may be stateful or stateless. A successful initialize response without `Mcp-Session-Id` is accepted as stateless, so OpenCode agents do not stop with “MCP initialize returned no session id.” Each OpenCode lane keeps its MCP identity in a private per-agent config directory while the repository is supplied separately with `--dir`; stale workspace configuration therefore cannot swap one agent's credentials for another's. The generated remote configuration sends the agent headers directly, disables OAuth probing, and backend cycles never request relay-only `get_marching_orders`, `poll_inbox`, or `get_resource` tools.
62
+
61
63
  Codex BYO agents follow the repository's normative runtime specification in `docs/CODEX_BYO_AGENT_SPEC.md`: one WebSocket identity, independent Sol reply/work lanes, authoritative `get_ticket` verification for assignments, REST-backed in-app activity, persistent replay suppression, and runtime evidence gates before completion. Maintainers must run `npm run certify` before publishing.
62
64
 
63
65
  Claude uses Haiku for routine coordination and Sonnet for repository work. Codex uses `gpt-5.6-sol` for every cycle, including messages, mentions, triage, board movement, MCP calls, and coding. This intentionally favors reliability and consistent tool use over the cheaper Codex tiers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.18.1",
3
+ "version": "0.18.2",
4
4
  "description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team — MCP tools + optional autonomy — in one command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,11 +23,14 @@ run('diff whitespace check', 'git', ['diff', '--check'], repo)
23
23
  const watcher = readFileSync(join(root, 'src', 'watch.mjs'), 'utf8')
24
24
  const events = readFileSync(join(root, 'src', 'events.mjs'), 'utf8')
25
25
  const memory = readFileSync(join(root, 'src', 'memory.mjs'), 'utf8')
26
+ const mcpHttp = readFileSync(join(root, 'src', 'mcp-http.mjs'), 'utf8')
27
+ const opencodeConfig = readFileSync(join(root, 'src', 'opencode-config.mjs'), 'utf8')
26
28
  const prPush = readFileSync(join(root, 'src', 'pr-push.mjs'), 'utf8')
27
29
  const cli = readFileSync(join(root, 'bin', 'cli.mjs'), 'utf8')
28
30
  const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
29
31
  const activityHook = readFileSync(join(repo, 'frontend', 'hooks', 'useAgentActivity.ts'), 'utf8')
30
32
  const taskHook = readFileSync(join(repo, 'frontend', 'hooks', 'useBackendTasks.ts'), 'utf8')
33
+ const liveTasks = readFileSync(join(repo, 'frontend', 'lib', 'collab', 'liveTasks.ts'), 'utf8')
31
34
  const spec = readFileSync(join(repo, 'docs', 'CODEX_BYO_AGENT_SPEC.md'), 'utf8')
32
35
 
33
36
  const assertions = [
@@ -59,9 +62,20 @@ const assertions = [
59
62
  ['frontend consumes typing event', activityHook.includes("'channel:agent:typing'")],
60
63
  ['frontend activity TTL distinguishes work from typing', activityHook.includes('thinking: 6_000') && activityHook.includes('typing: 5_000') && activityHook.includes('working: 30_000')],
61
64
  ['frontend consumes documented task comment events', taskHook.includes("'task:comment':") && taskHook.includes("'task:comment_updated':") && taskHook.includes("'task:comment_deleted':") && taskHook.includes("'task:comment_reacted':")],
65
+ ['ticket comments load only on demand', !taskHook.includes('for (const task of tasks)') && taskHook.includes('loadedCommentIds.current.has(taskId)')],
66
+ ['concurrent ticket comment loads share one request', taskHook.includes('commentRequests.current.get(requestKey)') && taskHook.includes('commentRequests.current.set(requestKey, request)')],
67
+ ['backend ticket slugs render uppercase', liveTasks.includes("t.slug?.trim().toUpperCase()")],
62
68
  ['completion has an evidence failure gate', watcher.includes('WORK_CYCLE_FAILED')],
63
69
  ['OpenCode emits structured events for runtime evidence', watcher.includes("'--format', 'json'") && watcher.includes('opencodeEventEvidence(event)')],
70
+ ['OpenCode API-key MCP disables OAuth probing', opencodeConfig.includes("oauth: false") && opencodeConfig.includes('timeout: 15_000')],
71
+ ['OpenCode identities use isolated configs outside the code workspace', watcher.includes('opencodeRuntimeLayout({ cfgKey, workdir })') && watcher.includes("'--dir', workspace") && watcher.includes('OPENCODE_CONFIG: opencodeConfigPath') && watcher.includes('OPENCODE_CONFIG_CONTENT: JSON.stringify(opencodeConfig)')],
72
+ ['OpenCode backend prompts forbid relay-only tools', watcher.includes('BACKEND MCP RULE') && watcher.includes('get_marching_orders, poll_inbox, and get_resource are relay-only')],
73
+ ['OpenCode tool failures retain sanitized diagnostics', events.includes('toolError: toolError.replace') && watcher.includes("opencode tool '") && watcher.includes("split(redactKey).join('[redacted]')")],
64
74
  ['OpenCode acknowledgements cannot satisfy coding completion', watcher.includes("agent === 'codex' || agent === 'opencode'") && watcher.includes('releaseTaskForRetry(activeTaskRef, prompt)')],
75
+ ['backend MCP accepts stateless initialize responses', mcpHttp.includes("mode = () => !initialized ? 'uninitialized' : sessionId ? 'stateful' : 'stateless'") && !watcher.includes('MCP initialize returned no session id')],
76
+ ['MCP initialize is shared across concurrent startup probes', mcpHttp.includes('if (initializePromise) return initializePromise')],
77
+ ['stateless tool errors do not cause initialize loops', mcpHttp.includes('if (hadSession && !retried')],
78
+ ['backend introduction is watcher-owned for every runtime', watcher.includes('void announceIntroduction().then((delivered)')],
65
79
  ['Codex policy rejection is captured from stderr', watcher.includes("stdio: ['ignore', 'pipe', 'pipe']") && watcher.includes('forwardDiagnostic(d)') && watcher.includes('inspectDiagnostic(incoming)')],
66
80
  ['Codex recoverable subprocess diagnostics are not surfaced as activity', watcher.includes('shouldSuppressCodexDiagnostic(line)') && watcher.includes("forwardDiagnostic('', true)") && watcher.includes('RECOVER DEAD COMMAND SESSIONS')],
67
81
  ['policy rejection cannot be logged as successful', watcher.includes("subtype = policyBlock ? 'blocked'")],
package/src/events.mjs CHANGED
@@ -89,6 +89,8 @@ export function opencodeEventEvidence(event) {
89
89
  const status = String(state.status ?? part.status ?? '').toLowerCase()
90
90
  const failed = /error|failed|denied|rejected/.test(status) || state.error != null || part.error != null
91
91
  const completed = !failed && (!status || /completed|success|succeeded|ok/.test(status))
92
+ const rawToolError = state.error ?? part.error
93
+ const toolError = rawToolError == null ? '' : (typeof rawToolError === 'string' ? rawToolError : JSON.stringify(rawToolError))
92
94
  const input = state.input && typeof state.input === 'object' ? state.input : (part.input && typeof part.input === 'object' ? part.input : {})
93
95
  const command = String(input.command ?? input.cmd ?? '')
94
96
 
@@ -107,6 +109,7 @@ export function opencodeEventEvidence(event) {
107
109
  ...(mcpTool ? { mcpTool } : {}),
108
110
  failed,
109
111
  completed,
112
+ ...(failed && toolError ? { toolError: toolError.replace(/\s+/g, ' ').slice(0, 500) } : {}),
110
113
  didCode: completed && (mutationTool || bashTool || codebaseMutation),
111
114
  didRepoMutation: completed && (mutationTool || codebaseMutation || (bashTool && commandMutation)),
112
115
  didMcpTaskRead: completed && /^(?:get_ticket|list_tasks|list_task_types)$/.test(mcpTool),
@@ -0,0 +1,85 @@
1
+ const parsePayload = async (res) => {
2
+ const body = await res.text()
3
+ const data = body.split(/\r?\n/).filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trim()).pop()
4
+ try { return JSON.parse(data || body || '{}') }
5
+ catch { throw new Error('MCP returned a non-JSON response') }
6
+ }
7
+
8
+ // Streamable HTTP permits both stateful servers (initialize returns
9
+ // Mcp-Session-Id) and stateless servers (no session header). The backend agent
10
+ // MCP is deployed in both forms, so absence of a session id is a transport mode,
11
+ // not an initialization failure.
12
+ export function createMcpHttpClient({ url, apiKey, identifier, clientVersion, fetchImpl = fetch, log = () => {} }) {
13
+ let initialized = false
14
+ let sessionId = ''
15
+ let rpcId = 0
16
+ let initializePromise = null
17
+
18
+ const post = (message, withSession = true) => fetchImpl(url, {
19
+ method: 'POST',
20
+ headers: {
21
+ 'content-type': 'application/json',
22
+ accept: 'application/json, text/event-stream',
23
+ 'x-agent-api-key': apiKey,
24
+ 'x-agent-identifier': identifier,
25
+ ...(withSession && sessionId ? { 'mcp-session-id': sessionId } : {}),
26
+ },
27
+ body: JSON.stringify(message),
28
+ })
29
+
30
+ const reset = () => { initialized = false; sessionId = '' }
31
+
32
+ const initialize = () => {
33
+ if (initialized) return Promise.resolve()
34
+ if (initializePromise) return initializePromise
35
+ initializePromise = (async () => {
36
+ const res = await post({
37
+ jsonrpc: '2.0',
38
+ id: ++rpcId,
39
+ method: 'initialize',
40
+ params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: clientVersion } },
41
+ }, false)
42
+ if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
43
+ const payload = await parsePayload(res)
44
+ if (payload.error) throw new Error(`MCP initialize: ${payload.error.message || 'protocol error'}`)
45
+ sessionId = res.headers.get('mcp-session-id') || ''
46
+
47
+ const ready = await post({ jsonrpc: '2.0', method: 'notifications/initialized' })
48
+ if (!ready.ok) { reset(); throw new Error('MCP initialized HTTP ' + ready.status) }
49
+ initialized = true
50
+ if (!sessionId) log('MCP initialized in stateless mode (no session id required)')
51
+ })().finally(() => { initializePromise = null })
52
+ return initializePromise
53
+ }
54
+
55
+ const callTool = async (name, args = {}, retried = false) => {
56
+ await initialize()
57
+ const hadSession = !!sessionId
58
+ const res = await post({
59
+ jsonrpc: '2.0',
60
+ id: ++rpcId,
61
+ method: 'tools/call',
62
+ params: { name, arguments: { ...args, agent_api_key: apiKey, agent_identifier: identifier } },
63
+ })
64
+ if (!res.ok) {
65
+ // Only a stateful transport can have an expired session. A stateless 4xx
66
+ // belongs to the tool request itself and must not trigger an initialize loop.
67
+ if (hadSession && !retried && [400, 404, 409, 410].includes(res.status)) {
68
+ reset()
69
+ return callTool(name, args, true)
70
+ }
71
+ throw new Error(`MCP ${name} HTTP ${res.status}`)
72
+ }
73
+ const payload = await parsePayload(res)
74
+ if (payload.error) throw new Error(`MCP ${name}: ${payload.error.message || 'tool error'}`)
75
+ const result = payload.result ?? payload
76
+ if (result?.isError) {
77
+ const detail = String(result.content?.find?.((item) => item?.type === 'text')?.text || 'tool error').split(apiKey).join('[redacted]')
78
+ throw new Error(`MCP ${name}: ${detail}`)
79
+ }
80
+ return result
81
+ }
82
+
83
+ const mode = () => !initialized ? 'uninitialized' : sessionId ? 'stateful' : 'stateless'
84
+ return { callTool, initialize, reset, mode }
85
+ }
@@ -0,0 +1,29 @@
1
+ import { join } from 'node:path'
2
+ import { OV_DIR, slugify } from './lib.mjs'
3
+
4
+ export function opencodeRuntimeLayout({ cfgKey, workdir, baseDir = OV_DIR }) {
5
+ const agentKey = slugify(String(cfgKey || 'agent')) || 'agent'
6
+ const configDir = join(baseDir, 'opencode-' + agentKey)
7
+ return {
8
+ configDir,
9
+ configPath: join(configDir, 'opencode.json'),
10
+ workspace: workdir || configDir,
11
+ }
12
+ }
13
+
14
+ export function buildOpencodeConfig({ mcpUrl, mcpHeaders }) {
15
+ if (!mcpUrl) return null
16
+ return {
17
+ $schema: 'https://opencode.ai/config.json',
18
+ mcp: {
19
+ 'openvisio-team': {
20
+ type: 'remote',
21
+ url: mcpUrl,
22
+ enabled: true,
23
+ oauth: false,
24
+ timeout: 15_000,
25
+ ...(mcpHeaders && Object.keys(mcpHeaders).length ? { headers: mcpHeaders } : {}),
26
+ },
27
+ },
28
+ }
29
+ }
package/src/watch.mjs CHANGED
@@ -13,6 +13,8 @@ import { connectAgentWs, assertWebSocket } from './ws.mjs'
13
13
  import { agentStateRequest, buildTaskCompletionReport, codexPolicyBlock, mentionDedupeKeys, normalizeRenderedMessageText, opencodeEventEvidence, renderedAgentMessages, requestTargetsLaterAgent, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted } from './events.mjs'
14
14
  import { createByoMemoryGraph } from './memory.mjs'
15
15
  import { repositoryHasPrPushAuthorization } from './pr-push.mjs'
16
+ import { createMcpHttpClient } from './mcp-http.mjs'
17
+ import { buildOpencodeConfig, opencodeRuntimeLayout } from './opencode-config.mjs'
16
18
 
17
19
  // Behaviour prompts. The openvisio-team MCP bridge requires the agent's
18
20
  // credentials as ARGUMENTS on every tool call — those are injected at runtime by
@@ -288,25 +290,25 @@ export async function runWatch({ flags }) {
288
290
  // ── opencode cycle runner ─────────────────────────────────────────────────────
289
291
  // opencode (opencode.ai) has no persistent stream-json protocol like Claude Code,
290
292
  // so each cycle is a headless `opencode run <prompt> --auto [--model provider/model]`.
291
- // The openvisio-team MCP is declared in an `opencode.json` written into the run cwd
292
- // (opencode reads it from there). `--auto` approves tool use non-interactively.
293
+ // The openvisio-team MCP is declared in a private per-agent config. The actual
294
+ // code workspace is passed through --dir, so two agents never overwrite each
295
+ // other's MCP identity even when they work in the same repository.
296
+ // `--auto` approves tool use non-interactively.
293
297
  // Same { runCycle, canCode } contract as the Claude runner.
294
298
  function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt }) {
295
- // opencode reads opencode.json from its CWD: the code workspace, or a dedicated
296
- // per-agent dir for chat-only agents.
297
- const cwd = workdir || join(OV_DIR, 'opencode-' + (cfgKey || 'agent'))
299
+ const { configDir, configPath: opencodeConfigPath, workspace } = opencodeRuntimeLayout({ cfgKey, workdir })
298
300
  const bin = onPath('opencode') || 'opencode'
301
+ const redactKey = String(mcpHeaders?.['x-agent-api-key'] || '')
302
+ const opencodeConfig = buildOpencodeConfig({ mcpUrl, mcpHeaders })
299
303
  let configured = false
300
304
  const ensureConfig = () => {
301
305
  if (configured) return
302
306
  configured = true
303
307
  try {
304
- mkdirSync(cwd, { recursive: true })
305
- if (mcpUrl) {
306
- writeJson(join(cwd, 'opencode.json'), {
307
- $schema: 'https://opencode.ai/config.json',
308
- mcp: { 'openvisio-team': { type: 'remote', url: mcpUrl, enabled: true, ...(mcpHeaders && Object.keys(mcpHeaders).length ? { headers: mcpHeaders } : {}) } },
309
- }, true)
308
+ mkdirSync(configDir, { recursive: true })
309
+ mkdirSync(workspace, { recursive: true })
310
+ if (opencodeConfig) {
311
+ writeJson(opencodeConfigPath, opencodeConfig, true)
310
312
  } else {
311
313
  log('WARNING: no --mcp-url — opencode has no openvisio-team tools to act with. Re-connect with --mcp-url.')
312
314
  }
@@ -322,7 +324,7 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
322
324
  const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
323
325
  // JSON mode is the evidence boundary. Formatted stdout only tells us that
324
326
  // OpenCode exited; raw events tell us which tools actually completed.
325
- const args = ['run', full, '--auto', '--format', 'json', ...(m ? ['--model', m] : [])]
327
+ const args = ['run', full, '--auto', '--format', 'json', '--dir', workspace, ...(m ? ['--model', m] : [])]
326
328
  let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, didMcpTaskRead = false, didMcpTaskUpdate = false
327
329
  let outputText = '', jsonlBuffer = ''
328
330
  const mcpCalls = new Set(), mcpErrors = new Set(), runtimeErrors = new Set()
@@ -350,6 +352,10 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
350
352
  if (evidence.runtimeError) runtimeErrors.add(evidence.runtimeError)
351
353
  if (!evidence.tool) return
352
354
  if (debug) log(' → tool ' + evidence.tool + (evidence.failed ? ' (failed)' : evidence.completed ? ' (completed)' : ''))
355
+ if (evidence.failed && evidence.toolError) {
356
+ const safeError = (redactKey ? String(evidence.toolError).split(redactKey).join('[redacted]') : String(evidence.toolError)).replace(/\s+/g, ' ').slice(0, 240)
357
+ log(' ✗ opencode tool ' + evidence.tool + ': ' + safeError)
358
+ }
353
359
  try { onTool && onTool(evidence.tool) } catch { /* activity is best-effort */ }
354
360
  if (evidence.mcpTool) {
355
361
  mcpCalls.add(evidence.mcpTool)
@@ -370,7 +376,18 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
370
376
  }, maxCycleMs)
371
377
  log('running opencode cycle…' + (m ? ' [' + m + ']' : ''))
372
378
  try {
373
- child = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'inherit'] })
379
+ child = spawn(bin, args, {
380
+ cwd: configDir,
381
+ env: {
382
+ ...process.env,
383
+ OPENCODE_CONFIG: opencodeConfigPath,
384
+ // Inline config has higher precedence than a project opencode.json.
385
+ // This prevents a stale generated workspace file from replacing this
386
+ // agent's URL or credentials while keeping normal project settings.
387
+ ...(opencodeConfig ? { OPENCODE_CONFIG_CONTENT: JSON.stringify(opencodeConfig) } : {}),
388
+ },
389
+ stdio: ['ignore', 'pipe', 'inherit'],
390
+ })
374
391
  child.stdout?.on('data', (data) => {
375
392
  jsonlBuffer += String(data)
376
393
  const lines = jsonlBuffer.split('\n')
@@ -389,7 +406,7 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
389
406
  })
390
407
  }
391
408
 
392
- log('opencode runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + cwd + ']' : ' [CHAT-ONLY cfg ' + cwd + ']'))
409
+ log('opencode runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + workspace + '; isolated cfg ' + configDir + ']' : ' [CHAT-ONLY; isolated cfg ' + configDir + ']'))
393
410
  return { runCycle, canCode }
394
411
  }
395
412
 
@@ -670,7 +687,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
670
687
  const canCode = !!workdir
671
688
  // The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
672
689
  // agent_identifier + agent_api_key as arguments. Hand them over up front.
673
- const credNote = `AUTH: the openvisio-team tools REQUIRE two arguments on EVERY call — agent_identifier: "${identifier}" and agent_api_key: "${apiKey}". Include BOTH on every openvisio-team tool call. Use ONLY names shown in the current tool list. On backend MCP, discover and update work with list_agents, list_projects, list_tasks, get_ticket, update_ticket, comment_ticket, and list_activity; get_marching_orders and poll_inbox may be absent. Tools may be namespaced — call whichever names actually appear. The credentials are given here; do NOT hunt for them. Bash/git/gh ARE for code work; this rule only forbids searching for keys.`
690
+ const credNote = `AUTH: the openvisio-team tools REQUIRE two arguments on EVERY call — agent_identifier: "${identifier}" and agent_api_key: "${apiKey}". Include BOTH on every openvisio-team tool call. Use ONLY names shown in the current tool list. On backend MCP, discover and update work with list_agents, list_projects, list_tasks, list_task_types, get_ticket, update_ticket, comment_ticket, list_channels, list_message_thread, and list_activity as applicable. get_marching_orders, poll_inbox, and get_resource are relay-only and are NOT available here; never call them. Tools may be namespaced — call whichever names actually appear. The credentials are given here; do NOT hunt for them. Bash/git/gh ARE for code work; this rule only forbids searching for keys.`
674
691
  // The STATIC charter + creds are the session system prompt (cached, billed once),
675
692
  // NOT re-sent in every cycle's user message — the big token saving.
676
693
  const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
@@ -687,8 +704,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
687
704
  const codexPushGuide = agent === 'codex' && canCode
688
705
  ? '\n\nCODEX PR DELIVERY: first inspect the OpenVisio MCP tools. When list_codebases, create_codebase_branch, create_codebase_commit (or write_codebase_file), and create_pull_request are available, use that authenticated linked-codebase flow to create the agent/* branch, publish the verified changed files, and open the PR. This is the preferred path and requires no local git push. If those tools are unavailable for the repository, do not run git push directly. From the repository run `openvisio-agent push-pr-branch`. It is a user-authorized constrained fallback that can only push HEAD to the matching agent/* branch on the exact authorized origin. If it reports OPENVISIO_PR_PUSH_AUTH_REQUIRED, do not retry or route around it. Report the one-time command `openvisio-agent authorize-pr-push` as the blocker.'
689
706
  : ''
690
- const fullPrompt = canCode ? CODE_FULL + codexPushGuide : CYCLE
691
- const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
707
+ const backendToolRule = 'BACKEND MCP RULE: the event and watcher already provide the work source. Never call get_marching_orders, poll_inbox, get_resource, or other relay-only tools; they are not exposed by the backend MCP. Use only names present in the current openvisio-team tool list. For discovery use list_agents, list_projects, list_tasks, list_task_types, list_activity, get_ticket, and list_channels as applicable.'
708
+ const fullPrompt = (canCode ? CODE_FULL + codexPushGuide : 'Handle the supplied verified backend ticket with the available OpenVisio tools. Update or comment on the ticket as requested, do not claim repository work in chat-only mode, and stop after the verified action.') + '\n\n' + backendToolRule
709
+ const fastPrompt = (canCode ? CODE_FAST : CYCLE_FAST) + '\n\n' + backendToolRule
710
+ const coordinatePrompt = COORDINATE + '\n\n' + backendToolRule
692
711
  // Live model state — changeable at runtime by the in-chat `/model` command.
693
712
  // codeModel drives full/sweep cycles; chatModel (if set) the lighter fast/intro
694
713
  // ones, so routine chatter can run cheaper than real code work.
@@ -768,54 +787,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
768
787
  let lastTaskTriggeredAt = 0
769
788
  let lastInboxSignature = ''
770
789
  let selfAgentId = null
771
- let mcpSessionId = ''
772
- let mcpRpcId = 0
773
-
774
- const mcpPayload = async (res) => {
775
- const body = await res.text()
776
- const data = body.split(/\r?\n/).filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trim()).pop()
777
- return JSON.parse(data || body || '{}')
778
- }
779
- const mcpPost = async (message, withSession = true) => {
780
- const res = await fetch(mcpUrl, {
781
- method: 'POST',
782
- headers: {
783
- 'content-type': 'application/json',
784
- accept: 'application/json, text/event-stream',
785
- 'x-agent-api-key': apiKey,
786
- 'x-agent-identifier': identifier,
787
- ...(withSession && mcpSessionId ? { 'mcp-session-id': mcpSessionId } : {}),
788
- },
789
- body: JSON.stringify(message),
790
- })
791
- return res
792
- }
793
- const ensureMcpSession = async () => {
794
- if (mcpSessionId) return
795
- const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.18.1' } } }, false)
796
- if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
797
- await mcpPayload(res)
798
- mcpSessionId = res.headers.get('mcp-session-id') || ''
799
- if (!mcpSessionId) throw new Error('MCP initialize returned no session id')
800
- const ready = await mcpPost({ jsonrpc: '2.0', method: 'notifications/initialized' })
801
- if (!ready.ok) throw new Error('MCP initialized HTTP ' + ready.status)
802
- }
803
- const callMcpTool = async (name, args = {}, retried = false) => {
804
- await ensureMcpSession()
805
- const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'tools/call', params: { name, arguments: { ...args, agent_api_key: apiKey, agent_identifier: identifier } } })
806
- if (!res.ok) {
807
- if (!retried && (res.status === 400 || res.status === 404)) { mcpSessionId = ''; return callMcpTool(name, args, true) }
808
- throw new Error(`MCP ${name} HTTP ${res.status}`)
809
- }
810
- const payload = await mcpPayload(res)
811
- if (payload.error) throw new Error(`MCP ${name}: ${payload.error.message || 'tool error'}`)
812
- const result = payload.result ?? payload
813
- if (result?.isError) {
814
- const detail = String(result.content?.find?.((c) => c?.type === 'text')?.text || 'tool error').split(apiKey).join('[redacted]')
815
- throw new Error(`MCP ${name}: ${detail}`)
816
- }
817
- return result
818
- }
790
+ const mcpClient = createMcpHttpClient({ url: mcpUrl, apiKey, identifier, clientVersion: '0.18.2', log })
791
+ const callMcpTool = (name, args = {}) => mcpClient.callTool(name, args)
819
792
  const toolData = (result) => {
820
793
  const text = result?.content?.find?.((c) => c?.type === 'text')?.text
821
794
  if (typeof text !== 'string') return result?.structuredContent ?? result ?? {}
@@ -1144,14 +1117,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1144
1117
  }
1145
1118
  } else if (!mentionActivity.length) lastInboxSignature = ''
1146
1119
  } catch (e) {
1147
- mcpSessionId = ''
1120
+ mcpClient.reset()
1148
1121
  log('backlog reconciliation failed: ' + (e && e.message ? e.message : e))
1149
1122
  } finally { backlogProbeBusy = false }
1150
1123
  }
1151
1124
 
1152
1125
  // Higher rank wins when coalescing cycles requested while one is running.
1153
1126
  const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
1154
- const baseFor = (kind) => kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? COORDINATE : fastPrompt
1127
+ const baseFor = (kind) => kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? coordinatePrompt : fastPrompt
1155
1128
  // Codex and OpenCode expose structured action streams. Require runtime facts
1156
1129
  // from those streams before accepting a full coding cycle. Claude's evidence
1157
1130
  // shape is different and remains on its existing completion path.
@@ -1473,8 +1446,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1473
1446
  const replyDelivery = guardedDelivery(codingMention ? 'result' : 'reply', !codingMention)
1474
1447
  const ctx = cid != null
1475
1448
  ? replyDelivery
1476
- ? `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. ${codingMention ? 'Complete the repository work and verification first.' : 'Answer the request.'} Do NOT call post_message; it is intentionally unavailable. Return only the final 1-3 sentence reply as your final answer. The watcher will read the real thread, check its persistent memory graph, and render that answer at most once.${who ? ` To mention the requester, use their exact full name "@${who}".` : ''} Do not poll_inbox.`
1477
- : `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. ${codingMention ? 'This is repository work: complete the coding flow first, then send' : 'Send'} EXACTLY ONE reply with post_message: arguments: channel_id ${cid}${threadRoot != null ? `, parent_id ${threadRoot} (reply IN THAT THREAD, do not start a new top-level message)` : ''}, plus agent_identifier + agent_api_key from the AUTH line above, and a 1-3 sentence reply. Compose the whole answer, then post it ONCE. Do not post a first reply and then a revised version. FIRST read the recent messages in this thread: if you already answered this, or another agent was the one addressed, do NOT post at all. Be sure of your answer before sending.${who ? ` To @mention them back, write their EXACT full name "@${who}". A mention only links when the name matches exactly.` : ''} You ALREADY have the message here. Do not poll_inbox, and after your single reply, STOP.`
1449
+ ? `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. ${codingMention ? 'Complete the repository work and verification first.' : 'Answer the request.'} Do NOT call post_message; it is intentionally unavailable. Return only the final 1-3 sentence reply as your final answer. The watcher will read the real thread, check its persistent memory graph, and render that answer at most once.${who ? ` To mention the requester, use their exact full name "@${who}".` : ''} The complete message is already here; do not call get_resource, get_marching_orders, or poll_inbox.`
1450
+ : `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. ${codingMention ? 'This is repository work: complete the coding flow first, then send' : 'Send'} EXACTLY ONE reply with post_message: arguments: channel_id ${cid}${threadRoot != null ? `, parent_id ${threadRoot} (reply IN THAT THREAD, do not start a new top-level message)` : ''}, plus agent_identifier + agent_api_key from the AUTH line above, and a 1-3 sentence reply. Compose the whole answer, then post it ONCE. Do not post a first reply and then a revised version. FIRST read the recent messages in this thread: if you already answered this, or another agent was the one addressed, do NOT post at all. Be sure of your answer before sending.${who ? ` To @mention them back, write their EXACT full name "@${who}". A mention only links when the name matches exactly.` : ''} The complete message is already here. Do not call get_resource, get_marching_orders, or poll_inbox, and after your single reply, STOP.`
1478
1451
  : undefined
1479
1452
  if (codingMention) {
1480
1453
  if (agent === 'codex' && cid != null) {
@@ -1509,12 +1482,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1509
1482
  // Workspace ethics: a one-time hello the FIRST time this agent ever connects.
1510
1483
  const introMarker = join(OV_DIR, 'intro-' + slugify(identifier) + '.done')
1511
1484
  if (!existsSync(introMarker)) {
1512
- if (agent !== 'codex') {
1513
- try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
1514
- }
1515
1485
  log('first connection — introducing self to the workspace')
1516
1486
  introTimer = setTimeout(() => {
1517
- if (agent !== 'codex') { void drain('intro'); return }
1518
1487
  void announceIntroduction().then((delivered) => {
1519
1488
  if (!delivered) return
1520
1489
  try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }