openvisio-agent 0.18.0 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.
@@ -69,7 +71,16 @@ openvisio-agent watch --name ada --workdir ~/repo # allow REAL work on a git br
69
71
  openvisio-agent stop --name ada # stop service + every ada watcher
70
72
  ```
71
73
 
72
- With `--workdir`, the agent gets file + Bash tools scoped to that repo and works on a branch. Guardrails are built in: it never pushes or merges, and destructive shell (`git push`, `rm`, `sudo`, `curl`, publish, PR-merge, …) is denied.
74
+ With `--workdir`, the agent gets file + shell tools scoped to the workspace and works on an `agent/*` branch. For a codebase linked to OpenVisio, Codex first uses the authenticated MCP branch, commit, and pull-request tools. That creates the PR through the existing integration and avoids a local `git push`.
75
+
76
+ When those linked-codebase tools are unavailable, a pull request still requires exporting the local branch to the private Git remote. The constrained fallback uses an explicit, one-time authorization per repository:
77
+
78
+ ```bash
79
+ cd /path/to/private-repo
80
+ openvisio-agent authorize-pr-push
81
+ ```
82
+
83
+ That command installs a narrow Codex rule for `openvisio-agent push-pr-branch` and records the repository's exact root and `origin`. The helper accepts no arguments and can only push `HEAD` to the same `agent/*` branch on that authorized origin. It disables repository hooks and rejects main/master, other branch namespaces, changed remotes, force pushes, local/file remotes, and credential-bearing URLs. Revoke it from the repository with `openvisio-agent revoke-pr-push`.
73
84
 
74
85
  `--install` sets up a background service (launchd on macOS, systemd `--user` on Linux) that runs `watch` and restarts on login. Logs go to `~/.openvisio/<agent>.log` (macOS) or `journalctl --user -u openvisio-<agent>` (Linux).
75
86
 
@@ -79,7 +90,7 @@ Do not chase auto-changing watcher PIDs. `openvisio-agent stop --name <agent>` u
79
90
 
80
91
  - **No opaque script.** You run a named, versioned npm package you can read here and on [npmjs.com](https://www.npmjs.com/package/openvisio-agent).
81
92
  - **Single-use code.** The `ovs_` code is exchanged once for a key; a leaked code is already spent.
82
- - **Least privilege.** Chat mode exposes only the `openvisio-team` MCP tools. Coding mode is opt-in per repo, branch-only, with a shell denylist.
93
+ - **Least privilege.** Chat mode exposes only the `openvisio-team` MCP tools. Codex private-repository pushes require an explicit per-repository opt-in and go through a no-argument `agent/*`-only helper; merges and protected-branch pushes remain unavailable.
83
94
  - **Local secrets.** Your agent key lives in `~/.openvisio/` with `600` permissions — never printed, never committed.
84
95
 
85
96
  ## Requirements
package/bin/cli.mjs CHANGED
@@ -15,6 +15,7 @@ import { fileURLToPath } from 'node:url'
15
15
  import { dirname, join } from 'node:path'
16
16
  import { parseFlags, slugify, stripSlash, exchangeToken, ensureClaude, ensureCodex, writeJson, mcpConfigPath, configPath, chmodSafe, onPath, OV_DIR, fail, ok, info } from '../src/lib.mjs'
17
17
  import { runWatch, installService, stopWatchers } from '../src/watch.mjs'
18
+ import { authorizePrPush, pushPrBranch, revokePrPush } from '../src/pr-push.mjs'
18
19
 
19
20
  const HERE = dirname(fileURLToPath(import.meta.url))
20
21
  const VERSION = (() => { try { return JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8')).version } catch { return '0.0.0' } })()
@@ -28,6 +29,9 @@ Usage:
28
29
  openvisio-agent connect --backend <url> --key <api-key> --id <identifier> [--name "<agent>"] [--ws <wss-url>] [--mcp-url <url>] [--agent claude|codex|opencode]
29
30
  openvisio-agent watch --name <agent> [--install] [--workspace <dir>] [--chat-only] [--model <m>] [--chat-model <m>] [--debug]
30
31
  openvisio-agent stop --name <agent>
32
+ openvisio-agent authorize-pr-push [--repo <dir>]
33
+ openvisio-agent push-pr-branch
34
+ openvisio-agent revoke-pr-push [--repo <dir>]
31
35
  openvisio-agent --help | --version
32
36
 
33
37
  connect
@@ -78,6 +82,19 @@ stop
78
82
  remaining watcher with that exact --name and clears its stale lock. Use this
79
83
  instead of killing changing PIDs: openvisio-agent stop --name Alex
80
84
 
85
+ authorize-pr-push
86
+ One-time, explicit authorization for the current private repository. Installs a
87
+ narrow Codex command rule and records the exact repository root + origin. The
88
+ permitted helper can only push the current agent/* branch and cannot force-push,
89
+ choose another remote/ref, push a protected branch, or merge.
90
+
91
+ push-pr-branch
92
+ Pushes HEAD to the same agent/* branch on an origin previously authorized with
93
+ authorize-pr-push. Intended for Codex work cycles; accepts no arguments.
94
+
95
+ revoke-pr-push
96
+ Removes the current repository from the helper's authorization list.
97
+
81
98
  Docs: https://www.npmjs.com/package/openvisio-agent`
82
99
 
83
100
  // Register the `openvisio-team` MCP at USER (global) scope so it's available in
@@ -291,6 +308,29 @@ async function main() {
291
308
  if (!name) fail('Missing agent name.\n Usage: openvisio-agent stop --name <agent>')
292
309
  return stopWatchers({ slug: slugify(name) })
293
310
  }
311
+ if (cmd === 'authorize-pr-push') {
312
+ const cwd = String(rest.flags.repo || rest.positional[0] || process.cwd())
313
+ const result = authorizePrPush({ cwd })
314
+ ok('Authorized constrained PR-branch pushes for this repository.')
315
+ info(`Repository: ${result.root}`)
316
+ info(`Origin: ${result.remote}`)
317
+ info('Codex can now run: openvisio-agent push-pr-branch')
318
+ info('This does not allow pushes to main/master, force pushes, arbitrary remotes, or merges.')
319
+ return
320
+ }
321
+ if (cmd === 'push-pr-branch') {
322
+ if (rest.positional.length || Object.keys(rest.flags).length) fail('push-pr-branch accepts no arguments. It uses the current repository, origin, and agent/* branch.')
323
+ const result = pushPrBranch()
324
+ ok(`Pushed ${result.branch} to its matching origin branch.`)
325
+ return
326
+ }
327
+ if (cmd === 'revoke-pr-push') {
328
+ const cwd = String(rest.flags.repo || rest.positional[0] || process.cwd())
329
+ const result = revokePrPush({ cwd })
330
+ if (result.revoked) ok(`Revoked constrained PR pushes for ${result.root}.`)
331
+ else info(`No PR-push authorization was stored for ${result.root}.`)
332
+ return
333
+ }
294
334
  fail(`Unknown command "${cmd}".\n\n${HELP}`)
295
335
  }
296
336
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.18.0",
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": {
@@ -21,9 +21,16 @@ run('package dry run', 'npm', ['pack', '--dry-run'])
21
21
  run('diff whitespace check', 'git', ['diff', '--check'], repo)
22
22
 
23
23
  const watcher = readFileSync(join(root, 'src', 'watch.mjs'), 'utf8')
24
+ const events = readFileSync(join(root, 'src', 'events.mjs'), 'utf8')
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')
28
+ const prPush = readFileSync(join(root, 'src', 'pr-push.mjs'), 'utf8')
29
+ const cli = readFileSync(join(root, 'bin', 'cli.mjs'), 'utf8')
24
30
  const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
25
31
  const activityHook = readFileSync(join(repo, 'frontend', 'hooks', 'useAgentActivity.ts'), 'utf8')
26
32
  const taskHook = readFileSync(join(repo, 'frontend', 'hooks', 'useBackendTasks.ts'), 'utf8')
33
+ const liveTasks = readFileSync(join(repo, 'frontend', 'lib', 'collab', 'liveTasks.ts'), 'utf8')
27
34
  const spec = readFileSync(join(repo, 'docs', 'CODEX_BYO_AGENT_SPEC.md'), 'utf8')
28
35
 
29
36
  const assertions = [
@@ -31,13 +38,19 @@ const assertions = [
31
38
  ['task signals are verified with get_ticket', watcher.includes("callMcpTool('get_ticket'")],
32
39
  ['review and testing handoffs do not restart work', watcher.includes('taskIsAwaitingReview(task, reviewIds)') && watcher.includes('taskIsAwaitingReview(ticket)')],
33
40
  ['review handoff releases the task key for future rework', watcher.includes('seenTasks.delete(taskKey)') && watcher.includes('seenTasks.delete(key)')],
34
- ['assigned coding completion is posted by the watcher', watcher.includes('announceTaskCompletion') && watcher.includes("callMcpTool('post_message', { project_id: projectId, channel_id: channelId, content: report.content })")],
41
+ ['assigned coding completion is posted by the watcher', watcher.includes('announceTaskCompletion') && watcher.includes('postMessageOnce({ key: `completion:${report.key}`')],
35
42
  ['completion requires review/done state and PR evidence', watcher.includes('buildTaskCompletionReport') && watcher.includes('completion report deferred')],
36
43
  ['completion delivery survives reconnect and deduplicates', watcher.includes('pendingCompletionReports: [...pendingCompletionReports]') && watcher.includes('reportedCompletions: [...reportedCompletions]') && watcher.includes('reportedCompletions.has(report.key)')],
37
44
  ['verified completion is persisted as one ticket comment', watcher.includes("callMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: report.content })") && watcher.includes('reportedTaskComments: [...reportedTaskComments]') && watcher.includes('reportedTaskComments.has(report.key)')],
38
45
  ['ticket comments cannot masquerade as channel completion', watcher.includes('didChannelMessage') && watcher.includes("mcpCalls.includes('post_message')")],
39
46
  ['single-watcher acquisition is atomic and fails closed', watcher.includes("openSync(lockPath, 'wx')") && watcher.includes('Could not acquire the single-watcher lock')],
40
47
  ['websocket and activity mention delivery share a replay guard', watcher.includes('markMentionHandled(activityMessage, activityChannelId)') && watcher.includes('markMentionHandled(msg, cid)') && watcher.includes('recentMentionSignatures')],
48
+ ['reconciled mentions reuse the guarded websocket delivery path', watcher.includes("onEvent('agent:mention'") && watcher.includes('_mentionAlreadyMarked: true')],
49
+ ['rendered backend replies are checked before every guarded thread post', watcher.includes("callMcpTool('list_message_thread'") && watcher.includes('renderedAgentMessages(live') && watcher.includes('same-content-rendered')],
50
+ ['Codex cannot race the watcher with post_message', watcher.includes("disabledMcpTools: ['post_message']") && watcher.includes('disabled_tools = [')],
51
+ ['reply delivery keys survive reconnects', watcher.includes('deliveredReplies: [...deliveredReplies]') && watcher.includes('deliveredReplies.has(deliveryKey)')],
52
+ ['guarded replies stay independent while a lane is busy', watcher.includes('lane.deferred.push') && watcher.includes('lane.deferred.shift()')],
53
+ ['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)')],
41
54
  ['two internal lanes exist', watcher.includes('work: createCycleRunner') && watcher.includes('reply: createCycleRunner')],
42
55
  ['work and reply activity targets are isolated', watcher.includes("laneStatusTargets = { work: new Set(), reply: new Set() }") && watcher.includes("emitLaneStatus('work', 'typing')") && watcher.includes("emitLaneStatus('reply', 'typing')")],
43
56
  ['assigned task activity resolves a project channel', watcher.includes("callMcpTool('list_channels'") && watcher.includes('projectStatusChannel(projectId)')],
@@ -49,13 +62,29 @@ const assertions = [
49
62
  ['frontend consumes typing event', activityHook.includes("'channel:agent:typing'")],
50
63
  ['frontend activity TTL distinguishes work from typing', activityHook.includes('thinking: 6_000') && activityHook.includes('typing: 5_000') && activityHook.includes('working: 30_000')],
51
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()")],
52
68
  ['completion has an evidence failure gate', watcher.includes('WORK_CYCLE_FAILED')],
53
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]')")],
54
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)')],
55
79
  ['Codex policy rejection is captured from stderr', watcher.includes("stdio: ['ignore', 'pipe', 'pipe']") && watcher.includes('forwardDiagnostic(d)') && watcher.includes('inspectDiagnostic(incoming)')],
56
80
  ['Codex recoverable subprocess diagnostics are not surfaced as activity', watcher.includes('shouldSuppressCodexDiagnostic(line)') && watcher.includes("forwardDiagnostic('', true)") && watcher.includes('RECOVER DEAD COMMAND SESSIONS')],
57
81
  ['policy rejection cannot be logged as successful', watcher.includes("subtype = policyBlock ? 'blocked'")],
58
82
  ['policy-blocked tickets are persisted and paused', watcher.includes('blockedTasks: [...blockedTasks]') && watcher.includes('WORK_CYCLE_BLOCKED')],
83
+ ['repository push authorization automatically resumes the paused ticket', watcher.includes('blockedTaskRepos: [...blockedTaskRepos]') && watcher.includes('repositoryHasPrPushAuthorization')],
84
+ ['Codex prefers linked-codebase MCP PR delivery', watcher.includes('CODEX PR DELIVERY') && watcher.includes('create_codebase_branch/create_codebase_commit/create_pull_request') && events.includes('const codebaseMutation')],
85
+ ['private PR pushes use an explicit constrained helper', cli.includes("cmd === 'authorize-pr-push'") && cli.includes("cmd === 'push-pr-branch'") && prPush.includes("'push', '-u', 'origin', destination")],
86
+ ['PR push helper rejects protected/alternate/force targets by construction', prPush.includes("/^agent\\/") && prPush.includes('entry?.root === root && entry?.remote === remote') && prPush.includes('accepts no force, remote, or ref args')],
87
+ ['Codex recognizes helper authorization as a blocker', events.includes('OPENVISIO_PR_PUSH_AUTH_REQUIRED') && watcher.includes("block?.kind === 'pr-push-authorization-required'")],
59
88
  ['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock)') && watcher.includes("Action required: I'm blocked")],
60
89
  ['blocker routing carries explicit task identity', watcher.includes('taskRefs: []') && watcher.includes('activeTaskRef') && watcher.includes('taskRef: activeTaskRef')],
61
90
  ['all runtime blockers have a delivery path', watcher.includes('publishBlocker') && watcher.includes('WORK_CYCLE_BLOCKED') && watcher.includes('COORDINATION_CYCLE_BLOCKED')],
package/src/events.mjs CHANGED
@@ -89,14 +89,17 @@ 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
 
95
97
  const lowerTool = tool.toLowerCase()
96
98
  const prefixed = /^(?:mcp__)?openvisio(?:-team|_team)(?:__|[_.:/-])(.+)$/i.exec(tool)
97
99
  const bareTool = lowerTool.replace(/[-.]/g, '_')
98
- const knownMcp = /^(?:get_ticket|list_tasks|list_task_types|update_ticket|post_message|comment_ticket|react_message|list_projects|list_agents|list_activity)$/
100
+ const knownMcp = /^(?:get_ticket|list_tasks|list_task_types|update_ticket|post_message|comment_ticket|react_message|list_projects|list_agents|list_activity|list_codebases|create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/
99
101
  const mcpTool = (prefixed?.[1] ? prefixed[1].replace(/[-.]/g, '_') : (knownMcp.test(bareTool) ? bareTool : '')).toLowerCase()
102
+ const codebaseMutation = /^(?:create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/.test(mcpTool)
100
103
  const mutationTool = /^(?:edit|write|patch|apply_patch|multiedit|multi_edit)$/i.test(tool)
101
104
  const bashTool = /^(?:bash|shell|terminal|exec|command)$/i.test(tool)
102
105
  const commandMutation = /\b(?:git\s+(?:commit|push)|gh\s+pr\s+create)\b/i.test(command)
@@ -106,8 +109,9 @@ export function opencodeEventEvidence(event) {
106
109
  ...(mcpTool ? { mcpTool } : {}),
107
110
  failed,
108
111
  completed,
109
- didCode: completed && (mutationTool || bashTool),
110
- didRepoMutation: completed && (mutationTool || (bashTool && commandMutation)),
112
+ ...(failed && toolError ? { toolError: toolError.replace(/\s+/g, ' ').slice(0, 500) } : {}),
113
+ didCode: completed && (mutationTool || bashTool || codebaseMutation),
114
+ didRepoMutation: completed && (mutationTool || codebaseMutation || (bashTool && commandMutation)),
111
115
  didMcpTaskRead: completed && /^(?:get_ticket|list_tasks|list_task_types)$/.test(mcpTool),
112
116
  didMcpTaskUpdate: completed && mcpTool === 'update_ticket',
113
117
  didMessage: completed && /^(?:post_message|comment_ticket)$/.test(mcpTool),
@@ -131,6 +135,16 @@ export function agentStateRequest(backend, channelId, state, apiKey, identifier)
131
135
 
132
136
  export function codexPolicyBlock(value) {
133
137
  const text = String(value || '')
138
+ if (/OPENVISIO_PR_PUSH_AUTH_REQUIRED/i.test(text)) {
139
+ const root = /From\s+([^\n,]+),\s*run:\s*openvisio-agent authorize-pr-push/i.exec(text)?.[1]?.trim() || ''
140
+ return {
141
+ kind: 'pr-push-authorization-required',
142
+ command: 'openvisio-agent push-pr-branch',
143
+ reason: 'The repository has not received the one-time constrained PR-branch authorization.',
144
+ root,
145
+ setupCommand: 'openvisio-agent authorize-pr-push',
146
+ }
147
+ }
134
148
  if (!/rejected due to unacceptable risk|action was rejected due to unacceptable risk|explicitly approves? the action/i.test(text)) return null
135
149
  const command = /exec_command failed for [`']([^`']+)[`']/.exec(text)?.[1] || ''
136
150
  const reasonTail = text.split(/Reason:\s*/i)[1] || ''
@@ -172,6 +186,54 @@ export function mentionDedupeKeys(message, channelId) {
172
186
  }
173
187
  }
174
188
 
189
+ export function normalizeRenderedMessageText(value) {
190
+ return String(value || '').replace(/\s+/g, ' ').trim().toLowerCase()
191
+ }
192
+
193
+ // Extract only messages visibly authored by this agent from the backend's live
194
+ // thread response. REST and WebSocket payloads use several sender shapes, so this
195
+ // mirrors the frontend normalizer instead of trusting one field name.
196
+ export function renderedAgentMessages(value, identity = {}) {
197
+ const rows = []
198
+ const seen = new Set()
199
+ const visit = (node, depth = 0) => {
200
+ if (depth > 6 || node == null) return
201
+ if (Array.isArray(node)) { for (const item of node) visit(item, depth + 1); return }
202
+ if (typeof node !== 'object') return
203
+ const row = node
204
+ const content = row.content ?? row.body ?? row.text ?? (typeof row.message === 'string' ? row.message : undefined)
205
+ const rowId = row.id ?? row.message_id ?? row.messageId
206
+ if (content != null && rowId != null) {
207
+ const sender = [row.sender, row.user, row.author, row.member].find((item) => item && typeof item === 'object') || {}
208
+ const expanded = [row.senderAgent, row.sender_agent, row.agent, sender.agent].find((item) => item && typeof item === 'object')
209
+ const senderKind = String(sender.type ?? sender.kind ?? sender.sender_type ?? row.sender_type ?? row.author_type ?? '').toLowerCase()
210
+ const senderLooksLikeAgent = /agent|bot/.test(senderKind) || sender.agent_id != null || sender.identifier != null || (sender.slug != null && !sender.email)
211
+ const agent = expanded || (senderLooksLikeAgent ? sender : {})
212
+ const agentId = Number(agent.id ?? agent.agent_id ?? row.agent_id ?? row.sender_agent_id ?? row.senderAgentId ?? NaN)
213
+ const identifier = String(agent.identifier ?? agent.slug ?? row.agent_identifier ?? row.sender_identifier ?? '')
214
+ const name = String(agent.name ?? agent.display_name ?? (senderLooksLikeAgent ? sender.name : '') ?? '')
215
+ const aliases = new Set([identity.identifier, identity.slug, identity.name].map((item) => String(item || '').toLowerCase()).filter(Boolean))
216
+ const isSelf = (Number.isFinite(Number(identity.id)) && agentId === Number(identity.id)) || aliases.has(identifier.toLowerCase()) || (!!expanded && aliases.has(name.toLowerCase()))
217
+ if (isSelf) {
218
+ const key = String(rowId)
219
+ if (!seen.has(key)) {
220
+ seen.add(key)
221
+ rows.push({
222
+ id: rowId,
223
+ parentId: row.parent_id ?? row.parentId ?? row.thread_id ?? null,
224
+ content: String(content),
225
+ })
226
+ }
227
+ }
228
+ }
229
+ for (const key of ['messages', 'replies', 'items', 'data', 'result', 'thread', 'message']) {
230
+ if (row[key] && typeof row[key] === 'object') visit(row[key], depth + 1)
231
+ }
232
+ }
233
+ visit(value)
234
+ return rows
235
+ }
236
+
175
237
  // A mention event means this agent's name appeared somewhere, not necessarily
176
238
  // that the request was addressed to it. Reject a later-agent hand-off before a
177
239
  // model starts, while keeping explicitly shared requests addressed to both.
@@ -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
+ }
package/src/memory.mjs ADDED
@@ -0,0 +1,82 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+
4
+ const clean = (value, max = 320) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, max)
5
+ const sameRef = (a, b) => a != null && b != null && String(a) === String(b)
6
+
7
+ // Small, deterministic memory graph for BYO watchers. Nodes are real events,
8
+ // tickets and deliveries; edges record what a reply answered or where a task was
9
+ // reported. It is intentionally not an LLM transcript or vector store: stable
10
+ // backend ids make exact recall cheaper and prevent old work from being replayed.
11
+ export function createByoMemoryGraph({ path, maxNodes = 1000, now = () => Date.now() }) {
12
+ let raw = {}
13
+ try { raw = JSON.parse(readFileSync(path, 'utf8')) } catch { /* first run */ }
14
+ const nodes = new Map((Array.isArray(raw.nodes) ? raw.nodes : []).filter((node) => node?.key).map((node) => [String(node.key), node]))
15
+ const edges = new Map((Array.isArray(raw.edges) ? raw.edges : []).filter((edge) => edge?.from && edge?.to && edge?.relation).map((edge) => [`${edge.from}|${edge.relation}|${edge.to}`, edge]))
16
+
17
+ const persist = () => {
18
+ try {
19
+ mkdirSync(dirname(path), { recursive: true })
20
+ writeFileSync(path, JSON.stringify({ version: 1, nodes: [...nodes.values()], edges: [...edges.values()] }, null, 2) + '\n', { mode: 0o600 })
21
+ } catch { /* memory is best-effort; live backend checks remain authoritative */ }
22
+ }
23
+ const trim = () => {
24
+ if (nodes.size <= maxNodes) return
25
+ const oldest = [...nodes.values()].sort((a, b) => Number(a.updatedAt || 0) - Number(b.updatedAt || 0)).slice(0, nodes.size - maxNodes)
26
+ const removed = new Set(oldest.map((node) => String(node.key)))
27
+ for (const key of removed) nodes.delete(key)
28
+ for (const [key, edge] of edges) if (removed.has(String(edge.from)) || removed.has(String(edge.to))) edges.delete(key)
29
+ }
30
+
31
+ const remember = ({ key, kind, state, summary, refs = {}, meta = {} }) => {
32
+ const id = String(key || '')
33
+ if (!id) return null
34
+ const previous = nodes.get(id)
35
+ const stamp = now()
36
+ const node = {
37
+ ...(previous || { key: id, createdAt: stamp }),
38
+ kind: clean(kind, 40) || previous?.kind || 'event',
39
+ state: clean(state, 40) || previous?.state || 'observed',
40
+ summary: clean(summary) || previous?.summary || '',
41
+ refs: { ...(previous?.refs || {}), ...refs },
42
+ meta: { ...(previous?.meta || {}), ...meta },
43
+ updatedAt: stamp,
44
+ }
45
+ nodes.set(id, node); trim(); persist()
46
+ return node
47
+ }
48
+
49
+ const connect = (from, to, relation) => {
50
+ const edge = { from: String(from || ''), to: String(to || ''), relation: clean(relation, 50), updatedAt: now() }
51
+ if (!edge.from || !edge.to || !edge.relation) return null
52
+ edges.set(`${edge.from}|${edge.relation}|${edge.to}`, edge); persist()
53
+ return edge
54
+ }
55
+
56
+ const recall = (refs = {}, limit = 8) => {
57
+ const direct = [...nodes.values()].filter((node) => {
58
+ const r = node.refs || {}
59
+ return sameRef(r.channelId, refs.channelId) && (refs.threadId == null || sameRef(r.threadId, refs.threadId)) ||
60
+ sameRef(r.projectId, refs.projectId) && sameRef(r.ticketId, refs.ticketId)
61
+ })
62
+ const keys = new Set(direct.map((node) => String(node.key)))
63
+ for (const edge of edges.values()) {
64
+ if (keys.has(String(edge.from))) keys.add(String(edge.to))
65
+ if (keys.has(String(edge.to))) keys.add(String(edge.from))
66
+ }
67
+ return [...nodes.values()].filter((node) => keys.has(String(node.key))).sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0)).slice(0, limit)
68
+ }
69
+
70
+ const context = (refs = {}, limit = 8) => {
71
+ const items = recall(refs, limit)
72
+ if (!items.length) return ''
73
+ return ['RELEVANT VERIFIED MEMORY (do not repeat completed/delivered actions):', ...items.map((node) => `- ${node.kind} ${node.state}: ${node.summary || node.key}`)].join('\n')
74
+ }
75
+
76
+ const has = (key, state) => {
77
+ const node = nodes.get(String(key || ''))
78
+ return !!node && (state == null || node.state === state)
79
+ }
80
+
81
+ return { remember, connect, recall, context, has, persist }
82
+ }
@@ -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
+ }
@@ -0,0 +1,112 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { dirname, join } from 'node:path'
5
+
6
+ const DEFAULT_AUTH_PATH = join(homedir(), '.openvisio', 'pr-push-authorizations.json')
7
+ const DEFAULT_RULE_PATH = join(homedir(), '.codex', 'rules', 'openvisio-agent.rules')
8
+
9
+ const gitText = (cwd, args, spawn = spawnSync) => {
10
+ const result = spawn('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
11
+ if (result.status !== 0) throw new Error(String(result.stderr || result.stdout || `git ${args.join(' ')} failed`).trim())
12
+ return String(result.stdout || '').trim()
13
+ }
14
+
15
+ const safeRemote = (value) => {
16
+ const remote = String(value || '').trim()
17
+ if (/^https?:\/\//i.test(remote) || /^ssh:\/\//i.test(remote)) {
18
+ const parsed = new URL(remote)
19
+ if (parsed.username || parsed.password) throw new Error('Origin contains credentials in its URL. Move credentials to your Git credential manager before authorizing PR pushes.')
20
+ return remote
21
+ }
22
+ if (/^[^@\s]+@[^:\s]+:[^\s]+$/.test(remote)) return remote
23
+ throw new Error('Origin must be an HTTPS or SSH repository URL. Local paths and file:// remotes cannot be authorized.')
24
+ }
25
+
26
+ export function isSafeAgentBranch(value) {
27
+ const branch = String(value || '')
28
+ return /^agent\/[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(branch) &&
29
+ !branch.includes('..') && !branch.includes('//') && !branch.includes('@{') &&
30
+ !/[~^:?*\[\\]/.test(branch) && !/[/.]$/.test(branch)
31
+ }
32
+
33
+ export function codexPrPushRule() {
34
+ return `# Generated by: openvisio-agent authorize-pr-push
35
+ # The helper enforces an exact pre-authorized repository + origin and only
36
+ # pushes the current agent/* branch. It accepts no force, remote, or ref args.
37
+ prefix_rule(
38
+ pattern = ["openvisio-agent", "push-pr-branch"],
39
+ decision = "allow",
40
+ justification = "The user explicitly authorized OpenVisio's constrained agent-branch PR push helper.",
41
+ match = ["openvisio-agent push-pr-branch"],
42
+ )
43
+ `
44
+ }
45
+
46
+ function readAuthorizations(path) {
47
+ try {
48
+ const value = JSON.parse(readFileSync(path, 'utf8'))
49
+ return Array.isArray(value.authorizations) ? value.authorizations : []
50
+ } catch { return [] }
51
+ }
52
+
53
+ function writePrivate(path, value) {
54
+ mkdirSync(dirname(path), { recursive: true })
55
+ writeFileSync(path, value, { mode: 0o600 })
56
+ try { chmodSync(path, 0o600) } catch { /* Windows / best-effort */ }
57
+ }
58
+
59
+ export function repositoryPushIdentity(cwd = process.cwd(), spawn = spawnSync) {
60
+ const root = realpathSync(gitText(cwd, ['rev-parse', '--show-toplevel'], spawn))
61
+ const remote = safeRemote(gitText(root, ['remote', 'get-url', '--push', 'origin'], spawn))
62
+ const branch = gitText(root, ['branch', '--show-current'], spawn)
63
+ return { root, remote, branch }
64
+ }
65
+
66
+ export function validatePrPushAuthorization({ root, remote, branch, authorizations }) {
67
+ if (!isSafeAgentBranch(branch)) {
68
+ throw new Error(`Refusing to push branch "${branch || '(detached HEAD)'}". OpenVisio PR pushes require the current branch to match agent/*.`)
69
+ }
70
+ const allowed = (Array.isArray(authorizations) ? authorizations : []).some((entry) => entry?.root === root && entry?.remote === remote)
71
+ if (!allowed) {
72
+ throw new Error(`OPENVISIO_PR_PUSH_AUTH_REQUIRED: This repository and its exact origin are not authorized. From ${root}, run: openvisio-agent authorize-pr-push`)
73
+ }
74
+ return true
75
+ }
76
+
77
+ export function repositoryHasPrPushAuthorization({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, spawn = spawnSync } = {}) {
78
+ try {
79
+ const { root, remote } = repositoryPushIdentity(cwd, spawn)
80
+ return readAuthorizations(authPath).some((entry) => entry?.root === root && entry?.remote === remote)
81
+ } catch { return false }
82
+ }
83
+
84
+ export function authorizePrPush({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, rulePath = DEFAULT_RULE_PATH, spawn = spawnSync, now = () => new Date().toISOString() } = {}) {
85
+ const { root, remote } = repositoryPushIdentity(cwd, spawn)
86
+ const authorizations = readAuthorizations(authPath).filter((entry) => entry?.root !== root)
87
+ authorizations.push({ root, remote, authorizedAt: now() })
88
+ writePrivate(authPath, JSON.stringify({ version: 1, authorizations }, null, 2) + '\n')
89
+ writePrivate(rulePath, codexPrPushRule())
90
+ return { root, remote, authPath, rulePath }
91
+ }
92
+
93
+ export function revokePrPush({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, spawn = spawnSync } = {}) {
94
+ const root = realpathSync(gitText(cwd, ['rev-parse', '--show-toplevel'], spawn))
95
+ const before = readAuthorizations(authPath)
96
+ const authorizations = before.filter((entry) => entry?.root !== root)
97
+ if (existsSync(authPath)) writePrivate(authPath, JSON.stringify({ version: 1, authorizations }, null, 2) + '\n')
98
+ return { root, revoked: authorizations.length !== before.length }
99
+ }
100
+
101
+ export function pushPrBranch({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, spawn = spawnSync } = {}) {
102
+ const identity = repositoryPushIdentity(cwd, spawn)
103
+ validatePrPushAuthorization({ ...identity, authorizations: readAuthorizations(authPath) })
104
+ const destination = `HEAD:refs/heads/${identity.branch}`
105
+ // The helper runs outside Codex's workspace sandbox after the user authorizes
106
+ // it. Disable repository hooks so an edited pre-push hook cannot widen this
107
+ // one operation into arbitrary host execution.
108
+ const nullHooks = process.platform === 'win32' ? 'NUL' : '/dev/null'
109
+ const result = spawn('git', ['-c', `core.hooksPath=${nullHooks}`, 'push', '-u', 'origin', destination], { cwd: identity.root, stdio: 'inherit' })
110
+ if (result.status !== 0) throw new Error(`git push failed with exit code ${result.status ?? 'unknown'}`)
111
+ return identity
112
+ }