openvisio-agent 0.25.6 → 0.25.7

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
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## [0.25.7] — 2026-09-21
6
+
7
+ - Restore recovery for addressed messages that the backend omits from the watcher WebSocket stream.
8
+ - Cache projects during connection recovery, then check only `list_activity` every 30 seconds while idle. The lightweight check does not rescan projects, tasks, boards, or task types and starts no model provider unless it finds a new addressed message.
9
+ - Add a behavioral regression test proving a missed mention is handled once without idle project or task scans.
10
+
5
11
  ## [0.25.6] — 2026-09-21
6
12
 
7
13
  - Keep healthy backend watchers event-driven after their connection recovery pass instead of polling every project for tasks and activity every 30 seconds.
package/README.md CHANGED
@@ -81,7 +81,7 @@ The integration follows Mastra's [semantic memory](https://mastra.ai/docs/memory
81
81
 
82
82
  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.
83
83
 
84
- Backend/BYO watchers reconcile once whenever the WebSocket starts or reconnects. 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. After the recovery pass, the healthy event connection waits without polling MCP tools and starts a model only when work arrives.
84
+ Backend/BYO watchers reconcile assignments whenever the WebSocket starts or reconnects. 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 work missed while offline. Because the backend can omit an addressed message from the watcher stream, the watcher retains the recovered project IDs and checks only recent `list_activity` every 30 seconds; it does not repeat project, board, task-type, or task scans while idle. 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 activity check starts a model only when it finds a new addressed message.
85
85
 
86
86
  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.
87
87
 
@@ -114,7 +114,7 @@ Authorization recovery checks paused repositories locally every two seconds and
114
114
 
115
115
  Fresh top-level messages belong in the agent’s dedicated channel; responses to existing requests stay in their source thread. The watcher resolves a unique exact identity/name match (including `agent-alex` and `alex-agent`) and never falls back to general. Use `connect ... --channel <id>` for a differently named dedicated channel. If it is missing or ambiguous, ticket results remain recorded and channel delivery stays pending; the watcher does not invent a channel-creation tool.
116
116
 
117
- Codex, OpenCode, Gemini CLI, and Qwen Code use Mastra ACP directly. OpenCode starts only for an accepted cycle and exits as soon as that cycle releases it, so the idle watcher does not retain an OpenCode child process. Other runtimes retain work sessions per ticket in a six-entry pool and reply sessions in an eight-entry pool; their idle sessions expire after five minutes, and active sessions cannot be evicted. Git snapshots are asynchronous, lightweight commands lazily load the watcher, and logs separate queue wait, preflight, first-event, and total runtime time. Studio reads watcher liveness from the local PID lock instead of requiring journal heartbeats, and model changes arrive through filesystem notifications. Projects reconcile with concurrency three and enqueue tickets before reading optional activity history. Coordination sessions can update the board without gaining repository access.
117
+ Codex, OpenCode, Gemini CLI, and Qwen Code use Mastra ACP directly. OpenCode starts only for an accepted cycle and exits as soon as that cycle releases it, so the idle watcher does not retain an OpenCode child process. Other runtimes retain work sessions per ticket in a six-entry pool and reply sessions in an eight-entry pool; their idle sessions expire after five minutes, and active sessions cannot be evicted. Git snapshots are asynchronous, lightweight commands lazily load the watcher, and logs separate queue wait, preflight, first-event, and total runtime time. Studio reads watcher liveness from the local PID lock instead of requiring journal heartbeats, and model changes arrive through filesystem notifications. Projects reconcile with concurrency three and enqueue tickets before reading optional activity history; later idle recovery reads activity only. Coordination sessions can update the board without gaining repository access.
118
118
 
119
119
  Assignments use independent FIFO entries with stable-key duplicate suppression and bounded, worktree-aware concurrency. Backlog recovery uses the same ticket verification path as WebSocket events, and each queued ticket is checked again before a model starts. MCP calls have a 20-second deadline including response bodies; tool discovery is cached and paginated. Codex MCP failures retain a bounded, credential-redacted reason in the watcher log. Cancellation targets only its ACP session. Credentials and replay state use atomic file replacement; Mastra persists contextual memory in libSQL.
120
120
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.25.6",
3
+ "version": "0.25.7",
4
4
  "description": "Connect Claude Code, Codex, OpenCode, Gemini CLI, or Qwen Code to an OpenVisio team \u2014 MCP tools + optional autonomy \u2014 in one command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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', name, 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, listActivity, 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' }
@@ -36,7 +36,7 @@ export function fixture({ agent = 'codex', name, task = true, run, threadError =
36
36
  if (name === 'update_ticket') { Object.assign(ticket, args); return { ticket: { ...ticket } } }
37
37
  if (name === 'list_channels' && listChannels) return { channels: await listChannels() }
38
38
  if (name === 'list_channels') return { channels: [{ id: 2, name: 'alex', agent_id: 7 }, { id: 9, name: 'general' }] }
39
- if (name === 'list_activity') return { activities: [] }
39
+ if (name === 'list_activity') return listActivity ? await listActivity(args) : { activities: [] }
40
40
  if (name === 'list_message_thread') {
41
41
  if (typeof threadError === 'function') await threadError()
42
42
  else if (threadError) throw new Error('Thread service unavailable')
@@ -52,9 +52,9 @@ const assertions = [
52
52
  ['new follow-ups only deduplicate later replies', watcher.includes('repliesAfterSource(renderedAgentMessages') && watcher.includes('sourceMessageId: mid')],
53
53
  ['Mastra snapshots do not block the event loop', mastraHarness.includes('await execFileAsync') && !mastraHarness.includes('spawnSync')],
54
54
  ['OpenCode exits after work while the event watcher stays idle', runtimeProviders.includes("id: 'opencode'") && runtimeProviders.includes('idleSessionMs: 0') && watcher.includes('idleMs: runtimeIdleMs')],
55
- ['healthy backend watchers do not poll projects while idle', watcher.includes('onConnect: () =>') && watcher.includes('void reconcileBacklog()') && !watcher.includes('taskProbeTimer') && !watcher.includes('taskProbeStartTimer')],
55
+ ['healthy backend watchers recover missed messages without polling projects or tasks while idle', watcher.includes('onConnect: () =>') && watcher.includes('void reconcileBacklog()') && watcher.includes('activityRecoveryTimer') && watcher.includes('replayRecentActivity(knownProjects)') && !watcher.includes('taskProbeTimer') && !watcher.includes('taskProbeStartTimer')],
56
56
  ['idle watchers use event notifications without journal or settings polling', watcher.includes('watchFs(stateDir') && !watcher.includes('modelSettingsTimer') && !journal.includes("emit('runtime.heartbeat'")],
57
- ['ticket pickup precedes optional activity reads', watcher.indexOf("await handleTaskSignal('task:assigned'", watcher.indexOf('const reconcileBacklog')) < watcher.indexOf("const activityData = await callMcpTool", watcher.indexOf('const reconcileBacklog'))],
57
+ ['ticket pickup precedes optional activity reads', watcher.indexOf("await handleTaskSignal('task:assigned'", watcher.indexOf('const reconcileBacklog')) < watcher.indexOf('await replayRecentActivity(projects)', watcher.indexOf('const reconcileBacklog'))],
58
58
  ['task:assigned is handled', watcher.includes("k === 'task:assigned'")],
59
59
  ['task signals are verified with get_ticket', watcher.includes("callMcpTool('get_ticket'")],
60
60
  ['review and testing handoffs do not restart work', watcher.includes('taskIsAwaitingReview(task, reviewIds)') && watcher.includes('taskIsAwaitingReview(ticket, typeSets.review)')],
package/src/watch.mjs CHANGED
@@ -898,6 +898,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
898
898
  // hoping poll_inbox re-surfaces the same item. Each queue entry retains that
899
899
  // source independently through work, continuation, and delivery.
900
900
  let backlogProbeBusy = false
901
+ let activityProbeBusy = false
902
+ let knownProjects = []
901
903
  let selfAgentId = null
902
904
  let selfOrganizationId = null
903
905
  let identityContext = memory.get?.(`identity:${identifier}`)?.meta?.context || agentProfileContext({}, identifier)
@@ -1424,6 +1426,87 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1424
1426
  return publishBlocker({ prompt, taskRef, delivery, notice, ticketNotice, pause: true })
1425
1427
  }
1426
1428
 
1429
+ const replayRecentActivity = async (projects) => {
1430
+ const mentionActivity = []
1431
+ let activityReplayTouched = false
1432
+ await ensureSelfAgent()
1433
+ const mentionNeedles = [...selfAliases].map((alias) => '@' + alias)
1434
+ await mapConcurrent(projects, 3, async (project) => {
1435
+ const activityData = await callMcpTool('list_activity', { project_id: project.id }).then(toolData).catch((error) => {
1436
+ log('activity reconciliation unavailable: ' + (error?.message || error))
1437
+ return {}
1438
+ })
1439
+ const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
1440
+ for (const item of activities) {
1441
+ const text = JSON.stringify(item)
1442
+ const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
1443
+ const data = item?.data && typeof item.data === 'object' ? item.data : null
1444
+ const activityMessage = item?.message && typeof item.message === 'object'
1445
+ ? item.message
1446
+ : data?.message && typeof data.message === 'object' ? data.message : item
1447
+ const activityText = String(activityMessage?.content ?? activityMessage?.body ?? activityMessage?.text ?? '').toLowerCase()
1448
+ const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
1449
+ const activityMessageId = activityMessage?.id ?? activityMessage?.message_id ?? activityMessage?.messageId
1450
+ const activityParentId = messageParentId(activityMessage)
1451
+ const activityThreadRoot = activityParentId != null ? activityParentId : activityMessageId
1452
+ const activityControlKey = threadControlKey(activityChannelId, activityThreadRoot)
1453
+ const ownedActivity = !!activityControlKey && memory.has(activityControlKey, 'active')
1454
+ // Search only the source message, never the whole activity envelope: an
1455
+ // old tagged thread root can otherwise make another agent's new reply
1456
+ // look addressed to this watcher. Explicit mentions and human follow-ups
1457
+ // in a persistently owned thread both enter the guarded per-message path.
1458
+ const addressedActivity = mentionNeedles.some((needle) => activityText.includes(needle)) || ownedActivity || activityParentId != null
1459
+ if (!seenActivities.has(activityKey) && addressedActivity && /message|mention|channel/i.test(text)) {
1460
+ seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
1461
+ // The same logical mention may already have arrived over WebSocket.
1462
+ // Share the id/signature guard instead of starting a second model turn.
1463
+ // Recover the former pre-routing acknowledgement bug exactly once
1464
+ // when an owned-thread source has no received ledger entry.
1465
+ if (markMentionHandled(activityMessage, activityChannelId)) {
1466
+ const activityMentionKeys = mentionDedupeKeys(activityMessage, activityChannelId)
1467
+ const activitySourceKey = `mention:${activityChannelId ?? '?'}:${activityMentionKeys.idKey || activityMentionKeys.signatureKey || activityThreadRoot}`
1468
+ if (memory.has(activitySourceKey, 'received')) continue
1469
+ }
1470
+ mentionActivity.push({
1471
+ projectId: project.id,
1472
+ project: project.name,
1473
+ channelId: activityChannelId,
1474
+ message: activityMessage,
1475
+ activity: item, activityKey,
1476
+ })
1477
+ }
1478
+ }
1479
+ })
1480
+ if (activityReplayTouched) persistReplay()
1481
+ if (!mentionActivity.length) return
1482
+ log('activity recovery found ' + mentionActivity.length + ' mention(s) -> guarded reply cycle(s)')
1483
+ // Replay each real message through the exact same delivery path as a live
1484
+ // WebSocket mention. This preserves thread ids and lets postMessageOnce
1485
+ // consult the rendered thread before any reply is emitted.
1486
+ for (const mention of mentionActivity) {
1487
+ void onEvent('agent:mention', {
1488
+ project_id: mention.projectId,
1489
+ channel_id: mention.channelId,
1490
+ message: mention.message,
1491
+ _mentionAlreadyMarked: true,
1492
+ })?.then((result) => {
1493
+ if (result?.status === 'retry') { seenActivities.delete(mention.activityKey); persistReplay() }
1494
+ })
1495
+ }
1496
+ }
1497
+
1498
+ // The backend can occasionally omit a targeted WebSocket message. Keep a
1499
+ // cached project list from connection recovery and check only recent activity
1500
+ // while idle. This does not scan projects, boards, task types, or tickets and
1501
+ // does not start a provider process unless a new addressed message is found.
1502
+ const reconcileActivity = async () => {
1503
+ if (stopping || !mcpUrl || backlogProbeBusy || activityProbeBusy || !knownProjects.length) return
1504
+ activityProbeBusy = true
1505
+ try { await replayRecentActivity(knownProjects) }
1506
+ catch (e) { log('activity recovery failed: ' + (e?.message || e)) }
1507
+ finally { activityProbeBusy = false }
1508
+ }
1509
+
1427
1510
  // Reconcile everything that may have arrived while disconnected. This spends no
1428
1511
  // model tokens unless the tools actually report pending work.
1429
1512
  const reconcileBacklog = async () => {
@@ -1433,8 +1516,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1433
1516
  try {
1434
1517
  const projectsData = toolData(await callMcpTool('list_projects'), 'list_projects')
1435
1518
  const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
1436
- const mentionActivity = []
1437
- let activityReplayTouched = false
1519
+ knownProjects = projects.map((project) => ({ id: project.id, name: project.name }))
1438
1520
  await mapConcurrent(projects, 3, async (project) => {
1439
1521
  try {
1440
1522
  const assigned = []
@@ -1484,71 +1566,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1484
1566
  seenTasks.delete(key)
1485
1567
  await handleTaskSignal('task:assigned', { task: { id: next.id, project_id: next.projectId } })
1486
1568
  }
1487
- const activityData = await callMcpTool('list_activity', { project_id: project.id }).then(toolData).catch((error) => {
1488
- log('activity reconciliation unavailable: ' + (error?.message || error))
1489
- return {}
1490
- })
1491
- const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
1492
- await ensureSelfAgent()
1493
- const mentionNeedles = [...selfAliases].map((alias) => '@' + alias)
1494
- for (const item of activities) {
1495
- const text = JSON.stringify(item)
1496
- const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
1497
- const data = item?.data && typeof item.data === 'object' ? item.data : null
1498
- const activityMessage = item?.message && typeof item.message === 'object'
1499
- ? item.message
1500
- : data?.message && typeof data.message === 'object' ? data.message : item
1501
- const activityText = String(activityMessage?.content ?? activityMessage?.body ?? activityMessage?.text ?? '').toLowerCase()
1502
- const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
1503
- const activityMessageId = activityMessage?.id ?? activityMessage?.message_id ?? activityMessage?.messageId
1504
- const activityParentId = messageParentId(activityMessage)
1505
- const activityThreadRoot = activityParentId != null ? activityParentId : activityMessageId
1506
- const activityControlKey = threadControlKey(activityChannelId, activityThreadRoot)
1507
- const ownedActivity = !!activityControlKey && memory.has(activityControlKey, 'active')
1508
- // Search only the source message, never the whole activity envelope: an
1509
- // old tagged thread root can otherwise make another agent's new reply
1510
- // look addressed to this watcher. Explicit mentions and human follow-ups
1511
- // in a persistently owned thread both enter the guarded per-message path.
1512
- const addressedActivity = mentionNeedles.some((needle) => activityText.includes(needle)) || ownedActivity || activityParentId != null
1513
- if (!seenActivities.has(activityKey) && addressedActivity && /message|mention|channel/i.test(text)) {
1514
- seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
1515
- // The same logical mention may already have arrived over WebSocket.
1516
- // Share the id/signature guard instead of starting a second model turn.
1517
- // Recover the former pre-routing acknowledgement bug exactly once
1518
- // when an owned-thread source has no received ledger entry.
1519
- if (markMentionHandled(activityMessage, activityChannelId)) {
1520
- const activityMentionKeys = mentionDedupeKeys(activityMessage, activityChannelId)
1521
- const activitySourceKey = `mention:${activityChannelId ?? '?'}:${activityMentionKeys.idKey || activityMentionKeys.signatureKey || activityThreadRoot}`
1522
- if (memory.has(activitySourceKey, 'received')) continue
1523
- }
1524
- mentionActivity.push({
1525
- projectId: project.id,
1526
- project: project.name,
1527
- channelId: activityChannelId,
1528
- message: activityMessage,
1529
- activity: item, activityKey,
1530
- })
1531
- }
1532
- }
1533
1569
  } catch (error) { log('Project reconciliation failed: ' + (error?.message || error)) }
1534
1570
  })
1535
- if (activityReplayTouched) persistReplay()
1536
- if (mentionActivity.length) {
1537
- log('backlog reconciliation found ' + mentionActivity.length + ' mention(s) -> guarded reply cycle(s)')
1538
- // Replay each real message through the exact same delivery path as a live
1539
- // WebSocket mention. This preserves thread ids and lets postMessageOnce
1540
- // consult the rendered thread before any reply is emitted.
1541
- for (const mention of mentionActivity) {
1542
- void onEvent('agent:mention', {
1543
- project_id: mention.projectId,
1544
- channel_id: mention.channelId,
1545
- message: mention.message,
1546
- _mentionAlreadyMarked: true,
1547
- })?.then((result) => {
1548
- if (result?.status === 'retry') { seenActivities.delete(mention.activityKey); persistReplay() }
1549
- })
1550
- }
1551
- }
1571
+ await replayRecentActivity(projects)
1552
1572
  } catch (e) {
1553
1573
  log('backlog reconciliation failed: ' + (e && e.message ? e.message : e))
1554
1574
  } finally { backlogProbeBusy = false }
@@ -2189,6 +2209,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2189
2209
  }
2190
2210
  const deliveryTimer = autoStart ? setInterval(() => void flushPendingReplies(), 30_000) : null
2191
2211
  deliveryTimer?.unref?.()
2212
+ const activityRecoveryTimer = autoStart ? setInterval(() => {
2213
+ if (!stopping) void trackOperation(reconcileActivity())
2214
+ }, 30_000) : null
2215
+ activityRecoveryTimer?.unref?.()
2192
2216
  const authorizationTimer = autoStart ? setInterval(() => void resumeReadyAuthorizations(), 2_000) : null
2193
2217
  authorizationTimer?.unref?.()
2194
2218
  let modelSettingsWatcher = null
@@ -2228,6 +2252,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2228
2252
  closePromise = (async () => {
2229
2253
  clearInterval(authorizationTimer)
2230
2254
  clearInterval(deliveryTimer)
2255
+ clearInterval(activityRecoveryTimer)
2231
2256
  modelSettingsWatcher?.close()
2232
2257
  if (introTimer) clearTimeout(introTimer)
2233
2258
  try { handle?.close() } catch { /* already disconnected */ }
@@ -2252,6 +2277,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2252
2277
  }
2253
2278
  return {
2254
2279
  done, close, receive: onEvent, reconcile: () => stopping ? Promise.resolve() : trackOperation(reconcileBacklog()),
2280
+ recoverActivity: () => stopping ? Promise.resolve() : trackOperation(reconcileActivity()),
2255
2281
  resumeAuthorizations: resumeReadyAuthorizations, retryDeliveries: flushPendingReplies,
2256
2282
  get pending() { return queues.work.size + queues.reply.size + mentionIntake.size },
2257
2283
  }