openvisio-agent 0.18.11 → 0.18.13

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/bin/cli.mjs CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.18.11",
3
+ "version": "0.18.13",
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": {
@@ -26,6 +26,7 @@ const memory = readFileSync(join(root, 'src', 'memory.mjs'), 'utf8')
26
26
  const mcpHttp = readFileSync(join(root, 'src', 'mcp-http.mjs'), 'utf8')
27
27
  const opencodeConfig = readFileSync(join(root, 'src', 'opencode-config.mjs'), 'utf8')
28
28
  const codexConfig = readFileSync(join(root, 'src', 'codex-config.mjs'), 'utf8')
29
+ const codexProxy = readFileSync(join(root, 'src', 'codex-mcp-proxy.mjs'), 'utf8')
29
30
  const prPush = readFileSync(join(root, 'src', 'pr-push.mjs'), 'utf8')
30
31
  const cli = readFileSync(join(root, 'bin', 'cli.mjs'), 'utf8')
31
32
  const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
@@ -63,9 +64,11 @@ const assertions = [
63
64
  ['Codex cannot race the watcher with post_message', watcher.includes("disabledMcpTools: ['post_message']") && codexConfig.includes('disabled_tools = [')],
64
65
  ['Codex cannot continue without OpenVisio MCP tools', watcher.includes('buildCodexMcpOverride') && codexConfig.includes('required = true') && codexConfig.includes('startup_timeout_sec = 30')],
65
66
  ['Codex discovers deferred OpenVisio actions before claiming they are missing', watcher.includes('Codex may defer MCP actions') && watcher.includes('use tool_search to find that exact action') && watcher.includes('initial-list miss is NOT evidence that the server is disconnected')],
67
+ ['Codex authentication is injected outside the model', codexConfig.includes('OPENVISIO_CODEX_API_KEY') && codexProxy.includes('toolWithoutCredentialInputs') && codexProxy.includes('client.callTool') && watcher.includes('local OpenVisio MCP bridge injects authentication outside the model')],
66
68
  ['reply delivery keys survive reconnects', watcher.includes('deliveredReplies: [...deliveredReplies]') && watcher.includes('deliveredReplies.has(deliveryKey)')],
67
69
  ['tickets and guarded replies use the behavior-tested serial queue', watcher.includes('createCycleQueue({') && watcher.includes('queues[laneName].enqueue') && cycleQueue.includes('const pending = new Map()')],
68
70
  ['queued assignments are checked again before model execution', watcher.includes('queued ticket no longer actionable; skipped before model start')],
71
+ ['feature titles cannot be mistaken for coordination commands', watcher.includes('taskIsCoordinationOnly(taskText)') && events.includes('Uploading files as messages') && events.includes('explicitCoordination')],
69
72
  ['reply runner has no coding workspace or coding charter', watcher.includes("workdir: '', systemPrompt: CHAT_CHARTER") && opencodeConfig.includes("'*': 'deny'")],
70
73
  ['MCP requests have a deadline including response bodies', mcpHttp.includes('controller.abort()') && mcpHttp.includes('const body = await res.text()')],
71
74
  ['BYO memory uses real ticket and thread identities', watcher.includes('createByoMemoryGraph') && watcher.includes('memory.context(memoryRefs)') && memory.includes('sameRef(r.projectId, refs.projectId)') && memory.includes('sameRef(r.threadId, refs.threadId)')],
@@ -5,13 +5,11 @@ const tomlString = (value) => JSON.stringify(String(value))
5
5
  // its tools, which makes an otherwise connected agent incorrectly claim that it
6
6
  // cannot inspect OpenVisio. Fail the subprocess instead so the watcher can retry
7
7
  // the event and report a real runtime failure without delivering model guesswork.
8
- export function buildCodexMcpOverride({ mcpUrl, mcpHeaders, disabledMcpTools = [] } = {}) {
8
+ export function buildCodexMcpOverride({ mcpUrl, proxyCommand, proxyPath, disabledMcpTools = [] } = {}) {
9
9
  if (!mcpUrl) return ''
10
- const headerEntries = Object.entries(mcpHeaders || {})
11
- .map(([key, value]) => `${tomlString(key)} = ${tomlString(value)}`)
12
- .join(', ')
10
+ if (!proxyCommand || !proxyPath) throw new Error('Codex MCP proxy command and path are required')
13
11
  const disabled = Array.isArray(disabledMcpTools) ? disabledMcpTools.filter(Boolean) : []
14
- const headerConfig = headerEntries ? `, http_headers = { ${headerEntries} }` : ''
15
12
  const disabledToolConfig = disabled.length ? `, disabled_tools = [${disabled.map(tomlString).join(', ')}]` : ''
16
- return `mcp_servers={ openvisio-team = { url = ${tomlString(mcpUrl)}, required = true, startup_timeout_sec = 30${headerConfig}${disabledToolConfig} } }`
13
+ const envVars = ['OPENVISIO_CODEX_MCP_URL', 'OPENVISIO_CODEX_API_KEY', 'OPENVISIO_CODEX_IDENTIFIER'].map(tomlString).join(', ')
14
+ return `mcp_servers={ openvisio-team = { command = ${tomlString(proxyCommand)}, args = [${tomlString(proxyPath)}], env_vars = [${envVars}], required = true, startup_timeout_sec = 30${disabledToolConfig} } }`
17
15
  }
@@ -0,0 +1,73 @@
1
+ import { createInterface } from 'node:readline'
2
+ import { resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { createMcpHttpClient } from './mcp-http.mjs'
5
+
6
+ export function toolWithoutCredentialInputs(tool) {
7
+ const copy = structuredClone(tool)
8
+ const schema = copy.inputSchema ?? copy.input_schema
9
+ if (!schema || typeof schema !== 'object') return copy
10
+ if (schema.properties && typeof schema.properties === 'object') {
11
+ delete schema.properties.agent_api_key
12
+ delete schema.properties.agent_identifier
13
+ }
14
+ if (Array.isArray(schema.required)) {
15
+ schema.required = schema.required.filter((name) => name !== 'agent_api_key' && name !== 'agent_identifier')
16
+ }
17
+ return copy
18
+ }
19
+
20
+ export function runCodexMcpProxy() {
21
+ const url = process.env.OPENVISIO_CODEX_MCP_URL
22
+ const apiKey = process.env.OPENVISIO_CODEX_API_KEY
23
+ const identifier = process.env.OPENVISIO_CODEX_IDENTIFIER
24
+ if (!url || !apiKey || !identifier) throw new Error('OpenVisio Codex MCP bridge is missing its watcher environment.')
25
+ const client = createMcpHttpClient({ url, apiKey, identifier, clientVersion: 'openvisio-agent-codex-proxy' })
26
+ const reply = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n')
27
+ const fail = (id, error, request) => {
28
+ const message = String(error?.message || error || 'MCP bridge error').split(apiKey).join('[redacted]')
29
+ if (request?.method === 'tools/call') {
30
+ const safeArgs = { ...(request.params?.arguments || {}) }
31
+ delete safeArgs.agent_api_key
32
+ delete safeArgs.agent_identifier
33
+ process.stderr.write(`OpenVisio MCP bridge call failed: ${String(request.params?.name || 'unknown')} ${JSON.stringify(safeArgs).slice(0, 1000)} — ${message.slice(0, 1000)}\n`)
34
+ }
35
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32000, message: message.slice(0, 1000) } }) + '\n')
36
+ }
37
+ const lines = createInterface({ input: process.stdin, crlfDelay: Infinity })
38
+ lines.on('line', async (line) => {
39
+ let request
40
+ try { request = JSON.parse(line) } catch { return }
41
+ const id = request.id
42
+ try {
43
+ if (request.method === 'initialize') {
44
+ reply(id, {
45
+ protocolVersion: request.params?.protocolVersion || '2025-03-26',
46
+ capabilities: { tools: { listChanged: false } },
47
+ serverInfo: { name: 'openvisio-agent', version: '1' },
48
+ })
49
+ } else if (request.method === 'notifications/initialized') {
50
+ // Notification: no response.
51
+ } else if (request.method === 'ping') {
52
+ reply(id, {})
53
+ } else if (request.method === 'tools/list') {
54
+ const tools = await client.listTools()
55
+ reply(id, { tools: tools.map(toolWithoutCredentialInputs) })
56
+ } else if (request.method === 'tools/call') {
57
+ reply(id, await client.callTool(request.params?.name, request.params?.arguments || {}))
58
+ } else if (id != null) {
59
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } }) + '\n')
60
+ }
61
+ } catch (error) {
62
+ if (id != null) fail(id, error, request)
63
+ }
64
+ })
65
+ }
66
+
67
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
68
+ try { runCodexMcpProxy() }
69
+ catch (error) {
70
+ process.stderr.write(String(error?.message || error) + '\n')
71
+ process.exitCode = 1
72
+ }
73
+ }
package/src/events.mjs CHANGED
@@ -471,6 +471,17 @@ export function conversationNeedsCode(value) {
471
471
  return action && target
472
472
  }
473
473
 
474
+ // Coordination tickets are imperative board/chat actions, not implementation
475
+ // work that merely mentions words such as "message", "status", or "label" in
476
+ // its feature title. Requiring the coordination verb at the start prevents
477
+ // tickets like "Uploading files as messages" from being routed away from the
478
+ // coding lane.
479
+ export function taskIsCoordinationOnly(value) {
480
+ const text = String(value || '').trim()
481
+ const explicitCoordination = /^(?:please\s+)?(?:move|moving|assign|reassign|unassign|comment(?:\s+on)?|reply(?:\s+to)?|send\s+(?:a\s+)?message|triage|prioriti[sz]e|rename|close|reopen|(?:add|remove|change|update)\s+(?:the\s+)?(?:status|column|label))\b/i.test(text)
482
+ return explicitCoordination && !conversationNeedsCode(text)
483
+ }
484
+
474
485
  // A mention event means this agent's name appeared somewhere, not necessarily
475
486
  // that the request was addressed to it. Reject a later-agent hand-off before a
476
487
  // model starts, while keeping explicitly shared requests addressed to both.
package/src/watch.mjs CHANGED
@@ -8,9 +8,10 @@ import { spawn, spawnSync } from 'node:child_process'
8
8
  import { closeSync, writeFileSync, mkdirSync, existsSync, openSync, readFileSync, unlinkSync } from 'node:fs'
9
9
  import { homedir } from 'node:os'
10
10
  import { join, dirname } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
11
12
  import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
12
13
  import { connectAgentWs, assertWebSocket } from './ws.mjs'
13
- import { agentAddedByName, agentStateRequest, buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, codexEventEvidence, codexPolicyBlock, combineRuntimeWorkEvidence, conversationNeedsCode, mentionDedupeKeys, missingRuntimeWorkEvidence, normalizeRenderedMessageText, opencodeEventEvidence, renderedAgentMessages, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, ticketDisplaySlug } from './events.mjs'
14
+ import { agentAddedByName, agentStateRequest, buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, codexEventEvidence, codexPolicyBlock, combineRuntimeWorkEvidence, conversationNeedsCode, mentionDedupeKeys, missingRuntimeWorkEvidence, normalizeRenderedMessageText, opencodeEventEvidence, renderedAgentMessages, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, ticketDisplaySlug } from './events.mjs'
14
15
  import { createByoMemoryGraph } from './memory.mjs'
15
16
  import { repositoryHasPrPushAuthorization } from './pr-push.mjs'
16
17
  import { createMcpHttpClient } from './mcp-http.mjs'
@@ -439,6 +440,7 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
439
440
  function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt }) {
440
441
  const bin = onPath('codex') || 'codex'
441
442
  const cwd = workdir || OV_DIR
443
+ const proxyPath = fileURLToPath(new URL('./codex-mcp-proxy.mjs', import.meta.url))
442
444
  let cancelActive = null
443
445
 
444
446
  function runCycle(prompt, cycleModel, cycleOptions = {}) {
@@ -446,7 +448,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
446
448
  const m = cycleModel || model
447
449
  const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
448
450
  const disabledMcpTools = Array.isArray(cycleOptions.disabledMcpTools) ? cycleOptions.disabledMcpTools.filter(Boolean) : []
449
- const mcpOverride = buildCodexMcpOverride({ mcpUrl, mcpHeaders, disabledMcpTools })
451
+ const mcpOverride = buildCodexMcpOverride({ mcpUrl, proxyCommand: process.execPath, proxyPath, disabledMcpTools })
450
452
  const args = ['exec', '--ignore-user-config', '--skip-git-repo-check', '--ephemeral', '--json', '--color', 'never',
451
453
  ...(canCode ? ['--approve-for-me'] : ['--sandbox', 'read-only']),
452
454
  ...(m ? ['--model', m] : []),
@@ -524,7 +526,19 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
524
526
  try {
525
527
  // Always inspect Codex JSONL so a successful process exit cannot be
526
528
  // mistaken for completed work. Keep it out of normal logs unless debug.
527
- child = spawn(bin, args, { ...modelProcessOptions, cwd, stdio: ['ignore', 'pipe', 'pipe'] })
529
+ child = spawn(bin, args, {
530
+ ...modelProcessOptions,
531
+ cwd,
532
+ env: {
533
+ ...process.env,
534
+ ...(mcpUrl ? {
535
+ OPENVISIO_CODEX_MCP_URL: mcpUrl,
536
+ OPENVISIO_CODEX_API_KEY: mcpHeaders?.['x-agent-api-key'] || '',
537
+ OPENVISIO_CODEX_IDENTIFIER: mcpHeaders?.['x-agent-identifier'] || '',
538
+ } : {}),
539
+ },
540
+ stdio: ['ignore', 'pipe', 'pipe'],
541
+ })
528
542
  if (child.stdout) child.stdout.on('data', (d) => {
529
543
  jsonlBuffer += String(d)
530
544
  const lines = jsonlBuffer.split('\n')
@@ -765,7 +779,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
765
779
  const canCode = !!workdir
766
780
  // The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
767
781
  // agent_identifier + agent_api_key as arguments. Hand them over up front.
768
- 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. 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: never invent or call comment_ticket unless that exact tool appears or tool_search finds it. get_marching_orders, poll_inbox, get_resource, list_mcp_resources, and list_mcp_resource_templates are NOT team-action tools here; never call them. Tools may be namespaced — call whichever names actually appear. The credentials are given here; do NOT hunt for them. Bash/git/gh ARE for code work; this rule only forbids searching for keys.`
782
+ const authRule = agent === 'codex'
783
+ ? 'AUTH: the local OpenVisio MCP bridge injects authentication outside the model. Never add, request, print, or search for agent credentials in tool arguments.'
784
+ : `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.`
785
+ 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: never invent or call comment_ticket unless that exact tool appears or tool_search finds it. get_marching_orders, poll_inbox, get_resource, list_mcp_resources, and list_mcp_resource_templates are NOT team-action tools here; never call them. Tools may be namespaced — call whichever names actually appear. Bash/git/gh ARE for code work; this rule only forbids searching for keys.`
769
786
  // The STATIC charter + creds are the session system prompt (cached, billed once),
770
787
  // NOT re-sent in every cycle's user message — the big token saving.
771
788
  const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
@@ -1462,7 +1479,6 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1462
1479
 
1463
1480
  // Route only concrete repository requests to the coding lane. Conversation
1464
1481
  // ownership is classified separately before this is consulted.
1465
- const coordinationOnly = (value) => /\b(?:move|moving|status|column|assign|reassign|unassign|comment|reply|message|triage|prioriti[sz]e|label|rename|close|reopen)\b/i.test(String(value || '')) && !conversationNeedsCode(value)
1466
1482
 
1467
1483
  // Engineers change the model under the hood from chat: "/model", "/model sonnet",
1468
1484
  // "use model haiku", "switch model to opus". Returns {report} | {set} | {invalid}.
@@ -1550,13 +1566,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1550
1566
  const ticketSlug = ticketDisplaySlug(ticket)
1551
1567
  memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'queued', summary: title, refs: { projectId, ticketId }, meta: { sourceEvent: kind } })
1552
1568
  const taskText = [title, ticket.description, ticket.type, ticket.kind].filter(Boolean).join(' ')
1553
- const cycleKind = canCode && !coordinationOnly(taskText) ? 'full' : 'coord'
1569
+ const cycleKind = canCode && !taskIsCoordinationOnly(taskText) ? 'full' : 'coord'
1554
1570
  log(kind + ' verified ticket #' + ticketId + ' “' + title + '” -> ' + cycleKind + ' lane')
1555
1571
  const activityChannel = await projectStatusChannel(projectId)
1556
1572
  if (activityChannel != null) sendStatus(activityChannel, 'thinking')
1557
1573
  if (cycleKind === 'full') { pendingCompletionReports.add(key); trimSeen(pendingCompletionReports); persistReplay() }
1558
1574
  const humanTicketRef = ticketSlug ? `ticket ${ticketSlug}` : `the ticket “${title}”`
1559
- void drain(cycleKind, `Authoritative get_ticket verification confirms ${humanTicketRef} is open and assigned to YOU. Internal tool identity: project_id ${projectId}, ticket_id ${ticketId}. 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 })}. 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 project channel.`, activityChannel == null ? [] : [activityChannel], { projectId, ticketId, channelId: activityChannel })
1575
+ 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 })}. 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 project channel.`, activityChannel == null ? [] : [activityChannel], { projectId, ticketId, channelId: activityChannel })
1560
1576
  } catch (e) {
1561
1577
  log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
1562
1578
  }