openvisio-agent 0.18.6 → 0.18.8

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
@@ -50,7 +50,7 @@ npx -y openvisio-agent@latest connect --backend https://api.your-org.example/dev
50
50
  - `--ws <wss-url>` — the org's API-Gateway WebSocket base (the same value the frontend uses as `NEXT_PUBLIC_BACKEND_WS_URL`).
51
51
  - `--mcp-url <url>` — registers the `openvisio-team` MCP (authed with the agent header pair) so the agent has tools to **act** on the events.
52
52
 
53
- Then `watch --name ada` auto-detects the backend agent and runs a WebSocket loop instead of polling: it connects with `?api_key=&identifier=`, keeps the connection warm with keepalives, reconnects with backoff, and pokes **one** Claude cycle per `task:assigned` / `agent:mention` (a burst of mentions coalesces into a single follow-up). Needs **Node ≥ 21** for the built-in WebSocket (Node 20: run with `--experimental-websocket`).
53
+ Then `watch --name ada` auto-detects the backend agent and runs a WebSocket loop instead of polling: it connects with `?api_key=&identifier=`, keeps the connection warm with keepalives, reconnects with backoff, and queues each accepted assignment or mention independently. A work cycle can contain many assistant, tool, and message actions; emitting a progress message does not end it. Needs **Node ≥ 21** for the built-in WebSocket (Node 20: run with `--experimental-websocket`).
54
54
 
55
55
  ### `watch --name <agent>`
56
56
 
@@ -62,7 +62,7 @@ The backend MCP may be stateful or stateless. A successful initialize response w
62
62
 
63
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.
64
64
 
65
- An `agent:mention` event only wakes the watcher; it does not grant ownership of the conversation. The actual source message is checked before any model starts. Messages redirected to another agent and unaddressed agent chatter stay silent, while a direct stand-down cancels queued/running work for that thread. Accepted coding work uses the activity indicator instead of a generic pickup message, then returns one verified result or concrete blocker in the source thread.
65
+ An `agent:mention` event only wakes the watcher; it does not grant ownership of the conversation. The actual source message is checked before any model starts. Messages redirected to another agent and unaddressed agent chatter stay silent, while a direct stand-down cancels queued/running work for that thread. Accepted coding work uses the activity indicator instead of a generic pickup message. Claude and OpenCode may add one concrete progress update after work begins, but must continue and post a distinct verified result or blocker afterward; Codex keeps cancellation-safe delivery watcher-owned and renders the verified final answer once.
66
66
 
67
67
  Ticket references follow the board UI: BYO agents use the project-scoped slug, such as `OVS-57`, in messages, comments, PR descriptions, blockers, and results. Numeric `project_id` and `ticket_id` values remain internal MCP arguments and are never used as human-facing ticket names. If an older backend omits the slug, the agent uses the ticket title rather than inventing one.
68
68
 
@@ -75,9 +75,9 @@ openvisio-agent watch --name ada --workdir ~/repo # allow REAL work on a git br
75
75
  openvisio-agent stop --name ada # stop service + every ada watcher
76
76
  ```
77
77
 
78
- 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`.
78
+ The work lane uses the configured workspace and an `agent/*` branch. It reuses local clones and is instructed to preserve dirty/staged work, create unique branches, use separate worktrees for shared checkouts, and stage only task-owned changes. The independent reply lane receives a chat charter and no coding workspace. Codex publishes local branches through the constrained helper, then opens a PR with `gh pr create`; linked-codebase MCP operations are a fallback when the repository cannot be obtained locally.
79
79
 
80
- 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:
80
+ Publishing a local branch uses an explicit, one-time authorization per repository:
81
81
 
82
82
  ```bash
83
83
  cd /path/to/private-repo
@@ -86,6 +86,10 @@ openvisio-agent authorize-pr-push
86
86
 
87
87
  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`.
88
88
 
89
+ Assignments use independent FIFO entries with stable-key duplicate suppression. Backlog recovery uses the same ticket verification path as WebSocket events, and each queued ticket is checked again before a model starts. MCP calls have a 20-second deadline including response bodies; tool discovery is cached and paginated. Activity requests are limited to one in flight per channel. Cancellation waits for process closure, with POSIX process-group termination for tool children. Credentials, replay state, and memory use atomic file replacement.
90
+
91
+ See `docs/BYO_SYSTEM_AUDIT.md` in the repository for the audit results and remaining live-validation limits. Local certification does not certify a deployed agent's behavior.
92
+
89
93
  `--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).
90
94
 
91
95
  Do not chase auto-changing watcher PIDs. `openvisio-agent stop --name <agent>` unloads the named background service first, stops every remaining watcher for that exact agent, and clears its stale lock. Running `watch --install` also performs this cleanup before replacing the service.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.18.6",
3
+ "version": "0.18.8",
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": {
@@ -28,6 +28,7 @@ const opencodeConfig = readFileSync(join(root, 'src', 'opencode-config.mjs'), 'u
28
28
  const prPush = readFileSync(join(root, 'src', 'pr-push.mjs'), 'utf8')
29
29
  const cli = readFileSync(join(root, 'bin', 'cli.mjs'), 'utf8')
30
30
  const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
31
+ const cycleQueue = readFileSync(join(root, 'src', 'cycle-queue.mjs'), 'utf8')
31
32
  const activityHook = readFileSync(join(repo, 'frontend', 'hooks', 'useAgentActivity.ts'), 'utf8')
32
33
  const taskHook = readFileSync(join(repo, 'frontend', 'hooks', 'useBackendTasks.ts'), 'utf8')
33
34
  const liveTasks = readFileSync(join(repo, 'frontend', 'lib', 'collab', 'liveTasks.ts'), 'utf8')
@@ -47,7 +48,7 @@ const assertions = [
47
48
  ['completion requires review/done state and PR evidence', watcher.includes('buildTaskCompletionReport') && watcher.includes('completion report deferred')],
48
49
  ['completion delivery survives reconnect and deduplicates', watcher.includes('pendingCompletionReports: [...pendingCompletionReports]') && watcher.includes('reportedCompletions: [...reportedCompletions]') && watcher.includes('reportedCompletions.has(report.key)')],
49
50
  ['optional ticket comments cannot block verified completion', watcher.includes("callOptionalMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: report.content })") && watcher.includes('comment_ticket is not exposed; completing ticket') && watcher.includes('reportedTaskComments: [...reportedTaskComments]') && watcher.includes('reportedTaskComments.has(report.key)')],
50
- ['ticket comments cannot masquerade as channel completion', watcher.includes('didChannelMessage') && watcher.includes("mcpCalls.includes('post_message')")],
51
+ ['ticket comments cannot masquerade as channel completion', events.includes("didChannelMessage: completed && mcpTool === 'post_message'") && watcher.includes('result.didChannelMessage')],
51
52
  ['single-watcher acquisition is atomic and fails closed', watcher.includes("openSync(lockPath, 'wx')") && watcher.includes('Could not acquire the single-watcher lock')],
52
53
  ['websocket and activity mention delivery share a replay guard', watcher.includes('markMentionHandled(activityMessage, activityChannelId)') && watcher.includes('markMentionHandled(msg, cid)') && watcher.includes('recentMentionSignatures')],
53
54
  ['reconciled mentions reuse the guarded websocket delivery path', watcher.includes("onEvent('agent:mention'") && watcher.includes('_mentionAlreadyMarked: true')],
@@ -60,7 +61,10 @@ const assertions = [
60
61
  ['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')],
61
62
  ['Codex cannot race the watcher with post_message', watcher.includes("disabledMcpTools: ['post_message']") && watcher.includes('disabled_tools = [')],
62
63
  ['reply delivery keys survive reconnects', watcher.includes('deliveredReplies: [...deliveredReplies]') && watcher.includes('deliveredReplies.has(deliveryKey)')],
63
- ['guarded replies stay independent while a lane is busy', watcher.includes('lane.deferred.push') && watcher.includes('lane.deferred.shift()')],
64
+ ['tickets and guarded replies use the behavior-tested serial queue', watcher.includes('createCycleQueue({') && watcher.includes('queues[laneName].enqueue') && cycleQueue.includes('const pending = new Map()')],
65
+ ['queued assignments are checked again before model execution', watcher.includes('queued ticket no longer actionable; skipped before model start')],
66
+ ['reply runner has no coding workspace or coding charter', watcher.includes("workdir: '', systemPrompt: CHAT_CHARTER") && opencodeConfig.includes("'*': 'deny'")],
67
+ ['MCP requests have a deadline including response bodies', mcpHttp.includes('controller.abort()') && mcpHttp.includes('const body = await res.text()')],
64
68
  ['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)')],
65
69
  ['two internal lanes exist', watcher.includes('work: createCycleRunner') && watcher.includes('reply: createCycleRunner')],
66
70
  ['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')")],
@@ -87,7 +91,9 @@ const assertions = [
87
91
  ['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)')],
88
92
  ['OpenCode backend prompts forbid relay and resource-discovery tools', watcher.includes('BACKEND MCP RULE') && watcher.includes('get_marching_orders, poll_inbox, get_resource, list_mcp_resources, list_mcp_resource_templates')],
89
93
  ['OpenCode tool failures retain sanitized diagnostics', events.includes('toolError: toolError.replace') && watcher.includes("opencode tool '") && watcher.includes("split(redactKey).join('[redacted]')")],
90
- ['OpenCode acknowledgements cannot satisfy coding completion', watcher.includes("agent === 'codex' || agent === 'opencode'") && watcher.includes('releaseTaskForRetry(activeTaskRef, prompt)')],
94
+ ['all runtime acknowledgements cannot satisfy coding completion', watcher.includes('missingRuntimeWorkEvidence(result') && watcher.includes('claudeEventEvidence(o, turnToolUses)') && watcher.includes('releaseTaskForRetry(activeTaskRef, prompt)')],
95
+ ['Codex prose cannot masquerade as repository evidence', watcher.includes('codexEventEvidence(event)') && events.includes("item.type === 'agent_message'") && events.includes("event.type === 'item.completed'")],
96
+ ['coding recovery retains source context and requires a final result', watcher.includes('ORIGINAL REQUEST AND ROUTING CONTEXT') && events.includes('resultMessageRequired && !result?.didResultMessage') && events.includes("outputText: second?.outputText || first?.outputText || ''")],
91
97
  ['backend MCP accepts stateless initialize responses', mcpHttp.includes("mode = () => !initialized ? 'uninitialized' : sessionId ? 'stateful' : 'stateless'") && !watcher.includes('MCP initialize returned no session id')],
92
98
  ['MCP initialize is shared across concurrent startup probes', mcpHttp.includes('if (initializePromise) return initializePromise')],
93
99
  ['stateless tool errors do not cause initialize loops', mcpHttp.includes('if (hadSession && !retried')],
@@ -104,10 +110,10 @@ const assertions = [
104
110
  ['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")],
105
111
  ['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')],
106
112
  ['Codex recognizes helper authorization as a blocker', events.includes('OPENVISIO_PR_PUSH_AUTH_REQUIRED') && watcher.includes("block?.kind === 'pr-push-authorization-required'")],
107
- ['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock)') && watcher.includes("Action required: I'm blocked")],
108
- ['blocker routing carries explicit task identity', watcher.includes('taskRefs: []') && watcher.includes('activeTaskRef') && watcher.includes('taskRef: activeTaskRef')],
113
+ ['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock, delivery)') && watcher.includes("Action required: I'm blocked")],
114
+ ['blocker routing carries explicit task identity', watcher.includes('const activeTaskRef = taskRef') && watcher.includes('taskRef: activeTaskRef')],
109
115
  ['all runtime blockers have a delivery path', watcher.includes('publishBlocker') && watcher.includes('WORK_CYCLE_BLOCKED') && watcher.includes('COORDINATION_CYCLE_BLOCKED')],
110
- ['ticket blocker cannot self-authorize', watcher.includes("ticketNotice = `I'm paused") && watcher.includes('publishBlocker({ prompt, taskRef, notice, ticketNotice, pause: true })')],
116
+ ['ticket blocker cannot self-authorize', watcher.includes("ticketNotice = `I'm paused") && watcher.includes('publishBlocker({ prompt, taskRef, delivery, notice, ticketNotice, pause: true })') && !watcher.includes('test(approvalText)')],
111
117
  ['agent messages use first-person voice', watcher.includes('FIRST-PERSON VOICE') && watcher.includes("I'm blocked") && !watcher.includes('Alex is blocked') && !watcher.includes('Alex is paused')],
112
118
  ['normative certification gates are documented', spec.includes('## Mandatory certification gates')],
113
119
  ]
@@ -0,0 +1,44 @@
1
+ // One immutable source per cycle. A Map provides FIFO ordering and constant-time
2
+ // replay checks without merging ticket identities or starving older assignments.
3
+ export function createCycleQueue({ run, onError = () => {} }) {
4
+ const pending = new Map()
5
+ let active = null
6
+ let sequence = 0
7
+
8
+ const pump = async () => {
9
+ if (active || !pending.size) return
10
+ const [key, entry] = pending.entries().next().value
11
+ pending.delete(key)
12
+ active = { key, ...entry }
13
+ try { entry.resolve(await run(entry.item)) }
14
+ catch (error) {
15
+ try { onError(error, entry.item) } catch { /* logging cannot strand the queue */ }
16
+ entry.resolve({ status: 'failed' })
17
+ } finally {
18
+ active = null
19
+ void pump()
20
+ }
21
+ }
22
+
23
+ return {
24
+ enqueue(item, key = `cycle:${++sequence}`) {
25
+ if (active?.key === key) return active.promise
26
+ if (pending.has(key)) return pending.get(key).promise
27
+ let resolve
28
+ const promise = new Promise((done) => { resolve = done })
29
+ pending.set(key, { item, promise, resolve })
30
+ void pump()
31
+ return promise
32
+ },
33
+ cancel(predicate, cancelActive = () => {}) {
34
+ for (const [key, entry] of pending) {
35
+ if (!predicate(entry.item)) continue
36
+ pending.delete(key)
37
+ entry.resolve({ status: 'canceled' })
38
+ }
39
+ if (active && predicate(active.item)) cancelActive(active.item)
40
+ },
41
+ has(key) { return active?.key === key || pending.has(key) },
42
+ get size() { return pending.size + (active ? 1 : 0) },
43
+ }
44
+ }
package/src/events.mjs CHANGED
@@ -56,7 +56,21 @@ export function taskIsAwaitingReview(task, reviewTypeIds = new Set()) {
56
56
  return /\b(?:review|test|testing|qa|quality\s+assurance|verification|approval)\b/i.test(state)
57
57
  }
58
58
 
59
- export function buildTaskCompletionReport(task, { projectId, fallbackText = '' } = {}) {
59
+ function personDisplayName(person) {
60
+ if (!person || typeof person !== 'object') return ''
61
+ return (`${person.first_name || person.firstName || ''} ${person.last_name || person.lastName || ''}`.trim() || person.name || '').trim()
62
+ }
63
+
64
+ /** The human responsible for a BYO agent is the person who added it, not the
65
+ * creator of whichever ticket it happens to complete. Backend deployments have
66
+ * exposed that relationship under both association and transport-style names. */
67
+ export function agentAddedByName(agent) {
68
+ if (!agent || typeof agent !== 'object') return ''
69
+ const addedBy = agent.agentCreator || agent.agent_creator || agent.createdByUser || agent.created_by_user || agent.owner || agent.user
70
+ return personDisplayName(addedBy)
71
+ }
72
+
73
+ export function buildTaskCompletionReport(task, { projectId, fallbackText = '', recipientName = '' } = {}) {
60
74
  if (!task || typeof task !== 'object' || task.id == null) return null
61
75
  if (!taskIsCompleted(task) && !taskIsAwaitingReview(task)) return null
62
76
 
@@ -64,8 +78,7 @@ export function buildTaskCompletionReport(task, { projectId, fallbackText = '' }
64
78
  const prUrl = /https:\/\/github\.com\/[^\s)\]}>]+\/pull\/\d+/i.exec(evidence)?.[0]?.replace(/[.,;:]+$/, '') || ''
65
79
  if (!prUrl) return null
66
80
 
67
- const creator = task.creator || task.created_by_user || task.createdByUser || {}
68
- const requester = (`${creator.first_name || creator.firstName || ''} ${creator.last_name || creator.lastName || ''}`.trim() || creator.name || '').trim()
81
+ const requester = String(recipientName || '').trim()
69
82
  const title = String(task.title || 'Untitled task').replace(/\s+/g, ' ').trim().slice(0, 180)
70
83
  const status = String(task.type?.name || task.task_type?.name || task.status || task.state || 'review').replace(/\s+/g, ' ').trim()
71
84
  const verification = /\bVerification:\s*([^\n]{1,180})/i.exec(evidence)?.[1]?.replace(/\s+/g, ' ').trim().replace(/[.]+$/, '') || ''
@@ -100,7 +113,7 @@ export function opencodeEventEvidence(event) {
100
113
  const state = part.state && typeof part.state === 'object' ? part.state : {}
101
114
  const status = String(state.status ?? part.status ?? '').toLowerCase()
102
115
  const failed = /error|failed|denied|rejected/.test(status) || state.error != null || part.error != null
103
- const completed = !failed && (!status || /completed|success|succeeded|ok/.test(status))
116
+ const completed = !failed && /^(?:completed|success|succeeded|ok)$/.test(status)
104
117
  const rawToolError = state.error ?? part.error
105
118
  const toolError = rawToolError == null ? '' : (typeof rawToolError === 'string' ? rawToolError : JSON.stringify(rawToolError))
106
119
  const input = state.input && typeof state.input === 'object' ? state.input : (part.input && typeof part.input === 'object' ? part.input : {})
@@ -124,13 +137,141 @@ export function opencodeEventEvidence(event) {
124
137
  ...(failed && toolError ? { toolError: toolError.replace(/\s+/g, ' ').slice(0, 500) } : {}),
125
138
  didCode: completed && (mutationTool || bashTool || codebaseMutation),
126
139
  didRepoMutation: completed && (mutationTool || codebaseMutation || (bashTool && commandMutation)),
127
- didMcpTaskRead: completed && /^(?:get_ticket|list_tasks|list_task_types)$/.test(mcpTool),
140
+ didMcpTaskRead: completed && /^(?:get_ticket|list_tasks)$/.test(mcpTool),
128
141
  didMcpTaskUpdate: completed && mcpTool === 'update_ticket',
129
142
  didMessage: completed && /^(?:post_message|comment_ticket)$/.test(mcpTool),
130
143
  didChannelMessage: completed && mcpTool === 'post_message',
131
144
  }
132
145
  }
133
146
 
147
+ // Codex emits started/completed JSONL records for the same item. Trust only a
148
+ // completed, non-failed record for mutation and MCP evidence. In particular,
149
+ // words such as "apply_patch" inside an agent_message are just prose.
150
+ export function codexEventEvidence(event) {
151
+ if (!event || typeof event !== 'object') return {}
152
+ const item = event.item ?? event
153
+ if (!item || typeof item !== 'object') return {}
154
+ if (item.type === 'agent_message') return { outputText: typeof item.text === 'string' ? item.text : '' }
155
+
156
+ const status = String(item.status ?? '').toLowerCase()
157
+ const completed = event.type === 'item.completed' || /^(?:completed|success|succeeded|ok)$/.test(status)
158
+ const failed = event.type === 'item.failed' || /(?:fail|error|denied|rejected)/.test(status) || item.error != null || item.result?.isError === true || (item.exit_code != null && Number(item.exit_code) !== 0)
159
+ const itemType = String(item.type ?? '')
160
+ if (/^(?:command_execution|file_change|apply_patch|shell_command|exec_command)$/.test(itemType)) {
161
+ const command = String(item.command ?? item.input?.command ?? item.input?.cmd ?? '')
162
+ return {
163
+ failed,
164
+ completed: completed && !failed,
165
+ didCode: completed && !failed,
166
+ didRepoMutation: completed && !failed && (/^(?:file_change|apply_patch)$/.test(itemType) || /\b(?:git\s+(?:commit|push)|gh\s+pr\s+create|openvisio-agent\s+push-pr-branch)\b/i.test(command)),
167
+ }
168
+ }
169
+ if (itemType !== 'mcp_tool_call') return {}
170
+
171
+ const rawTool = String(item.tool ?? item.name ?? item.method ?? 'unknown')
172
+ const prefixed = /^(?:mcp__)?openvisio(?:-team|_team)(?:__|[_.:/-])(.+)$/i.exec(rawTool)
173
+ const mcpTool = String(prefixed?.[1] ?? rawTool).replace(/[-.]/g, '_')
174
+ const succeeded = completed && !failed
175
+ return {
176
+ mcpTool,
177
+ failed,
178
+ completed: succeeded,
179
+ didCode: succeeded && /^(?:create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/.test(mcpTool),
180
+ didRepoMutation: succeeded && /^(?:create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/.test(mcpTool),
181
+ didMcpTaskRead: succeeded && /^(?:get_ticket|list_tasks)$/.test(mcpTool),
182
+ didMcpTaskUpdate: succeeded && mcpTool === 'update_ticket',
183
+ didMessage: succeeded && /^(?:post_message|comment_ticket)$/.test(mcpTool),
184
+ didChannelMessage: succeeded && mcpTool === 'post_message',
185
+ }
186
+ }
187
+
188
+ // Claude's stream-json protocol separates tool_use blocks from their later
189
+ // tool_result blocks. Keep the pending uses supplied by the caller and reduce
190
+ // only completed results to work evidence; announcing a tool call is not proof
191
+ // that the action succeeded.
192
+ export function claudeEventEvidence(event, pendingToolUses = new Map()) {
193
+ if (!event || typeof event !== 'object') return {}
194
+ const content = Array.isArray(event.message?.content) ? event.message.content : []
195
+ const output = []
196
+ const toolCalls = []
197
+ const toolResults = []
198
+
199
+ if (event.type === 'assistant') {
200
+ for (const block of content) {
201
+ if (block?.type === 'text' && typeof block.text === 'string' && block.text.trim()) output.push(block.text.trim())
202
+ if (block?.type !== 'tool_use' || !block.name) continue
203
+ const id = String(block.id ?? block.tool_use_id ?? '')
204
+ const use = { name: String(block.name), input: block.input && typeof block.input === 'object' ? block.input : {} }
205
+ if (id) pendingToolUses.set(id, use)
206
+ toolCalls.push(use.name)
207
+ }
208
+ }
209
+
210
+ if (event.type === 'user') {
211
+ for (const block of content) {
212
+ if (block?.type !== 'tool_result') continue
213
+ const id = String(block.tool_use_id ?? block.id ?? '')
214
+ const use = id ? pendingToolUses.get(id) : null
215
+ if (!use) continue
216
+ pendingToolUses.delete(id)
217
+ const failed = block.is_error === true
218
+ toolResults.push(opencodeEventEvidence({
219
+ type: 'tool_use',
220
+ part: {
221
+ type: 'tool',
222
+ tool: use.name,
223
+ state: {
224
+ status: failed ? 'error' : 'completed',
225
+ input: use.input,
226
+ ...(failed ? { error: block.content ?? 'Claude tool call failed' } : {}),
227
+ },
228
+ },
229
+ }))
230
+ }
231
+ }
232
+
233
+ return {
234
+ ...(output.length ? { outputText: output.join(' ') } : {}),
235
+ ...(toolCalls.length ? { toolCalls } : {}),
236
+ ...(toolResults.length ? { toolResults } : {}),
237
+ }
238
+ }
239
+
240
+ export function missingRuntimeWorkEvidence(result, { ticketCycle = false, resultMessageRequired = false } = {}) {
241
+ const missing = [
242
+ ticketCycle && !result?.didMcpTaskRead && 'read the ticket through get_ticket/list_tasks',
243
+ !result?.didRepoMutation && 'perform and verify the repository change',
244
+ ticketCycle && !result?.didMcpTaskUpdate && 'update the ticket through update_ticket',
245
+ resultMessageRequired && !result?.didResultMessage && 'post a final result after the repository work',
246
+ ].filter(Boolean)
247
+ if (result?.mcpErrors?.length) missing.push('resolve failed MCP calls: ' + result.mcpErrors.join(', '))
248
+ return missing
249
+ }
250
+
251
+ export function combineRuntimeWorkEvidence(first, second) {
252
+ return {
253
+ ...second,
254
+ didCode: !!first?.didCode || !!second?.didCode,
255
+ didRepoMutation: !!first?.didRepoMutation || !!second?.didRepoMutation,
256
+ didMessage: !!first?.didMessage || !!second?.didMessage,
257
+ didChannelMessage: !!first?.didChannelMessage || !!second?.didChannelMessage,
258
+ // A message in a continuation comes after repository work completed in the
259
+ // first turn, even when that continuation itself performs no new mutation.
260
+ didResultMessage: !!first?.didResultMessage || !!second?.didResultMessage || (!!first?.didRepoMutation && !!second?.didChannelMessage),
261
+ didMcpTaskRead: !!first?.didMcpTaskRead || !!second?.didMcpTaskRead,
262
+ didMcpTaskUpdate: !!first?.didMcpTaskUpdate || !!second?.didMcpTaskUpdate,
263
+ mcpCalls: [...new Set([...(first?.mcpCalls || []), ...(second?.mcpCalls || [])])],
264
+ // An unrelated successful turn cannot erase a failed action. Clear only
265
+ // tools actually retried successfully in the continuation.
266
+ mcpErrors: [...new Set([
267
+ ...(first?.mcpErrors || []).filter((name) => !(second?.mcpCalls || []).includes(name)),
268
+ ...(second?.mcpErrors || []),
269
+ ])],
270
+ // Never deliver a stale promise together with a later verified result.
271
+ outputText: second?.outputText || first?.outputText || '',
272
+ }
273
+ }
274
+
134
275
  export function agentStateRequest(backend, channelId, state, apiKey, identifier) {
135
276
  if (!['thinking', 'working', 'typing'].includes(state)) throw new Error('invalid agent state')
136
277
  const id = Number(channelId)
package/src/lib.mjs CHANGED
@@ -3,8 +3,9 @@
3
3
 
4
4
  import { spawnSync } from 'node:child_process'
5
5
  import { homedir } from 'node:os'
6
- import { join } from 'node:path'
7
- import { mkdirSync, writeFileSync, readFileSync, chmodSync } from 'node:fs'
6
+ import { join, dirname } from 'node:path'
7
+ import { mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, unlinkSync } from 'node:fs'
8
+ import { randomUUID } from 'node:crypto'
8
9
 
9
10
  export const OV_DIR = join(homedir(), '.openvisio')
10
11
  // The agent's default code WORKSPACE — a single dedicated root that holds the org's
@@ -92,9 +93,17 @@ export function ensureCodex() {
92
93
  }
93
94
 
94
95
  export function writeJson(path, obj, secret = false) {
95
- mkdirSync(OV_DIR, { recursive: true })
96
- writeFileSync(path, JSON.stringify(obj, null, 2))
97
- if (secret) chmodSafe(path, 0o600)
96
+ const contents = JSON.stringify(obj, null, 2)
97
+ mkdirSync(dirname(path), { recursive: true })
98
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`
99
+ try {
100
+ // Create private files privately, then replace atomically. A crash during a
101
+ // write must not truncate credentials or forget delivered-message guards.
102
+ writeFileSync(temporary, contents, { flag: 'wx', mode: secret ? 0o600 : 0o644 })
103
+ renameSync(temporary, path)
104
+ } finally {
105
+ try { unlinkSync(temporary) } catch (error) { if (error.code !== 'ENOENT') throw error }
106
+ }
98
107
  }
99
108
 
100
109
  export function readConfig(slug) {
package/src/mcp-http.mjs CHANGED
@@ -9,7 +9,8 @@ const parsePayload = async (res) => {
9
9
  // Mcp-Session-Id) and stateless servers (no session header). The backend agent
10
10
  // MCP is deployed in both forms, so absence of a session id is a transport mode,
11
11
  // not an initialization failure.
12
- export function createMcpHttpClient({ url, apiKey, identifier, clientVersion, fetchImpl = fetch, log = () => {} }) {
12
+ export function createMcpHttpClient({ url, apiKey, identifier, clientVersion, fetchImpl = fetch, log = () => {}, requestTimeoutMs = 20_000 }) {
13
+ if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) throw new Error('invalid MCP request timeout')
13
14
  let initialized = false
14
15
  let sessionId = ''
15
16
  let rpcId = 0
@@ -17,17 +18,36 @@ export function createMcpHttpClient({ url, apiKey, identifier, clientVersion, fe
17
18
  let toolsPromise = null
18
19
  let toolsCache = null
19
20
 
20
- const post = (message, withSession = true) => fetchImpl(url, {
21
- method: 'POST',
22
- headers: {
23
- 'content-type': 'application/json',
24
- accept: 'application/json, text/event-stream',
25
- 'x-agent-api-key': apiKey,
26
- 'x-agent-identifier': identifier,
27
- ...(withSession && sessionId ? { 'mcp-session-id': sessionId } : {}),
28
- },
29
- body: JSON.stringify(message),
30
- })
21
+ const post = async (message, withSession = true) => {
22
+ const controller = new AbortController()
23
+ let timer
24
+ const timeout = new Promise((_, reject) => {
25
+ timer = setTimeout(() => {
26
+ reject(new Error(`MCP ${message.method} timed out after ${requestTimeoutMs}ms`))
27
+ controller.abort()
28
+ }, requestTimeoutMs)
29
+ })
30
+ try {
31
+ // The deadline covers response bodies too: headers alone do not prove a
32
+ // streaming MCP request finished. Never retry an ambiguous mutation here.
33
+ return await Promise.race([timeout, (async () => {
34
+ const res = await fetchImpl(url, {
35
+ method: 'POST',
36
+ signal: controller.signal,
37
+ headers: {
38
+ 'content-type': 'application/json',
39
+ accept: 'application/json, text/event-stream',
40
+ 'x-agent-api-key': apiKey,
41
+ 'x-agent-identifier': identifier,
42
+ ...(withSession && sessionId ? { 'mcp-session-id': sessionId } : {}),
43
+ },
44
+ body: JSON.stringify(message),
45
+ })
46
+ const body = await res.text()
47
+ return { ok: res.ok, status: res.status, headers: res.headers, text: async () => body }
48
+ })()])
49
+ } finally { clearTimeout(timer) }
50
+ }
31
51
 
32
52
  const reset = () => { initialized = false; sessionId = ''; toolsCache = null }
33
53
 
@@ -56,6 +76,7 @@ export function createMcpHttpClient({ url, apiKey, identifier, clientVersion, fe
56
76
 
57
77
  const callTool = async (name, args = {}, retried = false) => {
58
78
  await initialize()
79
+ const requestSession = sessionId
59
80
  const hadSession = !!sessionId
60
81
  const res = await post({
61
82
  jsonrpc: '2.0',
@@ -67,7 +88,7 @@ export function createMcpHttpClient({ url, apiKey, identifier, clientVersion, fe
67
88
  // Only a stateful transport can have an expired session. A stateless 4xx
68
89
  // belongs to the tool request itself and must not trigger an initialize loop.
69
90
  if (hadSession && !retried && [400, 404, 409, 410].includes(res.status)) {
70
- reset()
91
+ if (sessionId === requestSession) reset()
71
92
  return callTool(name, args, true)
72
93
  }
73
94
  throw new Error(`MCP ${name} HTTP ${res.status}`)
@@ -82,13 +103,14 @@ export function createMcpHttpClient({ url, apiKey, identifier, clientVersion, fe
82
103
  return result
83
104
  }
84
105
 
85
- const requestTools = async (retried = false) => {
106
+ const requestTools = async (retried = false, cursor, collected = [], cursors = new Set()) => {
86
107
  await initialize()
108
+ const requestSession = sessionId
87
109
  const hadSession = !!sessionId
88
- const res = await post({ jsonrpc: '2.0', id: ++rpcId, method: 'tools/list', params: {} })
110
+ const res = await post({ jsonrpc: '2.0', id: ++rpcId, method: 'tools/list', params: cursor ? { cursor } : {} })
89
111
  if (!res.ok) {
90
112
  if (hadSession && !retried && [400, 404, 409, 410].includes(res.status)) {
91
- reset()
113
+ if (sessionId === requestSession) reset()
92
114
  return requestTools(true)
93
115
  }
94
116
  throw new Error(`MCP tools/list HTTP ${res.status}`)
@@ -97,8 +119,15 @@ export function createMcpHttpClient({ url, apiKey, identifier, clientVersion, fe
97
119
  if (payload.error) throw new Error(`MCP tools/list: ${payload.error.message || 'protocol error'}`)
98
120
  const tools = payload.result?.tools ?? payload.tools
99
121
  if (!Array.isArray(tools)) throw new Error('MCP tools/list returned no tool array')
100
- toolsCache = tools
101
- return tools
122
+ const combined = [...collected, ...tools]
123
+ const nextCursor = payload.result?.nextCursor ?? payload.nextCursor
124
+ if (nextCursor) {
125
+ if (cursors.has(nextCursor) || cursors.size >= 99) throw new Error('MCP tools/list pagination did not terminate')
126
+ cursors.add(nextCursor)
127
+ return requestTools(retried, nextCursor, combined, cursors)
128
+ }
129
+ toolsCache = [...new Map(combined.map((tool) => [tool.name, tool])).values()]
130
+ return toolsCache
102
131
  }
103
132
 
104
133
  // Capability discovery is shared and cached. BYO runtimes use it before
package/src/memory.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
- import { dirname } from 'node:path'
1
+ import { readFileSync } from 'node:fs'
2
+ import { writeJson } from './lib.mjs'
3
3
 
4
4
  const clean = (value, max = 320) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, max)
5
5
  const sameRef = (a, b) => a != null && b != null && String(a) === String(b)
@@ -16,8 +16,7 @@ export function createByoMemoryGraph({ path, maxNodes = 1000, now = () => Date.n
16
16
 
17
17
  const persist = () => {
18
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 })
19
+ writeJson(path, { version: 1, nodes: [...nodes.values()], edges: [...edges.values()] }, true)
21
20
  } catch { /* memory is best-effort; live backend checks remain authoritative */ }
22
21
  }
23
22
  const trim = () => {
@@ -11,19 +11,27 @@ export function opencodeRuntimeLayout({ cfgKey, workdir, baseDir = OV_DIR }) {
11
11
  }
12
12
  }
13
13
 
14
- export function buildOpencodeConfig({ mcpUrl, mcpHeaders }) {
15
- if (!mcpUrl) return null
14
+ export function buildOpencodeConfig({ mcpUrl, mcpHeaders, canCode = true }) {
15
+ if (!mcpUrl && canCode) return null
16
+ const replyPermissions = { '*': 'deny', 'openvisio-team_*': 'allow' }
16
17
  return {
17
18
  $schema: 'https://opencode.ai/config.json',
19
+ ...(!canCode ? {
20
+ permission: replyPermissions,
21
+ // Agent-level rules take precedence over global permissions. Select this
22
+ // private primary agent for reply runs, including stale project configs.
23
+ default_agent: 'openvisio-reply',
24
+ agent: { 'openvisio-reply': { mode: 'primary', permission: replyPermissions } },
25
+ } : {}),
18
26
  mcp: {
19
- 'openvisio-team': {
27
+ ...(mcpUrl ? { 'openvisio-team': {
20
28
  type: 'remote',
21
29
  url: mcpUrl,
22
30
  enabled: true,
23
31
  oauth: false,
24
32
  timeout: 15_000,
25
33
  ...(mcpHeaders && Object.keys(mcpHeaders).length ? { headers: mcpHeaders } : {}),
26
- },
34
+ } } : {}),
27
35
  },
28
36
  }
29
37
  }
@@ -0,0 +1,28 @@
1
+ // Model CLIs spawn shell/tool children. A lane remains occupied until its child
2
+ // closes; POSIX process groups let cancellation reach those descendants too.
3
+ export const modelProcessOptions = { detached: process.platform !== 'win32' }
4
+
5
+ export function stopModelProcess(child, { graceMs = 1000 } = {}) {
6
+ if (!child?.pid || child.exitCode != null || child.signalCode != null) return Promise.resolve()
7
+ return new Promise((resolve) => {
8
+ let timer
9
+ const signal = (name) => {
10
+ if (process.platform !== 'win32') {
11
+ try { process.kill(-child.pid, name); return } catch { /* exited or not a process group leader */ }
12
+ }
13
+ try { child.kill(name) } catch { /* already gone */ }
14
+ }
15
+ const closed = () => {
16
+ clearTimeout(timer)
17
+ child.removeListener('close', closed)
18
+ // A tool may have detached its stdio while retaining the process group.
19
+ if (process.platform !== 'win32') {
20
+ try { process.kill(-child.pid, 'SIGKILL') } catch { /* no descendants remain */ }
21
+ }
22
+ resolve()
23
+ }
24
+ child.once('close', closed)
25
+ timer = setTimeout(() => signal('SIGKILL'), graceMs)
26
+ signal('SIGTERM')
27
+ })
28
+ }