openvisio-agent 0.19.8 → 0.19.9

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/README.md CHANGED
@@ -58,7 +58,7 @@ Runs the **autonomy loop** — the agent replies to @mentions and picks up ticke
58
58
 
59
59
  Codex and OpenCode cycles run through Mastra's ACP harness. Codex uses the packaged `codex-acp` adapter and reuses the machine's existing ChatGPT/Codex login; OpenCode uses its native `opencode acp` server. Each accepted ticket gets its own ACP session and worktree. Mastra Memory stores ticket/thread context in local libSQL under `~/.openvisio/`, while a compact JSON ledger retains only exact replay, cancellation, and delivery keys.
60
60
 
61
- Model names are negotiated at session start. Codex and OpenCode match the configured name against the models advertised by ACP, preferring the same family and reasoning effort (`medium` for an unsuffixed family); provider-qualified and nearby generation names are resolved automatically. Claude versioned names are normalized to its stable `sonnet`, `opus`, `haiku`, or `fable` aliases and use the CLI's native ordered fallback chain.
61
+ 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.
62
62
 
63
63
  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 five minutes as a safety net; a model starts only when pending work exists.
64
64
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.19.8",
3
+ "version": "0.19.9",
4
4
  "description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team — MCP tools + optional autonomy — in one command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -77,7 +77,7 @@ const assertions = [
77
77
  ['MCP requests have a deadline including response bodies', mcpHttp.includes('controller.abort()') && mcpHttp.includes('const body = await res.text()')],
78
78
  ['Mastra memory uses real ticket and thread identities', watcher.includes('createMastraMemory') && watcher.includes('await memory.context(memoryRefs)') && memory.includes('new Memory({ storage') && memory.includes('new LibSQLStore') && memory.includes('ticket:${refs.projectId}:${refs.ticketId}') && memory.includes('channel:${refs.channelId}:thread:')],
79
79
  ['Codex and OpenCode use warm scoped Mastra ACP sessions', watcher.includes('createMastraAcpRunner') && watcher.includes('const replyRunners = new Map()') && watcher.includes('replyRunnerFor(item.delivery)') && mastraHarness.includes('new AcpAgentClass') && mastraHarness.includes('persistSession: true') && mastraHarness.includes("runtime: 'mastra-acp'")],
80
- ['runtime models are resolved before work starts', mastraHarness.includes('getAvailableModels()') && mastraHarness.includes('resolveAvailableModel(requestedModel') && mastraHarness.includes('await acp.setModel(selectedModel)') && modelSelection.includes("const DEFAULT_EFFORT = 'medium'") && watcher.includes('resolveClaudeModel') && watcher.includes("'--fallback-model'" )],
80
+ ['runtime models are selected explicitly before work starts', mastraHarness.includes('getAvailableModels()') && mastraHarness.includes('resolveAvailableModel(requestedModel') && mastraHarness.includes('await acp.setModel(resolution.selected)') && modelSelection.includes('automatic model substitution is disabled') && watcher.includes('if (changed) persistModel()')],
81
81
  ['one watcher owns isolated concurrent work and thread-scoped serial reply runtimes', watcher.includes('MAX_CONCURRENT_WORKERS = 3') && watcher.includes('createWorkRunner(item.control, item.workdir)') && watcher.includes('const replyRunners = new Map()') && watcher.includes('while (replyRunners.size > 8)')],
82
82
  ['workers serialize shared worktrees while independent worktrees run concurrently', watcher.includes('groupKey: (item) => item.workdir || workdir') && cycleQueue.includes('active.size < concurrency') && cycleQueue.includes('activeGroups.has(entry.group)')],
83
83
  ['work and reply cancellation targets are isolated', watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent')],
@@ -98,7 +98,7 @@ export function createMastraAcpRunner({
98
98
  const headers = Object.entries(mcpHeaders).filter(([, value]) => value != null && value !== '').map(([name, value]) => ({ name, value: String(value) }))
99
99
  const disabledMcpTools = Array.isArray(cycleOptions.disabledMcpTools) ? cycleOptions.disabledMcpTools.filter(Boolean) : []
100
100
  const mcpServers = !mcpUrl ? [] : agent === 'codex' ? [{
101
- name: 'openvisio-team', command: process.execPath, args: [proxyPath], env: [
101
+ name: 'openvisio-team-watcher', command: process.execPath, args: [proxyPath], env: [
102
102
  { name: 'OPENVISIO_CODEX_MCP_URL', value: mcpUrl },
103
103
  { name: 'OPENVISIO_CODEX_API_KEY', value: String(mcpHeaders['x-agent-api-key'] || '') },
104
104
  { name: 'OPENVISIO_CODEX_IDENTIFIER', value: String(mcpHeaders['x-agent-identifier'] || '') },
@@ -119,6 +119,10 @@ export function createMastraAcpRunner({
119
119
  env: agent === 'codex' ? {
120
120
  INITIAL_AGENT_MODE: canCode ? 'agent' : 'read-only',
121
121
  NO_BROWSER: '1',
122
+ // codex-acp otherwise keeps a pre-existing openvisio-team server and
123
+ // silently discards this session's authenticated stdio bridge.
124
+ DISABLE_MCP_CONFIG_FILTERING: 'true',
125
+ CODEX_CONFIG: JSON.stringify({ 'mcp_servers.openvisio-team.enabled': false }),
122
126
  } : {},
123
127
  session: {
124
128
  cwd: cycleCwd,
@@ -161,9 +165,9 @@ export function createMastraAcpRunner({
161
165
  if (requestedModel !== negotiatedRequested) {
162
166
  const availableModels = await acp.getAvailableModels()
163
167
  const resolution = resolveAvailableModel(requestedModel, availableModels)
164
- selectedModel = resolution.selected
165
- if (selectedModel) {
166
- await acp.setModel(selectedModel)
168
+ if (resolution.selected) {
169
+ await acp.setModel(resolution.selected)
170
+ selectedModel = resolution.selected
167
171
  if (selectedModel !== requestedModel) log(`model ${requestedModel} resolved to available ${selectedModel} [${resolution.reason}]`)
168
172
  } else {
169
173
  log(`model ${requestedModel} could not be negotiated because ${agent} did not advertise selectable models; using its session default`)
@@ -209,7 +213,9 @@ export function createMastraAcpRunner({
209
213
  const after = repositorySnapshot(canCode ? cycleCwd : '')
210
214
  if (before !== after) { didCode = true; didRepoMutation = true }
211
215
  didResultMessage = didChannelMessage && didRepoMutation
212
- log(`${agent} cycle done via Mastra ACP (ok)`)
216
+ const redact = (value) => Object.values(mcpHeaders).filter(Boolean).reduce((safe, secret) => safe.split(String(secret)).join('[redacted]'), String(value))
217
+ for (const [name, detail] of errors) log(`MCP ${name} failed: ${redact(detail)}`)
218
+ log(`${agent} cycle done via Mastra ACP (${errors.size ? 'tool-errors' : 'ok'})`)
213
219
  return {
214
220
  type: 'result', subtype: 'ok', runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
215
221
  mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
@@ -219,7 +225,9 @@ export function createMastraAcpRunner({
219
225
  } catch (error) {
220
226
  const canceled = controller.signal.aborted
221
227
  const subtype = timedOut ? 'timeout' : canceled ? 'canceled' : 'error'
222
- log(`${agent} cycle done via Mastra ACP (${subtype}${!canceled && error?.message ? ': ' + clean(error.message) : ''})`)
228
+ const errorDetail = [error?.message, error?.data ? json(error.data) : ''].filter(Boolean).join(': ')
229
+ const safeError = Object.values(mcpHeaders).filter(Boolean).reduce((safe, secret) => safe.split(String(secret)).join('[redacted]'), errorDetail)
230
+ log(`${agent} cycle done via Mastra ACP (${subtype}${!canceled && safeError ? ': ' + clean(safeError, 1200) : ''})`)
223
231
  invalidate()
224
232
  return {
225
233
  type: 'result', subtype, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
@@ -40,10 +40,18 @@ function similarity(requested, candidate, preferredEffort) {
40
40
  export function resolveAvailableModel(requestedValue, availableModels = [], { preferredEffort = DEFAULT_EFFORT } = {}) {
41
41
  const requested = parts(requestedValue)
42
42
  const choices = availableModels.map((entry) => typeof entry === 'string' ? entry : entry?.modelId).map(text).filter(Boolean)
43
- if (!choices.length) return { requested: requested.raw, selected: '', reason: 'runtime-default', available: [] }
43
+ if (!choices.length && requested.raw) return { requested: requested.raw, selected: requested.raw, reason: 'explicit-unadvertised', available: [] }
44
+ if (!choices.length) return { requested: '', selected: '', reason: 'runtime-default', available: [] }
44
45
  if (!requested.raw) return { requested: '', selected: '', reason: 'runtime-default', available: choices }
45
46
 
46
- const ranked = choices.map((id, index) => {
47
+ const compatible = choices.filter((id) => {
48
+ const candidate = parts(id)
49
+ return candidate.id === requested.id || (!requested.effort &&
50
+ candidate.leaf === requested.leaf && (!requested.provider || candidate.provider === requested.provider) &&
51
+ (!candidate.effort || candidate.effort === preferredEffort))
52
+ })
53
+ if (!compatible.length) throw new Error(`Configured model "${requested.raw}" is unavailable; automatic model substitution is disabled.`)
54
+ const ranked = compatible.map((id, index) => {
47
55
  const candidate = parts(id)
48
56
  return { id, index, score: similarity(requested, candidate, preferredEffort) }
49
57
  }).sort((a, b) => b.score - a.score || a.index - b.index)
@@ -59,17 +67,9 @@ export function resolveAvailableModel(requestedValue, availableModels = [], { pr
59
67
  const CLAUDE_FAMILIES = ['sonnet', 'opus', 'haiku', 'fable']
60
68
 
61
69
  export function resolveClaudeModel(requestedValue) {
62
- const requested = lower(requestedValue)
63
- if (!requested) return ''
64
- const family = CLAUDE_FAMILIES.find((name) => requested === name || requested.startsWith('claude-') && requested.includes(name))
65
- return family || text(requestedValue)
70
+ return text(requestedValue)
66
71
  }
67
72
 
68
73
  export function claudeFallbackModels(selectedValue) {
69
- const selected = lower(selectedValue)
70
- const preferred = selected === 'opus' ? ['sonnet', 'haiku']
71
- : selected === 'haiku' ? ['sonnet', 'opus']
72
- : selected === 'fable' ? ['sonnet', 'opus'] : ['opus', 'haiku']
73
- return preferred.filter((model) => model !== selected)
74
+ return []
74
75
  }
75
-
package/src/watch.mjs CHANGED
@@ -261,10 +261,9 @@ export async function runWatch({ flags }) {
261
261
  const workdir = chatOnly ? '' : (explicitWorkdir || (saved && (saved.workspace || saved.workdir)) || DEFAULT_WORKSPACE)
262
262
  if (workdir) { try { mkdirSync(workdir, { recursive: true }) } catch { /* best-effort; spawn will surface a real problem */ } }
263
263
 
264
- // Codex uses Sol for every watcher cycle. Reliability and consistent MCP/tool
265
- // behaviour take priority over maintaining a cheaper coordination lane.
266
- const defaultModel = agent === 'claude' ? 'sonnet' : agent === 'codex' ? 'gpt-5.6-sol' : ''
267
- const defaultChatModel = agent === 'claude' ? 'haiku' : agent === 'codex' ? 'gpt-5.6-sol' : ''
264
+ // Use a specific Codex ID; configured chat and code tiers remain independent.
265
+ const defaultModel = agent === 'claude' ? 'sonnet' : agent === 'codex' ? 'gpt-5.6-sol[medium]' : ''
266
+ const defaultChatModel = agent === 'claude' ? 'haiku' : agent === 'codex' ? 'gpt-5.6-sol[medium]' : ''
268
267
  const model = String(flags.model || (saved && saved.model) || defaultModel)
269
268
  const chatModel = String(flags['chat-model'] || (saved && saved.chatModel) || defaultChatModel)
270
269
 
@@ -1562,7 +1561,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1562
1561
  const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind, delivery)
1563
1562
  // Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
1564
1563
  // work (full/sweep) uses the main model.
1565
- const useModel = agent === 'codex' ? codeModel : kind === 'full' ? codeModel : liteModel
1564
+ const useModel = kind === 'full' ? codeModel : liteModel
1566
1565
  log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : '') + (useModel ? ' [' + useModel + ']' : ''))
1567
1566
  // Presence notifications are intentionally disabled; durable messages and
1568
1567
  // ticket transitions are the only user-visible progress signals.
@@ -1584,6 +1583,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1584
1583
  }
1585
1584
  if (cycleControl.cancelled) return
1586
1585
  const result = await runner.runCycle(prompt, useModel, runnerOptions)
1586
+ if (result?.model && result.model !== useModel) {
1587
+ // Persist only a successfully selected model and only if the user has
1588
+ // not changed that tier while this cycle was running.
1589
+ let changed = false
1590
+ if (codeModel === useModel) { codeModel = result.model; changed = true }
1591
+ if (liteModel === useModel) { liteModel = result.model; changed = true }
1592
+ if (changed) persistModel()
1593
+ }
1587
1594
  let completionResult = result
1588
1595
  if (cycleControl.cancelled || result?.subtype === 'canceled') {
1589
1596
  log(laneName + ' cycle cancelled; no blocker or reply will be published')
@@ -1686,9 +1693,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1686
1693
  const x = String(s || '').toLowerCase().replace(/[.,!?]+$/, '')
1687
1694
  if (x === 'opus' || x === 'sonnet' || x === 'haiku') return x
1688
1695
  if (/^claude-[a-z0-9.\-\[\]]+$/i.test(x)) return x
1689
- if (/^(?:gpt|codex|o[1-9])[a-z0-9.\-:]*$/i.test(x)) return x
1696
+ if (/^(?:gpt|codex|o[1-9])[a-z0-9.\-:]*(?:\[(?:low|medium|high|xhigh|max|ultra)\])?$/i.test(x)) return x
1690
1697
  // opencode models are provider/model (e.g. anthropic/claude-sonnet-4, openai/gpt-4o).
1691
- return /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9.\-:]*$/i.test(x) ? x : null
1698
+ return /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9.\-:]*(?:\[(?:low|medium|high|xhigh|max|ultra)\])?$/i.test(x) ? x : null
1692
1699
  }
1693
1700
  // Optional target tier: "chat"/"lite" → chat cycles only, "code"/"full" → code
1694
1701
  // cycles only, absent → both.