openvisio-agent 0.25.2 → 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 CHANGED
@@ -1,5 +1,10 @@
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
+
3
8
  ## [0.25.2] — 2026-09-21
4
9
 
5
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.
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 five minutes as a safety net; a model starts only when pending work exists.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.25.2",
3
+ "version": "0.25.3",
4
4
  "description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team \u2014 MCP tools + optional autonomy \u2014 in one command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.
@@ -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
@@ -900,7 +901,11 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
900
901
  // MCP server returns list_agents as human-readable markdown without identifiers.
901
902
  const selfAliases = new Set([slug, name, identifier].map((value) => String(value || '').trim().toLowerCase()).filter(Boolean))
902
903
  const rememberSelfAgent = (self) => {
903
- if (!self || typeof self !== 'object' || String(self.identifier || self.slug || '') !== identifier) return
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
904
909
  if (validBackendId(self.id)) selfAgentId = Number(self.id)
905
910
  for (const alias of [self.name, self.identifier, self.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
906
911
  identityContext = agentProfileContext(self, identifier)
@@ -933,8 +938,11 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
933
938
  const ensureSelfAgent = async () => {
934
939
  if (selfAgentId != null) return
935
940
  if (!selfRead && Date.now() < selfReadAfter) return
936
- if (!selfRead) selfRead = callMcpTool('list_agents').then(toolData).then((data) => {
937
- const self = (Array.isArray(data.agents) ? data.agents : []).find((row) => String(row?.identifier || row?.slug || '') === identifier)
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)
938
946
  rememberSelfAgent(self)
939
947
  }).catch((error) => {
940
948
  log('Agent profile unavailable; continuing with task ownership and configured aliases: ' + (error?.message || error))
@@ -989,13 +997,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
989
997
  }
990
998
  }
991
999
  if (mcpUrl) void discoverMcpTools().catch(() => {})
992
- const toolData = (result) => {
993
- const text = result?.content?.find?.((c) => c?.type === 'text')?.text
994
- if (typeof text !== 'string') return result?.structuredContent ?? result ?? {}
995
- try { return JSON.parse(text) } catch { return { text } }
996
- }
997
-
998
- 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)) }) })
999
1001
 
1000
1002
  const callMcpReadWithRetry = async (name, args = {}) => {
1001
1003
  try { return await callMcpTool(name, args) }
@@ -1006,21 +1008,21 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1006
1008
  }
1007
1009
 
1008
1010
  const loadPendingTickets = async () => {
1009
- const projectsData = toolData(await callMcpReadWithRetry('list_projects'))
1011
+ const projectsData = toolData(await callMcpReadWithRetry('list_projects'), 'list_projects')
1010
1012
  const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
1011
1013
  const groups = await mapConcurrent(projects.filter((project) => project?.id != null), 3, async (project) => {
1012
1014
  const [tasksResult, typesResult] = await Promise.all([
1013
1015
  callMcpReadWithRetry('list_tasks', { project_id: project.id }),
1014
1016
  taskTypeSets(project.id),
1015
1017
  ])
1016
- const tasksData = toolData(tasksResult)
1018
+ const tasksData = toolData(tasksResult, 'list_tasks')
1017
1019
  const { done: doneIds, review: reviewIds } = typesResult
1018
1020
  const tasks = await mapConcurrent(Array.isArray(tasksData.tasks) ? tasksData.tasks : [], 3, async (task) => {
1019
1021
  await resolveTaskOwner(task)
1020
1022
  if (ticketDisplaySlug(task) || task?.id == null || !taskBelongsToAgent(task, { id: selfAgentId, identifier })) return task
1021
1023
  // Some list responses omit the board slug. Resolve details by the real
1022
1024
  // database identity; never interpret the number in OPEN-105 as an id.
1023
- 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')
1024
1026
  const detail = data.ticket ?? data.task ?? data
1025
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.')
1026
1028
  return detail
@@ -1238,7 +1240,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1238
1240
  dedicatedChannelRead = (async () => {
1239
1241
  try {
1240
1242
  await ensureSelfAgent()
1241
- const data = toolData(await callMcpTool('list_channels'))
1243
+ const data = toolData(await callMcpTool('list_channels'), 'list_channels')
1242
1244
  const channels = data.channels ?? data.data?.channels ?? data.data ?? data.items ?? []
1243
1245
  const channel = dedicatedChannel(Array.isArray(channels) ? channels : [], { id: selfAgentId, identifier, aliases: [...selfAliases], channelId: readConfig(slug)?.dedicatedChannelId })
1244
1246
  const channelId = channel ? Number(channel.id) : null
@@ -1254,7 +1256,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1254
1256
  }
1255
1257
 
1256
1258
  const announceIntroduction = async () => {
1257
- const projectsData = toolData(await callMcpTool('list_projects'))
1259
+ const projectsData = toolData(await callMcpTool('list_projects'), 'list_projects')
1258
1260
  const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
1259
1261
  const project = projects.find((item) => Number.isFinite(Number(item.id)))
1260
1262
  if (!project) { log('no project available for first-connection introduction'); return false }
@@ -1423,7 +1425,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1423
1425
  if (!mcpUrl || backlogProbeBusy) return
1424
1426
  backlogProbeBusy = true
1425
1427
  try {
1426
- const projectsData = toolData(await callMcpTool('list_projects'))
1428
+ const projectsData = toolData(await callMcpTool('list_projects'), 'list_projects')
1427
1429
  const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
1428
1430
  const mentionActivity = []
1429
1431
  let activityReplayTouched = false
@@ -1431,7 +1433,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1431
1433
  try {
1432
1434
  const assigned = []
1433
1435
  const [tasksData, typesData] = await Promise.all([
1434
- callMcpTool('list_tasks', { project_id: project.id }).then(toolData),
1436
+ callMcpTool('list_tasks', { project_id: project.id }).then((result) => toolData(result, 'list_tasks')),
1435
1437
  taskTypeSets(project.id),
1436
1438
  ])
1437
1439
  const { done: doneIds, review: reviewIds } = typesData