openvisio-agent 0.24.0 → 0.25.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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.25.0] — 2026-09-18
4
+
5
+ - Reduce Studio journal-processing CPU by caching unchanged files, refreshing every two seconds, and disconnecting hidden or paused views.
6
+ - Add reviewed local agent removal in Studio: stop the watcher/service before deleting its local credentials, history, memory, and context; retain repositories and shared provider logins.
7
+ - Instruct coding agents to reuse one task branch/worktree across retries and follow-ups and retire only clean, inactive, fully merged local work.
8
+ - Resolve missing assignment slugs from verified ticket details; retain failed lookup context so “Try again” retries that same ticket lookup in its source thread, including after restart.
9
+ - Answer model-status questions directly from current watcher settings so provider failures cannot prevent the status reply.
10
+ - Recognize OpenCode provider errors including “Too many requests,” release stalled ACP sessions promptly, and report authentication/model failures with actionable guidance.
11
+ - Persist OpenCode provider pauses separately: retry rate-limited tickets after a 30-minute cooldown, and resume provider-blocked tickets when their selected model changes while preserving ownership, review, and policy checks.
12
+ - Add an isolated real-OpenCode smoke check: `node scripts/check-opencode.mjs` exercises successful MCP work, HTTP 429, and HTTP 401 against localhost fixtures.
13
+
14
+ ## [0.24.1] — 2026-09-10
15
+
16
+ - Deliver agent-authored acknowledgements and short plans to the originating channel or thread when a request moves into a coding workspace.
17
+ - Highlight the selected agent in Studio and show embedded avatars shared with the main app.
18
+
3
19
  ## [0.24.0] — 2026-09-10
4
20
 
5
21
  - Acknowledge accepted coding tasks with the agent's short native plan, then deliver its final response separately. Deduplicate plans through reconnects and use guarded source-thread delivery for every BYO runtime.
package/USER_GUIDE.md CHANGED
@@ -78,6 +78,8 @@ Replies stay in their source thread. New completion messages use the agent’s d
78
78
 
79
79
  For accepted coding tasks, the agent starts by publishing a short plan. You receive one acknowledgement with up to three steps, followed later by its result. The agent can delegate independent pieces to native sub-agents and remains responsible for checking their work. Thinking, working, and typing indicators follow runtime activity; they expire after work stops. A reconnect does not repeat the plan or cancel healthy work.
80
80
 
81
+ When a conversation turns into coding work, the agent includes its acknowledgement and two or three next steps in the workspace handoff. They are posted to the original channel thread before coding starts. Private continuation context stays internal, and the work session does not send a second pickup message.
82
+
81
83
  ## Open Agent Studio
82
84
 
83
85
  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.
@@ -169,6 +171,20 @@ Use `Tab` and `Shift+Tab` to move through controls, `Enter` to activate buttons
169
171
 
170
172
  ## Local history and privacy
171
173
 
174
+ Studio caches unchanged journals and refreshes live views every two seconds. A hidden tab or paused view disconnects live updates until you return. Watchers keep working.
175
+
176
+ ## Remove an agent from this computer
177
+
178
+ Select an agent in Studio, choose **Remove from computer**, review the local data, and type its local name to confirm. Studio stops its watcher and background service before removing local connection credentials, logs, memory, and context caches. Removal cannot be undone.
179
+
180
+ Repositories, branches, shared provider logins, and the team profile are retained. Context shared with another local connection is kept. Automatic removal supports macOS and Linux in the default `~/.openvisio` directory; custom state directories and Windows require manual management.
181
+
182
+ ## Reuse task branches
183
+
184
+ Agents should use one branch and worktree per task in each repository, including retries and review fixes. Check for the existing branch and PR before creating either. Separate unrelated tasks and concurrent conflicting work. Retire only fully merged local agent branches with clean, inactive worktrees; preserve unmerged changes and the primary checkout. Remote branch deletion requires explicit authorization.
185
+
186
+ ## Recorded history
187
+
172
188
  Studio reads bounded journals in `~/.openvisio/observability`. It serves up to 500 recent events, subject to file and byte limits. Journals rotate, long text is shortened, and bursts may drop records. Use the team’s ticket and channel history for delivered results.
173
189
 
174
190
  The journal excludes private reasoning and raw tool payloads and redacts known credentials and recognizable secret formats. Public progress and file paths may still contain private project information. Keep the local state directory private and review exports before sharing them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team \u2014 MCP tools + optional autonomy \u2014 in one command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -192,12 +192,12 @@ scenario('known-thread-read-outage', 'Explicitly addressed message is routed eve
192
192
  const f = fixture({ task: false, threadError: true })
193
193
  try {
194
194
  await f.mention('@Alex what model are you running?'); await f.idle()
195
- assert.equal(f.runs.length, 1, f.logs.join('\n'))
195
+ assert.equal(f.runs.length, 0, 'Model status is available without a provider call')
196
196
  assert.equal(f.posts.length, 0, 'Unavailable authoritative delivery read must not become an unchecked post')
197
197
  await f.watcher.close(); f.start()
198
198
  f.threadError = false
199
199
  await f.watcher.retryDeliveries()
200
- assert.equal(f.runs.length, 1, 'Restart retries delivery without regenerating the answer')
200
+ assert.equal(f.runs.length, 0, 'Restart retries delivery without a model call')
201
201
  assert.equal(f.posts.length, 1)
202
202
  await f.watcher.retryDeliveries()
203
203
  assert.equal(f.posts.length, 1)
@@ -241,7 +241,7 @@ scenario('distinct-source-identical-text', 'Different human message ids with ide
241
241
  try {
242
242
  await f.mention('@Alex what model are you running?', { id: 200 }); await f.idle()
243
243
  await f.mention('@Alex what model are you running?', { id: 201 }); await f.idle()
244
- assert.equal(f.runs.length, 2)
244
+ assert.equal(f.runs.length, 0, 'Both model-status answers are watcher-owned')
245
245
  assert.equal(f.posts.length, 2)
246
246
  } finally { await f.cleanup() }
247
247
  })
@@ -250,12 +250,12 @@ scenario('cancel-pending-then-reactivate', 'New request in a canceled thread can
250
250
  const f = fixture({ task: false, threadError: true })
251
251
  try {
252
252
  await f.mention('@Alex what model are you running?'); await f.idle()
253
- assert.equal(f.runs.length, 1)
253
+ assert.equal(f.runs.length, 0, 'The pending model-status answer needs no provider')
254
254
  await f.mention('Stop working on this.', { id: 201 }); await f.idle()
255
255
  f.threadError = false
256
256
  await f.mention('@Alex check the current project details.', { id: 202 }); await f.idle()
257
257
  await f.watcher.retryDeliveries()
258
- assert.equal(f.runs.length, 2)
258
+ assert.equal(f.runs.length, 1, 'Only the new project-details request calls the model')
259
259
  // Final response to the new question plus at most its stand-down confirmation.
260
260
  assert.equal(f.posts.filter((post) => !/standing down/.test(post.content)).length, 1)
261
261
  } finally { await f.cleanup() }
@@ -0,0 +1,73 @@
1
+ // Opt-in real CLI check: node scripts/check-opencode.mjs
2
+ // Uses only a localhost provider/MCP fixture and isolated OpenCode state.
3
+ import assert from 'node:assert/strict'
4
+ import { mkdtemp, mkdir, rm } from 'node:fs/promises'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { createServer } from 'node:http'
8
+ import { AcpAgent } from '@mastra/acp'
9
+ import { createMastraAcpRunner } from '../src/mastra-harness.mjs'
10
+
11
+ for (const status of [200, 429, 401]) {
12
+ const dir = await mkdtemp(join(tmpdir(), 'openvisio-opencode-check-'))
13
+ for (const name of ['config', 'data', 'cache', 'state', 'repo']) await mkdir(join(dir, name))
14
+ let toolCalls = 0
15
+ const server = createServer(async (req, res) => {
16
+ try {
17
+ let raw = ''
18
+ for await (const chunk of req) raw += chunk
19
+ const body = JSON.parse(raw)
20
+ if (req.url === '/mcp') {
21
+ if (body.id == null) { res.writeHead(202); res.end(); return }
22
+ let result = {}
23
+ if (body.method === 'initialize') result = { protocolVersion: '2025-03-26', capabilities: { tools: {} }, serverInfo: { name: 'fixture', version: '1' } }
24
+ if (body.method === 'tools/list') result = { tools: [{ name: 'get_ticket', description: 'Read a fixture ticket', inputSchema: { type: 'object', properties: { project_id: { type: 'number' }, ticket_id: { type: 'number' } }, required: ['project_id', 'ticket_id'] } }] }
25
+ if (body.method === 'tools/call') { toolCalls++; result = { content: [{ type: 'text', text: 'Fixture ticket' }] } }
26
+ res.writeHead(200, { 'content-type': 'application/json' })
27
+ res.end(JSON.stringify({ jsonrpc: '2.0', id: body.id, result }))
28
+ return
29
+ }
30
+ // OpenCode's background title request must succeed independently.
31
+ if (body.tools && status !== 200) {
32
+ res.writeHead(status, { 'content-type': 'application/json', 'retry-after': '120' })
33
+ res.end(JSON.stringify({ error: { message: status === 429 ? 'Too many requests' : 'Invalid API key' } }))
34
+ return
35
+ }
36
+ const needsTool = body.tools?.some(t => t.function.name === 'openvisio-team-watcher_get_ticket') && !body.messages.some(m => m.role === 'tool')
37
+ const delta = needsTool ? { role: 'assistant', tool_calls: [{ index: 0, id: 'fixture-call', type: 'function', function: { name: 'openvisio-team-watcher_get_ticket', arguments: JSON.stringify({ project_id: 1, ticket_id: 2 }) } }] } : { role: 'assistant', content: 'Fixture response.' }
38
+ res.writeHead(200, { 'content-type': 'text/event-stream' })
39
+ res.write(`data: ${JSON.stringify({ id: 'fixture', object: 'chat.completion.chunk', created: 1, model: 'test', choices: [{ index: 0, delta, finish_reason: null }] })}\n\n`)
40
+ res.write(`data: ${JSON.stringify({ id: 'fixture', object: 'chat.completion.chunk', created: 1, model: 'test', choices: [{ index: 0, delta: {}, finish_reason: needsTool ? 'tool_calls' : 'stop' }], usage: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 } })}\n\n`)
41
+ res.end('data: [DONE]\n\n')
42
+ } catch (error) { res.writeHead(500); res.end(String(error)) }
43
+ })
44
+ let runner
45
+ try {
46
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) })
47
+ const baseURL = `http://127.0.0.1:${server.address().port}`
48
+ class IsolatedAcp extends AcpAgent {
49
+ constructor(options) {
50
+ const config = { ...JSON.parse(options.env.OPENCODE_CONFIG_CONTENT), enabled_providers: ['fixture'], model: 'fixture/test', small_model: 'fixture/test', provider: { fixture: { npm: '@ai-sdk/openai-compatible', name: 'Fixture', options: { baseURL: `${baseURL}/v1`, apiKey: 'fixture' }, models: { test: { name: 'Test', limit: { context: 8192, output: 1024 } } } } } }
51
+ super({ ...options, env: { ...options.env, XDG_CONFIG_HOME: join(dir, 'config'), XDG_DATA_HOME: join(dir, 'data'), XDG_CACHE_HOME: join(dir, 'cache'), XDG_STATE_HOME: join(dir, 'state'), OPENCODE_CONFIG_CONTENT: JSON.stringify(config), OPENCODE_DISABLE_PROJECT_CONFIG: 'true', OPENCODE_DISABLE_DEFAULT_PLUGINS: 'true', OPENCODE_DISABLE_MODELS_FETCH: 'true' } })
52
+ }
53
+ }
54
+ runner = createMastraAcpRunner({ agent: 'opencode', mcpUrl: `${baseURL}/mcp`, mcpHeaders: { 'x-agent-api-key': 'fixture', 'x-agent-identifier': 'fixture-agent' }, workdir: join(dir, 'repo'), canCode: false, model: 'fixture/test', maxCycleMs: 20_000, AcpAgentClass: IsolatedAcp })
55
+ const start = performance.now()
56
+ const result = await runner.runCycle('Read the fixture ticket and reply.')
57
+ assert.equal(result.subtype, status === 200 ? 'ok' : status === 429 ? 'rate_limited' : 'authentication_error')
58
+ if (status === 200) {
59
+ assert.equal(result.outputText, 'Fixture response.')
60
+ assert.equal(result.didMcpTaskRead, true)
61
+ assert.equal(toolCalls, 1)
62
+ } else {
63
+ assert.ok(result.userMessage)
64
+ assert.equal(toolCalls, 0)
65
+ }
66
+ console.log(`PASS OpenCode HTTP ${status}: ${result.subtype} (${Math.round(performance.now() - start)} ms)`)
67
+ } finally {
68
+ runner?.close()
69
+ server.closeAllConnections()
70
+ await new Promise(resolve => server.close(resolve))
71
+ await rm(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 })
72
+ }
73
+ }
@@ -0,0 +1,115 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { constants } from 'node:fs'
3
+ import { lstat, open, readdir, readFile, rm } from 'node:fs/promises'
4
+ import { execFile } from 'node:child_process'
5
+ import { promisify } from 'node:util'
6
+ import { homedir } from 'node:os'
7
+ import { join, resolve } from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { OV_DIR, slugify } from './lib.mjs'
10
+
11
+ const exec = promisify(execFile)
12
+ const hash = value => createHash('sha256').update(value).digest('hex')
13
+ const problem = (message, status = 409) => Object.assign(new Error(message), { status })
14
+ const validSlug = value => typeof value === 'string' && /^[a-z0-9][a-z0-9_-]{0,79}$/i.test(value)
15
+
16
+ async function readConfig(stateDir, slug) {
17
+ if (!validSlug(slug)) throw problem('Agent not found.', 404)
18
+ const root = await lstat(stateDir)
19
+ if (!root.isDirectory() || root.isSymbolicLink()) throw problem('Agent storage must be a real local directory.')
20
+ const file = await open(join(stateDir, `${slug}.json`), constants.O_RDONLY | (constants.O_NOFOLLOW || 0))
21
+ try {
22
+ const stat = await file.stat()
23
+ if (!stat.isFile() || stat.size > 65536) throw problem('Agent configuration is invalid.')
24
+ const raw = await file.readFile('utf8')
25
+ const config = JSON.parse(raw)
26
+ if (config.slug !== slug || !(config.mode === 'backend' && config.identifier && config.apiKey || config.host && config.key)) throw problem('Agent configuration is invalid.')
27
+ return { config, version: hash(raw) }
28
+ } finally { await file.close() }
29
+ }
30
+
31
+ async function realDirectory(path) {
32
+ const stat = await lstat(path).catch(() => null)
33
+ return stat?.isDirectory() && !stat.isSymbolicLink()
34
+ }
35
+
36
+ export async function planAgentRemoval({ stateDir, slug }) {
37
+ const { config, version } = await readConfig(stateDir, slug)
38
+ const identifier = config.mode === 'backend' ? config.identifier : slug
39
+ const names = new Set([`${slug}.json`, `${slug}.env`, `${slug}.log`, `${slug}-team.mcp.json`, `${slug}-watch.mjs`, `${slug}-watch.sh`, `${slug}-cycle.sh`, `${slug}-cycle.sh.bak`, `watch-${slug}.lock`, `watch-${slug}-memory.json`, `watch-${slug}-replay.json`, `watch-${slug}-mastra.db`, `watch-${slug}-mastra.db-wal`, `watch-${slug}-mastra.db-shm`])
40
+ const entries = await readdir(stateDir)
41
+ // Configurations sharing an identity also share context. Keep that data until
42
+ // the last local connection is removed, regardless of its provider choice.
43
+ let shared = false
44
+ for (const name of entries.filter(name => name.endsWith('.json') && name !== `${slug}.json`)) {
45
+ try {
46
+ const other = (await readConfig(stateDir, name.slice(0, -5))).config
47
+ if ((other.identifier || other.slug) === identifier) shared = true
48
+ } catch { /* unrelated local file */ }
49
+ }
50
+ if (!shared) {
51
+ if (validSlug(identifier)) { names.add(`intro-${identifier}.done`); names.add(`sweep-${identifier}.at`) }
52
+ const key = slugify(identifier)
53
+ for (const name of entries) if (name === `opencode-${key}` || name.startsWith(`opencode-${key}-reply-`) || name.startsWith(`opencode-${key}-work-`)) names.add(name)
54
+ if (config.mode === 'backend') {
55
+ const backend = new URL(config.backend || config.mcpUrl || 'http://local.invalid')
56
+ const scope = hash(`${backend.origin}${backend.pathname.replace(/\/$/, '')}:${identifier}`).slice(0, 24)
57
+ for (const suffix of ['json', 'db', 'db-wal', 'db-shm']) names.add(`context-${scope}.${suffix}`)
58
+ names.add(`embeddings-${scope}`)
59
+ }
60
+ }
61
+ const files = []
62
+ for (const name of names) if (await lstat(join(stateDir, name)).catch(() => null)) files.push(name)
63
+ if (await realDirectory(join(stateDir, 'observability'))) {
64
+ // The stem contains this setup's slug and the exact provider/identity hash.
65
+ const stems = [...new Set(['claude', 'codex', 'opencode', config.agent || 'claude'])].map(provider => `${slug.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 60)}-${hash(`${identifier}:${provider}`).slice(0, 12)}`)
66
+ for (const name of await readdir(join(stateDir, 'observability'))) {
67
+ if (stems.some(stem => [`${stem}.jsonl`, `${stem}.1.jsonl`, `${stem}.2.jsonl`].includes(name))) files.push(`observability/${name}`)
68
+ }
69
+ }
70
+ return { slug, name: config.name || slug, version, files: files.sort(), sharedContextRetained: shared }
71
+ }
72
+
73
+ async function stopLocalAgent({ stateDir, slug }) {
74
+ if (resolve(stateDir) !== resolve(OV_DIR)) throw problem('Stopping agents is supported only in this computer’s agent storage.')
75
+ if (slugify(slug) !== slug) throw problem('This legacy local name cannot be stopped safely from Studio.')
76
+ if (process.platform === 'win32') throw problem('Stop the watcher manually before removal on Windows; automatic removal is not supported yet.')
77
+ const lock = await readFile(join(stateDir, `watch-${slug}.lock`), 'utf8').catch(() => '')
78
+ const pid = /^\d+\s*$/.test(lock) ? Number(lock.trim()) : null
79
+ const service = process.platform === 'darwin'
80
+ ? join(homedir(), 'Library', 'LaunchAgents', `io.openvisio.${slug}.plist`)
81
+ : join(homedir(), '.config', 'systemd', 'user', `openvisio-${slug}.service`)
82
+ if (process.platform !== 'darwin' && await lstat(service).catch(() => null)) {
83
+ await exec('systemctl', ['--user', 'disable', '--now', `openvisio-${slug}.service`], { timeout: 15000 })
84
+ }
85
+ await exec(process.execPath, [fileURLToPath(new URL('../bin/cli.mjs', import.meta.url)), 'stop', '--name', slug], { timeout: 20000, maxBuffer: 65536 })
86
+ if (Number.isSafeInteger(pid) && pid > 0) {
87
+ let alive = false
88
+ try { process.kill(pid, 0); alive = true } catch (error) { alive = error.code === 'EPERM' }
89
+ if (alive) throw problem('The recorded watcher process is still running. Stop it before removing its data.')
90
+ }
91
+ if (process.platform === 'darwin') {
92
+ const loaded = await exec('launchctl', ['print', `gui/${process.getuid()}/io.openvisio.${slug}`], { timeout: 5000 }).then(() => true, () => false)
93
+ if (loaded) throw problem('The background service is still loaded. Stop it before removing the agent.')
94
+ }
95
+ await rm(service, { force: true })
96
+ }
97
+
98
+ export async function removeLocalAgent({ stateDir, slug, version, confirmation, stop = stopLocalAgent }) {
99
+ const plan = await planAgentRemoval({ stateDir, slug })
100
+ if (confirmation !== slug) throw problem('Enter the agent’s local name to confirm removal.', 400)
101
+ if (version !== plan.version) throw problem('Agent settings changed. Review removal again.')
102
+ // Stop first: removing SQLite files while their watcher is running corrupts
103
+ // state and an installed service could immediately recreate it.
104
+ await stop({ stateDir, slug })
105
+ const current = await planAgentRemoval({ stateDir, slug })
106
+ if (current.version !== version) throw problem('Agent settings changed while stopping. Review removal again.')
107
+ // Remove config last so an interrupted cleanup remains retryable in Studio.
108
+ const files = current.files.filter(name => name !== `${slug}.json`)
109
+ for (const name of files) {
110
+ if (name.startsWith('observability/') && !await realDirectory(join(stateDir, 'observability'))) throw problem('Journal directory changed during removal.')
111
+ await rm(join(stateDir, name), { recursive: true, force: true })
112
+ }
113
+ await rm(join(stateDir, `${slug}.json`), { force: true })
114
+ return { removed: true, slug, filesRemoved: files.length + 1, sharedContextRetained: current.sharedContextRetained }
115
+ }
@@ -23,9 +23,9 @@ export async function routeAssignments({ request, loadTickets, handleTask, isCan
23
23
  const selected = tickets.filter((ticket) => ticket && (!request.slugs.length || request.slugs.includes(ticketDisplaySlug(ticket))))
24
24
  // A project-scoped slug must resolve uniquely before any work is started.
25
25
  for (const slug of request.slugs) {
26
- if (selected.filter((ticket) => ticketDisplaySlug(ticket) === slug).length !== 1) {
27
- throw new Error(`I couldn't uniquely resolve ${slug} among my open assignments.`)
28
- }
26
+ const matches = selected.filter((ticket) => ticketDisplaySlug(ticket) === slug)
27
+ if (!matches.length) throw new Error(`I couldn't find ${slug} among my open assignments. Check that it is assigned to me and still open, then ask me to try again.`)
28
+ if (matches.length > 1) throw new Error(`I found multiple open assignments named ${slug}. Please specify the project so I can uniquely resolve the ticket.`)
29
29
  }
30
30
  const actionable = selected.filter((ticket) => !ticket.awaitingReview && !taskIsAwaitingReview(ticket) && !taskIsCompleted(ticket))
31
31
  if (isCancelled()) return 0
@@ -8,6 +8,7 @@ import { codexPolicyBlock } from './events.mjs'
8
8
  import { WORK_SESSION_TOOL, workSessionRequest } from './runtime-control.mjs'
9
9
  import { contextToolNames } from './context-tools.mjs'
10
10
  import { buildOpencodeConfig } from './opencode-config.mjs'
11
+ import { opencodeProviderFailure } from './opencode-failure.mjs'
11
12
  import { isTransportInterruption } from './transport-recovery.mjs'
12
13
 
13
14
  const MCP_TOOL_NAMES = [
@@ -259,25 +260,29 @@ export function createMastraAcpRunner({
259
260
  let stderrOffset = String(acp.connection.stderr || '').length
260
261
  let stderrPending = ''
261
262
  let providerFailure = null
262
- const diagnosticTimer = agent === 'opencode' ? setInterval(() => {
263
+ const inspectDiagnostics = (flush = false) => {
264
+ if (agent !== 'opencode' || providerFailure) return
263
265
  const stderr = String(acp.connection.stderr || '')
264
266
  if (stderr.length < stderrOffset) stderrOffset = 0
265
267
  stderrPending += stderr.slice(stderrOffset)
266
268
  stderrOffset = stderr.length
267
269
  const lines = stderrPending.split('\n')
268
270
  stderrPending = lines.pop().slice(-8000)
271
+ if (flush && stderrPending) { lines.push(stderrPending); stderrPending = '' }
269
272
  const sessionId = acp.connection.sessionId
270
273
  for (const line of lines) {
271
274
  if (!sessionId || !line.includes(`session.id=${sessionId} `) ||
272
275
  !line.includes('level=ERROR ') || !line.includes('message="stream error"') ||
273
- !line.includes('small=false ') || !/Rate limit exceeded/i.test(line)) continue
274
- 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).'
275
- log(providerFailure)
276
- controller.abort(new Error(providerFailure))
276
+ !line.includes('small=false ')) continue
277
+ providerFailure = opencodeProviderFailure(line.slice(line.indexOf('error.error=')))
278
+ if (!providerFailure) continue
279
+ log(providerFailure.userMessage)
280
+ controller.abort(new Error(providerFailure.userMessage))
277
281
  invalidate()
278
282
  break
279
283
  }
280
- }, 250) : null
284
+ }
285
+ const diagnosticTimer = agent === 'opencode' ? setInterval(inspectDiagnostics, 250) : null
281
286
  const requestedModel = cycleModel || model || ''
282
287
  let selectedModel = ''
283
288
  try {
@@ -353,6 +358,7 @@ export function createMastraAcpRunner({
353
358
  else if (merged.status === 'completed') errors.delete(name)
354
359
  }
355
360
  }
361
+ inspectDiagnostics(true)
356
362
  controller.signal.throwIfAborted()
357
363
  // Only final, completed tool states are evidence. Started/failed edits and
358
364
  // board mutations cannot stand in for completed repository work.
@@ -389,8 +395,12 @@ export function createMastraAcpRunner({
389
395
  didMcpTaskRead, didMcpTaskUpdate,
390
396
  }
391
397
  } catch (error) {
398
+ if (!controller.signal.aborted) {
399
+ inspectDiagnostics(true)
400
+ if (agent === 'opencode') providerFailure ||= opencodeProviderFailure(error?.message)
401
+ }
392
402
  const canceled = controller.signal.aborted
393
- const subtype = providerFailure ? 'rate_limited' : timedOut ? 'timeout' : canceled ? 'canceled' : 'error'
403
+ const subtype = providerFailure?.subtype || (timedOut ? 'timeout' : canceled ? 'canceled' : 'error')
394
404
  const errorDetail = [error?.message, error?.data ? json(error.data) : ''].filter(Boolean).join(': ')
395
405
  const safeError = redact(errorDetail)
396
406
  flushProgress()
@@ -399,7 +409,7 @@ export function createMastraAcpRunner({
399
409
  log(`${agent} cycle done via Mastra ACP (${subtype}${!canceled && safeError ? ': ' + clean(safeError, 1200) : ''})`)
400
410
  invalidate()
401
411
  return {
402
- type: 'result', subtype, transportInterrupted: subtype === 'error' && isTransportInterruption(error), userMessage: providerFailure, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
412
+ type: 'result', subtype, transportInterrupted: subtype === 'error' && isTransportInterruption(error), userMessage: providerFailure?.userMessage || null, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
403
413
  mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
404
414
  didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage,
405
415
  didMcpTaskRead, didMcpTaskUpdate,
@@ -0,0 +1,14 @@
1
+ // Classify runtime diagnostics only, never assistant prose or tool output.
2
+ export function opencodeProviderFailure(value) {
3
+ const text = String(value || '').split('ACP agent stderr:')[0]
4
+ if (/rate[_ -]?limit|too many requests|\b(?:HTTP|status(?: code)?)\s*[:=]?\s*429\b/i.test(text)) {
5
+ return { subtype: 'rate_limited', userMessage: 'OpenCode’s model provider is rate-limiting this request. Retry after the provider limit resets, or select a model with available quota in Agent Studio.' }
6
+ }
7
+ if (/invalid[ _-]api[ _-]key|incorrect API key|authentication[_ -]error|unauthorized|\b(?:HTTP|status(?: code)?)\s*[:=]?\s*401\b/i.test(text)) {
8
+ return { subtype: 'authentication_error', userMessage: 'OpenCode’s model provider rejected its credentials. Update that provider’s credentials with opencode auth login, then retry, or select an authenticated provider in Agent Studio.' }
9
+ }
10
+ if (/Configured model .+ is unavailable|model[_ -]not[_ -]found|ProviderModelNotFoundError/i.test(text)) {
11
+ return { subtype: 'model_unavailable', userMessage: 'The configured OpenCode model is unavailable. Select an available model from the intended provider in Agent Studio, then retry.' }
12
+ }
13
+ return null
14
+ }
@@ -4,18 +4,25 @@ export function runtimeControlTools({ canCode = false, workspaceAvailable = fals
4
4
  if (canCode || !workspaceAvailable) return []
5
5
  return [{
6
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 },
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 private continuation context plus a teammate-facing acknowledgement and 2-3 concrete plan steps. The watcher posts that acknowledgement and plan in the original channel/thread before starting your work session, then delivers your eventual result separately. End this turn after requesting continuation; do not repeat the acknowledgement or ask for reassignment.',
8
+ inputSchema: { type: 'object', properties: {
9
+ context: { type: 'string', minLength: 1, maxLength: 12000, description: 'Private findings, decisions, remaining work, and context for your continuation. This field is not posted to chat.' },
10
+ acknowledgement: { type: 'string', minLength: 1, maxLength: 1200, description: 'A short, natural first-person acknowledgement for the teammate. Say what you have accepted; do not claim completion.' },
11
+ plan: { type: 'array', minItems: 2, maxItems: 3, items: { type: 'string', minLength: 1, maxLength: 240 }, description: '2-3 concrete next steps to post alongside your acknowledgement before doing the work.' },
12
+ }, required: ['context', 'acknowledgement', 'plan'], additionalProperties: false },
9
13
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
10
14
  }]
11
15
  }
12
16
 
13
17
  export function requestWorkSession(args, capabilities) {
14
18
  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() } }) }] }
19
+ if (!args || Object.keys(args).some((key) => !['context', 'acknowledgement', 'plan'].includes(key)) || typeof args.context !== 'string' || !args.context.trim() || args.context.length > 12000) throw new Error('Provide a nonempty context string of at most 12000 characters and only the advertised acknowledgement and plan fields')
20
+ if (typeof args.acknowledgement !== 'string' || !args.acknowledgement.trim() || args.acknowledgement.length > 1200 || !validPlan(args.plan)) throw new Error('Include a short acknowledgement and 2-3 nonempty plan steps of at most 240 characters each for delivery to the original conversation')
21
+ return { content: [{ type: 'text', text: JSON.stringify({ openvisioControl: { action: 'request_work_session', context: args.context.trim(), acknowledgement: args.acknowledgement.trim(), plan: args.plan.map(step => step.trim()) } }) }] }
17
22
  }
18
23
 
24
+ const validPlan = (plan) => Array.isArray(plan) && plan.length >= 2 && plan.length <= 3 && plan.every(step => typeof step === 'string' && step.trim() && step.length <= 240)
25
+
19
26
  // Only inspect outputs of our named local control tool, never arbitrary model
20
27
  // prose, commands, ticket text, or repository content.
21
28
  export function workSessionRequest(output, depth = 0) {
@@ -28,7 +35,11 @@ export function workSessionRequest(output, depth = 0) {
28
35
  return null
29
36
  }
30
37
  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() }
38
+ if (request?.action === 'request_work_session' && typeof request.context === 'string' && request.context.trim() && request.context.length <= 12000) return {
39
+ context: request.context.trim(),
40
+ ...(typeof request.acknowledgement === 'string' && request.acknowledgement.trim() && request.acknowledgement.length <= 1200 ? { acknowledgement: request.acknowledgement.trim() } : {}),
41
+ ...(validPlan(request.plan) ? { plan: request.plan.map(step => step.trim()) } : {}),
42
+ }
32
43
  for (const key of ['content', 'text', 'result', 'structuredContent']) {
33
44
  const nested = workSessionRequest(output[key], depth + 1)
34
45
  if (nested) return nested