openvisio-agent 0.25.0 → 0.25.1
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 +5 -0
- package/package.json +1 -1
- package/scenarios/workspace.scenarios.mjs +3 -2
- package/src/agent-processes.mjs +46 -0
- package/src/agent-removal.mjs +9 -6
- package/src/mastra-harness.mjs +1 -1
- package/src/task-types.mjs +18 -0
- package/src/watch.mjs +4 -4
- package/studio/app.mjs +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.25.1] — 2026-09-19
|
|
4
|
+
|
|
5
|
+
- Discover task columns by board when required by the live MCP schema; remove the incorrect project-only instruction that caused failed calls and assignment lookups.
|
|
6
|
+
- Stop surviving watcher descendants before deleting agent data, preserve shared Studio processes, and explain when an older Studio needs restarting to enable removal.
|
|
7
|
+
|
|
3
8
|
## [0.25.0] — 2026-09-18
|
|
4
9
|
|
|
5
10
|
- Reduce Studio journal-processing CPU by caching unchanged files, refreshing every two seconds, and disconnecting hidden or paused views.
|
package/package.json
CHANGED
|
@@ -23,14 +23,15 @@ export function fixture({ agent = 'codex', task = true, run, threadError = false
|
|
|
23
23
|
let watcher, connection, messageId = 100, closed = 0
|
|
24
24
|
const memory = createByoMemoryGraph({ path: join(dir, 'ledger.json') })
|
|
25
25
|
const mcp = {
|
|
26
|
-
listTools: async () => ['list_agents', 'list_projects', 'list_tasks', 'get_ticket', 'update_ticket', 'post_message', 'list_message_thread', 'list_channels', 'list_task_types', 'list_activity', ...commentTools].map((name) => ({ name })),
|
|
26
|
+
listTools: async () => ['list_agents', 'list_projects', 'list_boards', 'list_tasks', 'get_ticket', 'update_ticket', 'post_message', 'list_message_thread', 'list_channels', 'list_task_types', 'list_activity', ...commentTools].map((name) => ({ name, ...(name === 'list_task_types' ? { inputSchema: { required: ['project_id', 'board_id'], properties: { project_id: {}, board_id: {} } } } : {}) })),
|
|
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
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
|
-
if (name === '
|
|
33
|
+
if (name === 'list_boards') return { boards: [{ id: 4 }] }
|
|
34
|
+
if (name === 'list_task_types') { assert.equal(args.board_id, 4); return { types: [{ id: 1, name: 'In Progress' }, { id: 2, name: 'Testing' }, { id: 3, name: 'Done' }] } }
|
|
34
35
|
if (name === 'get_ticket') { assert.equal(args.project_id, 1); assert.equal(args.ticket_id, 77); return { ticket: getTicket ? getTicket(ticket) : { ...ticket } } }
|
|
35
36
|
if (name === 'update_ticket') { Object.assign(ticket, args); return { ticket: { ...ticket } } }
|
|
36
37
|
if (name === 'list_channels' && listChannels) return { channels: await listChannels() }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
4
|
+
|
|
5
|
+
const exec = promisify(execFile)
|
|
6
|
+
export async function agentProcessSnapshot() {
|
|
7
|
+
const { stdout } = await exec('ps', ['-axo', 'pid=,ppid=,stat=,command='], { maxBuffer: 4 * 1024 * 1024 })
|
|
8
|
+
return stdout.split('\n').flatMap(line => {
|
|
9
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/.exec(line)
|
|
10
|
+
return match && !match[3].startsWith('Z') ? [{ pid: Number(match[1]), parent: Number(match[2]), command: match[4] }] : []
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function agentProcessTree(rows, slug) {
|
|
15
|
+
const selected = new Map()
|
|
16
|
+
for (const row of rows) {
|
|
17
|
+
const name = /(?:^|\s)--name(?:=|\s+)["']?([^"'\s]+)/.exec(row.command)?.[1]
|
|
18
|
+
if (/\bopenvisio-agent\b/.test(row.command) && /\bwatch\b/.test(row.command) && name === slug) selected.set(row.pid, row)
|
|
19
|
+
}
|
|
20
|
+
let added
|
|
21
|
+
do {
|
|
22
|
+
added = false
|
|
23
|
+
for (const row of rows) {
|
|
24
|
+
// Studio is shared by all agents, even when a watcher launched it.
|
|
25
|
+
if (selected.has(row.pid) || !selected.has(row.parent) || /\bcli\.mjs\s+studio\b/.test(row.command)) continue
|
|
26
|
+
selected.set(row.pid, row); added = true
|
|
27
|
+
}
|
|
28
|
+
} while (added)
|
|
29
|
+
return [...selected.values()]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function stopAgentProcessTree(captured, { snapshot = agentProcessSnapshot, signal = process.kill.bind(process), wait = delay } = {}) {
|
|
33
|
+
const remaining = async () => (await snapshot()).filter(row => captured.some(old => old.pid === row.pid && old.command === row.command))
|
|
34
|
+
for (const name of ['SIGTERM', 'SIGKILL']) {
|
|
35
|
+
const rows = await remaining()
|
|
36
|
+
if (!rows.length) return
|
|
37
|
+
for (const row of rows.reverse()) {
|
|
38
|
+
try { signal(row.pid, name) } catch (error) { if (error.code !== 'ESRCH') throw error }
|
|
39
|
+
}
|
|
40
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
41
|
+
await wait(100)
|
|
42
|
+
if (!(await remaining()).length) return
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
throw new Error('Agent processes are still running. Local data has been kept; retry removal after they stop.')
|
|
46
|
+
}
|
package/src/agent-removal.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { homedir } from 'node:os'
|
|
|
7
7
|
import { join, resolve } from 'node:path'
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
9
9
|
import { OV_DIR, slugify } from './lib.mjs'
|
|
10
|
+
import { agentProcessSnapshot, agentProcessTree, stopAgentProcessTree } from './agent-processes.mjs'
|
|
10
11
|
|
|
11
12
|
const exec = promisify(execFile)
|
|
12
13
|
const hash = value => createHash('sha256').update(value).digest('hex')
|
|
@@ -74,8 +75,13 @@ async function stopLocalAgent({ stateDir, slug }) {
|
|
|
74
75
|
if (resolve(stateDir) !== resolve(OV_DIR)) throw problem('Stopping agents is supported only in this computer’s agent storage.')
|
|
75
76
|
if (slugify(slug) !== slug) throw problem('This legacy local name cannot be stopped safely from Studio.')
|
|
76
77
|
if (process.platform === 'win32') throw problem('Stop the watcher manually before removal on Windows; automatic removal is not supported yet.')
|
|
78
|
+
// Capture children before unloading the service, which can orphan runtimes.
|
|
79
|
+
// A stale lock PID is not proof of ownership and must never target another app.
|
|
80
|
+
const rows = await agentProcessSnapshot()
|
|
81
|
+
const captured = agentProcessTree(rows, slug)
|
|
77
82
|
const lock = await readFile(join(stateDir, `watch-${slug}.lock`), 'utf8').catch(() => '')
|
|
78
|
-
const
|
|
83
|
+
const lockedPid = /^\d+\s*$/.test(lock) ? Number(lock.trim()) : null
|
|
84
|
+
if (rows.some(row => row.pid === lockedPid) && !captured.some(row => row.pid === lockedPid)) throw problem('The recorded watcher uses an unrecognized command. Stop it before removing its data.')
|
|
79
85
|
const service = process.platform === 'darwin'
|
|
80
86
|
? join(homedir(), 'Library', 'LaunchAgents', `io.openvisio.${slug}.plist`)
|
|
81
87
|
: join(homedir(), '.config', 'systemd', 'user', `openvisio-${slug}.service`)
|
|
@@ -83,11 +89,8 @@ async function stopLocalAgent({ stateDir, slug }) {
|
|
|
83
89
|
await exec('systemctl', ['--user', 'disable', '--now', `openvisio-${slug}.service`], { timeout: 15000 })
|
|
84
90
|
}
|
|
85
91
|
await exec(process.execPath, [fileURLToPath(new URL('../bin/cli.mjs', import.meta.url)), 'stop', '--name', slug], { timeout: 20000, maxBuffer: 65536 })
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
}
|
|
92
|
+
await stopAgentProcessTree(captured)
|
|
93
|
+
if (agentProcessTree(await agentProcessSnapshot(), slug).length) throw problem('The watcher restarted. Stop its background service before removing data.')
|
|
91
94
|
if (process.platform === 'darwin') {
|
|
92
95
|
const loaded = await exec('launchctl', ['print', `gui/${process.getuid()}/io.openvisio.${slug}`], { timeout: 5000 }).then(() => true, () => false)
|
|
93
96
|
if (loaded) throw problem('The background service is still loaded. Stop it before removing the agent.')
|
package/src/mastra-harness.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { opencodeProviderFailure } from './opencode-failure.mjs'
|
|
|
12
12
|
import { isTransportInterruption } from './transport-recovery.mjs'
|
|
13
13
|
|
|
14
14
|
const MCP_TOOL_NAMES = [
|
|
15
|
-
WORK_SESSION_TOOL, ...contextToolNames, 'list_agents', 'list_projects', 'list_tasks', 'list_task_types', 'get_ticket',
|
|
15
|
+
WORK_SESSION_TOOL, ...contextToolNames, 'list_agents', 'list_projects', 'list_boards', 'list_tasks', 'list_task_types', 'get_ticket',
|
|
16
16
|
'create_ticket', 'update_ticket', 'list_channels', 'list_message_thread', 'list_activity',
|
|
17
17
|
'post_message', 'react_message', 'list_codebases', 'get_codebase',
|
|
18
18
|
'codebase_tree', 'create_codebase_branch', 'create_codebase_commit',
|
package/src/task-types.mjs
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
export async function loadProjectTaskTypes({ projectId, listTools, callTool }) {
|
|
2
|
+
const tools = await listTools()
|
|
3
|
+
const tool = tools.find(tool => tool.name === 'list_task_types')
|
|
4
|
+
const schema = tool?.inputSchema || tool?.input_schema
|
|
5
|
+
if (!schema?.properties?.board_id && !schema?.required?.includes('board_id')) return callTool('list_task_types', { project_id: projectId })
|
|
6
|
+
const result = await callTool('list_boards', { project_id: projectId })
|
|
7
|
+
if (!Array.isArray(result?.boards)) throw new Error('Cannot verify project boards')
|
|
8
|
+
const types = []
|
|
9
|
+
for (const board of result.boards) {
|
|
10
|
+
if (board?.id == null) throw new Error('Cannot verify board identity')
|
|
11
|
+
const data = await callTool('list_task_types', { project_id: projectId, board_id: board.id })
|
|
12
|
+
const rows = data?.types ?? data?.task_types ?? data?.taskTypes
|
|
13
|
+
if (!Array.isArray(rows)) throw new Error('Cannot verify task columns')
|
|
14
|
+
types.push(...rows)
|
|
15
|
+
}
|
|
16
|
+
return { types }
|
|
17
|
+
}
|
|
18
|
+
|
|
1
19
|
export function createTaskTypeLookup({ load, now = Date.now, ttlMs = 60_000 }) {
|
|
2
20
|
if (!Number.isFinite(ttlMs) || ttlMs < 0) throw new Error('Invalid task column cache lifetime')
|
|
3
21
|
const cache = new Map(), pending = new Map()
|
package/src/watch.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import { interruptedRuntimeResult, isTransportInterruption, transportRecoveryDel
|
|
|
27
27
|
import { mapConcurrent } from './concurrency.mjs'
|
|
28
28
|
import { releaseAuthorizedPause } from './authorization-resume.mjs'
|
|
29
29
|
import { dedicatedChannel, repliesAfterSource } from './channel-routing.mjs'
|
|
30
|
-
import { createTaskTypeLookup } from './task-types.mjs'
|
|
30
|
+
import { createTaskTypeLookup, loadProjectTaskTypes } from './task-types.mjs'
|
|
31
31
|
import { createThreadContextResolver } from './thread-context.mjs'
|
|
32
32
|
import { assignmentRequest, assignmentStatusOnly, routeAssignments } from './assignment-routing.mjs'
|
|
33
33
|
import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
|
|
@@ -993,7 +993,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
993
993
|
try { return JSON.parse(text) } catch { return { text } }
|
|
994
994
|
}
|
|
995
995
|
|
|
996
|
-
const taskTypeSets = createTaskTypeLookup({ load: (projectId) =>
|
|
996
|
+
const taskTypeSets = createTaskTypeLookup({ load: (projectId) => loadProjectTaskTypes({ projectId, listTools: () => mcpClient.listTools(), callTool: (name, args) => callMcpTool(name, args).then(toolData) }) })
|
|
997
997
|
|
|
998
998
|
const callMcpReadWithRetry = async (name, args = {}) => {
|
|
999
999
|
try { return await callMcpTool(name, args) }
|
|
@@ -1883,7 +1883,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1883
1883
|
if (activityChannel != null) sendStatus(activityChannel, 'thinking')
|
|
1884
1884
|
const humanTicketRef = ticketSlug ? `ticket ${ticketSlug}` : `the ticket “${title}”`
|
|
1885
1885
|
const workspaceDirective = preparedWorktree ? ` A prepared local git worktree for this ticket exists at ${JSON.stringify(preparedWorktree)}. Start repository inspection and edits there. Do not call list_codebases, codebase_tree, or get_codebase while this local worktree is available.` : ''
|
|
1886
|
-
void drain(cycleKind, `Authoritative get_ticket verification confirms ${humanTicketRef} is open and assigned to YOU. Internal tool identity: project_id ${projectId}, ticket_id ${ticketId}. For every get_ticket call pass exactly { project_id: ${projectId}, ticket_id: ${ticketId} };
|
|
1886
|
+
void drain(cycleKind, `Authoritative get_ticket verification confirms ${humanTicketRef} is open and assigned to YOU. Internal tool identity: project_id ${projectId}, ticket_id ${ticketId}. For every get_ticket call pass exactly { project_id: ${projectId}, ticket_id: ${ticketId} }; For list_task_types, follow its advertised schema: resolve the ticket’s board_id from the ticket or list_boards and include it when required. Never guess a board id. Numeric ids are MCP arguments only and must never appear in human-facing text; use ${ticketSlug || 'the ticket title'} instead. Ticket details: ${JSON.stringify({ slug: ticketSlug, title, description: ticket.description, priority: ticket.priority, typeId: ticket.type_id ?? ticket.typeId })}.${workspaceDirective} This assignment has no source thread: do not call post_message yourself. Choose the tools, context, validation, and ticket state appropriate to this request. Return your final response when you decide this turn is finished; the watcher will publish it once in the dedicated agent channel without changing ticket status.`, activityChannel == null ? [] : [activityChannel], { projectId, ticketId, channelId: activityChannel })
|
|
1887
1887
|
} catch (e) {
|
|
1888
1888
|
log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
|
|
1889
1889
|
}
|
|
@@ -2079,7 +2079,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2079
2079
|
? `The watcher verified this source message is addressed to you in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". ${codingMention ? 'Complete the concrete repository work and verification first.' : 'Answer the request directly.'} Do NOT call post_message; it is intentionally unavailable. Return only the final reply as your final answer. The watcher will re-check the live thread and render it at most once.${who ? ` To mention the requester, use their exact full name "@${who}".` : ''} Use additional discovery, resources, and history when they help answer this request.`
|
|
2080
2080
|
: `The watcher verified this source message is addressed to you in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". ${codingMention ? `This is concrete repository work. Start the coding flow now. You may send at most one concrete progress update after work begins, but do not stop there: continue the work and then send one distinct final result with evidence or a real blocker. Use post_message for the final result even if you already sent a progress message` : 'Send EXACTLY ONE reply with post_message'}: arguments: channel_id ${cid}${threadRoot != null ? `, parent_id ${threadRoot} (reply IN THAT THREAD, do not start a new top-level message)` : ''}, using the authenticated session bridge. Never repeat the same message.${who ? ` To @mention them back, write their EXACT full name "@${who}". A mention only links when the name matches exactly.` : ''} Use additional discovery, resources, and history when they help answer this request.${codingMention ? ' Stop only after the work and final result/blocker.' : ' After your single reply, STOP.'}`
|
|
2081
2081
|
: undefined
|
|
2082
|
-
if (ctx && creatingTicket) ctx += ' This is a ticket-creation request, not repository implementation. Discover create_ticket on the authenticated watcher server, resolve the project and valid type using list_projects and list_task_types, then create the requested ticket once. Follow its actual schema. If a required detail is ambiguous, ask for that detail rather than inventing it. Report the returned ticket reference. Never replay a creation after a timeout or uncertain response.'
|
|
2082
|
+
if (ctx && creatingTicket) ctx += ' This is a ticket-creation request, not repository implementation. Discover create_ticket on the authenticated watcher server, resolve the project, board, and valid type using list_projects, list_boards, and list_task_types, then create the requested ticket once. Follow its actual schema. If a required detail is ambiguous, ask for that detail rather than inventing it. Report the returned ticket reference. Never replay a creation after a timeout or uncertain response.'
|
|
2083
2083
|
if (ctx && raw._threadRootContent) ctx += ` Verified thread root authored by you (context, not a new request): ${JSON.stringify(raw._threadRootContent)}. For requested repairs, inspect the referenced PR and its branch, run the failing verification, and fix that existing work. A review/testing handoff does not prevent an explicit human repair request.`
|
|
2084
2084
|
if (codingMention) {
|
|
2085
2085
|
// Activity indicators make accepted work visible. Do not add a canned
|
package/studio/app.mjs
CHANGED
|
@@ -391,11 +391,13 @@ function initializeStudio() {
|
|
|
391
391
|
$('model-settings-agent').textContent = `${agentName(agent)} · Models`;
|
|
392
392
|
$('open-model-settings').disabled = !agent.settingsEditable;
|
|
393
393
|
$('open-agent-removal').disabled = !agent.removable;
|
|
394
|
+
$('open-agent-removal').title = agent.removable == null ? 'Restart Agent Studio with the updated openvisio-agent package to enable removal.' : !agent.removable ? 'Automatic removal is unavailable on this platform.' : '';
|
|
394
395
|
const settings = agent.modelSettings || {};
|
|
395
396
|
const pending = settings.revision && agent.appliedModelRevision !== settings.revision;
|
|
396
397
|
$('model-settings-state').textContent = !agent.settingsEditable ? 'Model controls are available for agents configured on this computer.'
|
|
397
398
|
: pending ? agent.modelControlsSupported && agent.status === 'online' ? 'Saved · waiting for the watcher to apply your choices.' : 'Saved for the next watcher connection. Older watchers need an update to apply changes live.'
|
|
398
399
|
: `Replies: ${settings.chatModel || settings.model || 'Runtime default'} · Coding: ${settings.model || 'Runtime default'}`;
|
|
400
|
+
if (agent.removable == null) $('model-settings-state').textContent += ' Restart Agent Studio with the updated package to enable agent removal.';
|
|
399
401
|
if (launchSettings && agent.settingsEditable) { launchSettings = false; void openModelSettings(agent); }
|
|
400
402
|
}
|
|
401
403
|
async function settingsFetch(path, options) {
|