openvisio-agent 0.25.0 → 0.25.2
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 +12 -0
- package/package.json +1 -1
- package/scenarios/workspace.scenarios.mjs +5 -4
- package/src/agent-processes.mjs +46 -0
- package/src/agent-removal.mjs +9 -6
- package/src/mastra-harness.mjs +33 -1
- package/src/model-settings.mjs +8 -1
- package/src/studio-server.mjs +36 -4
- package/src/task-types.mjs +18 -0
- package/src/watch.mjs +43 -23
- package/src/watcher-control.mjs +39 -0
- package/studio/app.mjs +34 -2
- package/studio/guide.html +4 -4
- package/studio/index.html +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.25.2] — 2026-09-21
|
|
4
|
+
|
|
5
|
+
- Load each provider's available models in Agent Studio, apply the selected model exactly to new ACP sessions, and show the model the running watcher actually uses.
|
|
6
|
+
- Add Start, Stop, and Restart controls for local watchers in Agent Studio, backed by lifecycle APIs that report current watcher state.
|
|
7
|
+
- Recognize mentions addressed to saved agent display names containing spaces or hyphens, so names such as `Andre - Opencode` reliably trigger their watcher.
|
|
8
|
+
- Reconcile backend activity every 30 seconds to recover missed mentions promptly while preserving immediate startup and reconnect checks.
|
|
9
|
+
|
|
10
|
+
## [0.25.1] — 2026-09-19
|
|
11
|
+
|
|
12
|
+
- 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.
|
|
13
|
+
- Stop surviving watcher descendants before deleting agent data, preserve shared Studio processes, and explain when an older Studio needs restarting to enable removal.
|
|
14
|
+
|
|
3
15
|
## [0.25.0] — 2026-09-18
|
|
4
16
|
|
|
5
17
|
- Reduce Studio journal-processing CPU by caching unchanged files, refreshing every two seconds, and disconnecting hidden or paused views.
|
package/package.json
CHANGED
|
@@ -16,21 +16,22 @@ async function eventually(predicate, description) {
|
|
|
16
16
|
assert.fail(`Watcher did not reach: ${description}`)
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export function fixture({ agent = 'codex', task = true, run, threadError = false, commentTools = [], onComment, selfProfile = self, listAgents, getTicket, listChannels, transportRecoveryDelays, activityReporter } = {}) {
|
|
19
|
+
export function fixture({ agent = 'codex', name, task = true, run, threadError = false, commentTools = [], onComment, selfProfile = self, listAgents, getTicket, listChannels, transportRecoveryDelays, activityReporter } = {}) {
|
|
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' }
|
|
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() }
|
|
@@ -70,7 +71,7 @@ export function fixture({ agent = 'codex', task = true, run, threadError = false
|
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
73
|
const start = () => {
|
|
73
|
-
watcher = createBackendWatcher({ wsUrl: 'wss://unused.example.test', mcpUrl: 'https://unused.example.test/mcp', apiKey: 'test-key', identifier: 'alex', slug: 'test-alex', agent, workdir: join(dir, 'workspace'), model: 'test-code', chatModel: 'test-chat' }, {
|
|
74
|
+
watcher = createBackendWatcher({ wsUrl: 'wss://unused.example.test', mcpUrl: 'https://unused.example.test/mcp', apiKey: 'test-key', identifier: 'alex', slug: 'test-alex', name, agent, workdir: join(dir, 'workspace'), model: 'test-code', chatModel: 'test-chat' }, {
|
|
74
75
|
stateDir: dir, memory, mcpClient: mcp, createRunner, transportRecoveryDelays, activityReporter, connect: (callbacks) => { connection = callbacks; return { close() {}, subscribeOrganization(id) { subscriptions.push(id) } } }, autoStart: false, log: (value) => logs.push(value),
|
|
75
76
|
})
|
|
76
77
|
return watcher
|
|
@@ -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',
|
|
@@ -43,6 +43,38 @@ function commandFor(agent) {
|
|
|
43
43
|
return null
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
export async function listAcpRuntimeModels(agent, { AcpAgentClass = AcpAgent, cwd = process.cwd(), timeoutMs = 15_000 } = {}) {
|
|
47
|
+
const runtime = commandFor(agent)
|
|
48
|
+
if (!runtime) return []
|
|
49
|
+
const acp = new AcpAgentClass({
|
|
50
|
+
id: `openvisio-${agent}-model-catalog-${Date.now()}`,
|
|
51
|
+
name: `OpenVisio ${agent} model catalog`,
|
|
52
|
+
description: 'Reads the models advertised by the local coding runtime.',
|
|
53
|
+
command: runtime.command,
|
|
54
|
+
args: runtime.args,
|
|
55
|
+
cwd,
|
|
56
|
+
session: { cwd, mcpServers: [] },
|
|
57
|
+
persistSession: false,
|
|
58
|
+
createClient: agent === 'codex' ? (client) => new Proxy(client, {
|
|
59
|
+
get(target, property, receiver) {
|
|
60
|
+
if (property === 'extNotification') return async () => {}
|
|
61
|
+
return Reflect.get(target, property, receiver)
|
|
62
|
+
},
|
|
63
|
+
}) : undefined,
|
|
64
|
+
})
|
|
65
|
+
let timer
|
|
66
|
+
try {
|
|
67
|
+
const models = await Promise.race([
|
|
68
|
+
acp.getAvailableModels(),
|
|
69
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${agent} model discovery timed out`)), timeoutMs); timer.unref?.() }),
|
|
70
|
+
])
|
|
71
|
+
return [...new Set((Array.isArray(models) ? models : []).map(entry => typeof entry === 'string' ? entry : entry?.modelId).map(value => String(value || '').trim()).filter(Boolean))]
|
|
72
|
+
} finally {
|
|
73
|
+
clearTimeout(timer)
|
|
74
|
+
try { acp.connection.disconnect() } catch { /* discovery may fail before the transport starts */ }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
46
78
|
const proxyPath = fileURLToPath(new URL('./codex-mcp-proxy.mjs', import.meta.url))
|
|
47
79
|
|
|
48
80
|
const execFileAsync = promisify(execFile)
|
package/src/model-settings.mjs
CHANGED
|
@@ -48,7 +48,14 @@ export function saveModelSettings(stateDir, slug, input) {
|
|
|
48
48
|
|
|
49
49
|
export async function listRuntimeModels(provider) {
|
|
50
50
|
if (provider === 'claude') return ['sonnet', 'opus', 'haiku']
|
|
51
|
-
if (provider === 'codex')
|
|
51
|
+
if (provider === 'codex') {
|
|
52
|
+
try {
|
|
53
|
+
const { listAcpRuntimeModels } = await import('./mastra-harness.mjs')
|
|
54
|
+
const models = (await listAcpRuntimeModels('codex')).filter(modelId)
|
|
55
|
+
if (models.length) return models.slice(0, 1000)
|
|
56
|
+
} catch { /* keep the configured watcher default available when ACP discovery is unavailable */ }
|
|
57
|
+
return ['gpt-5.6-sol[medium]']
|
|
58
|
+
}
|
|
52
59
|
if (provider !== 'opencode') return []
|
|
53
60
|
const command = onPath('opencode')
|
|
54
61
|
if (!command) throw problem('OpenCode is not installed on this computer.', 503)
|
package/src/studio-server.mjs
CHANGED
|
@@ -7,6 +7,8 @@ import { fileURLToPath } from 'node:url'
|
|
|
7
7
|
import { isPrivateThoughtEvent, sanitizeJournalData } from './agent-journal.mjs'
|
|
8
8
|
import { readModelSettings, saveModelSettings, listRuntimeModels } from './model-settings.mjs'
|
|
9
9
|
import { planAgentRemoval, removeLocalAgent } from './agent-removal.mjs'
|
|
10
|
+
import { controlLocalWatcher } from './watcher-control.mjs'
|
|
11
|
+
import { OV_DIR } from './lib.mjs'
|
|
10
12
|
|
|
11
13
|
const MAX_EVENTS = 500, MAX_FILES = 32, MAX_FILE_BYTES = 256 * 1024, MAX_TOTAL_BYTES = 4 * 1024 * 1024
|
|
12
14
|
const FRESH_MS = 45_000
|
|
@@ -67,7 +69,7 @@ async function configuredAgents(stateDir) {
|
|
|
67
69
|
const lock = await readSmallLocalFile(join(stateDir, `watch-${config.slug}.lock`), 64).catch(() => null)
|
|
68
70
|
if (/^\d+\s*$/.test(lock || '')) { pid = Number(lock.trim()); watcherAlive = pidAlive(pid) }
|
|
69
71
|
const identifier = backend ? config.identifier : config.slug
|
|
70
|
-
agents.push(sanitizeJournalData({ id: identifier, identifier, slug: config.slug, name: config.name || config.slug, provider: config.agent || 'claude', configured: true, removable: process.platform !== 'win32', settingsEditable: !!backend, modelSettings: backend ? readModelSettings(stateDir, config.slug) : null, runId: null, pid, watcherAlive, status: 'uninstrumented', lastSeen: null, lastEventType: null }))
|
|
72
|
+
agents.push(sanitizeJournalData({ id: identifier, identifier, slug: config.slug, name: config.name || config.slug, provider: config.agent || 'claude', configured: true, removable: process.platform !== 'win32', watcherControllable: !!backend && process.platform !== 'win32' && resolve(stateDir) === resolve(OV_DIR), settingsEditable: !!backend, modelSettings: backend ? readModelSettings(stateDir, config.slug) : null, runId: null, pid, watcherAlive, status: 'uninstrumented', lastSeen: null, lastEventType: null }))
|
|
71
73
|
} catch { /* unrelated, malformed or concurrently replaced setup file */ }
|
|
72
74
|
}
|
|
73
75
|
return agents
|
|
@@ -120,9 +122,12 @@ export async function readStudioSnapshot({ stateDir, now = Date.now, journalCach
|
|
|
120
122
|
const profile = profileEvent?.type === 'agent.profile' ? profileEvent.data : profileEvent?.data?.profile
|
|
121
123
|
if (typeof profile?.name === 'string' && profile.name.trim()) agent.name = profile.name.trim()
|
|
122
124
|
if (typeof profile?.avatarSeed === 'string' && profile.avatarSeed) agent.avatarSeed = profile.avatarSeed
|
|
123
|
-
const
|
|
125
|
+
const loaded = events.findLast(event => event.agent.identifier === agent.identifier && event.runId === agent.runId && typeof event.data?.modelSettingsRevision === 'string')
|
|
126
|
+
const applied = events.findLast(event => event.agent.identifier === agent.identifier && event.runId === agent.runId && event.type === 'models.applied' && typeof event.data?.modelSettingsRevision === 'string')
|
|
127
|
+
agent.loadedModelRevision = loaded?.data.modelSettingsRevision || ''
|
|
124
128
|
agent.appliedModelRevision = applied?.data.modelSettingsRevision || ''
|
|
125
|
-
agent.
|
|
129
|
+
agent.appliedModels = applied ? { requested: applied.data.requestedModel || '', selected: applied.data.model || '', tier: applied.data.tier || '' } : null
|
|
130
|
+
agent.modelControlsSupported = !!loaded
|
|
126
131
|
}
|
|
127
132
|
agents.sort((a, b) => a.name.localeCompare(b.name))
|
|
128
133
|
return { schemaVersion: 1, demo: false, generatedAt: new Date(now()).toISOString(), agents, events: events.slice(-MAX_EVENTS), limits: { maxEvents: MAX_EVENTS, maxFiles: MAX_FILES, maxTotalBytes: MAX_TOTAL_BYTES }, warnings }
|
|
@@ -153,7 +158,7 @@ function demoSnapshot(stamp) {
|
|
|
153
158
|
return { schemaVersion: 1, demo: true, generatedAt: new Date(stamp).toISOString(), agents, events: events.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)), limits: { maxEvents: MAX_EVENTS }, warnings: ['Demo mode contains simulated agent activity. No model or task is running.'] }
|
|
154
159
|
}
|
|
155
160
|
|
|
156
|
-
export async function startStudioServer({ stateDir, host = '127.0.0.1', port = 4317, assetsDir = DEFAULT_ASSETS, demo = false, modelCatalog = listRuntimeModels, removeAgent = removeLocalAgent }) {
|
|
161
|
+
export async function startStudioServer({ stateDir, host = '127.0.0.1', port = 4317, assetsDir = DEFAULT_ASSETS, demo = false, modelCatalog = listRuntimeModels, removeAgent = removeLocalAgent, watcherControl = controlLocalWatcher }) {
|
|
157
162
|
if (!['127.0.0.1', '::1', 'localhost'].includes(host)) throw new Error('Agent Studio can bind only to a loopback address')
|
|
158
163
|
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Agent Studio port must be an integer from 0 to 65535')
|
|
159
164
|
if (!stateDir) throw new Error('Agent Studio requires a local state directory')
|
|
@@ -163,6 +168,7 @@ export async function startStudioServer({ stateDir, host = '127.0.0.1', port = 4
|
|
|
163
168
|
const catalogs = new Map()
|
|
164
169
|
const journalCache = new Map()
|
|
165
170
|
const removals = new Set()
|
|
171
|
+
const watcherChanges = new Set()
|
|
166
172
|
const demoState = demo ? demoSnapshot(Date.now()) : null
|
|
167
173
|
let current, pending, stopped = false, interval
|
|
168
174
|
const delivered = new WeakMap()
|
|
@@ -221,6 +227,32 @@ export async function startStudioServer({ stateDir, host = '127.0.0.1', port = 4
|
|
|
221
227
|
}
|
|
222
228
|
return
|
|
223
229
|
}
|
|
230
|
+
const watcherRoute = /^\/api\/agents\/([a-z0-9][a-z0-9_-]{0,79})\/watcher$/i.exec(path)
|
|
231
|
+
if (watcherRoute && !demo) {
|
|
232
|
+
const slug = watcherRoute[1]
|
|
233
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
234
|
+
try {
|
|
235
|
+
if (req.method !== 'POST') { res.writeHead(405); res.end(JSON.stringify({ error: 'Method not allowed' })); return }
|
|
236
|
+
if (req.headers.origin !== `http://${req.headers.host}` || req.headers['content-type']?.split(';')[0] !== 'application/json') throw Object.assign(new Error('Use the local Studio page to control a watcher.'), { status: 403 })
|
|
237
|
+
let body = ''
|
|
238
|
+
for await (const chunk of req) { body += chunk; if (Buffer.byteLength(body) > 1024) throw Object.assign(new Error('Watcher request is too large.'), { status: 413 }) }
|
|
239
|
+
let input
|
|
240
|
+
try { input = JSON.parse(body) } catch { throw Object.assign(new Error('Invalid watcher request.'), { status: 400 }) }
|
|
241
|
+
if (!input || !['start', 'stop', 'restart'].includes(input.action)) throw Object.assign(new Error('Choose start, stop, or restart.'), { status: 400 })
|
|
242
|
+
if (watcherChanges.has(slug)) throw Object.assign(new Error('This watcher is already changing state.'), { status: 409 })
|
|
243
|
+
watcherChanges.add(slug)
|
|
244
|
+
try {
|
|
245
|
+
const result = await watcherControl({ stateDir, slug, action: input.action })
|
|
246
|
+
journalCache.clear()
|
|
247
|
+
current = null
|
|
248
|
+
res.end(JSON.stringify(result))
|
|
249
|
+
} finally { watcherChanges.delete(slug) }
|
|
250
|
+
} catch (error) {
|
|
251
|
+
res.writeHead(error.status || 503)
|
|
252
|
+
res.end(JSON.stringify({ error: error.status ? error.message : `Could not control this watcher: ${error.message || error}` }))
|
|
253
|
+
}
|
|
254
|
+
return
|
|
255
|
+
}
|
|
224
256
|
const settingsRoute = /^\/api\/agents\/([a-z0-9][a-z0-9_-]{0,79})\/(models|settings)$/i.exec(path)
|
|
225
257
|
if (settingsRoute && !demo) {
|
|
226
258
|
const [, slug, action] = settingsRoute
|
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'
|
|
@@ -325,7 +325,7 @@ export async function runWatch({ flags }) {
|
|
|
325
325
|
assertWebSocket(fail)
|
|
326
326
|
if (flags.install) return installService({ slug: slug || 'openvisio', claude, mcpConfig, workdir })
|
|
327
327
|
startStudioInBackground()
|
|
328
|
-
return loopBackendWs({ backend, wsUrl, apiKey, identifier, slug: slug || 'openvisio', claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug: !!flags.debug })
|
|
328
|
+
return loopBackendWs({ backend, wsUrl, apiKey, identifier, slug: slug || 'openvisio', name: String(saved?.name || slug || identifier), claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug: !!flags.debug })
|
|
329
329
|
}
|
|
330
330
|
|
|
331
331
|
const host = stripSlash(flags.host || (saved && saved.host) || '')
|
|
@@ -594,7 +594,7 @@ function loopBackendWs(options) {
|
|
|
594
594
|
|
|
595
595
|
// Production orchestration with replaceable I/O boundaries for deterministic
|
|
596
596
|
// workspace simulations. Tests run this same routing, queue and delivery code.
|
|
597
|
-
export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }, dependencies = {}) {
|
|
597
|
+
export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug, name, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }, dependencies = {}) {
|
|
598
598
|
const validBackendId = (value) => value != null && /^\d+$/.test(String(value)) && Number.isSafeInteger(Number(value)) && Number(value) > 0
|
|
599
599
|
const stateDir = dependencies.stateDir || OV_DIR
|
|
600
600
|
const makeRunner = dependencies.createRunner || createCycleRunner
|
|
@@ -896,7 +896,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
896
896
|
let identityContext = memory.get?.(`identity:${identifier}`)?.meta?.context || agentProfileContext({}, identifier)
|
|
897
897
|
let agentAppearance = memory.get?.(`identity:${identifier}`)?.meta?.appearance || null
|
|
898
898
|
if (agentAppearance) emit('agent.profile', agentAppearance)
|
|
899
|
-
|
|
899
|
+
// The saved display name is authoritative recipient context even when an older
|
|
900
|
+
// MCP server returns list_agents as human-readable markdown without identifiers.
|
|
901
|
+
const selfAliases = new Set([slug, name, identifier].map((value) => String(value || '').trim().toLowerCase()).filter(Boolean))
|
|
900
902
|
const rememberSelfAgent = (self) => {
|
|
901
903
|
if (!self || typeof self !== 'object' || String(self.identifier || self.slug || '') !== identifier) return
|
|
902
904
|
if (validBackendId(self.id)) selfAgentId = Number(self.id)
|
|
@@ -993,7 +995,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
993
995
|
try { return JSON.parse(text) } catch { return { text } }
|
|
994
996
|
}
|
|
995
997
|
|
|
996
|
-
const taskTypeSets = createTaskTypeLookup({ load: (projectId) =>
|
|
998
|
+
const taskTypeSets = createTaskTypeLookup({ load: (projectId) => loadProjectTaskTypes({ projectId, listTools: () => mcpClient.listTools(), callTool: (name, args) => callMcpTool(name, args).then(toolData) }) })
|
|
997
999
|
|
|
998
1000
|
const callMcpReadWithRetry = async (name, args = {}) => {
|
|
999
1001
|
try { return await callMcpTool(name, args) }
|
|
@@ -1681,6 +1683,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1681
1683
|
cycleControl.outcome = 'running'
|
|
1682
1684
|
log(`cycle timing preflight=${Math.round(performance.now() - cycleStartedAt)}ms kind=${kind}`)
|
|
1683
1685
|
const result = await runner.runCycle(prompt, useModel, runnerOptions)
|
|
1686
|
+
if (result?.model) {
|
|
1687
|
+
emit('models.applied', { requestedModel: useModel, model: result.model, tier: kind === 'full' ? 'coding' : 'reply', modelSettingsRevision: cycleModelRevision })
|
|
1688
|
+
}
|
|
1684
1689
|
await cycleControl.planDelivery
|
|
1685
1690
|
cycleControl.outcome = result?.is_error ? 'error' : result?.subtype || 'error'
|
|
1686
1691
|
if (result?.timings) log(`cycle timing runtime=${JSON.stringify(result.timings)} kind=${kind}`)
|
|
@@ -1883,7 +1888,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1883
1888
|
if (activityChannel != null) sendStatus(activityChannel, 'thinking')
|
|
1884
1889
|
const humanTicketRef = ticketSlug ? `ticket ${ticketSlug}` : `the ticket “${title}”`
|
|
1885
1890
|
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} };
|
|
1891
|
+
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
1892
|
} catch (e) {
|
|
1888
1893
|
log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
|
|
1889
1894
|
}
|
|
@@ -2079,7 +2084,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2079
2084
|
? `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
2085
|
: `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
2086
|
: 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.'
|
|
2087
|
+
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
2088
|
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
2089
|
if (codingMention) {
|
|
2085
2090
|
// Activity indicators make accepted work visible. Do not add a canned
|
|
@@ -2192,7 +2197,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2192
2197
|
// supported MCP tools directly, stays silent when empty, and hands verified
|
|
2193
2198
|
// coding tasks to the full lane instead of letting Luna announce it cannot code.
|
|
2194
2199
|
taskProbeStartTimer = setTimeout(() => void reconcileBacklog(), 3_000)
|
|
2195
|
-
|
|
2200
|
+
// WebSocket delivery is primary. A short bounded reconciliation interval
|
|
2201
|
+
// keeps mentions responsive when a deployment omits targeted dispatch.
|
|
2202
|
+
taskProbeTimer = setInterval(() => void reconcileBacklog(), 30_000)
|
|
2196
2203
|
} else {
|
|
2197
2204
|
log('no MCP config — skipping intro + daily sweep (agent has no tools to post/act)')
|
|
2198
2205
|
}
|
|
@@ -2387,8 +2394,13 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
|
|
|
2387
2394
|
// ── background service install (launchd / systemd) ───────────────────────────
|
|
2388
2395
|
/** Stop the auto-restarting service first, then every orphaned watcher whose
|
|
2389
2396
|
* --name resolves to this exact slug. Never use a broad pkill pattern. */
|
|
2390
|
-
export function stopWatchers({ slug, quiet = false }) {
|
|
2397
|
+
export function stopWatchers({ slug, quiet = false, stateDir = OV_DIR, throwOnError = false }) {
|
|
2391
2398
|
const key = slugify(slug || 'openvisio')
|
|
2399
|
+
const abort = (message) => {
|
|
2400
|
+
if (throwOnError) throw new Error(message)
|
|
2401
|
+
fail(message)
|
|
2402
|
+
}
|
|
2403
|
+
if (stateDir !== OV_DIR) abort('Watcher control is supported only in this computer’s agent storage.')
|
|
2392
2404
|
let serviceStopped = false
|
|
2393
2405
|
|
|
2394
2406
|
if (process.platform === 'darwin') {
|
|
@@ -2426,35 +2438,41 @@ export function stopWatchers({ slug, quiet = false }) {
|
|
|
2426
2438
|
if (stubborn.length) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 150)
|
|
2427
2439
|
|
|
2428
2440
|
const remaining = watcherPids()
|
|
2429
|
-
if (remaining.length)
|
|
2430
|
-
try { unlinkSync(join(
|
|
2441
|
+
if (remaining.length) abort(`Could not stop watcher${remaining.length === 1 ? '' : 's'} for "${key}": ${remaining.join(', ')}`)
|
|
2442
|
+
try { unlinkSync(join(stateDir, `watch-${key}.lock`)) } catch { /* absent */ }
|
|
2431
2443
|
if (!quiet) ok(`Stopped ${found.length} watcher process${found.length === 1 ? '' : 'es'} for "${key}"${serviceStopped ? ' and disabled its background service' : ''}.`)
|
|
2432
2444
|
return { stopped: found.length, serviceStopped }
|
|
2433
2445
|
}
|
|
2434
2446
|
|
|
2435
|
-
export function installService({ slug, workdir }) {
|
|
2447
|
+
export function installService({ slug, workdir, stateDir = OV_DIR, serviceCommand, throwOnError = false }) {
|
|
2448
|
+
const abort = (message) => {
|
|
2449
|
+
if (throwOnError) throw new Error(message)
|
|
2450
|
+
fail(message)
|
|
2451
|
+
}
|
|
2452
|
+
if (stateDir !== OV_DIR) abort('Watcher control is supported only in this computer’s agent storage.')
|
|
2436
2453
|
// Replacing a service must also remove manually-started watchers. Otherwise the
|
|
2437
2454
|
// new KeepAlive process repeatedly spawns, sees their lock, exits, and respawns.
|
|
2438
|
-
stopWatchers({ slug, quiet: true })
|
|
2439
|
-
const
|
|
2440
|
-
|
|
2455
|
+
stopWatchers({ slug, quiet: true, stateDir, throwOnError })
|
|
2456
|
+
const suppliedCommand = Array.isArray(serviceCommand) && serviceCommand.length ? serviceCommand.map(String) : null
|
|
2457
|
+
const binPath = suppliedCommand ? suppliedCommand[0] : onPath('openvisio-agent')
|
|
2458
|
+
if (!suppliedCommand && (!binPath || binPath.includes('/_npx/'))) {
|
|
2441
2459
|
info('Installing openvisio-agent globally so the background service has a stable path…')
|
|
2442
2460
|
spawnSync('npm', ['i', '-g', 'openvisio-agent'], { stdio: 'inherit', shell: process.platform === 'win32' })
|
|
2443
2461
|
}
|
|
2444
|
-
const
|
|
2462
|
+
const command = suppliedCommand || [onPath('openvisio-agent') || 'openvisio-agent']
|
|
2445
2463
|
const nodeDir = dirname(process.execPath)
|
|
2446
2464
|
const claudeBin = onPath('claude')
|
|
2447
2465
|
const opencodeBin = onPath('opencode')
|
|
2448
2466
|
const codexBin = onPath('codex')
|
|
2449
2467
|
const runPath = [nodeDir, claudeBin ? dirname(claudeBin) : '', codexBin ? dirname(codexBin) : '', opencodeBin ? dirname(opencodeBin) : '', join(homedir(), '.opencode', 'bin'), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin'].filter(Boolean).join(':')
|
|
2450
2468
|
const args = ['watch', '--name', slug, ...(workdir ? ['--workdir', workdir] : [])]
|
|
2451
|
-
mkdirSync(
|
|
2452
|
-
const logFile = join(
|
|
2469
|
+
mkdirSync(stateDir, { recursive: true })
|
|
2470
|
+
const logFile = join(stateDir, `${slug}.log`)
|
|
2453
2471
|
|
|
2454
2472
|
if (process.platform === 'darwin') {
|
|
2455
2473
|
const label = `io.openvisio.${slug}`
|
|
2456
2474
|
const plist = join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`)
|
|
2457
|
-
const progArgs = [
|
|
2475
|
+
const progArgs = [...command, ...args].map((a) => ` <string>${a}</string>`).join('\n')
|
|
2458
2476
|
writeFileSync(plist, `<?xml version="1.0" encoding="UTF-8"?>
|
|
2459
2477
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2460
2478
|
<plist version="1.0"><dict>
|
|
@@ -2471,15 +2489,16 @@ ${progArgs}
|
|
|
2471
2489
|
`)
|
|
2472
2490
|
spawnSync('launchctl', ['unload', plist], { stdio: 'ignore' })
|
|
2473
2491
|
const r = spawnSync('launchctl', ['load', plist], { stdio: 'inherit' })
|
|
2474
|
-
if (r.status !== 0)
|
|
2492
|
+
if (r.status !== 0) abort('launchctl load failed — check the plist at ' + plist)
|
|
2475
2493
|
ok(`Live in the background, auto-starts at login. Logs: tail -f ${logFile}`)
|
|
2494
|
+
return { running: true, service: 'launchd', slug }
|
|
2476
2495
|
} else if (process.platform === 'win32') {
|
|
2477
|
-
|
|
2496
|
+
abort('Background install isn\'t supported on Windows yet. Run it in the foreground:\n openvisio-agent watch --name ' + slug)
|
|
2478
2497
|
} else {
|
|
2479
2498
|
const dir = join(homedir(), '.config', 'systemd', 'user')
|
|
2480
2499
|
mkdirSync(dir, { recursive: true })
|
|
2481
2500
|
const unit = join(dir, `openvisio-${slug}.service`)
|
|
2482
|
-
const execStart = [
|
|
2501
|
+
const execStart = [...command, ...args].map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(' ')
|
|
2483
2502
|
writeFileSync(unit, `[Unit]
|
|
2484
2503
|
Description=OpenVisio agent watcher (${slug})
|
|
2485
2504
|
After=network-online.target
|
|
@@ -2496,7 +2515,8 @@ WantedBy=default.target
|
|
|
2496
2515
|
spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' })
|
|
2497
2516
|
const r = spawnSync('systemctl', ['--user', 'enable', '--now', `openvisio-${slug}.service`], { stdio: 'inherit' })
|
|
2498
2517
|
spawnSync('loginctl', ['enable-linger', process.env.USER || ''], { stdio: 'ignore' })
|
|
2499
|
-
if (r.status !== 0)
|
|
2518
|
+
if (r.status !== 0) abort('systemctl enable failed — is this a systemd user session?')
|
|
2500
2519
|
ok(`Live in the background, auto-starts at login. Logs: journalctl --user -u openvisio-${slug} -f`)
|
|
2520
|
+
return { running: true, service: 'systemd', slug }
|
|
2501
2521
|
}
|
|
2502
2522
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { constants, closeSync, fstatSync, openSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { OV_DIR, slugify } from './lib.mjs'
|
|
5
|
+
import { installService, stopWatchers } from './watch.mjs'
|
|
6
|
+
|
|
7
|
+
const problem = (message, status = 400) => Object.assign(new Error(message), { status })
|
|
8
|
+
|
|
9
|
+
function localConfig(stateDir, slug) {
|
|
10
|
+
if (resolve(stateDir) !== resolve(OV_DIR)) throw problem('Watcher control is supported only in this computer’s agent storage.', 403)
|
|
11
|
+
if (slugify(slug) !== slug) throw problem('Invalid agent.', 404)
|
|
12
|
+
const fd = openSync(join(stateDir, `${slug}.json`), constants.O_RDONLY | (constants.O_NOFOLLOW || 0))
|
|
13
|
+
try {
|
|
14
|
+
const stat = fstatSync(fd)
|
|
15
|
+
if (!stat.isFile() || stat.size > 64 * 1024) throw problem('Agent settings are unavailable.', 404)
|
|
16
|
+
const config = JSON.parse(readFileSync(fd, 'utf8'))
|
|
17
|
+
if (config.slug !== slug || config.mode !== 'backend' || !config.identifier || !config.apiKey) throw problem('This agent does not support watcher control.', 404)
|
|
18
|
+
return config
|
|
19
|
+
} finally { closeSync(fd) }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function controlLocalWatcher({ stateDir, slug, action }) {
|
|
23
|
+
const config = localConfig(stateDir, slug)
|
|
24
|
+
if (!['start', 'stop', 'restart'].includes(action)) throw problem('Choose start, stop, or restart.')
|
|
25
|
+
if (process.platform === 'win32') throw problem('Watcher control is not available on Windows yet.', 501)
|
|
26
|
+
if (action === 'stop') {
|
|
27
|
+
const result = stopWatchers({ slug, quiet: true, stateDir, throwOnError: true })
|
|
28
|
+
return { action, slug, running: false, ...result }
|
|
29
|
+
}
|
|
30
|
+
const cli = fileURLToPath(new URL('../bin/cli.mjs', import.meta.url))
|
|
31
|
+
const result = installService({
|
|
32
|
+
slug,
|
|
33
|
+
workdir: config.chatOnly ? '' : config.workspace || config.workdir || '',
|
|
34
|
+
stateDir,
|
|
35
|
+
serviceCommand: [process.execPath, cli],
|
|
36
|
+
throwOnError: true,
|
|
37
|
+
})
|
|
38
|
+
return { action, slug, ...result }
|
|
39
|
+
}
|
package/studio/app.mjs
CHANGED
|
@@ -260,6 +260,7 @@ function initializeStudio() {
|
|
|
260
260
|
let launchSettings = launch.get('settings') === '1';
|
|
261
261
|
let editingAgent = null, editingSettings = null, settingsRequest = 0;
|
|
262
262
|
let removalPlan = null, removalRequest = 0, removing = false;
|
|
263
|
+
let watcherChanging = false;
|
|
263
264
|
const state = {
|
|
264
265
|
snapshot: null, model: null, pending: null, paused: false, connected: false,
|
|
265
266
|
selectedAgent: launch.get('agent') || 'all', agentSearch: '', search: '', kind: 'all', status: 'all',
|
|
@@ -391,11 +392,22 @@ function initializeStudio() {
|
|
|
391
392
|
$('model-settings-agent').textContent = `${agentName(agent)} · Models`;
|
|
392
393
|
$('open-model-settings').disabled = !agent.settingsEditable;
|
|
393
394
|
$('open-agent-removal').disabled = !agent.removable;
|
|
395
|
+
const watcherRunning = agent.status === 'online' || agent.watcherAlive === true;
|
|
396
|
+
$('toggle-watcher').textContent = watcherRunning ? 'Stop watcher' : 'Start watcher';
|
|
397
|
+
$('toggle-watcher').dataset.action = watcherRunning ? 'stop' : 'start';
|
|
398
|
+
$('toggle-watcher').disabled = watcherChanging || !agent.watcherControllable;
|
|
399
|
+
$('restart-watcher').disabled = watcherChanging || !agent.watcherControllable;
|
|
400
|
+
const watcherTitle = agent.watcherControllable ? '' : 'Watcher control is available for local backend agents on macOS and Linux.';
|
|
401
|
+
$('toggle-watcher').title = watcherTitle;
|
|
402
|
+
$('restart-watcher').title = watcherTitle;
|
|
403
|
+
$('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
404
|
const settings = agent.modelSettings || {};
|
|
395
405
|
const pending = settings.revision && agent.appliedModelRevision !== settings.revision;
|
|
406
|
+
const selected = agent.appliedModels?.selected;
|
|
396
407
|
$('model-settings-state').textContent = !agent.settingsEditable ? 'Model controls are available for agents configured on this computer.'
|
|
397
|
-
: pending ? agent.
|
|
398
|
-
: `Replies: ${settings.chatModel || settings.model || 'Runtime default'} · Coding: ${settings.model || 'Runtime default'}`;
|
|
408
|
+
: pending ? agent.loadedModelRevision === settings.revision ? 'Saved · the watcher loaded your choices and will apply them to the next request.' : 'Saved for the next watcher connection.'
|
|
409
|
+
: `Replies: ${settings.chatModel || settings.model || 'Runtime default'} · Coding: ${settings.model || 'Runtime default'}${selected ? ` · Runtime applied: ${selected}` : ''}`;
|
|
410
|
+
if (agent.removable == null) $('model-settings-state').textContent += ' Restart Agent Studio with the updated package to enable agent removal.';
|
|
399
411
|
if (launchSettings && agent.settingsEditable) { launchSettings = false; void openModelSettings(agent); }
|
|
400
412
|
}
|
|
401
413
|
async function settingsFetch(path, options) {
|
|
@@ -877,6 +889,26 @@ function initializeStudio() {
|
|
|
877
889
|
if (request === settingsRequest) { $('model-settings-error').hidden = false; $('model-settings-error').textContent = error.message; }
|
|
878
890
|
} finally { $('save-model-settings').disabled = false; $('save-model-settings').textContent = 'Save models'; }
|
|
879
891
|
});
|
|
892
|
+
async function changeWatcher(action) {
|
|
893
|
+
const agent = state.model.agents.find(agent => agentId(agent) === state.selectedAgent);
|
|
894
|
+
if (!agent || watcherChanging || !agent.watcherControllable) return;
|
|
895
|
+
watcherChanging = true;
|
|
896
|
+
scheduleRender();
|
|
897
|
+
try {
|
|
898
|
+
await settingsFetch(`/api/agents/${encodeURIComponent(agent.slug)}/watcher`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action }) });
|
|
899
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
900
|
+
receive(await settingsFetch('/api/snapshot'), true);
|
|
901
|
+
toast(action === 'stop' ? 'Watcher stopped.' : action === 'restart' ? 'Watcher restarted with the current models.' : 'Watcher started with the current models.');
|
|
902
|
+
} catch (error) {
|
|
903
|
+
state.error = error.message;
|
|
904
|
+
scheduleRender();
|
|
905
|
+
} finally {
|
|
906
|
+
watcherChanging = false;
|
|
907
|
+
scheduleRender();
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
$('toggle-watcher').addEventListener('click', () => void changeWatcher($('toggle-watcher').dataset.action || 'start'));
|
|
911
|
+
$('restart-watcher').addEventListener('click', () => void changeWatcher('restart'));
|
|
880
912
|
$('pause-button').addEventListener('click', () => {
|
|
881
913
|
state.paused = !state.paused;
|
|
882
914
|
syncConnection();
|
package/studio/guide.html
CHANGED
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
<section id="models">
|
|
46
46
|
<h2>Change models</h2>
|
|
47
47
|
<p>Select an agent in the sidebar, choose <strong>Model settings</strong>, select its reply and coding models, then choose <strong>Save models</strong>. Model fields let you search the runtime’s choices or enter a custom model ID.</p>
|
|
48
|
-
<p>Updated watchers
|
|
48
|
+
<p>Updated watchers load saved choices immediately and apply them to the next request without a restart. Work already running continues with its original model. After a request starts, Studio shows the model the runtime actually selected. Offline agents retain the selection for their next connection.</p>
|
|
49
49
|
<p>OpenCode choices come from its local model catalog. Listing a model does not guarantee credentials or available quota. If a model is rate-limited, choose one with available quota or retry after the provider’s limit resets.</p>
|
|
50
50
|
<p>Studio controls agents configured on this computer. An agent running on another teammate’s computer must be managed there.</p>
|
|
51
51
|
</section>
|
|
@@ -59,11 +59,11 @@
|
|
|
59
59
|
<p>A <strong>watcher</strong> is the local program that listens for assignments and messages, then starts your coding agent when work arrives. Studio displays what the watcher records.</p>
|
|
60
60
|
<ol>
|
|
61
61
|
<li>Connect your agent using the setup command provided by your OpenVisio team. If you already connected it, keep that setup.</li>
|
|
62
|
-
<li>
|
|
62
|
+
<li>Select the agent in Studio and choose <strong>Start watcher</strong>. Studio installs its background watcher and starts it with the current package and saved model settings.</li>
|
|
63
63
|
<li>Return to Studio. Your agent should appear in the sidebar. Select its name to see its activity.</li>
|
|
64
64
|
</ol>
|
|
65
|
-
<p>
|
|
66
|
-
<p>
|
|
65
|
+
<p>Use <strong>Stop watcher</strong> to take an agent offline and <strong>Restart watcher</strong> after changing its runtime installation or troubleshooting a connection. Restarting preserves its configuration and model choices.</p>
|
|
66
|
+
<p>Watcher controls are available for backend agents in the default local agent storage on macOS and Linux. Their earlier activity cannot be reconstructed.</p>
|
|
67
67
|
</section>
|
|
68
68
|
<section id="find-work">
|
|
69
69
|
<h2>Find and inspect work</h2>
|
package/studio/index.html
CHANGED
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
<div id="notice" class="notice" role="status" hidden></div>
|
|
62
62
|
<section id="model-settings-summary" class="model-settings-summary" aria-label="Agent model settings" hidden>
|
|
63
63
|
<div><h2 id="model-settings-agent">Model settings</h2><p id="model-settings-state" role="status"></p></div>
|
|
64
|
-
<div class="header-actions"><button id="open-model-settings" class="button" type="button">Model settings</button><button id="open-agent-removal" class="button" type="button">Remove from computer</button></div>
|
|
64
|
+
<div class="header-actions"><button id="toggle-watcher" class="button" type="button">Start watcher</button><button id="restart-watcher" class="button" type="button">Restart watcher</button><button id="open-model-settings" class="button" type="button">Model settings</button><button id="open-agent-removal" class="button" type="button">Remove from computer</button></div>
|
|
65
65
|
</section>
|
|
66
66
|
<dialog id="model-settings-dialog" aria-labelledby="model-settings-title">
|
|
67
67
|
<form id="model-settings-form" class="model-settings-form">
|