openvisio-agent 0.21.1 → 0.22.0
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/CHANGELOG.md +11 -0
- package/README.md +5 -1
- package/USER_GUIDE.md +2 -2
- package/package.json +2 -2
- package/scenarios/runtime.scenarios.mjs +6 -11
- package/scenarios/workspace.scenarios.mjs +2 -1
- package/scripts/certify.mjs +11 -11
- package/src/codex-mcp-proxy.mjs +12 -1
- package/src/events.mjs +7 -1
- package/src/mastra-harness.mjs +19 -17
- package/src/memory.mjs +4 -3
- package/src/opencode-config.mjs +13 -16
- package/src/runtime-control.mjs +50 -0
- package/src/watch.mjs +153 -128
- package/studio/app.mjs +6 -5
- package/studio/guide.html +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.22.0] — 2026-09-10
|
|
4
|
+
|
|
5
|
+
- Carry the agent’s configured role and voice through every session, with cached identity when the roster is unavailable.
|
|
6
|
+
- Let agents request their own coding workspace and carry forward findings without asking teammates to reassign work. Record the continuation in the source context graph and Studio.
|
|
7
|
+
- Bound graph recall to direct source neighbors so shared nodes cannot pull in unrelated conversations.
|
|
8
|
+
|
|
9
|
+
- Let the agent's native final turn end a cycle. Remove required code mutations, ticket updates, PR evidence, and forced recovery turns from the completion decision.
|
|
10
|
+
- Deliver and persist the agent's actual response, including research, questions, and blockers, without automatically completing the ticket or rerunning an unchanged assignment after restart.
|
|
11
|
+
- Expose advertised MCP capabilities independently of scheduling classification; allow native reading, search, browsing, planning, and context tools in reply sessions while keeping local writes scoped to coding workspaces.
|
|
12
|
+
- Keep Claude context warm like Codex and OpenCode, tolerate unavailable optional history, and treat runtime deadlines as inactivity watchdogs.
|
|
13
|
+
|
|
3
14
|
## [0.21.1] — 2026-09-10
|
|
4
15
|
|
|
5
16
|
- Recover identifier-owned assignments even when the agent is missing from `list_agents` or the roster request fails. Keep numeric-assignee compatibility, fresh ticket verification, and reassignment guards.
|
package/README.md
CHANGED
|
@@ -83,6 +83,10 @@ openvisio-agent watch --name ada --workdir ~/repo # allow REAL work on a git br
|
|
|
83
83
|
openvisio-agent stop --name ada # stop service + every ada watcher
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
+
Agents choose their tools, investigation steps, context management, and when to end a turn. Each turn receives the agent’s configured identity, role, and voice when supplied by its profile; that context is cached across restarts. Reply sessions can request their configured coding workspace through `openvisio_request_work_session`, carrying their findings, source thread, and permissions into the continuation. They do not ask you to reassign work because of an internal scheduling choice. A successful native final turn ends the cycle; it does not mark a ticket done. The agent decides when to update the board. Research, audits, and already-satisfied requests can finish without code changes, ticket mutations, or a PR. Tool failures stay visible as diagnostics without forcing another model turn or replacing the agent's explanation.
|
|
87
|
+
|
|
88
|
+
The context graph records source-to-continuation relationships and recalls direct neighbors without walking into unrelated conversations. The watcher saves the agent's final response for delivery retry and avoids rerunning an unchanged assignment after restart. Claude, Codex, and OpenCode keep context within a ticket or conversation; native runtimes handle compaction. A stalled-session watchdog resets when activity arrives, so ongoing work is not stopped by a fixed task duration. Ownership, cancellation, delivery deduplication, credential injection, and repository permissions remain enforced.
|
|
89
|
+
|
|
86
90
|
The watcher can run up to three assigned coding tasks concurrently. Each ticket gets an isolated model runtime and ticket-specific worktree; tasks sharing the same mutable workspace remain serialized to prevent branch and file conflicts. Replies to one thread stay serialized while up to three independent threads run concurrently, and cancellation targets only the matching ticket or source thread. The work pool reuses local clones and is instructed to preserve dirty/staged work, create unique branches, 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.
|
|
87
91
|
|
|
88
92
|
PR publishing is part of normal coding setup. `connect` authorizes the selected `--workspace` (or `~/openvisio-workspace`) for routine agent-branch pushes and installs the constrained Codex helper rule. New clones and linked worktrees inherit that workspace authority. Existing coding configurations migrate on their next watcher start. Use `--no-pr-push` during setup to opt out.
|
|
@@ -107,7 +111,7 @@ Do not chase auto-changing watcher PIDs. `openvisio-agent stop --name <agent>` u
|
|
|
107
111
|
|
|
108
112
|
- **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).
|
|
109
113
|
- **Single-use code.** The `ovs_` code is exchanged once for a key; a leaked code is already spent.
|
|
110
|
-
- **
|
|
114
|
+
- **Scoped workspace access.** Chat sessions can discover advertised MCP actions and use reading, search, browsing, planning, and context tools. Local writes and shell execution require a coding workspace. Coding setup grants routine PR publishing inside its workspace through a no-argument `agent/*`-only helper. Repositories outside that workspace need an exact repository grant. The helper cannot merge or push protected branches.
|
|
111
115
|
- **Local secrets.** Your agent key lives in `~/.openvisio/` with `600` permissions — never printed, never committed.
|
|
112
116
|
|
|
113
117
|
## Requirements
|
package/USER_GUIDE.md
CHANGED
|
@@ -91,7 +91,7 @@ The demo makes no model calls. Close it with `Ctrl+C` in its terminal and run St
|
|
|
91
91
|
### Find a result
|
|
92
92
|
|
|
93
93
|
1. Select an agent in the sidebar.
|
|
94
|
-
2. Select **Cycles**. A cycle is
|
|
94
|
+
2. Select **Cycles**. A cycle is one agent turn, including its tool calls. The agent chooses when to finish; ticket completion is a separate action.
|
|
95
95
|
3. Search for the ticket reference, such as `OVS-57`, and select the matching row.
|
|
96
96
|
4. Switch between **Plan**, **Commands**, and **Response** to inspect steps, tool calls, and the final response. Expand **Runtime details** or **Cycle history** for more context.
|
|
97
97
|
|
|
@@ -113,7 +113,7 @@ Watchers running version **0.21.0 or newer** apply saved settings to the next re
|
|
|
113
113
|
| --- | --- |
|
|
114
114
|
| Live | Your browser is connected to Studio. Each watcher has its own connection status. |
|
|
115
115
|
| Queued / Active | Work is waiting or running in the current watcher run. |
|
|
116
|
-
| Completed | The selected action or
|
|
116
|
+
| Completed | The selected action finished, or the agent ended its turn. This does not automatically mark a ticket done. Review the final response for the outcome. |
|
|
117
117
|
| Blocked / Failed / Timed out | Read the outcome and watcher log. Resolve the stated cause before requesting more work. |
|
|
118
118
|
| Offline | The watcher stopped reporting or restarted. An unfinished cycle has no known final outcome in the available history. |
|
|
119
119
|
| No explicit plan recorded | No plan was provided in the available history. This alone is not a failure. |
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openvisio-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team
|
|
3
|
+
"version": "0.22.0",
|
|
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": {
|
|
7
7
|
"openvisio-agent": "bin/cli.mjs"
|
|
@@ -27,10 +27,10 @@ const formats = [
|
|
|
27
27
|
|
|
28
28
|
// Tool identity, runtime representation, and lane capability are independent
|
|
29
29
|
// dimensions: every row checks the actual permission boundary, not a snapshot.
|
|
30
|
-
for (const tool of [...readTools, ...coordinationTools, ...repositoryTools
|
|
30
|
+
for (const tool of [...readTools, ...coordinationTools, ...repositoryTools]) {
|
|
31
31
|
for (const mode of modes) for (const format of formats) {
|
|
32
32
|
add(`permission-${mode.name}-${format.name}-${tool}`, 'runtime-permissions', `${mode.name} handles ${tool} from ${format.name} with only that lane's authority`, async () => {
|
|
33
|
-
const permitted = mode.canCode ||
|
|
33
|
+
const permitted = mode.canCode || !localTools.includes(tool)
|
|
34
34
|
const result = acpPermissionResponse({ options: permissionOptions, toolCall: format.call(tool) }, mode)
|
|
35
35
|
assert.equal(result.outcome.optionId, permitted ? 'yes' : 'no')
|
|
36
36
|
})
|
|
@@ -317,12 +317,7 @@ for (const agent of ['codex', 'opencode']) for (const mode of modes) for (const
|
|
|
317
317
|
assert.equal(server.command, process.execPath)
|
|
318
318
|
const env = Object.fromEntries(server.env.map(({ name, value }) => [name, value]))
|
|
319
319
|
const allowed = JSON.parse(env.OPENVISIO_CODEX_ALLOWED_TOOLS)
|
|
320
|
-
|
|
321
|
-
else {
|
|
322
|
-
assert.ok(allowed.includes('get_ticket'))
|
|
323
|
-
assert.equal(allowed.includes('update_ticket'), mode.canCoordinate)
|
|
324
|
-
assert.equal(allowed.includes('write_codebase_file'), false)
|
|
325
|
-
}
|
|
320
|
+
assert.equal(allowed, null, 'Scheduling lanes do not hide advertised capabilities')
|
|
326
321
|
}
|
|
327
322
|
if (agent === 'opencode') {
|
|
328
323
|
const value = JSON.parse(config.env.OPENCODE_CONFIG_CONTENT)
|
|
@@ -353,10 +348,10 @@ for (const change of ['same', 'cwd', 'disabled-tools', 'model']) {
|
|
|
353
348
|
}
|
|
354
349
|
|
|
355
350
|
for (const localTool of localTools) {
|
|
356
|
-
add(`opencode-local-denial-${localTool}`, 'runtime-session-config', `OpenCode reply configuration
|
|
351
|
+
add(`opencode-local-denial-${localTool}`, 'runtime-session-config', `OpenCode reply configuration scopes ${localTool} access globally and at agent level`, async () => {
|
|
357
352
|
const config = buildOpencodeConfig({ canCode: false, mcpUrl: 'https://fixture.invalid/mcp', mcpHeaders: headers })
|
|
358
|
-
assert.equal(config.permission[localTool], 'deny')
|
|
359
|
-
assert.equal(config.agent['openvisio-reply'].permission[localTool], 'deny')
|
|
353
|
+
assert.equal(config.permission[localTool], ['read', 'task'].includes(localTool) ? 'allow' : 'deny')
|
|
354
|
+
assert.equal(config.agent['openvisio-reply'].permission[localTool], ['read', 'task'].includes(localTool) ? 'allow' : 'deny')
|
|
360
355
|
assert.equal(config.mcp['openvisio-team'].oauth, false)
|
|
361
356
|
assert.equal(config.mcp['openvisio-team'].headers['x-agent-api-key'], 'fixture-secret-key')
|
|
362
357
|
})
|
|
@@ -16,7 +16,7 @@ async function eventually(predicate, description) {
|
|
|
16
16
|
assert.fail(`Watcher did not reach: ${description}`)
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export function fixture({ agent = 'codex', task = true, run, threadError = false, commentTools = [], onComment, selfProfile = self, listAgents, getTicket } = {}) {
|
|
19
|
+
export function fixture({ agent = 'codex', task = true, run, threadError = false, commentTools = [], onComment, selfProfile = self, listAgents, getTicket, listChannels } = {}) {
|
|
20
20
|
const dir = mkdtempSync(join(tmpdir(), 'byo-workspace-'))
|
|
21
21
|
const posts = [], runs = [], calls = [], logs = [], messages = [], subscriptions = []
|
|
22
22
|
const ticket = { id: 77, project_id: 1, slug: 'OPEN-77', title: 'Repair the component', agent_id: 7, status: 'In Progress', type_id: 1, updated_at: 'revision-1' }
|
|
@@ -33,6 +33,7 @@ export function fixture({ agent = 'codex', task = true, run, threadError = false
|
|
|
33
33
|
if (name === 'list_task_types') return { types: [{ id: 1, name: 'In Progress' }, { id: 2, name: 'Testing' }, { id: 3, name: 'Done' }] }
|
|
34
34
|
if (name === 'get_ticket') { assert.equal(args.project_id, 1); assert.equal(args.ticket_id, 77); return { ticket: getTicket ? getTicket(ticket) : { ...ticket } } }
|
|
35
35
|
if (name === 'update_ticket') { Object.assign(ticket, args); return { ticket: { ...ticket } } }
|
|
36
|
+
if (name === 'list_channels' && listChannels) return { channels: await listChannels() }
|
|
36
37
|
if (name === 'list_channels') return { channels: [{ id: 2, name: 'alex', agent_id: 7 }, { id: 9, name: 'general' }] }
|
|
37
38
|
if (name === 'list_activity') return { activities: [] }
|
|
38
39
|
if (name === 'list_message_thread') {
|
package/scripts/certify.mjs
CHANGED
|
@@ -55,7 +55,7 @@ const assertions = [
|
|
|
55
55
|
['review and testing handoffs do not restart work', watcher.includes('taskIsAwaitingReview(task, reviewIds)') && watcher.includes('taskIsAwaitingReview(ticket, typeSets.review)')],
|
|
56
56
|
['review handoff releases the task key for future rework', watcher.includes('seenTasks.delete(taskKey)') && watcher.includes('seenTasks.delete(key)')],
|
|
57
57
|
['assigned coding completion is posted by the watcher', watcher.includes('announceTaskCompletion') && watcher.includes('postMessageOnce({ key: `completion:${report.key}`')],
|
|
58
|
-
['
|
|
58
|
+
['agent results are delivered independently of ticket completion', watcher.includes('agentFinal: true') && watcher.includes('pendingAgentResults')],
|
|
59
59
|
['completion delivery survives reconnect and deduplicates', watcher.includes('pendingCompletionReports: [...pendingCompletionReports]') && watcher.includes('reportedCompletions: [...reportedCompletions]') && watcher.includes('reportedCompletions.has(report.key)')],
|
|
60
60
|
['optional ticket comments cannot block verified completion', watcher.includes('callOptionalTicketComment({ projectId, ticketId, content: report.content })') && watcher.includes('no ticket comment tool is exposed; completing ticket') && watcher.includes('reportedTaskComments: [...reportedTaskComments]') && watcher.includes('reportedTaskComments.has(report.key)')],
|
|
61
61
|
['ticket comments prefer the advertised modern schema with legacy compatibility', watcher.includes("names.has('create_task_comment') ? 'create_task_comment' : names.has('comment_ticket') ? 'comment_ticket' : ''") && watcher.includes("name === 'create_task_comment' ? { content } : { text: content }")],
|
|
@@ -75,7 +75,7 @@ const assertions = [
|
|
|
75
75
|
['human-facing ticket references require slugs, never database ids', watcher.includes('TICKET SLUGS, NEVER DATABASE IDS') && watcher.includes('ticketDisplaySlug(task)') && events.includes('export function ticketDisplaySlug') && !events.includes('I finished ticket #${task.id}')],
|
|
76
76
|
['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')],
|
|
77
77
|
['Codex cannot race the watcher with post_message', watcher.includes("disabledMcpTools: ['post_message']") && codexConfig.includes('disabled_tools = [')],
|
|
78
|
-
['ACP sessions receive the authenticated OpenVisio proxy', mastraHarness.includes("name: 'openvisio-team-watcher'") && mastraHarness.includes('OPENVISIO_CODEX_MCP_URL')
|
|
78
|
+
['ACP sessions receive the authenticated OpenVisio proxy', mastraHarness.includes("name: 'openvisio-team-watcher'") && mastraHarness.includes('OPENVISIO_CODEX_MCP_URL')],
|
|
79
79
|
['Codex discovers deferred OpenVisio actions before claiming they are missing', watcher.includes('Codex may defer MCP actions') && watcher.includes('use tool_search to find that exact action') && watcher.includes('initial-list miss is NOT evidence that the server is disconnected')],
|
|
80
80
|
['Codex authentication is injected outside the model', codexConfig.includes('OPENVISIO_CODEX_API_KEY') && codexProxy.includes('toolWithoutCredentialInputs') && codexProxy.includes('client.callTool') && watcher.includes('local OpenVisio MCP bridge injects authentication outside the model')],
|
|
81
81
|
['reply delivery keys survive reconnects', watcher.includes('deliveredReplies: [...deliveredReplies]') && watcher.includes('deliveredReplies.has(deliveryKey)')],
|
|
@@ -106,18 +106,18 @@ const assertions = [
|
|
|
106
106
|
['bare-agent runs handle one source conversation', bareRun.includes('messageId?: string') && bareRun.includes('const targetRoot = target.parentId ?? target.messageId') && bareDriver.includes('messageId: source.messageId')],
|
|
107
107
|
['bare-agent posts have a live conversation guard', bareRun.includes('const canPostMessage') && bareRun.includes('{ canPostMessage }') && bareRuntime.includes('reply suppressed because the live thread was redirected, cancelled, or already answered')],
|
|
108
108
|
['quick replies preserve distinct top-level conversations', quickReply.includes('m.parentId ?? m.messageId') && quickReply.includes('...(parentId ? { parentId } : {})')],
|
|
109
|
-
['completion
|
|
110
|
-
['
|
|
109
|
+
['completion follows the native agent turn without a forced recovery', watcher.includes("cycleControl.finishedBy = 'agent'") && !watcher.includes('missingRuntimeWorkEvidence') && !watcher.includes('cycle.recovery')],
|
|
110
|
+
['runtime failures persist without restoring legacy evidence gates', watcher.includes('failedTaskVersions: [...failedTaskVersions]') && watcher.includes("replayState.completionPolicy === 'agent'") && watcher.includes('failedTaskRevisionIsCurrent(failedRevision, ticket)')],
|
|
111
111
|
['assigned coding tickets prefer their prepared local worktree', watcher.includes('findTicketWorktree(workdir, ticketId)') && watcher.includes('A prepared local git worktree for this ticket exists') && mastraHarness.includes('cwd: cycleCwd') && watcher.includes('workdir: ticketWorktree')],
|
|
112
112
|
['ACP evidence requires completed tools', mastraHarness.includes("if (tool.status !== 'completed') continue") && mastraHarness.includes('codexPolicyBlock(json(tool.rawOutput))')],
|
|
113
113
|
['OpenCode API-key MCP disables OAuth probing', opencodeConfig.includes("oauth: false") && opencodeConfig.includes('timeout: 15_000')],
|
|
114
114
|
['OpenCode ACP isolates server identity and reply permissions', mastraHarness.includes('OPENCODE_CONFIG_CONTENT') && mastraHarness.includes('buildOpencodeConfig({ canCode })') && mastraHarness.includes('mcpServers,')],
|
|
115
|
-
['
|
|
115
|
+
['agents can discover tools and choose context', watcher.includes('TOOL DISCOVERY') && watcher.includes('context compaction')],
|
|
116
116
|
['ACP tool failures retain sanitized diagnostics', mastraHarness.includes('for (const [name, detail] of errors)') && mastraHarness.includes('redact(detail)')],
|
|
117
|
-
['
|
|
117
|
+
['tool telemetry remains separate from the native final response', watcher.includes('claudeEventEvidence(o, turnToolUses)') && watcher.includes('outputText: finalText')],
|
|
118
118
|
['ACP prose cannot masquerade as repository evidence', mastraHarness.includes("if (event.type === 'text') { outputText += event.text; continue }") && mastraHarness.includes("if (tool.status !== 'completed') continue")],
|
|
119
119
|
['ACP MCP failures retain sanitized per-call diagnostics', mastraHarness.includes('mcpErrorDetails: Object.fromEntries(errors)') && mastraHarness.includes("join('[redacted]')")],
|
|
120
|
-
['
|
|
120
|
+
['same-source context stays warm across native turns', !watcher.includes('MAX_TURNS') && mastraHarness.includes('persistSession: true')],
|
|
121
121
|
['backend MCP accepts stateless initialize responses', mcpHttp.includes("mode = () => !initialized ? 'uninitialized' : sessionId ? 'stateful' : 'stateless'") && !watcher.includes('MCP initialize returned no session id')],
|
|
122
122
|
['MCP initialize is shared across concurrent startup probes', mcpHttp.includes('if (initializePromise) return initializePromise')],
|
|
123
123
|
['stateless tool errors do not cause initialize loops', mcpHttp.includes('if (hadSession && !retried')],
|
|
@@ -129,17 +129,17 @@ const assertions = [
|
|
|
129
129
|
['policy rejection cannot close a work cycle', mastraHarness.includes("subtype: policyBlock ? 'blocked' : 'ok'") && watcher.includes("result?.subtype === 'blocked'")],
|
|
130
130
|
['policy-blocked tickets are persisted and paused', watcher.includes('blockedTasks: [...blockedTasks]') && watcher.includes('WORK_CYCLE_BLOCKED')],
|
|
131
131
|
['repository push authorization automatically resumes the paused ticket', watcher.includes('blockedTaskRepos: [...blockedTaskRepos]') && watcher.includes('repositoryHasPrPushAuthorization')],
|
|
132
|
-
['BYO coding prefers an existing local repository', watcher.includes('A usable local clone is your primary code surface') && watcher.includes('
|
|
132
|
+
['BYO coding prefers an existing local repository', watcher.includes('A usable local clone is your primary code surface') && watcher.includes('Remote codebase tools are a fallback only when the repository cannot be obtained locally')],
|
|
133
133
|
['Codex publishes local branches with the constrained helper', watcher.includes('CODEX PR DELIVERY') && watcher.includes('openvisio-agent push-pr-branch') && watcher.includes('Use list_codebases/create_codebase_branch/create_codebase_commit/create_pull_request only as a fallback') && events.includes('const codebaseMutation')],
|
|
134
134
|
['private PR pushes use an explicit constrained helper', cli.includes("cmd === 'authorize-pr-push'") && cli.includes("cmd === 'push-pr-branch'") && prPush.includes("'push', '-u', identity.remote, destination")],
|
|
135
135
|
['PR push helper restricts branch and destination within setup authority', prPush.includes('isSafeAgentBranch(branch)') && prPush.includes('policyAllows(') && prPush.includes('accepts no force, remote, or ref args')],
|
|
136
136
|
['Codex recognizes helper authorization as a blocker', events.includes('OPENVISIO_PR_PUSH_AUTH_REQUIRED') && watcher.includes("block?.kind === 'pr-push-authorization-required'")],
|
|
137
137
|
['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock, delivery)') && watcher.includes("The local runtime rejected")],
|
|
138
138
|
['blocker routing carries explicit task identity', watcher.includes('const activeTaskRef = taskRef') && watcher.includes('taskRef: activeTaskRef')],
|
|
139
|
-
['
|
|
139
|
+
['agents retain control of their response after tool failures', watcher.includes('agent ended its turn with tool diagnostics') && !watcher.includes('blockingReplyMcpErrors')],
|
|
140
140
|
['pending-ticket questions use watcher-owned MCP reads without a chat model cycle', watcher.includes('conversationAsksPendingTickets(text)') && watcher.includes('pending-ticket question -> watcher-owned MCP lookup') && watcher.includes("callMcpReadWithRetry('list_tasks'")],
|
|
141
|
-
['chat ACP
|
|
142
|
-
['
|
|
141
|
+
['chat ACP discovers advertised tools through the authenticated proxy', mastraHarness.includes('opaqueMcpApproval') && !mastraHarness.includes('CHAT_SAFE_MCP_READS') && codexProxy.includes('export function toolAllowed')],
|
|
142
|
+
['runtime failures have a delivery path', watcher.includes('publishBlocker') && watcher.includes('WORK_CYCLE_BLOCKED')],
|
|
143
143
|
['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)')],
|
|
144
144
|
['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')],
|
|
145
145
|
['normative certification gates are documented', spec.includes('## Mandatory certification gates')],
|
package/src/codex-mcp-proxy.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline'
|
|
2
2
|
import { resolve } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { WORK_SESSION_TOOL, runtimeControlTools, requestWorkSession } from './runtime-control.mjs'
|
|
4
5
|
import { createMcpHttpClient } from './mcp-http.mjs'
|
|
5
6
|
|
|
6
7
|
export function toolWithoutCredentialInputs(tool) {
|
|
@@ -78,6 +79,7 @@ export function runCodexMcpProxy() {
|
|
|
78
79
|
const url = process.env.OPENVISIO_CODEX_MCP_URL
|
|
79
80
|
const apiKey = process.env.OPENVISIO_CODEX_API_KEY
|
|
80
81
|
const identifier = process.env.OPENVISIO_CODEX_IDENTIFIER
|
|
82
|
+
const capabilities = { canCode: process.env.OPENVISIO_CAN_CODE === 'true', workspaceAvailable: process.env.OPENVISIO_WORKSPACE_AVAILABLE === 'true' }
|
|
81
83
|
const { disabled, allowed: allowedTools } = proxyToolPolicyFromEnvironment()
|
|
82
84
|
if (!url || !apiKey || !identifier) throw new Error('OpenVisio Codex MCP bridge is missing its watcher environment.')
|
|
83
85
|
const client = createMcpHttpClient({ url, apiKey, identifier, clientVersion: 'openvisio-agent-codex-proxy' })
|
|
@@ -109,11 +111,20 @@ export function runCodexMcpProxy() {
|
|
|
109
111
|
} else if (request.method === 'ping') {
|
|
110
112
|
reply(id, {})
|
|
111
113
|
} else if (request.method === 'tools/list') {
|
|
112
|
-
const
|
|
114
|
+
const controls = runtimeControlTools(capabilities)
|
|
115
|
+
let remote
|
|
116
|
+
try { remote = await client.listTools() }
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (!controls.length) throw error
|
|
119
|
+
process.stderr.write('OpenVisio backend discovery is unavailable; local workspace continuation remains available.\n')
|
|
120
|
+
remote = []
|
|
121
|
+
}
|
|
122
|
+
const tools = [...remote.filter((tool) => tool.name !== WORK_SESSION_TOOL), ...controls]
|
|
113
123
|
reply(id, { tools: tools.filter((tool) => toolAllowed(tool.name, { disabled, allowed: allowedTools })).map(toolWithoutCredentialInputs) })
|
|
114
124
|
} else if (request.method === 'tools/call') {
|
|
115
125
|
const name = String(request.params?.name || '')
|
|
116
126
|
if (!toolAllowed(name, { disabled, allowed: allowedTools })) throw new Error('This tool is disabled for the current delivery lane.')
|
|
127
|
+
if (name === WORK_SESSION_TOOL) { reply(id, requestWorkSession(request.params?.arguments, capabilities)); return }
|
|
117
128
|
let result
|
|
118
129
|
try { result = await client.callTool(name, request.params?.arguments || {}) }
|
|
119
130
|
catch (error) {
|
package/src/events.mjs
CHANGED
|
@@ -99,8 +99,14 @@ export function agentAddedByName(agent) {
|
|
|
99
99
|
return personDisplayName(addedBy)
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
export function buildTaskCompletionReport(task, { projectId, fallbackText = '', recipientName = '', completedTypeIds = new Set(), reviewTypeIds = new Set() } = {}) {
|
|
102
|
+
export function buildTaskCompletionReport(task, { projectId, fallbackText = '', agentFinal = false, recipientName = '', completedTypeIds = new Set(), reviewTypeIds = new Set() } = {}) {
|
|
103
103
|
if (!task || typeof task !== 'object' || task.id == null) return null
|
|
104
|
+
if (agentFinal) {
|
|
105
|
+
const text = String(fallbackText || '').trim()
|
|
106
|
+
if (!text) return null
|
|
107
|
+
const label = ticketDisplaySlug(task) || String(task.title || 'Task')
|
|
108
|
+
return { key: `${projectId ?? task.project_id ?? task.projectId ?? '?'}:${task.id}:${taskRevision(task)}`, content: `${label}\n\n${text}`, prUrl: '' }
|
|
109
|
+
}
|
|
104
110
|
if (!taskIsCompleted(task, completedTypeIds) && !taskIsAwaitingReview(task, reviewTypeIds)) return null
|
|
105
111
|
|
|
106
112
|
const evidence = [task.description, fallbackText].filter(Boolean).join('\n')
|
package/src/mastra-harness.mjs
CHANGED
|
@@ -5,22 +5,16 @@ import { AcpAgent } from '@mastra/acp'
|
|
|
5
5
|
import { onPath } from './lib.mjs'
|
|
6
6
|
import { resolveAvailableModel } from './model-selection.mjs'
|
|
7
7
|
import { codexPolicyBlock } from './events.mjs'
|
|
8
|
+
import { WORK_SESSION_TOOL, workSessionRequest } from './runtime-control.mjs'
|
|
8
9
|
import { buildOpencodeConfig } from './opencode-config.mjs'
|
|
9
10
|
|
|
10
11
|
const MCP_TOOL_NAMES = [
|
|
11
|
-
'list_agents', 'list_projects', 'list_tasks', 'list_task_types', 'get_ticket',
|
|
12
|
+
WORK_SESSION_TOOL, 'list_agents', 'list_projects', 'list_tasks', 'list_task_types', 'get_ticket',
|
|
12
13
|
'create_ticket', 'update_ticket', 'list_channels', 'list_message_thread', 'list_activity',
|
|
13
14
|
'post_message', 'react_message', 'list_codebases', 'get_codebase',
|
|
14
15
|
'codebase_tree', 'create_codebase_branch', 'create_codebase_commit',
|
|
15
16
|
'create_pull_request', 'write_codebase_file', 'create_task_comment', 'comment_ticket',
|
|
16
17
|
]
|
|
17
|
-
const CHAT_SAFE_MCP_READS = new Set([
|
|
18
|
-
'list_agents', 'list_projects', 'list_tasks', 'list_task_types', 'get_ticket',
|
|
19
|
-
'list_channels', 'list_message_thread', 'list_activity', 'list_codebases',
|
|
20
|
-
'get_codebase', 'codebase_tree',
|
|
21
|
-
])
|
|
22
|
-
const COORDINATION_TOOLS = ['create_ticket', 'update_ticket', 'post_message', 'react_message', 'create_task_comment', 'comment_ticket']
|
|
23
|
-
|
|
24
18
|
const clean = (value, max = 300) => String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max)
|
|
25
19
|
const json = (value) => { try { return JSON.stringify(value) } catch { return String(value ?? '') } }
|
|
26
20
|
|
|
@@ -63,6 +57,8 @@ function toolName(update) {
|
|
|
63
57
|
const candidates = [update?.title, update?.rawInput?.tool, update?.rawInput?.toolName].filter((value) => typeof value === 'string')
|
|
64
58
|
for (const candidate of candidates) {
|
|
65
59
|
const value = candidate.toLowerCase().trim()
|
|
60
|
+
const namespaced = /^(?:mcp__openvisio-team(?:-watcher)?__|openvisio-team(?:-watcher)?[_ .:/]+)([a-z][a-z0-9_]+)$/.exec(value)
|
|
61
|
+
if (namespaced) return namespaced[1]
|
|
66
62
|
for (const name of MCP_TOOL_NAMES) {
|
|
67
63
|
if (value === name || (/^(?:mcp|openvisio)[\w .:/-]*?/.test(value) && new RegExp(`[_ .:/]${name}$`).test(value))) return name
|
|
68
64
|
}
|
|
@@ -74,10 +70,11 @@ export function acpPermissionResponse(request, { canCode = false, canCoordinate
|
|
|
74
70
|
const options = Array.isArray(request?.options) ? request.options : []
|
|
75
71
|
const name = toolName(request?.toolCall || {})
|
|
76
72
|
const opaqueMcpApproval = request?._meta?.is_mcp_tool_approval === true
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
const
|
|
73
|
+
// The authenticated bridge exposes the server's advertised capabilities.
|
|
74
|
+
// The agent chooses actions within the user's request; the proxy still owns
|
|
75
|
+
// credentials and the per-request duplicate-delivery exclusion.
|
|
76
|
+
const exploratory = ['read', 'search', 'fetch', 'think', 'switch_mode'].includes(request?.toolCall?.kind)
|
|
77
|
+
const allow = canCode || exploratory || !!name || (opaqueMcpApproval && proxyProtected)
|
|
81
78
|
const preferred = allow ? 'allow_once' : 'reject_once'
|
|
82
79
|
const validOptions = options.filter((option) => option && typeof option.optionId === 'string' && option.optionId && ['allow_once', 'allow_always', 'reject_once', 'reject_always'].includes(option.kind))
|
|
83
80
|
const selected = validOptions.find((option) => option.kind === preferred) || validOptions.find((option) => option.kind === (allow ? 'allow_always' : 'reject_always'))
|
|
@@ -92,7 +89,7 @@ function contentText(content) {
|
|
|
92
89
|
}
|
|
93
90
|
|
|
94
91
|
export function createMastraAcpRunner({
|
|
95
|
-
agent, mcpUrl, mcpHeaders = {}, workdir, canCode = !!workdir, canCoordinate = false, maxCycleMs = 20 * 60_000,
|
|
92
|
+
agent, mcpUrl, mcpHeaders = {}, workdir, canCode = !!workdir, canCoordinate = false, workspaceAvailable = canCode, maxCycleMs = 20 * 60_000,
|
|
96
93
|
log = () => {}, debug = false, model, onTool, onEvent, systemPrompt = '', AcpAgentClass = AcpAgent,
|
|
97
94
|
}) {
|
|
98
95
|
const runtime = commandFor(agent)
|
|
@@ -173,7 +170,9 @@ export function createMastraAcpRunner({
|
|
|
173
170
|
{ name: 'OPENVISIO_CODEX_API_KEY', value: String(mcpHeaders['x-agent-api-key'] || '') },
|
|
174
171
|
{ name: 'OPENVISIO_CODEX_IDENTIFIER', value: String(mcpHeaders['x-agent-identifier'] || '') },
|
|
175
172
|
{ name: 'OPENVISIO_CODEX_DISABLED_TOOLS', value: JSON.stringify(disabledMcpTools) },
|
|
176
|
-
{ name: 'OPENVISIO_CODEX_ALLOWED_TOOLS', value:
|
|
173
|
+
{ name: 'OPENVISIO_CODEX_ALLOWED_TOOLS', value: 'null' },
|
|
174
|
+
{ name: 'OPENVISIO_CAN_CODE', value: String(canCode) },
|
|
175
|
+
{ name: 'OPENVISIO_WORKSPACE_AVAILABLE', value: String(workspaceAvailable) },
|
|
177
176
|
],
|
|
178
177
|
}] : [{ type: 'http', name: 'openvisio-team', url: mcpUrl, headers }]
|
|
179
178
|
const sessionKey = JSON.stringify([cycleCwd, disabledMcpTools, mcpServers])
|
|
@@ -196,8 +195,7 @@ export function createMastraAcpRunner({
|
|
|
196
195
|
DISABLE_MCP_CONFIG_FILTERING: 'true',
|
|
197
196
|
CODEX_CONFIG: JSON.stringify({ 'mcp_servers.openvisio-team.enabled': false }),
|
|
198
197
|
} : agent === 'opencode' ? {
|
|
199
|
-
//
|
|
200
|
-
// installations whose ordinary OpenCode config auto-approves tools.
|
|
198
|
+
// Reply sessions can explore and manage context without a writable workspace.
|
|
201
199
|
OPENCODE_CONFIG_CONTENT: JSON.stringify({
|
|
202
200
|
...buildOpencodeConfig({ canCode }),
|
|
203
201
|
mcp: { 'openvisio-team': { enabled: false } },
|
|
@@ -248,6 +246,7 @@ export function createMastraAcpRunner({
|
|
|
248
246
|
negotiatedSelected = ''
|
|
249
247
|
}
|
|
250
248
|
}
|
|
249
|
+
// This is a stalled-session watchdog, not a budget for finishing a task.
|
|
251
250
|
const timer = setTimeout(() => { timedOut = true; controller.abort(); invalidate() }, maxCycleMs)
|
|
252
251
|
// OpenCode can keep its ACP prompt open while the provider retries a 429.
|
|
253
252
|
// Mastra 0.4.1 buffers child stderr but does not forward these diagnostics.
|
|
@@ -303,6 +302,7 @@ export function createMastraAcpRunner({
|
|
|
303
302
|
while (true) {
|
|
304
303
|
const next = await abortable(() => stream.next(), controller.signal)
|
|
305
304
|
if (next.done) break
|
|
305
|
+
timer.refresh()
|
|
306
306
|
const event = next.value
|
|
307
307
|
if (!readyReported) {
|
|
308
308
|
readyReported = true
|
|
@@ -352,10 +352,12 @@ export function createMastraAcpRunner({
|
|
|
352
352
|
controller.signal.throwIfAborted()
|
|
353
353
|
// Only final, completed tool states are evidence. Started/failed edits and
|
|
354
354
|
// board mutations cannot stand in for completed repository work.
|
|
355
|
+
let workRequest = null
|
|
355
356
|
for (const tool of [...tools.values()].sort((a, b) => a.order - b.order)) {
|
|
356
357
|
const name = toolName(tool)
|
|
357
358
|
if (tool.status === 'failed') policyBlock ||= codexPolicyBlock(json(tool.rawOutput))
|
|
358
359
|
if (tool.status !== 'completed') continue
|
|
360
|
+
if (name === WORK_SESSION_TOOL) workRequest = workSessionRequest(tool.rawOutput ?? tool.content) || workRequest
|
|
359
361
|
didMessage ||= ['post_message', 'create_task_comment', 'comment_ticket'].includes(name)
|
|
360
362
|
didChannelMessage ||= name === 'post_message'
|
|
361
363
|
didResultMessage ||= name === 'post_message' && didRepoMutation
|
|
@@ -377,7 +379,7 @@ export function createMastraAcpRunner({
|
|
|
377
379
|
flushProgress()
|
|
378
380
|
if (outputText.trim()) emit('output.final', { text: redact(outputText.trim()).slice(0, 8000), status: policyBlock ? 'blocked' : 'ok' })
|
|
379
381
|
return {
|
|
380
|
-
type: 'result', subtype: policyBlock ? 'blocked' : 'ok', policyBlock, timings: { firstEventMs, totalMs: Math.round(performance.now() - startedAt) }, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
|
|
382
|
+
type: 'result', subtype: policyBlock ? 'blocked' : 'ok', stopReason: 'end_turn', workRequest, policyBlock, timings: { firstEventMs, totalMs: Math.round(performance.now() - startedAt) }, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
|
|
381
383
|
mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
|
|
382
384
|
didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage,
|
|
383
385
|
didMcpTaskRead, didMcpTaskUpdate,
|
package/src/memory.mjs
CHANGED
|
@@ -66,10 +66,11 @@ export function createByoMemoryGraph({ path, maxNodes = 1000, now = () => Date.n
|
|
|
66
66
|
return sameRef(r.channelId, refs.channelId) && (refs.threadId == null || sameRef(r.threadId, refs.threadId)) ||
|
|
67
67
|
sameRef(r.projectId, refs.projectId) && sameRef(r.ticketId, refs.ticketId)
|
|
68
68
|
})
|
|
69
|
-
const
|
|
69
|
+
const roots = new Set(direct.map((node) => String(node.key)))
|
|
70
|
+
const keys = new Set(roots)
|
|
70
71
|
for (const edge of edges.values()) {
|
|
71
|
-
if (
|
|
72
|
-
if (
|
|
72
|
+
if (roots.has(String(edge.from))) keys.add(String(edge.to))
|
|
73
|
+
if (roots.has(String(edge.to))) keys.add(String(edge.from))
|
|
73
74
|
}
|
|
74
75
|
return [...nodes.values()].filter((node) => keys.has(String(node.key))).sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0)).slice(0, limit)
|
|
75
76
|
}
|
package/src/opencode-config.mjs
CHANGED
|
@@ -13,27 +13,24 @@ export function opencodeRuntimeLayout({ cfgKey, workdir, baseDir = OV_DIR }) {
|
|
|
13
13
|
|
|
14
14
|
export function buildOpencodeConfig({ mcpUrl, mcpHeaders, canCode = true }) {
|
|
15
15
|
if (!mcpUrl && canCode) return null
|
|
16
|
-
// Keep
|
|
17
|
-
//
|
|
18
|
-
// merged/default agent configuration. Explicit denials prevent a reply cycle
|
|
19
|
-
// from invoking local grep (and hitting its 64 KiB JSON-record limit), reading
|
|
20
|
-
// the workspace, editing files, or delegating before the MCP allow-list wins.
|
|
16
|
+
// Keep local writes scoped to coding connections while allowing the agent
|
|
17
|
+
// to explore tools, retrieve context, plan, and use native compaction.
|
|
21
18
|
const replyPermissions = {
|
|
22
19
|
'*': 'deny',
|
|
23
|
-
read: '
|
|
20
|
+
read: 'allow',
|
|
24
21
|
edit: 'deny',
|
|
25
|
-
glob: '
|
|
26
|
-
grep: '
|
|
27
|
-
list: '
|
|
22
|
+
glob: 'allow',
|
|
23
|
+
grep: 'allow',
|
|
24
|
+
list: 'allow',
|
|
28
25
|
bash: 'deny',
|
|
29
|
-
task: '
|
|
26
|
+
task: 'allow',
|
|
30
27
|
external_directory: 'deny',
|
|
31
|
-
todowrite: '
|
|
32
|
-
webfetch: '
|
|
33
|
-
websearch: '
|
|
34
|
-
lsp: '
|
|
35
|
-
skill: '
|
|
36
|
-
question: '
|
|
28
|
+
todowrite: 'allow',
|
|
29
|
+
webfetch: 'allow',
|
|
30
|
+
websearch: 'allow',
|
|
31
|
+
lsp: 'allow',
|
|
32
|
+
skill: 'allow',
|
|
33
|
+
question: 'allow',
|
|
37
34
|
'openvisio-team_*': 'allow',
|
|
38
35
|
'openvisio-team-watcher_*': 'allow',
|
|
39
36
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export const WORK_SESSION_TOOL = 'openvisio_request_work_session'
|
|
2
|
+
|
|
3
|
+
export function runtimeControlTools({ canCode = false, workspaceAvailable = false } = {}) {
|
|
4
|
+
if (canCode || !workspaceAvailable) return []
|
|
5
|
+
return [{
|
|
6
|
+
name: WORK_SESSION_TOOL,
|
|
7
|
+
description: 'Continue this same request in your configured coding workspace. Use when investigation reveals that local execution or edits are needed. You remain the same agent and retain the original task, recipient, and permissions. Supply the context worth carrying forward, then end this turn; the watcher starts your work session and delivers its eventual response. Do not ask the user to reassign the task.',
|
|
8
|
+
inputSchema: { type: 'object', properties: { context: { type: 'string', minLength: 1, maxLength: 12000, description: 'Relevant findings, decisions, remaining work, and context for your continuation.' } }, required: ['context'], additionalProperties: false },
|
|
9
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
10
|
+
}]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function requestWorkSession(args, capabilities) {
|
|
14
|
+
if (!runtimeControlTools(capabilities).length) throw new Error('No additional coding workspace is available in this session')
|
|
15
|
+
if (!args || Object.keys(args).some((key) => key !== 'context') || typeof args.context !== 'string' || !args.context.trim() || args.context.length > 12000) throw new Error('Provide only a nonempty context string of at most 12000 characters')
|
|
16
|
+
return { content: [{ type: 'text', text: JSON.stringify({ openvisioControl: { action: 'request_work_session', context: args.context.trim() } }) }] }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Only inspect outputs of our named local control tool, never arbitrary model
|
|
20
|
+
// prose, commands, ticket text, or repository content.
|
|
21
|
+
export function workSessionRequest(output, depth = 0) {
|
|
22
|
+
if (depth > 5 || output == null) return null
|
|
23
|
+
if (typeof output === 'string') {
|
|
24
|
+
try { return workSessionRequest(JSON.parse(output), depth + 1) } catch { return null }
|
|
25
|
+
}
|
|
26
|
+
if (Array.isArray(output)) {
|
|
27
|
+
for (const item of output) { const request = workSessionRequest(item, depth + 1); if (request) return request }
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
const request = output.openvisioControl
|
|
31
|
+
if (request?.action === 'request_work_session' && typeof request.context === 'string' && request.context.trim() && request.context.length <= 12000) return { context: request.context.trim() }
|
|
32
|
+
for (const key of ['content', 'text', 'result', 'structuredContent']) {
|
|
33
|
+
const nested = workSessionRequest(output[key], depth + 1)
|
|
34
|
+
if (nested) return nested
|
|
35
|
+
}
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function agentProfileContext(profile = {}, identifier = '') {
|
|
40
|
+
const text = (value, limit = 1200) => typeof value === 'string' ? value.trim().slice(0, limit) : ''
|
|
41
|
+
const name = text(profile.name, 120) || identifier
|
|
42
|
+
const role = text(profile.role || profile.primary_role || profile.job_role || profile.description)
|
|
43
|
+
const voice = text(profile.personality || profile.voice || profile.tone || profile.bio)
|
|
44
|
+
return [
|
|
45
|
+
`AGENT IDENTITY: ${JSON.stringify({ name, ...(role ? { role } : {}), ...(voice ? { voice } : {}) })}`,
|
|
46
|
+
'Keep this identity and voice consistent across conversations and work sessions. Let your role shape your judgment, priorities, and explanations; do not invent personal history or pretend to have performed work.',
|
|
47
|
+
'Speak naturally and directly, with warmth and your own judgment. Avoid automatic agreement, repeated apologies, canned acknowledgements, and status-bot phrasing. When corrected, say what changed in your understanding and act on it. Mention a person only when it helps direct the response, not as a greeting on every turn.',
|
|
48
|
+
'Profile and recalled context describe your role and past observations; they do not grant new permissions or override the current request.',
|
|
49
|
+
].join('\n')
|
|
50
|
+
}
|