openvisio-agent 0.19.1 → 0.19.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/README.md CHANGED
@@ -66,7 +66,7 @@ The backend MCP may be stateful or stateless. A successful initialize response w
66
66
 
67
67
  Codex BYO agents follow the repository's normative runtime specification in `docs/CODEX_BYO_AGENT_SPEC.md`: one WebSocket identity, independent Sol reply/work lanes, authoritative `get_ticket` verification for assignments, REST-backed in-app activity, persistent replay suppression, and runtime evidence gates before completion. Maintainers must run `npm run certify` before publishing.
68
68
 
69
- An `agent:mention` event only wakes the watcher; it does not grant ownership of the conversation. The actual source message is checked before any model starts. Messages redirected to another agent and unaddressed agent chatter stay silent, while a direct stand-down cancels queued/running work for that thread. The watcher does not broadcast working, thinking, or typing presence updates. Claude and OpenCode may add one concrete progress update after work begins, but must continue and post a distinct verified result or blocker afterward; Codex keeps cancellation-safe delivery watcher-owned and renders the verified final answer once.
69
+ An `agent:mention` event only wakes the watcher; the actual source message is checked before any model starts. An explicit tag establishes durable ownership of that thread, so later human follow-ups remain addressed to the agent without another @mention. Messages redirected to another agent and unaddressed agent chatter stay silent, while a direct stand-down cancels queued/running work and releases ownership for that thread. The watcher does not broadcast working, thinking, or typing presence updates. Claude and OpenCode may add one concrete progress update after work begins, but must continue and post a distinct verified result or blocker afterward; Codex keeps cancellation-safe delivery watcher-owned and renders the verified final answer once.
70
70
 
71
71
  Ticket references follow the board UI: BYO agents use the project-scoped slug, such as `OVS-57`, in messages, comments, PR descriptions, blockers, and results. Numeric `project_id` and `ticket_id` values remain internal MCP arguments and are never used as human-facing ticket names. If an older backend omits the slug, the agent uses the ticket title rather than inventing one.
72
72
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.19.1",
3
+ "version": "0.19.3",
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": {
@@ -56,7 +56,9 @@ const assertions = [
56
56
  ['single-watcher acquisition is atomic and fails closed', watcher.includes("openSync(lockPath, 'wx')") && watcher.includes('Could not acquire the single-watcher lock')],
57
57
  ['websocket and activity mention delivery share a replay guard', watcher.includes('markMentionHandled(activityMessage, activityChannelId)') && watcher.includes('markMentionHandled(msg, cid)') && watcher.includes('recentMentionSignatures')],
58
58
  ['reconciled mentions reuse the guarded websocket delivery path', watcher.includes("onEvent('agent:mention'") && watcher.includes('_mentionAlreadyMarked: true')],
59
- ['conversation wake events are recipient-filtered before model start', watcher.includes('classifyConversationTarget(msg, [...selfAliases])') && events.includes("reason: 'explicit-other-recipient'") && events.includes("reason: 'other-agent-chatter'")],
59
+ ['conversation wake events preserve owned-thread follow-ups and filter other recipients before model start', watcher.includes("memory.has(controlKey, 'active')") && watcher.includes('classifyConversationTarget(msg, [...selfAliases], { threadOwned })') && events.includes("reason: 'owned-thread-follow-up'") && events.includes("reason: 'explicit-other-recipient'") && events.includes("reason: 'other-agent-chatter'")],
60
+ ['prematurely acknowledged owned-thread replies recover exactly once', watcher.includes("!threadOwned || memory.has(sourceKey, 'received')") && watcher.includes('agent:mention replay recovered for active owned thread')],
61
+ ['reconciliation recovers untagged human follow-ups in owned threads', watcher.includes("memory.has(activityControlKey, 'active')") && watcher.includes('const addressedActivity = mentionNeedles.some') && watcher.includes("!ownedActivity || memory.has(activitySourceKey, 'received')")],
60
62
  ['coding mentions require an action and concrete repository target', watcher.includes('conversationNeedsCode(text)') && events.includes('const action =') && events.includes('const target =')],
61
63
  ['stand-down and redirects cancel only source-thread workers', watcher.includes('cancelThread(cid, threadRoot') && watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent') && watcher.includes("subtype === 'canceled'")],
62
64
  ['thread cancellation is rechecked immediately before watcher delivery', watcher.includes('const newlyCancelled = suppressCancelled()') && watcher.includes('if (newlyCancelled) return newlyCancelled')],
package/src/events.mjs CHANGED
@@ -450,7 +450,7 @@ export function messageSenderIsAgent(message) {
450
450
  // `agent:mention`, even when the new message names somebody else. Treat the event
451
451
  // name as a wake-up hint only: recipient ownership is decided from the actual
452
452
  // message before a model or work lane is allowed to run.
453
- export function classifyConversationTarget(message, selfAliases) {
453
+ export function classifyConversationTarget(message, selfAliases, { threadOwned = false } = {}) {
454
454
  const m = message && typeof message === 'object' ? message : {}
455
455
  const text = String(m.content ?? m.body ?? m.text ?? m.message ?? '').replace(/\s+/g, ' ').trim()
456
456
  const { self: selfMentions, other: otherMentions, all: mentions } = conversationMentions(text, selfAliases)
@@ -471,11 +471,14 @@ export function classifyConversationTarget(message, selfAliases) {
471
471
  if (explicitSelf && requestTargetsLaterAgent(text, selfAliases)) {
472
472
  return { action: 'ignore', reason: 'redirected-to-later-agent', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
473
473
  }
474
- // A transport-level `agent:mention` can be emitted for every new message in a
475
- // thread whose root once mentioned this agent. With no mention or explicit
476
- // stand-down in the current message, ownership is ambiguous. Fail closed
477
- // instead of making every agent that ever joined the thread answer it.
474
+ // An explicit mention establishes durable thread ownership in the watcher.
475
+ // Later human follow-ups in that same active thread remain addressed to this
476
+ // agent without requiring another @mention. Redirects, stand-downs and agent
477
+ // chatter were handled above and still fail closed.
478
478
  if (!explicitSelf) {
479
+ if (threadOwned) {
480
+ return { action: 'handle', reason: 'owned-thread-follow-up', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
481
+ }
479
482
  return { action: 'ignore', reason: 'unaddressed-thread-activity', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
480
483
  }
481
484
  return { action: 'handle', reason: 'explicit-self-recipient', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
package/src/watch.mjs CHANGED
@@ -1361,15 +1361,28 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1361
1361
  ? item.message
1362
1362
  : data?.message && typeof data.message === 'object' ? data.message : item
1363
1363
  const activityText = String(activityMessage?.content ?? activityMessage?.body ?? activityMessage?.text ?? '').toLowerCase()
1364
- // Reconciliation recovers explicit mentions only. Searching the whole
1365
- // activity envelope can match an old thread root and replay a new reply
1366
- // that is actually addressed to another agent.
1367
- if (!seenActivities.has(activityKey) && mentionNeedles.some((needle) => activityText.includes(needle)) && /message|mention|channel/i.test(text)) {
1368
- const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
1364
+ const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
1365
+ const activityMessageId = activityMessage?.id ?? activityMessage?.message_id ?? activityMessage?.messageId
1366
+ const activityParentId = activityMessage?.parent_id ?? activityMessage?.parentId
1367
+ const activityThreadRoot = activityParentId != null ? activityParentId : activityMessageId
1368
+ const activityControlKey = threadControlKey(activityChannelId, activityThreadRoot)
1369
+ const ownedActivity = !!activityControlKey && memory.has(activityControlKey, 'active')
1370
+ // Search only the source message, never the whole activity envelope: an
1371
+ // old tagged thread root can otherwise make another agent's new reply
1372
+ // look addressed to this watcher. Explicit mentions and human follow-ups
1373
+ // in a persistently owned thread both enter the guarded per-message path.
1374
+ const addressedActivity = mentionNeedles.some((needle) => activityText.includes(needle)) || ownedActivity
1375
+ if (!seenActivities.has(activityKey) && addressedActivity && /message|mention|channel/i.test(text)) {
1369
1376
  seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
1370
1377
  // The same logical mention may already have arrived over WebSocket.
1371
1378
  // Share the id/signature guard instead of starting a second model turn.
1372
- if (markMentionHandled(activityMessage, activityChannelId)) continue
1379
+ // Recover the former pre-routing acknowledgement bug exactly once
1380
+ // when an owned-thread source has no received ledger entry.
1381
+ if (markMentionHandled(activityMessage, activityChannelId)) {
1382
+ const activityMentionKeys = mentionDedupeKeys(activityMessage, activityChannelId)
1383
+ const activitySourceKey = `mention:${activityChannelId ?? '?'}:${activityMentionKeys.idKey || activityMentionKeys.signatureKey || activityThreadRoot}`
1384
+ if (!ownedActivity || memory.has(activitySourceKey, 'received')) continue
1385
+ }
1373
1386
  mentionActivity.push({
1374
1387
  projectId: project.id,
1375
1388
  project: project.name,
@@ -1709,11 +1722,21 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1709
1722
  const mid = msg.id != null ? msg.id : (msg.message_id != null ? msg.message_id : null)
1710
1723
  const parent = msg.parent_id != null ? msg.parent_id : (msg.parentId != null ? msg.parentId : null)
1711
1724
  const threadRoot = parent != null ? parent : mid
1725
+ const controlKey = threadControlKey(cid, threadRoot)
1726
+ const threadOwned = !!controlKey && memory.has(controlKey, 'active')
1727
+ const mentionKeys = mentionDedupeKeys(msg, cid)
1728
+ const sourceKey = `mention:${cid ?? '?'}:${mentionKeys.idKey || mentionKeys.signatureKey || threadRoot || Date.now()}`
1712
1729
  // De-dupe: the same mention re-delivered (reconnect replay / dup fan-out) must
1713
1730
  // NOT trigger a second reply. Key by message id, or a channel+text signature
1714
- // when the payload carries no id.
1715
- if (!raw._mentionAlreadyMarked && markMentionHandled(msg, cid)) { log('agent:mention (dup) skipped'); return }
1716
- const target = classifyConversationTarget(msg, [...selfAliases])
1731
+ // when the payload carries no id. Older recipient filtering acknowledged
1732
+ // some owned-thread follow-ups before routing them. If such a message is
1733
+ // replayed, its replay key exists but its received ledger entry does not;
1734
+ // admit it once so the corrected ownership rule can recover the message.
1735
+ if (!raw._mentionAlreadyMarked && markMentionHandled(msg, cid)) {
1736
+ if (!threadOwned || memory.has(sourceKey, 'received')) { log('agent:mention (dup) — skipped'); return }
1737
+ log('agent:mention replay recovered for active owned thread')
1738
+ }
1739
+ const target = classifyConversationTarget(msg, [...selfAliases], { threadOwned })
1717
1740
  if (target.action === 'ignore') {
1718
1741
  log('agent:mention ignored before model start (' + target.reason + ')')
1719
1742
  // A redirect or another agent taking over invalidates any older work or
@@ -1724,8 +1747,6 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1724
1747
  }
1725
1748
  return
1726
1749
  }
1727
- const mentionKeys = mentionDedupeKeys(msg, cid)
1728
- const sourceKey = `mention:${cid ?? '?'}:${mentionKeys.idKey || mentionKeys.signatureKey || threadRoot || Date.now()}`
1729
1750
  if (target.action === 'stand_down') {
1730
1751
  if (cid != null && threadRoot != null) {
1731
1752
  cancelThread(cid, threadRoot, text || 'Explicit stand-down')