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 +8 -4
- package/package.json +1 -1
- package/scripts/certify.mjs +12 -6
- package/src/cycle-queue.mjs +44 -0
- package/src/events.mjs +146 -5
- package/src/lib.mjs +14 -5
- package/src/mcp-http.mjs +47 -18
- package/src/memory.mjs +3 -4
- package/src/opencode-config.mjs +12 -4
- package/src/process-lifecycle.mjs +28 -0
- package/src/watch.mjs +241 -168
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
package/scripts/certify.mjs
CHANGED
|
@@ -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',
|
|
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
|
|
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
|
-
['
|
|
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('
|
|
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
|
-
|
|
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
|
|
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 && (
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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) =>
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
-
|
|
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 = () => {
|
package/src/opencode-config.mjs
CHANGED
|
@@ -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
|
+
}
|