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 +16 -0
- package/USER_GUIDE.md +16 -0
- package/package.json +1 -1
- package/scenarios/workspace.scenarios.mjs +5 -5
- package/scripts/check-opencode.mjs +73 -0
- package/src/agent-removal.mjs +115 -0
- package/src/assignment-routing.mjs +3 -3
- package/src/mastra-harness.mjs +18 -8
- package/src/opencode-failure.mjs +14 -0
- package/src/runtime-control.mjs +16 -5
- package/src/studio-server.mjs +59 -16
- package/src/watch.mjs +78 -17
- package/studio/app.mjs +82 -16
- package/studio/bot-avatar.mjs +70 -0
- package/studio/guide.html +7 -0
- package/studio/index.html +13 -1
- package/studio/style.css +10 -4
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
|
@@ -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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
27
|
-
|
|
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
|
package/src/mastra-harness.mjs
CHANGED
|
@@ -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
|
|
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 ')
|
|
274
|
-
providerFailure =
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
}
|
|
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
|
|
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
|
+
}
|
package/src/runtime-control.mjs
CHANGED
|
@@ -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
|
|
8
|
-
inputSchema: { type: 'object', properties: {
|
|
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) =>
|
|
16
|
-
|
|
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 {
|
|
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
|