openvisio-agent 0.25.1 → 0.25.3
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/README.md +1 -1
- package/package.json +1 -1
- package/scenarios/workspace.scenarios.mjs +2 -2
- package/src/channel-routing.mjs +2 -1
- package/src/mastra-harness.mjs +32 -0
- package/src/model-settings.mjs +8 -1
- package/src/studio-server.mjs +36 -4
- package/src/team-data.mjs +41 -0
- package/src/watch.mjs +58 -36
- package/src/watcher-control.mjs +39 -0
- package/studio/app.mjs +32 -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.3] — 2026-09-21
|
|
4
|
+
|
|
5
|
+
- Normalize structured and compact Markdown MCP list results through one provider-neutral discovery path, so every runtime can resolve projects, agents, boards, tasks, task types, and channels from the same saved setup.
|
|
6
|
+
- Recognize backend-generated dedicated channel names that append the agent identifier suffix, allowing first-connection introductions and fresh threads to reach the correct agent channel.
|
|
7
|
+
|
|
8
|
+
## [0.25.2] — 2026-09-21
|
|
9
|
+
|
|
10
|
+
- 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.
|
|
11
|
+
- Add Start, Stop, and Restart controls for local watchers in Agent Studio, backed by lifecycle APIs that report current watcher state.
|
|
12
|
+
- Recognize mentions addressed to saved agent display names containing spaces or hyphens, so names such as `Andre - Opencode` reliably trigger their watcher.
|
|
13
|
+
- Reconcile backend activity every 30 seconds to recover missed mentions promptly while preserving immediate startup and reconnect checks.
|
|
14
|
+
|
|
3
15
|
## [0.25.1] — 2026-09-19
|
|
4
16
|
|
|
5
17
|
- 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.
|
package/README.md
CHANGED
|
@@ -79,7 +79,7 @@ The integration follows Mastra's [semantic memory](https://mastra.ai/docs/memory
|
|
|
79
79
|
|
|
80
80
|
Model selections are stable. Exact IDs (including reasoning effort) are preserved. An unsuffixed ACP model may resolve once to the same model with `medium` effort; the watcher saves that exact selection. Unavailable selections produce an explicit error rather than switching generations or families. Claude versioned IDs are preserved, with automatic family fallbacks disabled. Codex uses the configured chat model for replies.
|
|
81
81
|
|
|
82
|
-
Backend/BYO watchers reconcile immediately whenever the process starts or the WebSocket connects. A direct MCP session discovers and caches the backend's actual `tools/list` response, then uses available tools such as `list_agents`, `list_projects`, `list_tasks`, `list_task_types`, and `list_activity` to recover assigned tasks and recent mention activity missed while offline. Optional actions such as ticket comments are used only when advertised; their absence cannot strand a completed ticket in a retry loop. The same zero-model check runs every
|
|
82
|
+
Backend/BYO watchers reconcile immediately whenever the process starts or the WebSocket connects. A direct MCP session discovers and caches the backend's actual `tools/list` response, then uses available tools such as `list_agents`, `list_projects`, `list_tasks`, `list_task_types`, and `list_activity` to recover assigned tasks and recent mention activity missed while offline. Team discovery is shared by every runtime provider: structured MCP results and compact Markdown list envelopes are normalized once before the watcher resolves its project, identity, boards, tickets, or dedicated channel. Adding another model provider therefore reuses the same saved team connection and setup path instead of adding provider-specific project discovery. Optional actions such as ticket comments are used only when advertised; their absence cannot strand a completed ticket in a retry loop. The same zero-model check runs every 30 seconds as a safety net; a model starts only when pending work exists.
|
|
83
83
|
|
|
84
84
|
The backend MCP may be stateful or stateless. A successful initialize response without `Mcp-Session-Id` is accepted as stateless, so OpenCode agents do not stop with “MCP initialize returned no session id.” Each OpenCode lane keeps its MCP identity in a private per-agent config directory while the repository is supplied separately with `--dir`; stale workspace configuration therefore cannot swap one agent's credentials for another's. The generated remote configuration sends the agent headers directly, disables OAuth probing, and backend cycles never request relay-only inbox calls or MCP resource-discovery tools in place of team actions.
|
|
85
85
|
|
package/package.json
CHANGED
|
@@ -16,7 +16,7 @@ async function eventually(predicate, description) {
|
|
|
16
16
|
assert.fail(`Watcher did not reach: ${description}`)
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export function fixture({ agent = 'codex', task = true, run, threadError = false, commentTools = [], onComment, selfProfile = self, 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' }
|
|
@@ -71,7 +71,7 @@ export function fixture({ agent = 'codex', task = true, run, threadError = false
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
const start = () => {
|
|
74
|
-
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' }, {
|
|
75
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),
|
|
76
76
|
})
|
|
77
77
|
return watcher
|
package/src/channel-routing.mjs
CHANGED
|
@@ -22,9 +22,10 @@ export function dedicatedChannel(channels, { id, identifier, aliases = [], chann
|
|
|
22
22
|
return identifier && ownerIdentifier(channel) === identifier
|
|
23
23
|
})
|
|
24
24
|
if (owned.length) return owned.length === 1 ? owned[0] : null
|
|
25
|
+
const identifierSuffix = String(identifier || '').split('-').at(-1)?.toLowerCase() || ''
|
|
25
26
|
const names = new Set([identifier, ...aliases].filter(Boolean).flatMap((alias) => {
|
|
26
27
|
const name = normalize(alias)
|
|
27
|
-
return [name, `agent-${name}`, `${name}-agent`]
|
|
28
|
+
return [name, `agent-${name}`, `${name}-agent`, ...(identifierSuffix ? [`${name}-${identifierSuffix}`] : [])]
|
|
28
29
|
}).filter((name) => !['general', 'team', 'project', 'dev', 'development'].includes(name)))
|
|
29
30
|
// Explicit ownership always outranks a similar name. A recycled agent name
|
|
30
31
|
// must never route a new thread into somebody else's dedicated channel.
|
package/src/mastra-harness.mjs
CHANGED
|
@@ -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
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const COLLECTIONS = {
|
|
2
|
+
list_agents: ['agents', 'name'],
|
|
3
|
+
list_boards: ['boards', 'name'],
|
|
4
|
+
list_channels: ['channels', 'name'],
|
|
5
|
+
list_projects: ['projects', 'name'],
|
|
6
|
+
list_tasks: ['tasks', 'title'],
|
|
7
|
+
list_task_types: ['types', 'name'],
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function markdownRows(markdown, labelKey) {
|
|
11
|
+
if (typeof markdown !== 'string') return []
|
|
12
|
+
const rows = []
|
|
13
|
+
for (const line of markdown.split(/\r?\n/)) {
|
|
14
|
+
const match = /^\s*-\s+`(#?\d+)`(?:\s+(.+?))?\s*$/.exec(line)
|
|
15
|
+
if (!match) continue
|
|
16
|
+
const id = Number(match[1].replace(/^#/, ''))
|
|
17
|
+
if (!Number.isSafeInteger(id) || id <= 0) continue
|
|
18
|
+
rows.push({ id, [labelKey]: String(match[2] || '').trim() })
|
|
19
|
+
}
|
|
20
|
+
return rows
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeCollection(data, toolName) {
|
|
24
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return data
|
|
25
|
+
const collection = COLLECTIONS[toolName]
|
|
26
|
+
if (!collection) return data
|
|
27
|
+
const [key, labelKey] = collection
|
|
28
|
+
if (Array.isArray(data[key])) return data
|
|
29
|
+
const rows = markdownRows(data.markdown, labelKey)
|
|
30
|
+
return { ...data, [key]: rows }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Team tools may return either structured arrays or a compact JSON envelope
|
|
34
|
+
// containing Markdown rows. Normalize that transport difference here so every
|
|
35
|
+
// runtime provider uses the same watcher discovery path.
|
|
36
|
+
export function toolData(result, toolName = '') {
|
|
37
|
+
const text = result?.content?.find?.((item) => item?.type === 'text')?.text
|
|
38
|
+
if (typeof text !== 'string') return normalizeCollection(result?.structuredContent ?? result ?? {}, toolName)
|
|
39
|
+
try { return normalizeCollection(JSON.parse(text), toolName) }
|
|
40
|
+
catch { return normalizeCollection(result?.structuredContent ?? { text }, toolName) }
|
|
41
|
+
}
|
package/src/watch.mjs
CHANGED
|
@@ -36,6 +36,7 @@ import { claudeFallbackModels, resolveClaudeModel } from './model-selection.mjs'
|
|
|
36
36
|
import { createAgentJournal } from './agent-journal.mjs'
|
|
37
37
|
import { readModelSettings } from './model-settings.mjs'
|
|
38
38
|
import { startStudioInBackground } from './studio-cli.mjs'
|
|
39
|
+
import { toolData } from './team-data.mjs'
|
|
39
40
|
|
|
40
41
|
function runtimeJournal(options, supplied) {
|
|
41
42
|
let journal
|
|
@@ -325,7 +326,7 @@ export async function runWatch({ flags }) {
|
|
|
325
326
|
assertWebSocket(fail)
|
|
326
327
|
if (flags.install) return installService({ slug: slug || 'openvisio', claude, mcpConfig, workdir })
|
|
327
328
|
startStudioInBackground()
|
|
328
|
-
return loopBackendWs({ backend, wsUrl, apiKey, identifier, slug: slug || 'openvisio', claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug: !!flags.debug })
|
|
329
|
+
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
330
|
}
|
|
330
331
|
|
|
331
332
|
const host = stripSlash(flags.host || (saved && saved.host) || '')
|
|
@@ -594,7 +595,7 @@ function loopBackendWs(options) {
|
|
|
594
595
|
|
|
595
596
|
// Production orchestration with replaceable I/O boundaries for deterministic
|
|
596
597
|
// 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 = {}) {
|
|
598
|
+
export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug, name, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }, dependencies = {}) {
|
|
598
599
|
const validBackendId = (value) => value != null && /^\d+$/.test(String(value)) && Number.isSafeInteger(Number(value)) && Number(value) > 0
|
|
599
600
|
const stateDir = dependencies.stateDir || OV_DIR
|
|
600
601
|
const makeRunner = dependencies.createRunner || createCycleRunner
|
|
@@ -896,9 +897,15 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
896
897
|
let identityContext = memory.get?.(`identity:${identifier}`)?.meta?.context || agentProfileContext({}, identifier)
|
|
897
898
|
let agentAppearance = memory.get?.(`identity:${identifier}`)?.meta?.appearance || null
|
|
898
899
|
if (agentAppearance) emit('agent.profile', agentAppearance)
|
|
899
|
-
|
|
900
|
+
// The saved display name is authoritative recipient context even when an older
|
|
901
|
+
// MCP server returns list_agents as human-readable markdown without identifiers.
|
|
902
|
+
const selfAliases = new Set([slug, name, identifier].map((value) => String(value || '').trim().toLowerCase()).filter(Boolean))
|
|
900
903
|
const rememberSelfAgent = (self) => {
|
|
901
|
-
if (!self || typeof self !== 'object'
|
|
904
|
+
if (!self || typeof self !== 'object') return
|
|
905
|
+
const declaredIdentifier = String(self.identifier || self.slug || '')
|
|
906
|
+
const matchesIdentifier = declaredIdentifier === identifier
|
|
907
|
+
const matchesSavedName = !declaredIdentifier && String(name || '').trim() && String(self.name || '').trim().toLowerCase() === String(name).trim().toLowerCase()
|
|
908
|
+
if (!matchesIdentifier && !matchesSavedName) return
|
|
902
909
|
if (validBackendId(self.id)) selfAgentId = Number(self.id)
|
|
903
910
|
for (const alias of [self.name, self.identifier, self.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
|
|
904
911
|
identityContext = agentProfileContext(self, identifier)
|
|
@@ -931,8 +938,11 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
931
938
|
const ensureSelfAgent = async () => {
|
|
932
939
|
if (selfAgentId != null) return
|
|
933
940
|
if (!selfRead && Date.now() < selfReadAfter) return
|
|
934
|
-
if (!selfRead) selfRead = callMcpTool('list_agents').then(toolData).then((data) => {
|
|
935
|
-
const
|
|
941
|
+
if (!selfRead) selfRead = callMcpTool('list_agents').then((result) => toolData(result, 'list_agents')).then((data) => {
|
|
942
|
+
const agents = Array.isArray(data.agents) ? data.agents : []
|
|
943
|
+
const identified = agents.find((row) => String(row?.identifier || row?.slug || '') === identifier)
|
|
944
|
+
const named = agents.filter((row) => !row?.identifier && !row?.slug && String(row?.name || '').trim().toLowerCase() === String(name || '').trim().toLowerCase())
|
|
945
|
+
const self = identified || (named.length === 1 ? named[0] : null)
|
|
936
946
|
rememberSelfAgent(self)
|
|
937
947
|
}).catch((error) => {
|
|
938
948
|
log('Agent profile unavailable; continuing with task ownership and configured aliases: ' + (error?.message || error))
|
|
@@ -987,13 +997,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
987
997
|
}
|
|
988
998
|
}
|
|
989
999
|
if (mcpUrl) void discoverMcpTools().catch(() => {})
|
|
990
|
-
const
|
|
991
|
-
const text = result?.content?.find?.((c) => c?.type === 'text')?.text
|
|
992
|
-
if (typeof text !== 'string') return result?.structuredContent ?? result ?? {}
|
|
993
|
-
try { return JSON.parse(text) } catch { return { text } }
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
const taskTypeSets = createTaskTypeLookup({ load: (projectId) => loadProjectTaskTypes({ projectId, listTools: () => mcpClient.listTools(), callTool: (name, args) => callMcpTool(name, args).then(toolData) }) })
|
|
1000
|
+
const taskTypeSets = createTaskTypeLookup({ load: (projectId) => loadProjectTaskTypes({ projectId, listTools: () => mcpClient.listTools(), callTool: (name, args) => callMcpTool(name, args).then((result) => toolData(result, name)) }) })
|
|
997
1001
|
|
|
998
1002
|
const callMcpReadWithRetry = async (name, args = {}) => {
|
|
999
1003
|
try { return await callMcpTool(name, args) }
|
|
@@ -1004,21 +1008,21 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1004
1008
|
}
|
|
1005
1009
|
|
|
1006
1010
|
const loadPendingTickets = async () => {
|
|
1007
|
-
const projectsData = toolData(await callMcpReadWithRetry('list_projects'))
|
|
1011
|
+
const projectsData = toolData(await callMcpReadWithRetry('list_projects'), 'list_projects')
|
|
1008
1012
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
1009
1013
|
const groups = await mapConcurrent(projects.filter((project) => project?.id != null), 3, async (project) => {
|
|
1010
1014
|
const [tasksResult, typesResult] = await Promise.all([
|
|
1011
1015
|
callMcpReadWithRetry('list_tasks', { project_id: project.id }),
|
|
1012
1016
|
taskTypeSets(project.id),
|
|
1013
1017
|
])
|
|
1014
|
-
const tasksData = toolData(tasksResult)
|
|
1018
|
+
const tasksData = toolData(tasksResult, 'list_tasks')
|
|
1015
1019
|
const { done: doneIds, review: reviewIds } = typesResult
|
|
1016
1020
|
const tasks = await mapConcurrent(Array.isArray(tasksData.tasks) ? tasksData.tasks : [], 3, async (task) => {
|
|
1017
1021
|
await resolveTaskOwner(task)
|
|
1018
1022
|
if (ticketDisplaySlug(task) || task?.id == null || !taskBelongsToAgent(task, { id: selfAgentId, identifier })) return task
|
|
1019
1023
|
// Some list responses omit the board slug. Resolve details by the real
|
|
1020
1024
|
// database identity; never interpret the number in OPEN-105 as an id.
|
|
1021
|
-
const data = toolData(await callMcpReadWithRetry('get_ticket', { project_id: project.id, ticket_id: task.id }))
|
|
1025
|
+
const data = toolData(await callMcpReadWithRetry('get_ticket', { project_id: project.id, ticket_id: task.id }), 'get_ticket')
|
|
1022
1026
|
const detail = data.ticket ?? data.task ?? data
|
|
1023
1027
|
if (String(detail.id) !== String(task.id) || (detail.project_id != null && String(detail.project_id) !== String(project.id))) throw new Error('Ticket detail identity did not match its assignment.')
|
|
1024
1028
|
return detail
|
|
@@ -1236,7 +1240,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1236
1240
|
dedicatedChannelRead = (async () => {
|
|
1237
1241
|
try {
|
|
1238
1242
|
await ensureSelfAgent()
|
|
1239
|
-
const data = toolData(await callMcpTool('list_channels'))
|
|
1243
|
+
const data = toolData(await callMcpTool('list_channels'), 'list_channels')
|
|
1240
1244
|
const channels = data.channels ?? data.data?.channels ?? data.data ?? data.items ?? []
|
|
1241
1245
|
const channel = dedicatedChannel(Array.isArray(channels) ? channels : [], { id: selfAgentId, identifier, aliases: [...selfAliases], channelId: readConfig(slug)?.dedicatedChannelId })
|
|
1242
1246
|
const channelId = channel ? Number(channel.id) : null
|
|
@@ -1252,7 +1256,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1252
1256
|
}
|
|
1253
1257
|
|
|
1254
1258
|
const announceIntroduction = async () => {
|
|
1255
|
-
const projectsData = toolData(await callMcpTool('list_projects'))
|
|
1259
|
+
const projectsData = toolData(await callMcpTool('list_projects'), 'list_projects')
|
|
1256
1260
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
1257
1261
|
const project = projects.find((item) => Number.isFinite(Number(item.id)))
|
|
1258
1262
|
if (!project) { log('no project available for first-connection introduction'); return false }
|
|
@@ -1421,7 +1425,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1421
1425
|
if (!mcpUrl || backlogProbeBusy) return
|
|
1422
1426
|
backlogProbeBusy = true
|
|
1423
1427
|
try {
|
|
1424
|
-
const projectsData = toolData(await callMcpTool('list_projects'))
|
|
1428
|
+
const projectsData = toolData(await callMcpTool('list_projects'), 'list_projects')
|
|
1425
1429
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
1426
1430
|
const mentionActivity = []
|
|
1427
1431
|
let activityReplayTouched = false
|
|
@@ -1429,7 +1433,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1429
1433
|
try {
|
|
1430
1434
|
const assigned = []
|
|
1431
1435
|
const [tasksData, typesData] = await Promise.all([
|
|
1432
|
-
callMcpTool('list_tasks', { project_id: project.id }).then(toolData),
|
|
1436
|
+
callMcpTool('list_tasks', { project_id: project.id }).then((result) => toolData(result, 'list_tasks')),
|
|
1433
1437
|
taskTypeSets(project.id),
|
|
1434
1438
|
])
|
|
1435
1439
|
const { done: doneIds, review: reviewIds } = typesData
|
|
@@ -1681,6 +1685,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1681
1685
|
cycleControl.outcome = 'running'
|
|
1682
1686
|
log(`cycle timing preflight=${Math.round(performance.now() - cycleStartedAt)}ms kind=${kind}`)
|
|
1683
1687
|
const result = await runner.runCycle(prompt, useModel, runnerOptions)
|
|
1688
|
+
if (result?.model) {
|
|
1689
|
+
emit('models.applied', { requestedModel: useModel, model: result.model, tier: kind === 'full' ? 'coding' : 'reply', modelSettingsRevision: cycleModelRevision })
|
|
1690
|
+
}
|
|
1684
1691
|
await cycleControl.planDelivery
|
|
1685
1692
|
cycleControl.outcome = result?.is_error ? 'error' : result?.subtype || 'error'
|
|
1686
1693
|
if (result?.timings) log(`cycle timing runtime=${JSON.stringify(result.timings)} kind=${kind}`)
|
|
@@ -2192,7 +2199,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2192
2199
|
// supported MCP tools directly, stays silent when empty, and hands verified
|
|
2193
2200
|
// coding tasks to the full lane instead of letting Luna announce it cannot code.
|
|
2194
2201
|
taskProbeStartTimer = setTimeout(() => void reconcileBacklog(), 3_000)
|
|
2195
|
-
|
|
2202
|
+
// WebSocket delivery is primary. A short bounded reconciliation interval
|
|
2203
|
+
// keeps mentions responsive when a deployment omits targeted dispatch.
|
|
2204
|
+
taskProbeTimer = setInterval(() => void reconcileBacklog(), 30_000)
|
|
2196
2205
|
} else {
|
|
2197
2206
|
log('no MCP config — skipping intro + daily sweep (agent has no tools to post/act)')
|
|
2198
2207
|
}
|
|
@@ -2387,8 +2396,13 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
|
|
|
2387
2396
|
// ── background service install (launchd / systemd) ───────────────────────────
|
|
2388
2397
|
/** Stop the auto-restarting service first, then every orphaned watcher whose
|
|
2389
2398
|
* --name resolves to this exact slug. Never use a broad pkill pattern. */
|
|
2390
|
-
export function stopWatchers({ slug, quiet = false }) {
|
|
2399
|
+
export function stopWatchers({ slug, quiet = false, stateDir = OV_DIR, throwOnError = false }) {
|
|
2391
2400
|
const key = slugify(slug || 'openvisio')
|
|
2401
|
+
const abort = (message) => {
|
|
2402
|
+
if (throwOnError) throw new Error(message)
|
|
2403
|
+
fail(message)
|
|
2404
|
+
}
|
|
2405
|
+
if (stateDir !== OV_DIR) abort('Watcher control is supported only in this computer’s agent storage.')
|
|
2392
2406
|
let serviceStopped = false
|
|
2393
2407
|
|
|
2394
2408
|
if (process.platform === 'darwin') {
|
|
@@ -2426,35 +2440,41 @@ export function stopWatchers({ slug, quiet = false }) {
|
|
|
2426
2440
|
if (stubborn.length) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 150)
|
|
2427
2441
|
|
|
2428
2442
|
const remaining = watcherPids()
|
|
2429
|
-
if (remaining.length)
|
|
2430
|
-
try { unlinkSync(join(
|
|
2443
|
+
if (remaining.length) abort(`Could not stop watcher${remaining.length === 1 ? '' : 's'} for "${key}": ${remaining.join(', ')}`)
|
|
2444
|
+
try { unlinkSync(join(stateDir, `watch-${key}.lock`)) } catch { /* absent */ }
|
|
2431
2445
|
if (!quiet) ok(`Stopped ${found.length} watcher process${found.length === 1 ? '' : 'es'} for "${key}"${serviceStopped ? ' and disabled its background service' : ''}.`)
|
|
2432
2446
|
return { stopped: found.length, serviceStopped }
|
|
2433
2447
|
}
|
|
2434
2448
|
|
|
2435
|
-
export function installService({ slug, workdir }) {
|
|
2449
|
+
export function installService({ slug, workdir, stateDir = OV_DIR, serviceCommand, throwOnError = false }) {
|
|
2450
|
+
const abort = (message) => {
|
|
2451
|
+
if (throwOnError) throw new Error(message)
|
|
2452
|
+
fail(message)
|
|
2453
|
+
}
|
|
2454
|
+
if (stateDir !== OV_DIR) abort('Watcher control is supported only in this computer’s agent storage.')
|
|
2436
2455
|
// Replacing a service must also remove manually-started watchers. Otherwise the
|
|
2437
2456
|
// new KeepAlive process repeatedly spawns, sees their lock, exits, and respawns.
|
|
2438
|
-
stopWatchers({ slug, quiet: true })
|
|
2439
|
-
const
|
|
2440
|
-
|
|
2457
|
+
stopWatchers({ slug, quiet: true, stateDir, throwOnError })
|
|
2458
|
+
const suppliedCommand = Array.isArray(serviceCommand) && serviceCommand.length ? serviceCommand.map(String) : null
|
|
2459
|
+
const binPath = suppliedCommand ? suppliedCommand[0] : onPath('openvisio-agent')
|
|
2460
|
+
if (!suppliedCommand && (!binPath || binPath.includes('/_npx/'))) {
|
|
2441
2461
|
info('Installing openvisio-agent globally so the background service has a stable path…')
|
|
2442
2462
|
spawnSync('npm', ['i', '-g', 'openvisio-agent'], { stdio: 'inherit', shell: process.platform === 'win32' })
|
|
2443
2463
|
}
|
|
2444
|
-
const
|
|
2464
|
+
const command = suppliedCommand || [onPath('openvisio-agent') || 'openvisio-agent']
|
|
2445
2465
|
const nodeDir = dirname(process.execPath)
|
|
2446
2466
|
const claudeBin = onPath('claude')
|
|
2447
2467
|
const opencodeBin = onPath('opencode')
|
|
2448
2468
|
const codexBin = onPath('codex')
|
|
2449
2469
|
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
2470
|
const args = ['watch', '--name', slug, ...(workdir ? ['--workdir', workdir] : [])]
|
|
2451
|
-
mkdirSync(
|
|
2452
|
-
const logFile = join(
|
|
2471
|
+
mkdirSync(stateDir, { recursive: true })
|
|
2472
|
+
const logFile = join(stateDir, `${slug}.log`)
|
|
2453
2473
|
|
|
2454
2474
|
if (process.platform === 'darwin') {
|
|
2455
2475
|
const label = `io.openvisio.${slug}`
|
|
2456
2476
|
const plist = join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`)
|
|
2457
|
-
const progArgs = [
|
|
2477
|
+
const progArgs = [...command, ...args].map((a) => ` <string>${a}</string>`).join('\n')
|
|
2458
2478
|
writeFileSync(plist, `<?xml version="1.0" encoding="UTF-8"?>
|
|
2459
2479
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2460
2480
|
<plist version="1.0"><dict>
|
|
@@ -2471,15 +2491,16 @@ ${progArgs}
|
|
|
2471
2491
|
`)
|
|
2472
2492
|
spawnSync('launchctl', ['unload', plist], { stdio: 'ignore' })
|
|
2473
2493
|
const r = spawnSync('launchctl', ['load', plist], { stdio: 'inherit' })
|
|
2474
|
-
if (r.status !== 0)
|
|
2494
|
+
if (r.status !== 0) abort('launchctl load failed — check the plist at ' + plist)
|
|
2475
2495
|
ok(`Live in the background, auto-starts at login. Logs: tail -f ${logFile}`)
|
|
2496
|
+
return { running: true, service: 'launchd', slug }
|
|
2476
2497
|
} else if (process.platform === 'win32') {
|
|
2477
|
-
|
|
2498
|
+
abort('Background install isn\'t supported on Windows yet. Run it in the foreground:\n openvisio-agent watch --name ' + slug)
|
|
2478
2499
|
} else {
|
|
2479
2500
|
const dir = join(homedir(), '.config', 'systemd', 'user')
|
|
2480
2501
|
mkdirSync(dir, { recursive: true })
|
|
2481
2502
|
const unit = join(dir, `openvisio-${slug}.service`)
|
|
2482
|
-
const execStart = [
|
|
2503
|
+
const execStart = [...command, ...args].map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(' ')
|
|
2483
2504
|
writeFileSync(unit, `[Unit]
|
|
2484
2505
|
Description=OpenVisio agent watcher (${slug})
|
|
2485
2506
|
After=network-online.target
|
|
@@ -2496,7 +2517,8 @@ WantedBy=default.target
|
|
|
2496
2517
|
spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' })
|
|
2497
2518
|
const r = spawnSync('systemctl', ['--user', 'enable', '--now', `openvisio-${slug}.service`], { stdio: 'inherit' })
|
|
2498
2519
|
spawnSync('loginctl', ['enable-linger', process.env.USER || ''], { stdio: 'ignore' })
|
|
2499
|
-
if (r.status !== 0)
|
|
2520
|
+
if (r.status !== 0) abort('systemctl enable failed — is this a systemd user session?')
|
|
2500
2521
|
ok(`Live in the background, auto-starts at login. Logs: journalctl --user -u openvisio-${slug} -f`)
|
|
2522
|
+
return { running: true, service: 'systemd', slug }
|
|
2501
2523
|
}
|
|
2502
2524
|
}
|
|
@@ -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,12 +392,21 @@ 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;
|
|
394
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.' : '';
|
|
395
404
|
const settings = agent.modelSettings || {};
|
|
396
405
|
const pending = settings.revision && agent.appliedModelRevision !== settings.revision;
|
|
406
|
+
const selected = agent.appliedModels?.selected;
|
|
397
407
|
$('model-settings-state').textContent = !agent.settingsEditable ? 'Model controls are available for agents configured on this computer.'
|
|
398
|
-
: pending ? agent.
|
|
399
|
-
: `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}` : ''}`;
|
|
400
410
|
if (agent.removable == null) $('model-settings-state').textContent += ' Restart Agent Studio with the updated package to enable agent removal.';
|
|
401
411
|
if (launchSettings && agent.settingsEditable) { launchSettings = false; void openModelSettings(agent); }
|
|
402
412
|
}
|
|
@@ -879,6 +889,26 @@ function initializeStudio() {
|
|
|
879
889
|
if (request === settingsRequest) { $('model-settings-error').hidden = false; $('model-settings-error').textContent = error.message; }
|
|
880
890
|
} finally { $('save-model-settings').disabled = false; $('save-model-settings').textContent = 'Save models'; }
|
|
881
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'));
|
|
882
912
|
$('pause-button').addEventListener('click', () => {
|
|
883
913
|
state.paused = !state.paused;
|
|
884
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">
|