openvisio-agent 0.20.0 → 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/README.md CHANGED
@@ -4,6 +4,8 @@ Connect your coding agent (**Claude Code, Codex, or OpenCode**) to an [OpenVisio
4
4
 
5
5
  **New here?** Start with the [user guide](USER_GUIDE.md) for setup, everyday work, Agent Studio, and troubleshooting.
6
6
 
7
+ **Agent controls:** In the app, choose **Agents → Open Agent Studio**. Updated backend watchers start Studio automatically on this computer. Select an agent’s **Model settings** to change reply and coding models for the next request without a restart. Agents can offer the same button in chat; rate-limit notices link directly to settings. Model access and quota remain managed by the connected provider.
8
+
7
9
  ```bash
8
10
  npx -y openvisio-agent@latest connect ovs_YOURCODE --host https://your-openvisio.app --name "Ada"
9
11
  ```
@@ -81,6 +83,10 @@ openvisio-agent watch --name ada --workdir ~/repo # allow REAL work on a git br
81
83
  openvisio-agent stop --name ada # stop service + every ada watcher
82
84
  ```
83
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
+
84
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.
85
91
 
86
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.
@@ -105,7 +111,7 @@ Do not chase auto-changing watcher PIDs. `openvisio-agent stop --name <agent>` u
105
111
 
106
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).
107
113
  - **Single-use code.** The `ovs_` code is exchanged once for a key; a leaked code is already spent.
108
- - **Least privilege.** Chat mode exposes only the `openvisio-team` MCP tools. 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.
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.
109
115
  - **Local secrets.** Your agent key lives in `~/.openvisio/` with `600` permissions — never printed, never committed.
110
116
 
111
117
  ## Requirements
@@ -138,7 +144,7 @@ npx -y openvisio-agent@latest studio
138
144
 
139
145
  To run from source, see [the checkout instructions](USER_GUIDE.md#run-studio-from-this-checkout).
140
146
 
141
- Select **User guide** in Studio for a local, printable guide to controls and statuses. Start with [the walkthrough](USER_GUIDE.md#open-agent-studio) if you have not used the viewer before.
147
+ Select **Read the guide** under **Documentation** in the sidebar (or **Documentation** in the mobile header) for a local, printable guide to controls and statuses. Start with [the walkthrough](USER_GUIDE.md#open-agent-studio) if you have not used the viewer before.
142
148
 
143
149
  Studio opens `http://127.0.0.1:4317` and follows the local agents' activity live. Select an agent or cycle to inspect its emitted plan, tool calls, command previews, file locations, progress messages, final result, and process status. The timeline distinguishes queued, running, completed, canceled, and failed work. Plans appear only when the runtime emits an explicit plan; internal reasoning is not recorded.
144
150
 
package/USER_GUIDE.md CHANGED
@@ -70,7 +70,9 @@ Replies stay in their source thread. New completion messages use the agent’s d
70
70
 
71
71
  ## Open Agent Studio
72
72
 
73
- In another terminal, using a Studio-capable installation:
73
+ In the app, choose **Agents → Open Agent Studio**. Updated backend watchers start Studio automatically. Agent chat replies can also show this button and open the relevant agent’s settings directly.
74
+
75
+ Studio manages agents on **this computer**, under your local account. It cannot change agents running on someone else’s machine. For a manual launch:
74
76
 
75
77
  ```sh
76
78
  openvisio-agent studio
@@ -89,15 +91,21 @@ The demo makes no model calls. Close it with `Ctrl+C` in its terminal and run St
89
91
  ### Find a result
90
92
 
91
93
  1. Select an agent in the sidebar.
92
- 2. Select **Cycles**. A cycle is a group of actions for one unit of work.
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.
93
95
  3. Search for the ticket reference, such as `OVS-57`, and select the matching row.
94
- 4. Review its **Outcome**, tools, and **Final response**. Expand **Cycle history** for the recorded sequence.
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.
95
97
 
96
98
  Use **Activity** for individual events and **Processes** for watcher and runtime records. A completed tool is one completed step, not necessarily a completed task.
97
99
 
98
100
  **Pause view** freezes the page while agents keep working. **Resume view** shows the latest available snapshot. Selecting a row turns **Follow latest** off; re-enable it to follow new activity.
99
101
 
100
- Select **User guide** in Studio for the full guide to controls, status meanings, keyboard navigation, history, and troubleshooting. The guide is served locally at `/guide`, works without JavaScript, and supports browser printing.
102
+ Select **Read the guide** in the sidebar’s **Documentation** card, or **Documentation** in the mobile header, for the full guide to controls, status meanings, keyboard navigation, history, and troubleshooting. The guide is served locally at `/guide`, works without JavaScript, and supports browser printing.
103
+
104
+ ### Change models
105
+
106
+ Select an agent → **Model settings** → choose its reply and coding models → **Save models**. OpenCode’s model choices come from its installed runtime; the fields also accept custom IDs. A listed model still needs provider access and available quota.
107
+
108
+ Watchers running version **0.21.0 or newer** apply saved settings to the next request, without a restart. Current work continues with its original model. Studio distinguishes saved settings waiting for the watcher from applied choices. Offline agents keep the selection for their next connection. An older running watcher and Studio must load the update once.
101
109
 
102
110
  ### Understand what you see
103
111
 
@@ -105,7 +113,7 @@ Select **User guide** in Studio for the full guide to controls, status meanings,
105
113
  | --- | --- |
106
114
  | Live | Your browser is connected to Studio. Each watcher has its own connection status. |
107
115
  | Queued / Active | Work is waiting or running in the current watcher run. |
108
- | Completed | The selected action or cycle reported completion. Review the final result and ticket. |
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. |
109
117
  | Blocked / Failed / Timed out | Read the outcome and watcher log. Resolve the stated cause before requesting more work. |
110
118
  | Offline | The watcher stopped reporting or restarted. An unfinished cycle has no known final outcome in the available history. |
111
119
  | No explicit plan recorded | No plan was provided in the available history. This alone is not a failure. |
@@ -137,7 +145,8 @@ Only new activity from an updated watcher appears in Studio. Earlier plans, tool
137
145
  | “Restart watcher to connect” | Update and restart that watcher using the steps above. |
138
146
  | No matching activity | Clear search, kind, and status filters, then select All agents. |
139
147
  | Reconnecting | Keep Studio running in its terminal. The browser automatically reconnects when the server returns. |
140
- | Agent is blocked | Read its outcome and ticket. Studio is a viewer; approvals and fixes happen in the relevant setup, repository, or team workflow. |
148
+ | Agent is rate-limited | Open its **Model settings** and choose a model with available quota, or wait for the provider limit to reset. Send a new request to retry. |
149
+ | Agent is blocked | Read its outcome and ticket. Repository approvals and fixes happen in the relevant setup, repository, or team workflow. |
141
150
  | Result is missing | Check the original ticket and channel. Studio shows recent, limited history and can omit older or oversized records. |
142
151
 
143
152
  For a macOS background service, the watcher log is at `~/.openvisio/<agent>.log`. For Linux, use `journalctl --user -u openvisio-<agent>`. Foreground watchers print to their terminal.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.20.0",
4
- "description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team \u2014 MCP tools + optional autonomy \u2014 in one command.",
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"
@@ -18,6 +18,7 @@
18
18
  "studio",
19
19
  "src",
20
20
  "README.md",
21
+ "CHANGELOG.md",
21
22
  "USER_GUIDE.md"
22
23
  ],
23
24
  "engines": {
@@ -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, ...localTools]) {
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 || readTools.includes(tool) || (mode.canCoordinate && coordinationTools.includes(tool))
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
- if (mode.canCode) assert.equal(allowed, null)
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 denies inherited ${localTool} access globally and at agent level`, async () => {
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 } = {}) {
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' }
@@ -27,12 +27,13 @@ export function fixture({ agent = 'codex', task = true, run, threadError = false
27
27
  callTool: async (name, args = {}) => {
28
28
  calls.push({ name, args })
29
29
  if (commentTools.includes(name)) return onComment ? await onComment(name, args) : { id: 500 }
30
- if (name === 'list_agents') return { agents: [selfProfile] }
30
+ if (name === 'list_agents') return listAgents ? await listAgents() : { agents: [selfProfile] }
31
31
  if (name === 'list_projects') return { projects: [{ id: 1, name: 'Workspace' }] }
32
32
  if (name === 'list_tasks') return { tasks: task ? [{ ...ticket }] : [] }
33
33
  if (name === 'list_task_types') return { types: [{ id: 1, name: 'In Progress' }, { id: 2, name: 'Testing' }, { id: 3, name: 'Done' }] }
34
- if (name === 'get_ticket') { assert.equal(args.project_id, 1); assert.equal(args.ticket_id, 77); return { ticket: { ...ticket } } }
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') {
@@ -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
- ['completion requires review/done state and PR evidence', watcher.includes('buildTaskCompletionReport') && watcher.includes('completion report deferred')],
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 }")],
@@ -68,14 +68,14 @@ const assertions = [
68
68
  ['prematurely acknowledged owned-thread replies recover exactly once', watcher.includes("!threadOwned || memory.has(sourceKey, 'received')") && watcher.includes('agent:mention replay recovered for active owned thread')],
69
69
  ['reconciliation recovers untagged human follow-ups in owned threads', watcher.includes("memory.has(activityControlKey, 'active')") && watcher.includes('const addressedActivity = mentionNeedles.some') && watcher.includes("memory.has(activitySourceKey, 'received')")],
70
70
  ['coding mentions require an action and concrete repository target', watcher.includes('conversationNeedsCode(text, { rootContent:') && events.includes('const action =') && events.includes('const target =')],
71
- ['agent-authored roots establish ownership and legacy roots are verified before routing', watcher.includes('saveOwnThread(channelId, postedId, message)') && watcher.includes('await resolveOwnThread(cid, parent)') && watcher.includes('_threadRootContent: rootContent')],
71
+ ['agent-authored roots establish ownership and unaddressed legacy roots are verified before routing', watcher.includes('saveOwnThread(channelId, postedId, message)') && watcher.includes('const context = resolveOwnThread(cid, parent)') && watcher.includes('})]) : await context') && watcher.includes('_threadRootContent: rootContent')],
72
72
  ['stand-down and redirects cancel only source-thread workers', watcher.includes('cancelThread(cid, threadRoot') && watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent') && watcher.includes("subtype === 'canceled'")],
73
73
  ['thread cancellation is rechecked immediately before watcher delivery', watcher.includes('const newlyCancelled = suppressCancelled()') && watcher.includes('if (newlyCancelled) return newlyCancelled')],
74
74
  ['generic coding pickup messages are absent', !watcher.includes("I've picked this up and will return here with the verified result")],
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') && watcher.includes('missingRuntimeWorkEvidence(result')],
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 has an evidence failure gate', watcher.includes('WORK_CYCLE_FAILED')],
110
- ['failed evidence revisions persist and suppress self-triggered retries', watcher.includes('failedTaskVersions: [...failedTaskVersions]') && watcher.includes("failedTaskVersions.set(key, 'pending')") && watcher.includes('failedTaskRevisionIsCurrent(failedRevision, ticket)') && watcher.includes('ticket paused until its revision changes')],
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
- ['OpenCode backend prompts forbid relay and resource-discovery tools', watcher.includes('BACKEND MCP RULE') && ['get_marching_orders', 'poll_inbox', 'get_resource', 'list_mcp_resources', 'list_mcp_resource_templates'].every((name) => watcher.includes(name))],
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
- ['all runtime acknowledgements cannot satisfy coding completion', watcher.includes('missingRuntimeWorkEvidence(result') && watcher.includes('claudeEventEvidence(o, turnToolUses)') && watcher.includes('releaseTaskForRetry(activeTaskRef, prompt)')],
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
- ['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 || ''")],
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('do not use remote codebase tools') && watcher.includes('Remote codebase tools are a fallback only when the repository cannot be obtained locally')],
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
- ['reply discovery failures stay scoped while failed mutations fail closed', watcher.includes('blockingReplyMcpErrors(result?.mcpErrors)') && watcher.includes('preserving the scoped model reply') && events.includes('export function blockingReplyMcpErrors')],
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'") && watcher.includes("if (selfAgentId == null) {\n const agentsData = toolData(await callMcpReadWithRetry('list_agents'))")],
141
- ['chat ACP allows opaque MCP approvals behind a strict read-only proxy', mastraHarness.includes('export function acpPermissionResponse') && mastraHarness.includes('opaqueMcpApproval') && mastraHarness.includes('OPENVISIO_CODEX_ALLOWED_TOOLS') && codexProxy.includes('export function toolAllowed') && codexProxy.includes('allowedTools.has(name)')],
142
- ['all runtime blockers have a delivery path', watcher.includes('publishBlocker') && watcher.includes('WORK_CYCLE_BLOCKED') && watcher.includes('COORDINATION_CYCLE_BLOCKED')],
139
+ ['agents retain control of their response after tool failures', watcher.includes('agent ended its turn with tool diagnostics') && !watcher.includes('blockingReplyMcpErrors')],
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 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')],
@@ -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 tools = await client.listTools()
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
@@ -26,11 +26,15 @@ export function taskAgentId(task) {
26
26
  return task.agent_id ?? task.agentId ?? task.agent?.id ?? task.assigned_agent?.id ?? task.assignedAgent?.id ?? null
27
27
  }
28
28
 
29
+ export function taskAgentIdentifier(task) {
30
+ return task?.agent_identifier ?? task?.agentIdentifier ?? task?.agent?.identifier ?? task?.agent?.slug ?? task?.assigned_agent?.identifier ?? task?.assigned_agent?.slug ?? task?.assignedAgent?.identifier ?? task?.assignedAgent?.slug ?? null
31
+ }
32
+
29
33
  export function taskBelongsToAgent(task, { id, identifier } = {}) {
30
34
  if (!task || typeof task !== 'object') return false
31
35
  const assignedId = taskAgentId(task)
32
36
  if (assignedId != null && id != null) return String(assignedId) === String(id)
33
- const assignedIdentifier = task.agent_identifier ?? task.agentIdentifier ?? task.agent?.identifier ?? task.agent?.slug ?? task.assigned_agent?.identifier ?? task.assigned_agent?.slug ?? task.assignedAgent?.identifier ?? task.assignedAgent?.slug
37
+ const assignedIdentifier = taskAgentIdentifier(task)
34
38
  return !!identifier && assignedIdentifier != null && String(assignedIdentifier) === String(identifier)
35
39
  }
36
40
 
@@ -95,8 +99,14 @@ export function agentAddedByName(agent) {
95
99
  return personDisplayName(addedBy)
96
100
  }
97
101
 
98
- 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() } = {}) {
99
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
+ }
100
110
  if (!taskIsCompleted(task, completedTypeIds) && !taskIsAwaitingReview(task, reviewTypeIds)) return null
101
111
 
102
112
  const evidence = [task.description, fallbackText].filter(Boolean).join('\n')
@@ -555,12 +565,22 @@ export function conversationAsksPendingTickets(value) {
555
565
  return pending && question
556
566
  }
557
567
 
568
+ // Filing a ticket is a team mutation, even when its description asks for code.
569
+ // Questions about a previous creation and negated requests stay read-only.
570
+ export function conversationCreatesTicket(value) {
571
+ const text = String(value || '').replace(/^@\S+\s*[,,:]?\s*/, '')
572
+ return text.split(/[;!?]|\b(?:but|however|instead)\b/i).some((clause) => {
573
+ const positive = clause.split(/\b(?:do\s+not|don't|dont|never|stop)\b/i)[0]
574
+ return /(?:^\s*|\bplease\s+|\b(?:can|could|would|will)\s+you\s+)(?:create|file|open|add|make)\s+(?:(?:a|an|the|new|demo|test|sample|another|one|pending|backlog)\s+){0,4}(?:ticket|task|bug|issue)\b(?!\s+(?:form|component|page|screen|endpoint|schema|template)\b)/i.test(positive)
575
+ })
576
+ }
577
+
558
578
  // Read-only discovery failures in a reply cycle are not failed mutations and
559
579
  // must not be inflated into a generic user-facing blocker. The model can give a
560
580
  // precise, scoped answer (or say which live fact it could not read). Failed team
561
581
  // mutations remain fail-closed so prose can never masquerade as a completed act.
562
582
  export function blockingReplyMcpErrors(errors) {
563
- const mutation = /^(?:update_ticket|post_message|create_task_comment|comment_ticket|react_message|create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/
583
+ const mutation = /^(?:create_ticket|update_ticket|post_message|create_task_comment|comment_ticket|react_message|create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/
564
584
  return [...new Set((Array.isArray(errors) ? errors : []).map((name) => String(name || '').replace(/^.*(?:__|[.:/])/, '').replace(/[-.]/g, '_').toLowerCase()).filter((name) => mutation.test(name)))]
565
585
  }
566
586
 
@@ -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
- 'update_ticket', 'list_channels', 'list_message_thread', 'list_activity',
12
+ WORK_SESSION_TOOL, 'list_agents', 'list_projects', 'list_tasks', 'list_task_types', 'get_ticket',
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 = ['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
 
@@ -38,7 +32,7 @@ async function abortable(operation, signal) {
38
32
  }
39
33
 
40
34
  function commandFor(agent) {
41
- if (agent === 'opencode') return { command: onPath('opencode') || 'opencode', args: ['acp'] }
35
+ if (agent === 'opencode') return { command: onPath('opencode') || 'opencode', args: ['acp', '--print-logs', '--log-level', 'ERROR'] }
42
36
  if (agent === 'codex') {
43
37
  const filename = process.platform === 'win32' ? 'codex-acp.cmd' : 'codex-acp'
44
38
  return { command: fileURLToPath(new URL(`../node_modules/.bin/${filename}`, import.meta.url)), args: [] }
@@ -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
- // Codex omits the tool name from correlated ACP permission requests. Chat
78
- // sessions expose only CHAT_SAFE_MCP_READS through their private proxy, so
79
- // accepting an opaque MCP approval cannot grant a mutation or local access.
80
- const allow = canCode || CHAT_SAFE_MCP_READS.has(name) || (canCoordinate && COORDINATION_TOOLS.includes(name)) || (opaqueMcpApproval && proxyProtected)
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)
@@ -113,9 +110,9 @@ export function createMastraAcpRunner({
113
110
  try { Promise.resolve(onEvent?.(type, data)).catch(() => {}) } catch { /* diagnostics cannot block work */ }
114
111
  }
115
112
  const disconnect = (acp, status = 'disconnected') => {
116
- acp.connection.disconnect()
117
113
  if (!disconnected.has(acp)) {
118
114
  disconnected.add(acp)
115
+ acp.connection.disconnect()
119
116
  lastProcessEvent?.('process.stopped', { status })
120
117
  }
121
118
  }
@@ -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: canCode ? 'null' : JSON.stringify([...CHAT_SAFE_MCP_READS, ...(canCoordinate ? COORDINATION_TOOLS : [])]) },
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
- // Keep local tools denied in lightweight ACP sessions, including
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,7 +246,34 @@ 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)
251
+ // OpenCode can keep its ACP prompt open while the provider retries a 429.
252
+ // Mastra 0.4.1 buffers child stderr but does not forward these diagnostics.
253
+ // Only recognize the current session's main model failure, never title
254
+ // generation, tool output, or diagnostics left over from a warm cycle.
255
+ let stderrOffset = String(acp.connection.stderr || '').length
256
+ let stderrPending = ''
257
+ let providerFailure = null
258
+ const diagnosticTimer = agent === 'opencode' ? setInterval(() => {
259
+ const stderr = String(acp.connection.stderr || '')
260
+ if (stderr.length < stderrOffset) stderrOffset = 0
261
+ stderrPending += stderr.slice(stderrOffset)
262
+ stderrOffset = stderr.length
263
+ const lines = stderrPending.split('\n')
264
+ stderrPending = lines.pop().slice(-8000)
265
+ const sessionId = acp.connection.sessionId
266
+ for (const line of lines) {
267
+ if (!sessionId || !line.includes(`session.id=${sessionId} `) ||
268
+ !line.includes('level=ERROR ') || !line.includes('message="stream error"') ||
269
+ !line.includes('small=false ') || !/Rate limit exceeded/i.test(line)) continue
270
+ providerFailure = 'OpenCode’s model provider is rate-limiting this request. Retry after the provider limit resets, or select a model with available quota using --chat-model (replies) or --model (coding).'
271
+ log(providerFailure)
272
+ controller.abort(new Error(providerFailure))
273
+ invalidate()
274
+ break
275
+ }
276
+ }, 250) : null
252
277
  const requestedModel = cycleModel || model || ''
253
278
  let selectedModel = ''
254
279
  try {
@@ -277,6 +302,7 @@ export function createMastraAcpRunner({
277
302
  while (true) {
278
303
  const next = await abortable(() => stream.next(), controller.signal)
279
304
  if (next.done) break
305
+ timer.refresh()
280
306
  const event = next.value
281
307
  if (!readyReported) {
282
308
  readyReported = true
@@ -326,10 +352,12 @@ export function createMastraAcpRunner({
326
352
  controller.signal.throwIfAborted()
327
353
  // Only final, completed tool states are evidence. Started/failed edits and
328
354
  // board mutations cannot stand in for completed repository work.
355
+ let workRequest = null
329
356
  for (const tool of [...tools.values()].sort((a, b) => a.order - b.order)) {
330
357
  const name = toolName(tool)
331
358
  if (tool.status === 'failed') policyBlock ||= codexPolicyBlock(json(tool.rawOutput))
332
359
  if (tool.status !== 'completed') continue
360
+ if (name === WORK_SESSION_TOOL) workRequest = workSessionRequest(tool.rawOutput ?? tool.content) || workRequest
333
361
  didMessage ||= ['post_message', 'create_task_comment', 'comment_ticket'].includes(name)
334
362
  didChannelMessage ||= name === 'post_message'
335
363
  didResultMessage ||= name === 'post_message' && didRepoMutation
@@ -351,14 +379,14 @@ export function createMastraAcpRunner({
351
379
  flushProgress()
352
380
  if (outputText.trim()) emit('output.final', { text: redact(outputText.trim()).slice(0, 8000), status: policyBlock ? 'blocked' : 'ok' })
353
381
  return {
354
- 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(),
355
383
  mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
356
384
  didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage,
357
385
  didMcpTaskRead, didMcpTaskUpdate,
358
386
  }
359
387
  } catch (error) {
360
388
  const canceled = controller.signal.aborted
361
- const subtype = timedOut ? 'timeout' : canceled ? 'canceled' : 'error'
389
+ const subtype = providerFailure ? 'rate_limited' : timedOut ? 'timeout' : canceled ? 'canceled' : 'error'
362
390
  const errorDetail = [error?.message, error?.data ? json(error.data) : ''].filter(Boolean).join(': ')
363
391
  const safeError = redact(errorDetail)
364
392
  flushProgress()
@@ -367,7 +395,7 @@ export function createMastraAcpRunner({
367
395
  log(`${agent} cycle done via Mastra ACP (${subtype}${!canceled && safeError ? ': ' + clean(safeError, 1200) : ''})`)
368
396
  invalidate()
369
397
  return {
370
- type: 'result', subtype, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
398
+ type: 'result', subtype, userMessage: providerFailure, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
371
399
  mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
372
400
  didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage,
373
401
  didMcpTaskRead, didMcpTaskUpdate,
@@ -376,6 +404,7 @@ export function createMastraAcpRunner({
376
404
  flushProgress()
377
405
  if (!disconnected.has(acp)) emit('process.ready', { status: 'idle', sessionId: acp.connection.sessionId || null })
378
406
  clearTimeout(timer)
407
+ clearInterval(diagnosticTimer)
379
408
  if (active?.acp === acp) active = null
380
409
  }
381
410
  }
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 keys = new Set(direct.map((node) => String(node.key)))
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 (keys.has(String(edge.from))) keys.add(String(edge.to))
72
- if (keys.has(String(edge.to))) keys.add(String(edge.from))
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
  }